R-286: one cast declaration, and a mention of one is not a declaration

CLEAN_GROUND.md contains two things matching <!-- cast: ... -->: the real
declaration, and the warning ten lines below it that quotes the marker inside
backticks and matches with an empty capture. check-rollable and declared-cast both
took .match(), first hit wins, so the arrangement has been correct only because the
declaration comes first.

Move that warning above the list and check-rollable reads a cast of nobody, falls
back to ROSTER_BEST, and prints the same OK line having held every skill in the
document to the full duty roster instead of the declared six. Instrumented and read
off: "the duty roster" against "its declared cast of 6".

castMarkersIn blanks code spans and fenced blocks before scanning (padded, so line
numbers still point at the source) and refuses more than one surviving marker,
naming every line. check-rollable imports it instead of carrying a second regex, so
the guard that checks the cast and the guards that measure the fight cannot
disagree about who is in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 15:30:36 +01:00
co-authored by Claude Opus 5
parent ecbad45fc6
commit 0453255ecc
2 changed files with 47 additions and 7 deletions
+14 -3
View File
@@ -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(/<!--\s*cast:\s*([^>]*?)\s*-->/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`);
+33 -4
View File
@@ -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 —
`<!-- cast: -->` 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(/<!--\s*cast:\s*([^>]*?)\s*-->/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(/<!--\s*cast:\s*([^>]*?)\s*-->/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 `
+ `<!-- cast: ... --> 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. */