Files
RingBRP/tools/check-generator.mjs
T
slaguru666andClaude Opus 5 4dd72c6248 Generated NPCs draw their posting's kit
generateNPC read R.core, R.support and R.label. It never read R.kit, so the
role card's DRAWS FROM STORES promised a Containment officer nine items and
the actor arrived holding a utility knife with a flat naturalArmour standing
in for armour it was never issued.

postings.mjs now owns the decision — drawKitFor(role, tier, {classify}) plus
a draw table per tier — because the engine resolves kit keys against the
compendium and check-generator resolves them against content.mjs, and the two
cannot import each other. Classification comes from catalogue membership and
never from the key: climbing_kit is "Breaching charges" and prybar is "Entry
bar", so anything reading a key's spelling is wrong in that posting first.

The weapon↔skill pairing table is gone. Each weapon item already names the
skill that fires it, so the skill is granted from the weapon, and a generated
NPC cannot hold what it cannot use. THREATS.attacks is now creature-tiers
only; those two keep their natural attacks on top of what they drew.

Armour is an item. naturalArmour drops to 0 on the four human tiers, where
the number was standing in for the item, and stays on the two creature tiers,
where it stacks with drawn armour as locationArmourFor already intends.

check-generator (guard 25) was written first and observed failing on nine
counts against unchanged source. One of its assertions was itself wrong —
it read for the creature-tier restriction after the loop instead of before
it, and so reported the fixed code as broken; re-scoped, it passes on the fix
and still fails against git show HEAD:ringbrp.mjs.

Measured with the new tools/npc-cohort.mjs, 2000 runs, seed 11, against the
party check-lethality freezes. Apex · Containment 68.2% -> 88.5% wipe,
Anomalous · Containment 0.3% -> 19.2%, and Dangerous · Field Lead got weaker
because its posting carries one pistol where the tier used to hand it a
rifle. Full table and three incidental findings in R-310.

Two of those findings are guards, not content. check-rules failed on a
comment that quoted the pattern it hunts for, so comment-only lines are
skipped now. update-readme held the guard list as a hardcoded array whose own
comments recorded it going stale twice; it reads the check script instead,
and so does the count-word rewrite that was separately capped at sixteen.

Not changed, and flagged in R-310: breach_suit and riot_shield declare no
coverage, so they armour every location including the head. The location
counterplay NPC_KIT_PLAN.md relies on does not exist, and that is a balance
decision rather than a side effect of this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:08:12 +01:00

156 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What the role card promises, the generated NPC must be holding.
*
* Every one of the 36 postings carries a `kit` array, and `generateDialog` prints it
* on the role card as DRAWS FROM STORES. `generateCharacter` issues it. `generateNPC`
* did not read it at all, so the card advertised a marksman rifle, a breaching suit
* and seven other things while the actor was handed whichever of six hardcoded
* weapons its tier named. Nothing measured the gap, which is why it survived.
*
* This guard measures it in two halves, because half of it is a decision over pure
* data and half of it is whether the engine bothers to ask:
*
* - `drawKitFor()` in postings.mjs decides what a posting issues at a tier. Pure,
* so it can be asserted here directly.
* - `generateNPC` in ringbrp.mjs must actually call it. A correct decision function
* nothing consumes is exactly the defect being fixed, one layer further in, so
* the source is scanned as well.
*
* bun tools/check-generator.mjs
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WEAPONS, ARMOURS, GEAR, DEVICES, SKILL_CATALOGUE } from "./content.mjs";
import * as POSTINGS from "../postings.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const { ROLES, TRADES, THREATS } = POSTINGS;
const problems = [];
/* Classification comes from catalogue membership, never from the spelling of a key.
* Containment's `climbing_kit` is "Breaching charges" and its `prybar` is "Entry bar":
* anything reading meaning out of a key is wrong in that one posting first. */
const CLASSES = [["weapon", WEAPONS], ["armour", ARMOURS], ["gear", [...GEAR, ...(DEVICES ?? [])]]];
const classOf = new Map();
for (const [cls, list] of CLASSES) {
for (const x of list) {
if (classOf.has(x.key) && classOf.get(x.key) !== cls) {
problems.push(`kit key "${x.key}" is in both the ${classOf.get(x.key)} and ${cls} catalogues — `
+ `classification by catalogue membership needs each key to live in exactly one`);
}
classOf.set(x.key, cls);
}
}
const classify = key => classOf.get(key) ?? null;
const SKILLS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
/** The skill a weapon names for itself. The pairing table is the weapon. */
const skillOfWeapon = key => {
const w = WEAPON_BY_KEY.get(key);
if (!w) return null;
return w.spec ? `${w.fam}:${w.spec}` : w.fam;
};
/* ---------- 1. the tier tables must say how much a tier draws ---------- */
for (const [tier, TH] of Object.entries(THREATS)) {
const d = TH.draw;
if (!d) { problems.push(`THREATS.${tier} has no draw table — the tier cannot say how much of the posting's stores it takes`); continue; }
for (const field of ["weapons", "armour", "gear"]) {
if (!Number.isInteger(d[field]) || d[field] < 0) {
problems.push(`THREATS.${tier}.draw.${field} is ${JSON.stringify(d[field])}, not a count`);
}
}
}
/* ---------- 2. the decision function must exist and be pure ---------- */
const drawKitFor = POSTINGS.drawKitFor;
if (typeof drawKitFor !== "function") {
problems.push(`postings.mjs does not export drawKitFor() — there is no one place that `
+ `decides what a posting issues at a tier, so the engine and this guard cannot agree on it`);
}
/* ---------- 3. every posting × tier issues from its own stores, usably ---------- */
let pairs = 0, issuedWeapons = 0;
if (typeof drawKitFor === "function") {
for (const [roleKey, R] of Object.entries({ ...ROLES, ...(TRADES ?? {}) })) {
const kitSet = new Set(R.kit ?? []);
const kitWeapons = (R.kit ?? []).filter(k => classify(k) === "weapon");
const kitArmour = (R.kit ?? []).filter(k => classify(k) === "armour");
for (const [tier, TH] of Object.entries(THREATS)) {
pairs++;
const where = `${tier} · ${roleKey}`;
let drawn;
try { drawn = drawKitFor(roleKey, tier, { classify }); }
catch (e) { problems.push(`${where}: drawKitFor threw ${e.message}`); continue; }
if (!drawn) { problems.push(`${where}: drawKitFor returned nothing`); continue; }
for (const group of ["weapons", "armour", "gear"]) {
for (const key of drawn[group] ?? []) {
// Assertion 4 of the design: the generator may issue less than the card shows.
// It may never issue something the card never mentioned.
if (!kitSet.has(key)) problems.push(`${where}: issued "${key}" as ${group}, which is not in the posting's kit`);
else if (classify(key) !== group.replace(/s$/, "").replace("armou", "armour")) {
const want = { weapons: "weapon", armour: "armour", gear: "gear" }[group];
if (classify(key) !== want) problems.push(`${where}: "${key}" issued as ${group} but the catalogues call it ${classify(key)}`);
}
}
}
// Assertion 3: a tier that draws weapons, from a posting that has one, gets one.
const wantW = Math.min(TH.draw?.weapons ?? 0, kitWeapons.length);
const gotW = (drawn.weapons ?? []).length;
if (gotW !== wantW) problems.push(`${where}: draws ${TH.draw?.weapons ?? 0} weapon(s) from a kit holding ${kitWeapons.length}, and received ${gotW}`);
issuedWeapons += gotW;
const wantA = Math.min(TH.draw?.armour ?? 0, kitArmour.length);
if ((drawn.armour ?? []).length !== wantA) problems.push(`${where}: expected ${wantA} armour, received ${(drawn.armour ?? []).length}`);
// Assertion 2: a weapon arrives with the skill it names, or the NPC cannot use it.
for (const key of drawn.weapons ?? []) {
const sk = skillOfWeapon(key);
if (!sk) { problems.push(`${where}: issued weapon "${key}" is in no catalogue`); continue; }
if (!SKILLS.has(sk)) problems.push(`${where}: weapon "${key}" names skill "${sk}", which is not a skill`);
}
// Assertion 1: nothing on the card may be unresolvable.
for (const key of R.kit ?? []) {
if (!classify(key)) problems.push(`${where}: the role card prints "${key}", which is in no catalogue`);
}
}
}
}
/* ---------- 4. the engine must actually ask ---------- */
{
const src = await readFile(path.join(ROOT, "ringbrp.mjs"), "utf8");
const start = src.indexOf("export async function generateNPC(");
if (start === -1) problems.push(`ringbrp.mjs no longer defines generateNPC — this guard is looking at the wrong function`);
else {
const after = src.slice(start + 10);
const ends = [after.indexOf("\nexport "), after.indexOf("\nasync function "), after.indexOf("\nfunction ")]
.filter(i => i !== -1);
const body = after.slice(0, ends.length ? Math.min(...ends) : after.length);
if (!/drawKitFor\s*\(/.test(body)) {
problems.push(`generateNPC never calls drawKitFor — it builds an NPC without reading the `
+ `posting's kit, so the role card's DRAWS FROM STORES describes stores the actor never visits`);
}
// The natural-attack loop must sit inside a creature-tier restriction. Read the
// text BEFORE the loop, not after it: the condition is upstream of the thing it
// guards, and the first version of this assertion looked downstream and so
// reported the fixed code as broken.
const at = body.indexOf("TH.attacks");
if (at !== -1 && !/CREATURE_TIER\.has|=== "anomalous"|=== "apex"/.test(body.slice(Math.max(0, at - 400), at))) {
problems.push(`generateNPC applies THREATS.attacks with no creature-tier restriction above it — `
+ `those six hardcoded weapons are for the two tiers that have no posting to draw from, `
+ `and on a human tier they replace the kit rather than adding to it`);
}
}
}
if (problems.length) {
console.error(`check-generator: FAILED — ${problems.length} problem(s)`);
for (const p of problems.slice(0, 40)) console.error(" " + p);
if (problems.length > 40) console.error(` … and ${problems.length - 40} more`);
process.exit(1);
}
console.log(`check-generator: OK — ${pairs} posting × tier pairs draw from their own stores, `
+ `${issuedWeapons} weapons issued, each with the skill it names`);