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>
88 lines
4.8 KiB
JavaScript
88 lines
4.8 KiB
JavaScript
/**
|
|
* 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 `<!-- cast: ... -->` 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 —
|
|
`<!-- 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)) {
|
|
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 `
|
|
+ `<!-- cast: ... --> 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 };
|
|
}
|