From 327d060eb1a5ed651f4809d4630ba1d6976f1a39 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:12:33 +0100 Subject: [PATCH] R-283: tell a reworded bestiary sentence apart from a missing one R-282 matched the page with String.includes, so rewording the exemption sentence reported "BESTIARY never says it is off the stripping ladder" -- sending a maintainer after a sentence that is sitting right there, and never naming the real problem, which is a pattern that has silently stopped reading. Each textual rule now reads twice. Strict is the sentence as it stands and is tighter than before (the bold and the full stop, not the bare clause a substring accepted); loose is the same claim in any wording. Strict passes, loose-only is reported as a reword with the line quoted and the page presumed right, neither is the omission. The loose anchor was wrong on its first pass in the way that matters: "a sentence with 40% and 80%" also matched the courier's own statblock line, so deleting the sentence reported a reword and quoted the statblock back. It now excludes that generated marker, which makes it a test of the claim and not of the digits, and degrades to omission rather than to a false reword. Four discriminations proved in a worktree with the message read in each. Co-Authored-By: Claude Opus 5 --- docs/REVIEW_LOG.md | 27 ++++++++++++++ tools/check-powers.mjs | 83 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 262e51c..9d3d435 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6610,3 +6610,30 @@ new `reachBonus` effect fails for having no page rule. Exit codes read directly. assertions are textual — they check that the page says a thing, not that what it says is true. A `damageFactor` rule is registered as a no-op because nothing wires one today, and it will need writing when something does; the guard will demand it, but it cannot write it. + +## R-283 — a reworded sentence and a missing one are different failures + +R-282 checked the bestiary's prose with `String.includes`, which has one bad failure and it +is the one this suite keeps finding elsewhere: **a guard that reports the wrong thing +confidently.** Reword the exemption sentence and the message read "BESTIARY never says it is +off the stripping ladder" — sending a maintainer to look for a sentence that is sitting +right there, while the real problem (a pattern that has quietly stopped reading) goes +unmentioned. + +Each textual rule now reads the page twice. **Strict** is the sentence as it stands, and +tighter than before: `**Name is not on this ladder at all.**` with its bold and its full +stop, not the bare clause a substring would have accepted. **Loose** is the same claim in +any wording. Three outcomes, three messages — strict passes; loose-only is named a REWORD, +quotes the line it found, and says the page may well be right; neither is the omission. + +**The loose test was itself wrong first, and the way it was wrong is the point.** Anchoring +the courier's rule on "a sentence containing 40% and 80%" matched its own statblock line — +`**Attacks with** Grasping limb at 40% ... **dodges** 80%` — so *deleting* the sentence +outright reported a reword and quoted the statblock back as the surviving claim. Confident +and wrong, one level below the defect the two-tier read exists to prevent. The loose anchor +now excludes that generated marker, which makes it a test of the claim rather than of the +digits; reword the statblock too and this degrades to OMISSION, the safe direction. + +Four discriminations proved in a worktree, message read in each: reword kept → REWORD; +line deleted → OMISSION; wrong number, right wording → mismatch naming both figures; redcap +back on the ladder → the R-282 contradiction, unchanged. diff --git a/tools/check-powers.mjs b/tools/check-powers.mjs index 4d387c0..2640e3d 100644 --- a/tools/check-powers.mjs +++ b/tools/check-powers.mjs @@ -116,6 +116,54 @@ if (!page) { 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 SENTENCES = page.split(/\n+|(?<=\.)[ \t]+/); + const sentencesWith = (...needles) => SENTENCES.filter(s => needles.every(n => s.includes(n))); + const quote = s => s.trim().replace(/\s+/g, " ").slice(0, 120); + + /** strict -> [] ; 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, strict, loose, still, omission }) => { + if (strict.test(page)) return []; + const has = Array.isArray(loose) ? loose : loose.has; + const not = Array.isArray(loose) ? [] : (loose.not ?? []); + const near = sentencesWith(...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 @@ -127,14 +175,26 @@ 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 (!page.includes(`${spec.name} is not on this ladder at all`)) { - out.push(` ${spec.key}: BESTIARY never says it is off the stripping ladder. A reader ` + 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.`); - } + + `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) out.push(` BESTIARY no longer says how many creatures spend defences at all`); + if (!m) { + const near = sentencesWith("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 ` @@ -148,10 +208,15 @@ if (!page) { const arm = c.arms?.[0]; if (!arm) return []; const effective = Math.floor(arm.rating * effect.attackFactor); - return page.includes(`**${effective}%**, not ${arm.rating}%`) ? [] - : [` ${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.`]; + return reads({ + who: spec.key, + strict: new RegExp(`\\*\\*${effective}%\\*\\*, not ${arm.rating}%`), + 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: () => [] };