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>
287 lines
15 KiB
JavaScript
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`);
|