Files
RingBRP/tools/check-powers.mjs
T
slaguru666andClaude Opus 5 1a2ca44c52 R-284: report a wrong figure as a wrong figure, and read the right entry
R-283 left the intact-sentence-wrong-number case falling through to omission, so
the guard said BESTIARY "does not state" a rating the page was stating. reads()
now has a shape tier between strict and loose: the strict pattern with its value
slot loosened, reporting which of the two numbers moved.

Scoped the per-creature rules while adding it. They read the whole page, and each
is the only rule of its kind today, so a page-wide match found the right line by
luck; a second attackFactor creature would have let the courier's rule match that
creature's sentence and report the courier correct. They now read the creature's
own "### Name" entry -- proved by deleting the courier's line and planting an
identical one under the redcap: still omission, where before it would have passed.

Five discriminations plus the decoy, proved in a worktree with the message read.

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

287 lines
15 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] ?? "";
/* MATCHING PROSE HAS ONE BAD FAILURE MODE, and it is the failure this suite keeps finding
everywhere else: a guard that reports the wrong thing confidently. Reword the page and a
literal `includes` says the document never makes the claim — when the claim is sitting
there and only the wording moved. A maintainer then goes looking for a missing sentence
that exists, and the true problem (a pattern that has quietly stopped reading) is the one
thing the message does not mention.
So each textual rule reads the page twice. STRICT is the sentence as it stands, tight
enough that a changed word fails rather than passing on a substring — `**Name is not on
this ladder at all.**` with its bold and its full stop, not the bare clause. LOOSE is the
same claim in any wording: the subject and the idea inside one sentence. Three outcomes,
three different messages:
strict matches the page says it, in the form this guard reads pass
loose only the page still says it; this guard cannot read it REWORD
neither the page does not make the claim OMISSION
Only the last is a defect in the document. The middle one is a decision for whoever
changed the prose — restore the wording, or move the pattern — and reporting it as the
last would send them to fix a page that is already right. */
const rx = t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const sentencesIn = (text, needles) =>
text.split(/\n+|(?<=\.)[ \t]+/).filter(s => needles.every(n => s.includes(n)));
const quote = s => s.trim().replace(/\s+/g, " ").slice(0, 120);
/* A per-creature rule must read that creature's entry, not the page. Every rule here
happens to be the only one of its kind today, so a page-wide match finds the right line
by luck; wire a second attackFactor creature and the courier's rule would match ITS
sentence and report the courier correct. Entries are `### Name` and run to the next
heading. */
const entryOf = name => {
const at = page.indexOf(`### ${name}\n`);
if (at < 0) return null;
const rest = page.slice(at);
const end = rest.slice(1).search(/\n#{2,3} /);
return end < 0 ? rest : rest.slice(0, end + 1);
};
/** strict -> [] ; right shape, wrong value -> `shape.mismatch` ; loose only -> a reword
problem naming the line ; neither -> `omission`.
`loose` is {has, not}: the needles the claim must carry AND the ones that mark a line
as something else. The exclusion is not fussiness. The courier's factored-attack
sentence prints 40% and 80%, and so does its own statblock line — `**Attacks with**
Grasping limb at 40% ... **dodges** 80%` — so deleting the sentence outright first
reported a REWORD and quoted the statblock back as the surviving claim. The message was
confident and wrong, which is the exact defect the two-tier read exists to prevent, one
level down. Excluding the statblock's own generated marker makes the loose test about
the claim rather than about the digits; reword that marker too and this degrades to
OMISSION, which is the safe direction to fail. */
const reads = ({ who, where, strict, shape, loose, still, omission }) => {
const text = where ?? page;
if (strict.test(text)) return [];
/* THE SENTENCE IS INTACT AND THE NUMBER INSIDE IT IS WRONG. Without this tier that case
fell through to `omission`, which said the page "does not state" a rating the page was
stating — the right figures were named, so it pointed at the fix, but it described a
document that does not exist. `shape` is the strict pattern with the value slot
loosened, so the guard can tell a figure that moved from a sentence that went. */
const m = shape && text.match(shape.re);
if (m) return [shape.mismatch(m)];
const has = Array.isArray(loose) ? loose : loose.has;
const not = Array.isArray(loose) ? [] : (loose.not ?? []);
const near = sentencesIn(text, has).filter(s => !not.some(n => s.includes(n)));
if (!near.length) return [omission];
return [` ${who}: BESTIARY still ${still}, but no longer in the form this guard reads, so `
+ `nothing checks it any more. The line it found: "${quote(near[0])}". The page may well `
+ `be right — this is a reword, not a missing claim. Restore the wording or update the `
+ `pattern in DOCUMENT_RULES. A guard that has silently stopped reading its own sentence `
+ `is worse than one that never read it, because the build stays green.`];
};
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.`);
}
out.push(...reads({
who: spec.key,
strict: new RegExp(`\\*\\*${rx(spec.name)} is not on this ladder at all\\.\\*\\*`),
loose: [spec.name, "ladder"],
still: `mentions ${spec.name} and the ladder in one sentence`,
omission: ` ${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) {
const near = sentencesIn(page, ["spend defences"]);
out.push(near.length
? ` BESTIARY still talks about creatures that spend defences — "${quote(near[0])}" — but `
+ `not in the counted form "Across N creatures that spend defences", so the number is `
+ `no longer reconciled against powers.mjs. This is a reword: fix the wording or the `
+ `pattern, not the count.`
: ` 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);
const entry = entryOf(spec.name);
if (!entry) return [` ${spec.key}: BESTIARY has no "### ${spec.name}" entry to check`];
return reads({
who: spec.key,
where: entry,
strict: new RegExp(`\\*\\*${effective}%\\*\\*, not ${arm.rating}%`),
shape: {
re: /\*\*(\d+)%\*\*, not (\d+)%/,
mismatch: ([line, printed, base]) =>
` ${spec.key}: BESTIARY prints "${line}" and the simulator fights it at `
+ `${effective}% of ${arm.rating}% — ${Number(printed) !== effective ? `the effective `
+ `rating is wrong (${printed}%, should be ${effective}%)` : `the rating it is taken `
+ `from is wrong (${base}%, should be ${arm.rating}%)`}. A figure the page states `
+ `and the harness contradicts is worse than one it omits: a GM can act on it.`
},
loose: { has: [`${effective}%`, `${arm.rating}%`], not: ["**Attacks with**"] },
still: `prints ${effective}% and ${arm.rating}% together`,
omission: ` ${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`);