From 67e76639f39e9961a159a66717fdab0c177660b9 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:22:04 +0100 Subject: [PATCH] R-285: scope the defence-stacking rules to the section that owns them R-284 scoped the factored-rating rule to the creature's entry and left the defence-stacking rules matching anywhere on the page, so the exemption sentence and the "Across N creatures that spend defences" count were accepted wherever they happened to sit. Moving the exemption line out of its section into the redcap's statblock -- the page silent exactly where a GM reads the stripping advice -- passes at R-284 and fails here; confirmed by running HEAD's copy against the same tree. The owning slice is not always the creature's entry. A factored rating belongs to the creature; the ladder exemption is an answer to the paragraph it sits in and is generated into "## Shooting at something that moves", so scoping that rule to "### Redcap" would have failed a correct page. sliceOf now takes a heading at any level and each rule names the slice that owns its claim. A renamed section fails by name rather than scoping to nothing, which is the shape of every guard that passes because it found nothing to check. Co-Authored-By: Claude Opus 5 --- docs/REVIEW_LOG.md | 28 ++++++++++++++++++++++++++++ tools/check-powers.mjs | 39 +++++++++++++++++++++++++++------------ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index bf5ed34..5bdb7a1 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6662,3 +6662,31 @@ would have passed. Five cases proved in a worktree, message read in each: effective rating wrong, base rating wrong, both wrong, reworded with the numbers kept, deleted. Plus the decoy above. + +## R-285 — the ladder rules read the ladder, and the owning slice is not always the entry + +R-284 scoped the factored-rating rule to `### The Courier` and left the defence-stacking +rules reading the whole page. Same latent defect, other half of the file: the exemption +sentence and the "Across N creatures that spend defences" count were matched anywhere in +`docs/BESTIARY.md`, so the guard would accept the claim wherever it happened to sit. + +Proved rather than assumed. Moving the exemption sentence out of its section and into the +redcap's statblock entry — the page now silent exactly where a GM reads the stripping advice +— **passes at R-284** and fails after this change. Same for the counted line. + +**The owning slice is not the creature's entry, and assuming it was would have broken a +correct page.** A factored rating belongs to the creature: `### The Courier` owns it. The +ladder exemption does not — "stripping is not a plan against it" is an answer to the +paragraph it sits in, and the sentence is generated into `## Shooting at something that +moves`. Scoping that rule to `### Redcap` would have failed a document that is right. So +`sliceOf` takes a heading at any level and returns it down to the next heading of the same +or higher level, and each rule names the slice that owns its claim. + +Renaming the section itself now fails by name — *BESTIARY has no "## Shooting at something +that moves" section, which is where the defence-stacking rules are checked* — rather than +silently scoping to nothing, which is the shape of every guard that passes because it found +nothing to check. + +Five cases in a worktree: two decoys (fail here, pass at R-284, the pass confirmed by running +HEAD's copy against the same tree), a reword inside the section, the renamed heading, and the +clean control. diff --git a/tools/check-powers.mjs b/tools/check-powers.mjs index f8a1469..f6b78e1 100644 --- a/tools/check-powers.mjs +++ b/tools/check-powers.mjs @@ -114,7 +114,6 @@ 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 @@ -141,19 +140,28 @@ if (!page) { 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`); + /* 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(/\n#{2,3} /); + 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 @@ -186,6 +194,11 @@ if (!page) { + `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 @@ -197,8 +210,10 @@ if (!page) { + `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`, @@ -207,9 +222,9 @@ if (!page) { + `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/); + const m = ladder.match(/Across \*{0,2}(\d+)\*{0,2} creatures that spend defences/); if (!m) { - const near = sentencesIn(page, ["spend defences"]); + 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 ` @@ -230,7 +245,7 @@ if (!page) { const arm = c.arms?.[0]; if (!arm) return []; const effective = Math.floor(arm.rating * effect.attackFactor); - const entry = entryOf(spec.name); + const entry = sliceOf(`### ${spec.name}`); if (!entry) return [` ${spec.key}: BESTIARY has no "### ${spec.name}" entry to check`]; return reads({ who: spec.key,