Three things Tim asked for together: measure the long end of a fight, make the swing check bite on stale prose, and record in rules.mjs the coincidence that hid an invented rule from two readers. GUARD 15 — check-fight-tail, on tools/fight-tail.mjs. Fourteen guards measured this scenario's combat and none could see a long fight, because every one of them averages. The cap is the measurement here, so the tool passes its own (400, not runFight's default 40) and --update refuses to record anything reaching it. Verified by lowering it back to 40: the cut loses 20 fights to the ceiling, the six-a-side 117, 107 of those ending neither won nor wiped, and the recorder stops. Two claims, both read from a fresh measurement rather than the baseline, so --update cannot silence them. "The long end is twice the median" was rejected as a claim because it is true of both encounters and so distinguishes nothing. Instead: a fight past 15 rounds wipes the party materially more often than a short one, and the SIX-A-SIDE fight is the longer one (median 14 vs 10.7) -- R-270 showing up as duration, since a disabled fighter keeps fighting 30 points down. The cut is shorter because it is decisive, not safer. GUARD 16 — check-cited, on tools/check-cited.mjs. check-firstblood and check-attackers catch the game changing; neither reads the document. Re-record after a re-cast and the artifact updates, the guard goes green, and the prose keeps printing the old number under a citation saying where the new one lives. So citations are now machine-readable -- **40**<!-- cite: first-blood cut.swing --> -- and resolved on every build. 25 of them. It failed three times on its first runs, all real: a config keyed "column" that the prose called "line", two figures rounded 32.3 -> 32, and a vacuous pass on zero citations, now fatal in its own right. It also refuses citation of unstable fields. fight-tail.longest may not reach prose: same party, same seeds, same runs, and renaming a config moved it 71 -> 90 rounds, because seedFor derives the stream from the id. Across seven labels -- median spread 0, p95 1, p99 3, longest 21. A sample maximum reads like a bound and is a property of the label. EXPOSURE states p99 instead. Same discipline on the deadlier ratio: 2.42 with seed spread 1.1, so the document gives its direction and declines to quote its size. RULES.MJS — one comment, no rule change. Over resolveLocationHit: its two thresholds are unrelated and usually agree. disabled is a fraction of the pool per location; majorWound is ceil(hp/2) and feeds only dyingLimitFor; neither removes anyone from a fight, which is conditionFor at 2 hit points or a destroyed head. At 10 hp, leg/abdomen/chest capacity is 5 and majorWoundFor is 5, and those locations take 12 of 20 melee results and 15 of 20 ranged -- so two readers reconstructed a rule that does not exist, checked it against the log, and were confirmed by it. The note says to test an arm, the only place the difference shows. Guards verified to bite, not assumed: drift, re-cast, censoring, the longest refusal, the rounding catch and the vacuous-pass catch were each forced and each failed the build with the right guidance, then restored. npm run check: 16 guards, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
6.9 KiB
JavaScript
121 lines
6.9 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-rollable.mjs", "check-creatures.mjs", "check-anatomy.mjs",
|
|
"check-lethality.mjs", "check-focus.mjs", "check-firstblood.mjs",
|
|
"check-attackers.mjs", "check-fight-tail.mjs", "check-cited.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);
|
|
}
|
|
}
|
|
|
|
/* 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. */
|
|
const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
|
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
|
"fifteen", "sixteen"];
|
|
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|thirteen|fourteen|fifteen|sixteen) 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`);
|