/** * CLEAN GROUND's declared cast, read from the document rather than copied out of it. * * Two guards now measure that scenario's own encounter — check-firstblood and * check-attackers — and both must be measuring the people the document actually casts. * A cast written out by hand in either of them would go on measuring the old six after a * re-cast and report success, which is the defect this project keeps finding in itself. * R-267 committed exactly that inside the guard built to prevent a weaker version of it. * * One reader rather than two, because two copies of this would be the duplication * check-rules exists to forbid, and would let the two guards disagree about who is in the * fight. check-rollable established the `` marker; this reads the same * one and asks nothing further of the document. * * NOT named scenario-something: check-scenarios sweeps `tools/scenario-*` into the * scenario corpus, so a file with that prefix has its prose read as a scenario's and the * marker quoted below read as a real cast declaration. It was, and check-rollable said so. */ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); export const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md"); /* The scaling note drops Pollard at five and Okonkwo as well at four, so the cut is the declared cast less those two. Named rather than sliced: "the first four" would survive a reordering of the cast list and quietly measure different people. */ const DROPPED_AT_FOUR = ["pollard", "okonkwo"]; /* A DECLARATION AND A MENTION OF ONE LOOK IDENTICAL TO A REGEX. CLEAN_GROUND.md carries the real marker under "## Casting" and, ten lines later, the warning that explains it — `` inside backticks, which matches this pattern with an empty capture. Both readers took `.match()`, the FIRST hit, so the whole arrangement worked because the declaration happens to come first. Move the warning above the list, or add a second worked example, and the guards read a cast of nobody without saying so; check-firstblood and check-attackers would then measure a fight this scenario does not cast, which is the defect declared-cast.mjs was written to prevent, arriving through the door it left open. R-268 already had this exact bug from the other side, when a tools file's own example was read as a real declaration. So: code spans and fenced blocks are blanked before scanning (padded, so line numbers still point at the source), and more than one surviving marker is fatal rather than first-wins. */ const CODE = /```[\s\S]*?```|`[^`\n]*`/g; const scannable = text => text.replace(CODE, m => " ".repeat(m.length)); const lineAt = (text, i) => text.slice(0, i).split("\n").length; /* `cast\s*:` rather than `cast:`. Tested on strings rather than by editing the scenario, and the spacing is why: `` was invisible — no marker, so check-rollable fell back to the whole duty roster and went green, and R-288 could not catch it because there was nothing present to be empty. Putting a space before a colon is not an exotic mistake. It is the check-cited defect exactly, where `Cite:`, `CITE:` and `cite :` all failed open while the guard reported a count identical to a clean tree. */ export function castMarkersIn(text) { return [...scannable(text).matchAll(//gi)] .map(m => ({ keys: m[1], line: lineAt(text, m.index) })); } /* ANYTHING a person might have meant as a cast declaration, however spelled. Deliberately ASYMMETRIC with the reader above: the reader accepts what the format specifies, this recognises anything cast-shaped, and the difference is reported rather than skipped. Widening `castMarkersIn` fixes the spelling we thought of; this is what names the one we did not. A marker nothing reads is worse than no marker, because whoever wrote it believes the cast is declared. `cast\w*` catches casting/caster too, so an ordinary comment near the casting section can trip it. That is the intended direction: a false alarm costs a reword, and the message says a comment meant as prose belongs in backticks or a fence, where nothing here looks at it. The opposite error costs a guard that checks the wrong six people and says OK. */ export function castLikeIn(text) { const text2 = scannable(text); const covered = [...text2.matchAll(//gi)] .map(m => [m.index, m.index + m[0].length]); return [...text2.matchAll(//gi)] .filter(m => !covered.some(([a, b]) => m.index >= a && m.index < b)) .map(m => ({ raw: m[0].trim().slice(0, 80), line: lineAt(text, m.index) })); } /** The six the document declares, without their `pc_` prefixes. Fatal if it declares none. */ export function declaredCast(who = "scenario-cast") { if (!existsSync(SCENARIO)) { console.error(`${who}: ${path.relative(ROOT, SCENARIO)} is missing, and the cast is read ` + `from it. Nothing here can be measured against a scenario that is not there.`); process.exit(1); } const rel = path.relative(ROOT, SCENARIO); const markers = castMarkersIn(readFileSync(SCENARIO, "utf8")); if (!markers.length) { console.error(`${who}: ${rel} declares no cast. Add the ` + ` marker check-rollable already reads, or this guard is measuring ` + `a fight the scenario no longer casts.`); process.exit(1); } if (markers.length > 1) { console.error(`${who}: ${rel} carries ${markers.length} cast markers, at lines ` + `${markers.map(m => m.line).join(", ")}. Reading the first one is how this guard would ` + `measure a fight the scenario does not cast while reporting success. One declaration; ` + `write any example inside backticks or a fenced block, where this reader ignores it.`); process.exit(1); } return markers[0].keys.split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean); } /** The declared cast and its four-player cut, checked for the shape the guards recorded. */ export function castAndCut(who = "scenario-cast") { const line = declaredCast(who); const cut = line.filter(k => !DROPPED_AT_FOUR.includes(k)); if (cut.length !== 4 || line.length !== 6) { console.error(`${who}: CLEAN GROUND now casts ${line.length} and its four-player cut works ` + `out at ${cut.length}. The recorded fights are six against six and four against three; ` + `re-read the scaling note and re-record with --update rather than reverting.`); process.exit(1); } return { line, cut }; }