Files
RingBRP/tools/update-readme.mjs
T
slaguru666andClaude Opus 5 4cfacd47fe check-lethality compares exactly, because it already seeded per creature (R-97)
I asked for this on a false premise of my own: I reported that adding a
creature shifted a shared random stream and perturbed every other
creature's recorded number. That is not true and the code never did it.
measure() builds its own mulberry32 from the seed on every call, and
check-lethality calls it once per creature, so a creature's numbers do
not depend on its neighbours or its position. Measured rather than
argued: inserting a creature ahead of the barghest changes 0 of 47
existing entries. The file's own claim — "the only thing that can move
the number is a change to the rules or to the creature" — was accurate
all along, and my last commit message says otherwise. It is wrong.

The real cause, found by replaying each commit against the baseline as
committed at 4b71859:

  4b71859  baseline recorded            0 of 46 differ
  322389b  bestiary                     0 of 46 differ
  ddc4f99  hit locations reach combat  26 of 46 differ   <-- here
  a90c4f3 .. e5dc9b5                   26 of 46 differ

ddc4f99 routed every ordinary blow through the hit location table. That
is the largest change the combat system has had and it moved 26 of 46
creatures, which is correct and expected. What is not correct is that
nobody noticed for four commits: each creature moved by one or two
points, the guard allowed six, and it reported OK while describing a
game nobody was playing.

So the tolerance goes. It exists for sampling noise and there is no
sampling noise here — same party, same seed, same counts, and two
recordings of unchanged code are byte-identical. Anything that moves is
a real change, which is the entire point of the file. `rounds` is now
compared too; it was recorded and then never read, so a creature could
take a round longer to kill forever without a word.

Because exactness only means something if the measurement is exact, the
guard now proves it instead of assuming it: one creature measured twice
must come back identical, and it says so plainly if a future change
reaches for Math.random.

Negative-tested. A 2% change to locationMaxHp now trips 8 creatures at
+1.5 and +0.5 points of wipe rate — every one of which the old tolerance
would have passed. Breaking determinism is caught and named.

Also fixes update-readme, which advertised 8 guards while the build ran
9: check-anatomy was added without touching the list, which is precisely
what the comment above that list already warned had happened once. The
list is no longer trusted — it is checked against the `check` script in
package.json, and refuses to write a README advertising a different set
than the build runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:22:19 +01:00

97 lines
5.2 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"];
/* 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); }
await writeFile(readme, withGuards, "utf8");
console.log(`update-readme: OK — ${items} items, ${Object.keys(ROLES).length} postings, `
+ `${Object.keys(TRADES).length} trades, ${guardLines.length} guard lines`);