/** * 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. every path that builds an actor must finish the sheet ---------- */ { const src = await readFile(path.join(ROOT, "ringbrp.mjs"), "utf8"); /* generateCharacter has called grantFullSkillList since it was written, and build-packs gives all 62 skills to every packed creature. generateNPC did not, so a generated adversary carried about eight rows and a GM asked for Listen or Dodge had nothing on the sheet to roll — the first thing reported about this generator. A shared finishing step that one of three callers omits is invisible from inside that caller, which is what makes it worth naming here rather than remembering. */ for (const fn of ["generateCharacter", "generateNPC", "generateCreature"]) { const at = src.indexOf(`export async function ${fn}(`); if (at === -1) { problems.push(`ringbrp.mjs no longer defines ${fn}`); continue; } const after = src.slice(at + 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 (!/grantFullSkillList\s*\(/.test(body)) { problems.push(`${fn} never calls grantFullSkillList — the actor it builds reaches the ` + `table with only the skills it was explicitly given, and every other actor in the ` + `game has all 62 with the untrained ones at their printed base`); } } } /* ---------- 5. the engine must actually ask for the kit ---------- */ { 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`);