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>
133 lines
7.5 KiB
JavaScript
133 lines
7.5 KiB
JavaScript
/**
|
|
* Rewrite the README's counted sections from the real data.
|
|
*
|
|
* Those tables have been wrong three times, because a catalogue that grows every
|
|
* session and a number typed by hand are not the same kind of thing. A stale count in
|
|
* a README is the same defect as a stale comment in the engine: a claim nothing checks.
|
|
*
|
|
* Only the marked regions are touched; the prose around them is left alone.
|
|
*/
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, TALENTS }
|
|
from "./content.mjs";
|
|
/* The whole population, through the one door. Reading NPCS from content.mjs meant
|
|
this tool could only see the 47 creatures that happen to live in that file, so a
|
|
creature from tools/bestiary-*.mjs was simply "not found". See check-seam.mjs. */
|
|
import { collectSpecs } from "./all-specs.mjs";
|
|
const NPCS = (await collectSpecs()).filter(r => r.kind === "npc").map(r => r.spec);
|
|
import { ROLES, TRADES } from "../postings.mjs";
|
|
import { ICON_MAP } from "./icons.mjs";
|
|
import { RULES_PAGES } from "./rules-text.mjs";
|
|
|
|
const root = new URL("../", import.meta.url);
|
|
const readme = new URL("README.md", root);
|
|
let text = await readFile(readme, "utf8");
|
|
|
|
const items = WEAPONS.length + ARMOURS.length + GEAR.length + VEHICLES.length;
|
|
|
|
// The guard lines are quoted from the guards themselves rather than paraphrased.
|
|
const runGuard = name => {
|
|
try {
|
|
return execFileSync(process.execPath, [fileURLToPath(new URL(name, import.meta.url))],
|
|
{ encoding: "utf8" }).split("\n").find(l => l.startsWith(name.replace(".mjs", "")));
|
|
} catch { return null; }
|
|
};
|
|
/* All of them, in the order `npm run check` runs them — READ from the script that runs
|
|
them, not retyped beside it.
|
|
|
|
This was a hardcoded array, and the two comments that used to live here both recorded
|
|
it going stale: four long while the suite was seven, then missing check-anatomy. The
|
|
response at the time was to keep the array and add a check that it matched
|
|
package.json, which turned a silent omission into a loud one — an improvement, and
|
|
still a list of valid values maintained by hand. It went stale twice more (check-seam,
|
|
check-generator) and stopped the build both times.
|
|
|
|
There is only one suite. It is the `check` script. The non-guard steps in that script
|
|
(detention-tables, bellhouse-plans) run with `--check` and are not named check-*, so
|
|
this picks out exactly the guards, in build order. */
|
|
const GUARDS = (() => {
|
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
const found = [...(pkg.scripts?.check ?? "").matchAll(/(check-[\w-]+\.mjs)/g)].map(m => m[1]);
|
|
if (!found.length) {
|
|
console.error("update-readme: FAILED — package.json has no `check` script to read the guards from");
|
|
process.exit(1);
|
|
}
|
|
return [...new Set(found)];
|
|
})();
|
|
|
|
/* A guard that fails, or that prints under a different name, returns null here. Dropping
|
|
it silently writes a README advertising fewer guards than the build runs and quietly
|
|
lowers the count word — which is how "thirteen guards" was published while fourteen ran
|
|
and check-rollable was red. A missing line is now fatal. */
|
|
const guardLines = GUARDS.map(name => [name, runGuard(name)]);
|
|
const silent = guardLines.filter(([, line]) => !line).map(([name]) => name);
|
|
if (silent.length) {
|
|
console.error(`update-readme: FAILED — no line from ${silent.join(", ")}. A guard that is `
|
|
+ `failing, or that prints under a name other than its own, must not be quietly left out `
|
|
+ `of the README. Run \`npm run check\` and fix it first.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const box = `| | |
|
|
|---|---|
|
|
| **Postings** | ${Object.keys(ROLES).length}, each with a key characteristic, trained skills, talents and its own kit |
|
|
| **Trades** | ${Object.keys(TRADES).length} ordinary jobs the department recruits from, plus induction |
|
|
| **Catalogue** | ${items} items — ${GEAR.length} gear, ${WEAPONS.length} weapons, ${ARMOURS.length} armour, ${VEHICLES.length} vehicles |
|
|
| **Skills / talents** | ${SKILL_CATALOGUE.length} skills, ${TALENTS.length} talents |
|
|
| **Bestiary** | ${NPCS.length} statblocks across folklore, horror and the far side |
|
|
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
|
|
| **Icons** | ${Object.keys(ICON_MAP).length}, all generated from \`tools/icons.mjs\`; no external art |
|
|
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
|
|
| **Compendia** | pregens, NPCs, skills, weapons, armour, gear, vehicles, powers, tables, rules, the starter |
|
|
| **Rules journal** | ${RULES_PAGES.length} pages, generated from \`rules.mjs\` so the book cannot drift from the code |`;
|
|
|
|
const replaceRegion = (src, marker, body) => {
|
|
const open = `<!-- ${marker}:start -->`, close = `<!-- ${marker}:end -->`;
|
|
const i = src.indexOf(open), j = src.indexOf(close);
|
|
if (i < 0 || j < 0) return null;
|
|
return src.slice(0, i + open.length) + "\n" + body + "\n" + src.slice(j);
|
|
};
|
|
|
|
// First run: wrap the existing hand-written regions in markers.
|
|
if (!text.includes("<!-- box:start -->")) {
|
|
text = text.replace(/\| \| \|\n\|---\|---\|\n(?:\|.*\n)+/,
|
|
m => `<!-- box:start -->\n${m}<!-- box:end -->\n`);
|
|
text = text.replace(/```\ncheck-rules:[\s\S]*?```/,
|
|
m => `<!-- guards:start -->\n${m}\n<!-- guards:end -->`);
|
|
}
|
|
|
|
const withBox = replaceRegion(text, "box", box);
|
|
if (!withBox) { console.error("update-readme: box markers missing"); process.exit(1); }
|
|
const withGuards = replaceRegion(withBox, "guards",
|
|
"```\n" + guardLines.map(([, l]) => l).join("\n") + "\n```");
|
|
if (!withGuards) { console.error("update-readme: guard markers missing"); process.exit(1); }
|
|
|
|
/* The guard BLOCK was generated and the sentences introducing it were not, so the README
|
|
listed nine guards while three places in the prose still said eight. Count words go
|
|
stale the same way a hardcoded list does — the lesson two comments up, one layer out. */
|
|
/* Ran out at sixteen, one guard after the comment above warned about hardcoded lists
|
|
going stale. It fails loudly rather than silently, which is why this is an inconvenience
|
|
and not a defect — but it will run out again, so it now reaches far enough ahead that
|
|
the next person to hit it is unlikely to be working this year. */
|
|
const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
|
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty",
|
|
"twenty-one", "twenty-two", "twenty-three", "twenty-four",
|
|
"twenty-five", "twenty-six", "twenty-seven", "twenty-eight",
|
|
"twenty-nine", "thirty"];
|
|
const word = COUNT_WORD[guardLines.length];
|
|
if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); }
|
|
/* The words this rewrites FROM were a second hardcoded list, and a shorter one: it
|
|
stopped at "sixteen", so once the README said "seventeen guards" no later run could
|
|
correct that prose again. Both lists are the same list. */
|
|
const counted = withGuards.replace(
|
|
new RegExp(`\\b(${[...COUNT_WORD].sort((a, b) => b.length - a.length).join("|")}) guards\\b`, "g"),
|
|
`${word} guards`);
|
|
|
|
await writeFile(readme, counted, "utf8");
|
|
console.log(`update-readme: OK — ${items} items, ${Object.keys(ROLES).length} postings, `
|
|
+ `${Object.keys(TRADES).length} trades, ${guardLines.length} guard lines`);
|