Files
RingBRP/tools/check-powers.mjs
T
slaguru666andClaude Opus 5 1a80d3637c R-282: guard the bestiary against the powers, including the next one
check-bestiary proves the page matches its generator, and the generator had never
heard of powers.mjs -- so the redcap sat in "the ones that take the most
stripping" under eighteen green guards while its own entry said it never spends a
defence. Two files agreeing with each other while both disagree with the engine
is a quorum, not a check.

check-powers now asserts per effect kind what the page must say: defenceStacking
requires the creature off the stripping list, named as exempt, and the "across N
creatures that spend defences" count reconciled against powers.mjs; attackFactor
requires the rating the simulator actually uses printed as a number, which the
courier's entry now carries.

The clause that matters is the failure on an unknown effect kind -- a wired effect
with no DOCUMENT_RULE fails the build, so the next one cannot arrive without
somebody deciding what the document owes it. Without that this would guard the
mistake already made and nothing else.

Proved three ways in a worktree, exit codes read directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:01:04 +01:00

188 lines
9.0 KiB
JavaScript

/**
* Every creature's POWER must be classified, and every wired one must actually reach the fight.
*
* 41 statblocks carry a POWER in their tactics prose and, until R-275, not one of them
* reached `simulate.mjs`. The harness read characteristics and weapons and nothing else,
* so a redcap — whose entry says it ignores the cumulative defence penalty — was measured
* as a creature that tires, in check-lethality, in check-focus, and in every figure this
* project has published about it. The defect was not that the powers were unimplemented.
* It was that nothing anywhere said they were not.
*
* So this guard asserts two things, and the second is the one that matters:
*
* 1. Every POWER in content.mjs is classified in powers.mjs — wired, notSimulable with
* a reason, or outOfCombat. A creature added tomorrow cannot arrive unclassified.
*
* 2. Every WIRED power changes a fight. Not "is referenced in the harness" — measured:
* the creature is fought with its power and again with it stripped, and the two must
* disagree. A power the simulator reads and does not act on is the defect this whole
* entry is about, one layer in, and a guard that checked for the import would have
* passed on it.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { POWERS, EFFECT_KINDS, combatPowerFor } from "./powers.mjs";
import { runFight, makeRng, seedFor, buildCombatant } from "./simulate.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const RUNS = 400;
const SEED = 11;
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
const party = PARTY_KEYS.map(k => {
const found = ROSTER.find(r => r.key === k);
if (!found) { console.error(`check-powers: the frozen party names "${k}", not on the roster`); process.exit(1); }
return found;
});
const problems = [];
/* 1. Classification. The prose is the authority for what a power SAYS; powers.mjs is the
authority for what the harness does about it, and every one must be in both. */
const withPower = NPCS.filter(n => /POWER:/.test(n.tactics ?? ""));
for (const spec of withPower) {
const p = POWERS[spec.key];
if (!p) {
problems.push(` ${spec.key} carries a POWER in its tactics and is not classified in powers.mjs — `
+ `add it as wired, notSimulable (with a reason) or outOfCombat`);
continue;
}
if (!["wired", "notSimulable", "outOfCombat"].includes(p.kind)) {
problems.push(` ${spec.key}: unknown classification "${p.kind}"`);
continue;
}
if (p.kind === "wired") {
const keys = Object.keys(p.effect ?? {});
if (!keys.length) problems.push(` ${spec.key}: wired but carries no effect`);
for (const k of keys) {
if (!EFFECT_KINDS.includes(k)) {
problems.push(` ${spec.key}: effect "${k}" is not one the harness knows (${EFFECT_KINDS.join(", ")})`);
}
}
}
if (p.kind === "notSimulable" && !p.why) {
problems.push(` ${spec.key}: notSimulable without a reason. A gap nobody wrote down is the defect this guard exists for.`);
}
}
for (const key of Object.keys(POWERS)) {
if (!withPower.some(s => s.key === key)) {
problems.push(` ${key} is classified in powers.mjs but no longer carries a POWER in content.mjs`);
}
}
/* 2. Every wired power must MOVE something. Fought with its power and without it. */
const winRate = (spec, strip) => {
let won = 0;
const seed = seedFor(SEED, spec.key);
for (let i = 0; i < RUNS; i++) {
const foe = strip ? { ...spec, key: `${spec.key}__nopower` } : spec;
if (runFight(makeRng(seed + i * 2654435761), party, [foe, foe]).won) won++;
}
return (100 * won) / RUNS;
};
const moved = [];
for (const key of Object.keys(POWERS)) {
if (POWERS[key].kind !== "wired") continue;
const spec = NPCS.find(n => n.key === key);
if (!spec) continue;
const withIt = winRate(spec, false);
const without = winRate(spec, true);
if (withIt === without) {
problems.push(` ${key}: ${POWERS[key].name} is wired and changes nothing — the party wins `
+ `${withIt}% of fights either way over ${RUNS} runs. Either the harness is not applying it `
+ `or the effect does not bite on this creature. A power that is read and not acted on is `
+ `exactly what R-275 was about.`);
} else {
moved.push(`${key} ${withIt.toFixed(1)}% -> ${without.toFixed(1)}% without it`);
}
}
/* 3. The document must not describe a fight the power overrides.
R-275 wired NOT TIRED and the bestiary went on listing the redcap among "the ones
that take the most stripping" — the opposite of what its own entry said three pages
down — and eighteen guards were green over it, because check-bestiary only proves the
page matches its generator and the generator had never heard of powers.mjs. Two files
agreeing with each other while both disagree with the engine is not a check.
So each wired EFFECT KIND carries an assertion about the page here. A new effect kind
arrives with no assertion and check-powers says so, which is the part that makes this
cover the next one rather than only the last one. */
const PAGE = path.join(ROOT, "docs", "BESTIARY.md");
const page = existsSync(PAGE) ? readFileSync(PAGE, "utf8") : null;
if (!page) {
problems.push(` docs/BESTIARY.md is missing, and the powers are checked against it`);
} else {
const dodgers = NPCS.filter(n => (n.skills ?? []).some(k => k.fam === "dodge" && k.val > 0));
const strippingList = (page.split("The ones that take the most stripping:")[1] ?? "").split("\n\n")[1] ?? "";
const DOCUMENT_RULES = {
/* A creature that never spends a defence is not on the stripping ladder, must not be
counted among those that are, and must be named — because the advice the section
gives is exactly wrong for it. */
defenceStacking: (spec) => {
const out = [];
if (strippingList.includes(spec.name)) {
out.push(` ${spec.key}: BESTIARY lists it among "the ones that take the most stripping", `
+ `and ${POWERS[spec.key].name} means it never spends a defence. That is the opposite `
+ `of the advice, printed for a GM to act on.`);
}
if (!page.includes(`${spec.name} is not on this ladder at all`)) {
out.push(` ${spec.key}: BESTIARY never says it is off the stripping ladder. A reader `
+ `who takes the section's advice against it is being told to do the one thing that `
+ `does not work.`);
}
const exemptDodgers = dodgers.filter(n => combatPowerFor(n.key)?.defenceStacking === "ignores").length;
const m = page.match(/Across \*{0,2}(\d+)\*{0,2} creatures that spend defences/);
if (!m) out.push(` BESTIARY no longer says how many creatures spend defences at all`);
else if (Number(m[1]) !== dodgers.length - exemptDodgers) {
out.push(` BESTIARY counts ${m[1]} creatures that spend defences; the game has `
+ `${dodgers.length} that dodge and ${exemptDodgers} exempt, so it should be `
+ `${dodgers.length - exemptDodgers}`);
}
return out;
},
/* A rating the simulator halves must appear halved, as a number. */
attackFactor: (spec, effect) => {
const c = buildCombatant(spec, { side: "enemy" });
const arm = c.arms?.[0];
if (!arm) return [];
const effective = Math.floor(arm.rating * effect.attackFactor);
return page.includes(`**${effective}%**, not ${arm.rating}%`) ? []
: [` ${spec.key}: BESTIARY does not state the rating the simulator actually uses `
+ `(${effective}%, not ${arm.rating}%). Without it the page prints the statblock and `
+ `every measured figure beside it is from a different creature.`];
},
damageFactor: () => []
};
for (const key of Object.keys(POWERS)) {
const p = POWERS[key];
if (p.kind !== "wired") continue;
const spec = NPCS.find(n => n.key === key);
if (!spec) continue;
for (const [kind, value] of Object.entries(p.effect)) {
const rule = DOCUMENT_RULES[kind];
if (!rule) {
problems.push(` effect "${kind}" has no rule about what BESTIARY must say. Add one to `
+ `DOCUMENT_RULES: an effect the harness applies and the page never mentions is how `
+ `the redcap came to be listed under advice that does not work on it.`);
continue;
}
problems.push(...rule(spec, p.effect));
}
}
}
if (problems.length) {
console.error(`check-powers: FAILED — ${problems.length} problem(s)`);
problems.forEach(p => console.error(p));
process.exit(1);
}
const by = { wired: 0, notSimulable: 0, outOfCombat: 0 };
for (const p of Object.values(POWERS)) by[p.kind]++;
console.log(`check-powers: OK — ${withPower.length} powers classified, ${by.wired} wired and each one `
+ `measurably changes its fight and is accounted for in the bestiary, ${by.notSimulable} recorded `
+ `as beyond the harness with a reason, ${by.outOfCombat} not fight rules`);