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>
166 lines
8.4 KiB
JavaScript
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`);
|