Files
RingBRP/tools/check-lethality.mjs
T
slaguru666andClaude Opus 5 4b718595fe check-lethality: guard eight, and the README stops advertising a subset
Seven guards checked that content is WELL FORMED. None checked what it DOES, so a
change to a damage modifier, a hit-point formula or the armour value on a service vest
could double a creature's lethality without touching one line of that creature - and
nothing in the build would notice, because the creature did not change.

Built as a regression test rather than the hand-declared bands the creature-forge plan
described. Bands are the wrong shape here: declaring "keepers: dangerous" across
forty-six creatures means inventing forty-six judgements, and after --spread it is
clear the interesting question is not "is this dangerous" - that has no single answer -
but "is this the same as it was". A recorded baseline answers exactly that, needs no
authoring, and cannot be argued with.

Every creature is measured solo against a FROZEN party of four, two armed postings and
two trades, at a fixed seed, so the only thing that can move a number is a change to
the rules or to the creature. Tolerances are 6 points of wipe rate and 0.35 agents,
which is outside the noise floor of 200 runs - a guard that cries wolf gets deleted.

Verified by tampering: told the baseline the nuckelavee was harmless and the guard
caught it at +16.5 points and +1.67 agents down, exit 1. Runs in half a second, so it
joins the pre-build checks rather than being something to remember to run.

Also: the README's guard block was a hardcoded list of four while the suite was seven.
It had silently stopped mentioning every guard added after it was written, including
check-creatures and check-scenarios. Now all eight, and the prose says eight.

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

137 lines
6.3 KiB
JavaScript

/**
* check-lethality — no creature may quietly become something else.
*
* The seven guards before this one check that content is WELL FORMED. None of them
* checks what it DOES. A creature can validate perfectly, pack perfectly, and have
* become twice as dangerous as it was last week because somebody adjusted a damage
* modifier, a hit-point formula or the armour on a service vest. That change is
* invisible in a diff of the creature, because the creature did not change.
*
* This is the guard the creature-forge plan called for, built as a regression test
* rather than as a set of hand-declared bands. Bands were the plan's design and they
* are the wrong one here: declaring "keepers: dangerous" on forty-six creatures means
* inventing forty-six judgements, and the judgement that matters is not "is this
* dangerous" but "is this the same as it was". A baseline answers that exactly, needs
* no authoring, and cannot be argued with.
*
* node tools/check-lethality.mjs compare against the committed baseline
* node tools/check-lethality.mjs --update re-record it (after a deliberate change)
* node tools/check-lethality.mjs --verbose print every creature, not just drift
*
* WHY A FIXED PARTY. --spread exists because one number cannot describe an encounter:
* the Act Three standoff runs 0% to 100% depending which four agents were picked. That
* is true and it is why this guard does NOT try to describe danger. It measures the
* same creature against the same four agents with the same seed, so the only thing that
* can move the number is a change to the rules or to the creature. The party below is
* deliberately mixed — two armed postings and two trades — so that a change affecting
* either kind of agent shows up, and it is frozen for reproducibility rather than
* chosen for realism.
*/
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { measure } from "./simulate.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const BASELINE = path.join(ROOT, "tools", "lethality-baseline.json");
const argv = process.argv.slice(2);
const UPDATE = argv.includes("--update");
const VERBOSE = argv.includes("--verbose");
/* Frozen. Changing this invalidates every recorded number, so if it ever must change,
change it in the same commit as a --update and say why in the message. */
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
const RUNS = 200;
const SEED = 11;
/* How far a number may move before it is drift rather than noise. 200 runs of a coin
flip has a standard error around 3.5 points, so anything under 6 is inside the noise
and failing on it would make the guard cry wolf until somebody deleted it. */
const WIPE_TOLERANCE = 6.0; // percentage points
const DOWN_TOLERANCE = 0.35; // agents, of four
const party = PARTY_KEYS.map(k => {
const found = ROSTER.find(r => r.key === k);
if (!found) {
console.error(`check-lethality: the frozen party names "${k}", which is not on the roster.`);
process.exit(1);
}
return found;
});
/** Solo, because a creature is the unit under test — counts are an encounter's business. */
const current = {};
for (const spec of NPCS) {
const r = measure(party, [spec], { runs: RUNS, seed: SEED });
current[spec.key] = {
wipe: Number((r.wipeRate * 100).toFixed(1)),
down: Number(r.downMean.toFixed(2)),
rounds: r.roundsMedian
};
}
if (UPDATE) {
await writeFile(BASELINE, JSON.stringify({
note: "Generated by tools/check-lethality.mjs --update. Do not edit by hand.",
party: PARTY_KEYS, runs: RUNS, seed: SEED,
creatures: current
}, null, 2) + "\n", "utf8");
console.log(`check-lethality: baseline recorded — ${Object.keys(current).length} creatures, `
+ `party ${PARTY_KEYS.map(k => k.replace(/^pc_/, "")).join(", ")}, ${RUNS} runs, seed ${SEED}`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.error("check-lethality: no baseline. Run with --update to record one.");
process.exit(1);
}
const base = JSON.parse(await readFile(BASELINE, "utf8"));
/* The recorded numbers mean nothing if they were taken under different conditions, and
a guard comparing incomparable numbers is worse than no guard. */
if (base.runs !== RUNS || base.seed !== SEED
|| base.party.join() !== PARTY_KEYS.join()) {
console.error("check-lethality: FAILED — the baseline was recorded under different conditions "
+ `(party ${base.party.join(",")}, ${base.runs} runs, seed ${base.seed}). Re-record it with --update.`);
process.exit(1);
}
const problems = [];
const added = [], removed = [];
for (const [key, now] of Object.entries(current)) {
const was = base.creatures[key];
if (!was) { added.push(key); continue; }
const dWipe = now.wipe - was.wipe;
const dDown = now.down - was.down;
if (Math.abs(dWipe) > WIPE_TOLERANCE || Math.abs(dDown) > DOWN_TOLERANCE) {
problems.push(`${key}: wiped ${was.wipe}% -> ${now.wipe}% (${dWipe >= 0 ? "+" : ""}${dWipe.toFixed(1)}), `
+ `down ${was.down} -> ${now.down} (${dDown >= 0 ? "+" : ""}${dDown.toFixed(2)} of 4)`);
}
}
for (const key of Object.keys(base.creatures)) if (!(key in current)) removed.push(key);
if (VERBOSE) {
for (const [key, now] of Object.entries(current)) {
console.log(` ${key.padEnd(22)} ${String(now.wipe).padStart(5)}% wiped ${now.down.toFixed(2)} down ${now.rounds} rounds`);
}
}
if (added.length) console.log(` note: ${added.length} new creature(s) not in the baseline — ${added.join(", ")}`);
if (removed.length) console.log(` note: ${removed.length} creature(s) gone from content — ${removed.join(", ")}`);
if (problems.length) {
console.error("check-lethality: FAILED — a creature does a different amount of damage than recorded");
for (const p of problems) console.error(" " + p);
console.error("\n If this was deliberate, re-record with --update and say what changed in the commit.");
process.exit(1);
}
console.log(`check-lethality: OK — ${Object.keys(current).length} creatures, none drifted more than `
+ `${WIPE_TOLERANCE} points of wipe rate against the frozen party`
+ `${added.length ? `, ${added.length} new` : ""}`);