Files
RingBRP/tools/check-powers.mjs
slaguru666andClaude Opus 5 f68b98190f bestiary + check-powers: read the seam, and classify what that reveals
bestiary.mjs built the document from NPCS, so it described 47 creatures of
147 while check-bestiary agreed with it — two things agreeing is not
verification when both read the same wrong source. It reads collectSpecs()
now: 184 creatures across 5 sections, every one with a measured lethality
figure, which only works because check-lethality was fixed first.

Three faults surfaced fixing it:

  - a second NPCS reference in focusParagraph()
  - which then broke on `rows`, because focusParagraph declares its own
    local `rows` for focus-fire data and shadowed the creature list. Keyed
    lookup now, so the shadowing cannot bite again.
  - the document grouped on the raw role tag against a list of four, and
    `if (!group.length) continue` DROPS an unlisted tag rather than
    mis-filing it. Nine of the twelve tags were absent, so those creatures
    would not have appeared at all. Twelve tags now map onto five sections,
    with a new "Beasts and heraldry", and a completeness assertion counts
    every creature into a section and fails if one is unplaced.

check-powers was the fifth consumer bypassing all-specs.mjs. It reconciled
its counts against BESTIARY while reading NPCS, so it called the document
wrong when the document was right.

Reading the real population then demanded 101 classifications. All 101 are
done and NOT ONE was wired. That is the right answer, not a shortfall:
the three available effects describe the creature — its own attack rating,
the damage it takes, whether it feels defence stacking — and essentially
every power is conditional on a place, a posture, a round count, a weapon
type, an attacker count, a named agent, or Coherence. R-275 records what
wiring one anyway costs: 25.1% to 75.2% on a single creature.

The near-misses are the evidence the reluctance was applied, not recited:
tangie's "HALF damage" is edged-only and ends in open flame; the_heron is
+30% or -20% depending on movement the harness does not have, so which
branch applies is undecidable; mirrorline's halving lands on an AGENT, not
on the creature, so wiring it would have protected the wrong combatant;
yale's defence waiver is capped at two attackers and its written counter is
a fourth agent, which defenceStacking:"ignores" cannot express.

Classifications live in four band files and are spread into POWERS last, so
a hand-written entry always wins a collision. 142 classified: 2 wired, 78
beyond the harness with a reason, 62 not fight rules.

All 23 guards pass.

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

340 lines
18 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 { collectSpecs } from "./all-specs.mjs";
/* Read the same list BESTIARY is generated from.
*
* This imported NPCS from content.mjs and reconciled its counts against docs/BESTIARY.md.
* That held only while the two had the same source. Once the bestiary moved into its own
* files, the document described 184 creatures and this guard checked 47 of them, so it
* reported the document wrong when the document was right — the fifth consumer found
* bypassing all-specs.mjs, after build-packs, bestiary, check-lethality and the art
* tools. A guard reconciling two numbers must read the population both of them describe. */
const NPCS = (await collectSpecs()).filter(r => r.kind === "npc").map(r => r.spec);
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\s*:/.test(n.tactics ?? ""));
/* A POWER THIS READER CANNOT SEE IS A POWER NOBODY REPORTS UNCLASSIFIED. The marker was the
literal `POWER:` in both this guard and bestiary.mjs, so `Power:` or `POWER :` generated no
entry line AND passed the classification check — R-275's defect with the guard written to
prevent it looking straight past it, and R-289's fail-open one file over.
Widening the reader fixes the spellings we thought of. This is the other half: anything
power-marker-shaped, case and spacing free, that the strict reader did not consume. It is
deliberately looser, so a creature whose tactics prose happens to say "power:" trips it and
has to be reworded — which costs a sentence, where the opposite error costs a power the
whole book measures without. Measured against content.mjs before wiring: 41 strict markers,
zero loose ones outside them, so this starts from a clean corpus rather than from an
exception list. */
{
const STRICT = /POWER\s*:/g, LOOSE = /\bpowers?\s*:/gi;
for (const n of NPCS) {
const t = String(n.tactics ?? "");
const spans = [...t.matchAll(STRICT)].map(m => [m.index, m.index + m[0].length]);
for (const m of t.matchAll(LOOSE)) {
if (spans.some(([a, b]) => m.index >= a && m.index < b)) continue;
problems.push(` ${n.key}: tactics carry "${m[0]}", which looks like a POWER marker and is `
+ `not one either reader can see — "…${t.slice(Math.max(0, m.index - 40), m.index + 30)
.replace(/\s+/g, " ")}…". Spelled this way it generates no bestiary line and is never `
+ `reported unclassified. The marker is \`POWER:\` in capitals; if the word is ordinary `
+ `prose here, reword it.`);
}
}
}
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));
/* 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 rule must read the part of the page that OWNS its claim, 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. `sliceOf` takes a heading and returns it plus
everything down to the next heading of the same or higher level. */
const sliceOf = heading => {
const at = page.indexOf(`${heading}\n`);
if (at < 0) return null;
const level = heading.match(/^#+/)[0].length;
const rest = page.slice(at);
const end = rest.slice(1).search(new RegExp(`\\n#{2,${level}} `));
return end < 0 ? rest : rest.slice(0, end + 1);
};
/* The owning slice is not always the creature's own entry, and assuming it was would have
been wrong here. A factored rating belongs to the creature, so `### The Courier` owns it.
The ladder exemption does NOT: it is advice about a section — "stripping is not a plan
against it" answers the paragraph it sits in — so `## Shooting at something that moves`
owns it, and scoping that rule to `### Redcap` would have failed a page that is correct. */
const LADDER_HEADING = "## Shooting at something that moves";
const ladder = sliceOf(LADDER_HEADING);
/** 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.`];
};
if (!ladder) problems.push(` BESTIARY has no "${LADDER_HEADING}" section, which is where the `
+ `defence-stacking rules are checked`);
const strippingList = ((ladder ?? "").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 (!ladder) return out;
out.push(...reads({
who: spec.key,
where: ladder,
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 = ladder.match(/Across \*{0,2}(\d+)\*{0,2} creatures that spend defences/);
if (!m) {
const near = sentencesIn(ladder, ["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 = sliceOf(`### ${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`);