diff --git a/tools/check-rollable.mjs b/tools/check-rollable.mjs index d18d49e..fcb909b 100644 --- a/tools/check-rollable.mjs +++ b/tools/check-rollable.mjs @@ -39,6 +39,7 @@ import { SKILL_CATALOGUE } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; import { expandFromRegister } from "./expand-spec.mjs"; import { scenarioFiles, scenarioText, tagsIn, skillsIn } from "./check-scenarios.mjs"; +import { castMarkersIn } from "./declared-cast.mjs"; /** * The lowest chance that still counts as a route, in percent. @@ -92,10 +93,20 @@ for (const [label, path] of scenarioFiles()) { const raw = readFileSync(path, "utf8"); const text = scenarioText(raw); - // Read the declaration off the RAW text: scenarioText strips HTML, comments included. - const declared = raw.match(//i); + /* Read the declaration off the RAW text: scenarioText strips HTML, comments included. + Through castMarkersIn rather than a regex of its own, so this guard and the one that + measures the fight cannot disagree about who is cast — and so a marker mentioned in + prose is not read as a second declaration. CLEAN_GROUND.md carries one of each. */ + const markers = castMarkersIn(raw); + if (markers.length > 1) { + problems.push(`${label}: declares a cast ${markers.length} times, at lines ` + + `${markers.map(m => m.line).join(", ")}. This guard read the first and held every ` + + `skill in the document to it; the others were never seen. Keep one declaration and ` + + `put any example inside backticks, where the reader ignores it.`); + } + const declared = markers[0]; const cast = declared - ? declared[1].split(",").map(x => x.trim()).filter(Boolean).map(k => { + ? declared.keys.split(",").map(x => x.trim()).filter(Boolean).map(k => { const spec = ROSTER.find(r => r.key === k); if (!spec) { problems.push(`${label}: cast declares "${k}", which is not on the duty roster`); diff --git a/tools/declared-cast.mjs b/tools/declared-cast.mjs index 7fa199e..10ea0f7 100644 --- a/tools/declared-cast.mjs +++ b/tools/declared-cast.mjs @@ -27,6 +27,27 @@ export const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md"); 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; +export function castMarkersIn(text) { + const scannable = text.replace(CODE, m => " ".repeat(m.length)); + return [...scannable.matchAll(//gi)] + .map(m => ({ keys: m[1], line: text.slice(0, m.index).split("\n").length })); +} + /** The six the document declares, without their `pc_` prefixes. Fatal if it declares none. */ export function declaredCast(who = "scenario-cast") { if (!existsSync(SCENARIO)) { @@ -34,14 +55,22 @@ export function declaredCast(who = "scenario-cast") { + `from it. Nothing here can be measured against a scenario that is not there.`); process.exit(1); } - const m = readFileSync(SCENARIO, "utf8").match(//i); - if (!m) { - console.error(`${who}: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the ` + 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); } - return m[1].split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean); + 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. */