Files
RingBRP/tools/check-generator.mjs
slaguru666andClaude Opus 5 dda8238a12 Generated NPCs get the full skill list, like everything else does
"When I create a new NPC or Threat they do not have a set of skills" was the
original report, and the kit fix did not cover all of it. A generated NPC
carried its posting's core plus two support — about eight rows — so a GM
asked for Listen or Dodge or Spot by a player had nothing on the sheet to
roll.

generateCharacter has called grantFullSkillList since it was written, and
build-packs gives all 62 to every packed creature, so the generated NPC was
the only actor in the game without them. It calls it now, in the same place
the PC path does: after the items, before stowOverload.

check-generator holds all three construction paths to it. A shared finishing
step that one of three callers omits is invisible from inside that caller,
which is why it is worth a check rather than a memory. Proved by removing the
call again.

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

180 lines
9.5 KiB
JavaScript
Raw Permalink 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. 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`);