Files
RingBRP/tools/check-handouts.mjs
T
slaguru666andClaude Opus 5 9f3a63174f CLEAN GROUND v0.12: convention one-shot, the print pack, and the real opposed roll
Tim settled the last two questions: convention one-shot, and rules.mjs gains a
real opposed roll (landed by a peer session at R-273). Both decisions change
the document, and the one-shot unblocked the print pack.

THE PRINT PACK — docs/scenarios/CLEAN_GROUND_HANDOUTS.html, guard 17.

Four A4 sheets, self-contained, no external fonts or assets: H01 the coroner's
note on white, H02 the 1962 committee minute on cream with photocopy grain,
H03a and H03b the almanac on ruled paper. 11pt floor throughout.

The binding constraint is that H03a and H03b must print identically or the
almanac trick dies, so that is structural rather than careful: they share one
`.almanac` class and every dimension comes from a variable defined once. There
is no selector anywhere that names one page and not the other, and
check-handouts fails the build if one appears, if their markup structures
diverge, if their columns differ, if any row stops reading "41 mi", if the
counts stop being forty-one now against fifty-three then, or if the pack and
the scenario drift apart.

It earned itself immediately: its first run failed my own pack for five rules
at 10.5pt, under the house 11pt floor. Rendering was checked visually too,
which caught two things no guard would have — the "TO CLEAN GROUND" header
colliding with NOTES, and the writing crossing the red margin rule instead of
starting right of it.

THE ONE-SHOT. Countdown step 6 said "and this is a campaign", which the
decision contradicts. Rewritten, and the Close's "leave it filed" ending now
says how to land it tonight: do not end on "you'll be back", because the table
never will and a hook they cannot take reads as an unfinished scenario. Name
the next agent who gets sent, and have Registry thank them.

THE OPPOSED ROLL. The scenario-local ruling is deleted; GM ESSENTIALS points
at the game. Both beats were re-priced against the real rule by enumerating
all 10,000 roll pairs -- exact, no seeds -- and two things fell out:

  THE OFFER IS PRICED. Ashcroft refusing is taken 21.9% of the time, the
  safest file at the table. Accepting: 48.7%, past Braithwaite's 37.6%.
  Accepting does not make him a bit more vulnerable, it makes him the easiest
  person in the room, and the countdown reaches for the easiest.

  STEP 5 CAN COME TO NOTHING, 14.5% of the time against an Ashcroft who
  accepted, and that row fires once and is marked permanent. Previously a
  silent gap in a climactic beat. It is now a written outcome with read-aloud
  text: the reach fails, it wears the wrong face for a moment, the party learns
  what it is and cannot prove it, and the clock still turns to step 6.

Also: update-readme's count-word list ran out at sixteen, one guard after its
own comment warned about hardcoded lists going stale. It failed loudly rather
than silently, so it is an inconvenience and not a defect. Extended.

npm run check: 17 guards, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:50:03 +01:00

166 lines
8.4 KiB
JavaScript

/**
* The almanac trick only works if the two pages are indistinguishable.
*
* CLEAN GROUND's print guidance says H03a and H03b "must be the same hand on the same
* ruled paper — that is the whole point", and the scenario is blunt about the cost of
* getting it wrong: the trick dies. It dies silently, too. Nobody proof-reads a handout
* pack against itself; they print it, hand it over, and find out at the table that one
* page is a millimetre narrower than the other and the players are looking at the
* formatting instead of the numbers.
*
* So this checks the pack the way check-cited checks a citation: the claim in the file is
* that the two almanacs share one class and one set of variables, and here that claim is
* tested rather than trusted.
*
* node tools/check-handouts.mjs check the pack
* node tools/check-handouts.mjs --list print what it found
*
* IT ALSO CHECKS THE PACK AGAINST THE SCENARIO. The handout text lives in two places —
* the markdown a GM reads and the HTML they print — which is exactly the arrangement
* this repo keeps being caught by. Every almanac row must appear in both.
*/
import { readFileSync, existsSync } 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")), "..");
const PACK = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND_HANDOUTS.html");
const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md");
const LIST = process.argv.slice(2).includes("--list");
/* Body copy floor. Convention halls are dim and eyes are tired; the house print checklist
puts handout text at 11pt minimum and this is where that stops being a good intention. */
const MIN_PT = 11;
if (!existsSync(PACK)) {
console.error(`check-handouts: FAILED — no handout pack at docs/scenarios/CLEAN_GROUND_HANDOUTS.html. `
+ `The scenario's STATUS block promises one.`);
process.exit(1);
}
const html = readFileSync(PACK, "utf8");
const md = readFileSync(SCENARIO, "utf8");
const problems = [];
const notes = [];
/* ── 1. The two almanac sheets must be built the same way ─────────────────────────── */
const sections = [...html.matchAll(/<section class="([^"]*)"[^>]*>([\s\S]*?)<\/section>/g)]
.map(m => ({ cls: m[1], body: m[2] }));
const almanacs = sections.filter(s => s.cls.includes("sheet--almanac"));
if (almanacs.length !== 2) {
problems.push(` expected exactly two almanac sheets (H03a and H03b), found ${almanacs.length}`);
} else {
const [a, b] = almanacs;
if (a.cls !== b.cls) {
problems.push(` the two almanac sheets carry different classes — "${a.cls}" and "${b.cls}". `
+ `They must be styled by the same selectors or they will not print the same.`);
}
/* Structure means everything except the words: tags, classes and column definitions.
Strip the text content and the two pages must be byte-identical. */
const skeleton = s => s
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/>[^<]*</g, "><")
.replace(/\s+/g, "");
if (skeleton(a.body) !== skeleton(b.body)) {
problems.push(` the two almanac sheets have different MARKUP STRUCTURE, ignoring their text. `
+ `One has a tag, class or column the other does not, so they cannot print identically. `
+ `They must differ only in their dates and figures.`);
}
const cols = s => (s.match(/<col class="[^"]*">/g) ?? []).join("");
if (cols(a.body) !== cols(b.body)) {
problems.push(` the two almanac sheets define different columns, so the "41 mi" column will `
+ `not line up across the pair. That column lining up IS the reveal.`);
}
notes.push(`two almanac sheets, identical markup structure and columns`);
}
/* A selector that names only one of them is the failure this file is built to prevent —
the moment one page can be styled without the other, the trick is one edit from dead. */
const css = (html.match(/<style>([\s\S]*?)<\/style>/) ?? [, ""])[1].replace(/\/\*[\s\S]*?\*\//g, "");
const onlyOne = [...css.matchAll(/(^|[\s,{}])([.#][\w-]*(?:h03[ab]|almanac-[ab])[\w-]*)/gi)].map(m => m[2]);
if (onlyOne.length) {
problems.push(` ${onlyOne.length} CSS selector(s) name one almanac page and not the other: `
+ `${[...new Set(onlyOne)].join(", ")}. Style both through .almanac and the shared variables, `
+ `or the pages will drift apart the first time one is adjusted.`);
}
/* ── 2. The printed pack must agree with the scenario a GM reads ──────────────────── */
const rowsOf = body => [...body.matchAll(/<tr><td>([\s\S]*?)<\/tr>/g)]
.map(m => m[0].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
for (const [i, sheet] of almanacs.entries()) {
const id = ["H03a", "H03b"][i] ?? `sheet ${i + 1}`;
for (const row of rowsOf(sheet.body)) {
const cells = row.split(" ").filter(Boolean);
const day = cells[0];
/* The reveal, checked literally: every line of both pages says 41 miles to clean
ground, and a row that lost it would be a handout that no longer works. */
if (!/\b41 mi\b/.test(row)) {
problems.push(` ${id}: the ${day} row does not read "41 mi" to clean ground. That column `
+ `never moving is the whole reveal and no roll is required to see it.`);
}
/* Each row's notes text must appear in the scenario's own copy of the handout. */
const note = row.match(/41 mi (.+)$/)?.[1]?.trim();
if (note && !md.includes(note)) {
problems.push(` ${id}: the ${day} row reads "${note}", which does not appear in `
+ `CLEAN_GROUND.md. The pack and the scenario have drifted; fix whichever is wrong.`);
}
}
}
/* The two counts are the cost of the loop, and swapping them would invert the finding. */
const counts = almanacs.map(s => s.body.match(/Counted this morning:\s*([\w-]+)/)?.[1]);
if (counts[0] !== "forty-one" || counts[1] !== "fifty-three") {
problems.push(` the almanac counts read ${counts.join(" then ")}; H03a (now) must be `
+ `forty-one and H03b (eleven years earlier) fifty-three. Twelve people died in between `
+ `and the order of those two numbers is the point.`);
} else {
notes.push(`counts forty-one now against fifty-three then`);
}
/* ── 3. Nothing prints below the floor ────────────────────────────────────────────── */
const small = [...css.matchAll(/font-size:\s*([\d.]+)pt/g)]
.map(m => Number(m[1]))
.filter(pt => pt < MIN_PT);
/* The slug is deliberately tiny: it is a GM's filing label in the corner, not body copy,
and it is not read by a player at a table. Everything else is held to the floor. */
const slugPt = Number((css.match(/\.slug\s*\{[^}]*font-size:\s*([\d.]+)pt/) ?? [])[1] ?? NaN);
const tooSmall = small.filter(pt => pt !== slugPt);
if (tooSmall.length) {
problems.push(` ${tooSmall.length} rule(s) set body text below ${MIN_PT}pt (${[...new Set(tooSmall)].join(", ")}pt). `
+ `Convention halls are dim; the print checklist puts handout text at ${MIN_PT}pt minimum.`);
} else {
notes.push(`nothing below ${MIN_PT}pt but the GM's corner slug`);
}
/* ── 4. Every handout the scenario promises is actually in the pack ───────────────── */
const promised = [...md.matchAll(/^\| (CG-H\d+[ab]?) \|/gm)].map(m => m[1]);
const present = [...html.matchAll(/<b>(CG-H\d+[ab]?)<\/b>/g)].map(m => m[1]);
const missing = promised.filter(id => !present.includes(id));
const extra = present.filter(id => !promised.includes(id));
if (missing.length) {
problems.push(` the scenario's handout table promises ${missing.join(", ")}, which the pack does not print.`);
}
if (extra.length) {
problems.push(` the pack prints ${extra.join(", ")}, which the scenario's handout table does not list.`);
}
if (promised.length && !missing.length && !extra.length) {
notes.push(`all ${promised.length} promised handouts present (${promised.join(", ")})`);
}
if (LIST) {
notes.forEach(n => console.log(` ${n}`));
problems.forEach(p => console.log(p));
process.exit(0);
}
if (problems.length) {
console.error("check-handouts: FAILED — the print pack no longer does what the scenario says it does");
problems.forEach(p => console.error(p));
process.exit(1);
}
console.log(`check-handouts: OK — ${present.length} handouts, the two almanac pages identical in `
+ `structure and columns, every line 41 miles from clean ground, nothing under ${MIN_PT}pt`);