diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 9d3d435..bf5ed34 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6637,3 +6637,28 @@ digits; reword the statblock too and this degrades to OMISSION, the safe directi 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. + +## R-284 — the third tier: a figure that moved is not a sentence that went + +R-283 left a case falling through to the omission branch: the sentence intact, the number +inside it wrong. The message said BESTIARY "does not state the rating the simulator actually +uses" about a page that was stating one. It named the right figures, so it pointed at the +fix, but it described a document that does not exist — the same class of confidently-wrong +report R-283 was written to remove, one case further in. + +`reads` now runs four tiers: **strict** (the sentence as it stands) passes; **shape** — the +strict pattern with its value slot loosened — reports a MISMATCH and says which of the two +numbers moved; **loose** reports a REWORD; nothing reports the OMISSION. The mismatch text +is the sharpest of the four on purpose: *a figure the page states and the harness +contradicts is worse than one it omits, because a GM can act on it.* + +**Scoped while adding it.** The rules read `page`, and every one of them is the only rule of +its kind today, so a page-wide match found the right line by luck. Wire a second +`attackFactor` creature and the courier's rule would have matched that creature's sentence +and reported the courier correct — a guard passing on someone else's evidence. Per-creature +rules now read the creature's own `### Name` entry. Proved by deleting the courier's line and +planting an identical one in the redcap's entry: still OMISSION, where before scoping it +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. diff --git a/tools/check-powers.mjs b/tools/check-powers.mjs index 2640e3d..f8a1469 100644 --- a/tools/check-powers.mjs +++ b/tools/check-powers.mjs @@ -137,11 +137,25 @@ if (!page) { 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 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); - /** strict -> [] ; loose only -> a reword problem naming the line ; neither -> `omission`. + /* 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** @@ -151,11 +165,19 @@ if (!page) { 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 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 = sentencesWith(...has).filter(s => !not.some(n => s.includes(n))); + 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 ` @@ -187,7 +209,7 @@ if (!page) { 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 = sentencesWith("spend defences"); + 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 ` @@ -208,9 +230,21 @@ if (!page) { 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 `