/** * 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\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`);