DEFENCE_STEP is -30, so a creature's Dodge is not a percentage it has for a round — it is one it has once or twice. The median dodge in the book is 45% and goes 45 -> 15 -> 1 inside a single round. No player-facing document said so, which hid a real decision: what order the party shoots in. The bestiary now has a section on it. Only a landing hit spends a defence, so a miss strips nothing; each landing hit makes the next attack of that round easier; therefore the cheapest reliable weapon fires first and the one you most need to land fires last — against the supporter, the weapon the borough does not own. Everything it states is generated from DEFENCE_STEP, resolveBands and the statblocks: the step, the ladder, the median, the spread (13 of 47 creatures stripped by one landing hit, 25 by two, 9 by three) and the list of the hardest to strip. Negative-tested by setting the step to -15 and -20; the page rewrites itself both times. No win rates are published: the ordering measurements come from a table-side harness that ignores wound penalties, bleeding and dying, so the page states the mechanism and quotes no percentages. My first draft used the dodgiest creature in the book as the worked example — 80%, three landing hits to strip — directly beneath a claim that the first attack of the round eats the dodge. True of a modest dodge, false of a good one, and both were on the same page. tools/bestiary.mjs --check has existed since the document did, with the comment "(for the guards)", and no guard ever ran it: a check written and wired to nothing, the same shape as every rule found computed and never read. That was tolerable while the page restated statblocks and is not now that it derives from rules — change DEFENCE_STEP and the document lies to a GM. check-bestiary.mjs is the tenth guard, negative-tested both ways it can fail: a rule change that staler the page, and a hand-edit of the page. update-readme flagged it as "run by the build, not advertised" before I listed it, and the README's count word went from nine to ten by itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
6.0 KiB
JavaScript
108 lines
6.0 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, NPCS, TALENTS }
|
|
from "./content.mjs";
|
|
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. This list was four long while the
|
|
suite was seven, so the README advertised a subset and silently stopped mentioning
|
|
every guard added after it was written — including the three that catch the most. */
|
|
const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs",
|
|
"check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs",
|
|
"check-creatures.mjs", "check-anatomy.mjs", "check-lethality.mjs",
|
|
"check-bestiary.mjs"];
|
|
|
|
/* The list above went stale the moment a guard was added without touching this file —
|
|
which is what the comment above it already warned about, and which happened again
|
|
with check-anatomy. So it is no longer trusted: the suite of record is the `check`
|
|
script in package.json, and this refuses to write a README that advertises a
|
|
different set than the one the build actually runs. */
|
|
{
|
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
const actual = [...(pkg.scripts?.check ?? "").matchAll(/(check-[\w-]+\.mjs)/g)].map(m => m[1]);
|
|
const missing = actual.filter(g => !GUARDS.includes(g));
|
|
const phantom = GUARDS.filter(g => !actual.includes(g));
|
|
if (missing.length || phantom.length) {
|
|
console.error("update-readme: FAILED — the README's guard list does not match `npm run check`");
|
|
if (missing.length) console.error(" run by the build, not advertised: " + missing.join(", "));
|
|
if (phantom.length) console.error(" advertised, not run by the build: " + phantom.join(", "));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const guardLines = GUARDS.map(runGuard).filter(Boolean);
|
|
|
|
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.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. */
|
|
const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine", "ten", "eleven", "twelve"];
|
|
const word = COUNT_WORD[guardLines.length];
|
|
if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); }
|
|
const counted = withGuards.replace(/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve) 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`);
|