From 9f3a63174f21d687b0a52884593f1490b9c1966f Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:50:03 +0100 Subject: [PATCH] CLEAN GROUND v0.12: convention one-shot, the print pack, and the real opposed roll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 11 +- docs/scenarios/CLEAN_GROUND.md | 100 ++++-- docs/scenarios/CLEAN_GROUND_HANDOUTS.html | 371 ++++++++++++++++++++++ package.json | 2 +- tools/check-handouts.mjs | 165 ++++++++++ tools/update-readme.mjs | 9 +- 6 files changed, 621 insertions(+), 37 deletions(-) create mode 100644 docs/scenarios/CLEAN_GROUND_HANDOUTS.html create mode 100644 tools/check-handouts.mjs diff --git a/README.md b/README.md index f8b5baa..23cb472 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ packs or regenerate art on that machine: ```bash npm install # pulls classic-level, used to write the LevelDB packs npm run build # rebuilds packs/ from tools/content.mjs -npm run check # the sixteen guards; the build refuses to run if they fail +npm run check # the seventeen guards; the build refuses to run if they fail ``` To update a deployed server: `git pull` and restart Foundry. If the pull touches @@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art packs/ built LevelDB compendia (committed — see Deploying) tools/ content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs - build-packs.mjs builds packs/ — runs the sixteen guards first and refuses on failure + build-packs.mjs builds packs/ — runs the seventeen guards first and refuses on failure make-icons.mjs draws all 231 icons rules-text.mjs generates the rules journal FROM rules.mjs mission.mjs the case generator @@ -178,11 +178,11 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a literal, or if an exported rule has no spot-check. The packs used to be built under one set of numbers and played under another; this makes that impossible to ship. -The build runs all sixteen guards before it writes anything: +The build runs all seventeen guards before it writes anything: ``` -check-rules: OK — 110 rules, 46 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified +check-rules: OK — 112 rules, 47 files scanned, 3 aliases + 46 constant sets checked, 413 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 1018 keys, no leaf/branch collisions check-templates: OK — 19 templates compile @@ -196,7 +196,8 @@ check-focus: OK — focus fire measured against 31 packs, helps in all of them, check-firstblood: OK — the first disabling blow is worth 40 points of wipe rate in the four-player cut, 15.4 in the six-a-side line check-attackers: OK — three of the four-player cut disable on 11.1% of their attacks or better, and one of them on 0.1% check-fight-tail: OK — a fight past 15 rounds wipes the party 1.4x as often in the four-player cut and 2.4x at six a side, and nothing was truncated -check-cited: OK — 23 cited figures across 13 scenario files resolve against 3 baselines +check-cited: OK — 25 cited figures across 13 scenario files resolve against 3 baselines +check-handouts: OK — 4 handouts, the two almanac pages identical in structure and columns, every line 41 miles from clean ground, nothing under 11pt check-bestiary: OK — 47 creatures, the document matches the game ``` diff --git a/docs/scenarios/CLEAN_GROUND.md b/docs/scenarios/CLEAN_GROUND.md index 9bf6fe1..3a7b1f0 100644 --- a/docs/scenarios/CLEAN_GROUND.md +++ b/docs/scenarios/CLEAN_GROUND.md @@ -9,10 +9,11 @@ | | | |---|---| -| **Version** | **v0.10**, 13 September 2026 | +| **Version** | **v0.12**, 13 September 2026 | | **Can it be run?** | **Yes, from this document alone.** A GM with this file and the compendium can run it end to end. | -| **Is it convention-ready?** | **No.** It has never been played by human beings, and there is no print pack. | +| **Is it convention-ready?** | **Nearly.** The print pack is built (`CLEAN_GROUND_HANDOUTS.html`, held by `check-handouts`). What is missing is a run with human beings, and a slot. | | **Playtesting** | Three desk passes, all fixes applied: `CLEAN_GROUND_PLAYTEST_1.md` (GM chair, seed 4271), `_2.md` (Act Four stress, seed 9314), `_3.md` (player chair, contrarian, seed 5580). Twenty-three fixes plus one post-pass. | +| **What it is** | A **convention one-shot**, decided 13 September 2026. It ends in the slot; there is no second session and the text must not promise one. | | **Runtime** | 4h00 including a break. Desk pass 1 ran 4:25; the **Pacing Note** carries ~25 minutes of pre-written cuts. Take them as the default. | | **Players** | 4–6 from the duty roster. The cast is declared in the Casting section and the build enforces it. | @@ -20,8 +21,8 @@ Read GM ESSENTIALS in full — this is only what a GM most often discovers too late. -1. **Opposed rolls use a scenario-local ruling**, because `rules.mjs` has none. GM - ESSENTIALS has it. Two beats depend on it. +1. **An opposed roll's tie goes to whoever is being acted upon**, which makes resisting + far stronger than the numbers look. GM ESSENTIALS has it. Two beats depend on it. 2. **Ashcroft cannot fight** — no weapon, no unarmed skill, a 1% attack. That is deliberate and his player must be told before the game. See his entry in Casting. 3. **The lethal encounter is the column, in Act Two** — not the monster in Act Four, which @@ -29,20 +30,23 @@ Read GM ESSENTIALS in full — this is only what a GM most often discovers too l ### Still to do -- **A print-ready A4 handout pack**, 11pt minimum. **CG-H03a and CG-H03b must print - identically** or the almanac trick dies. - **A run with human beings.** No desk pass can settle whether Act Four's spine holds 45 minutes with no mechanical threat, or whether THE OFFER plays as a decision. +- **A slot.** Contingency 2027 has room. That is the last thing between this and a print run. -### Two decisions that are not a scenario's to make +*The handout pack is done: `CLEAN_GROUND_HANDOUTS.html`, four A4 sheets, 11pt floor. +H03a and H03b share one CSS class and one set of variables so they cannot be styled apart, +and `check-handouts` fails the build if their markup ever diverges — that pair printing +identically is the almanac trick and there is now no way to break it quietly.* -- **Does `rules.mjs` gain a real opposed roll?** If it does, delete the local ruling. -- **Convention one-shot, or campaign opener?** The cancellation ending is a one-shot; the - "leave it filed" branch is a spine. This decides what finished means. +### Decisions, both now made -Those are the two that change the document. **Open Questions at the foot carries the full -list**, including the ones already settled, and is the only place decisions are recorded — -this block does not keep a second copy. +- **Convention one-shot**, 13 September 2026. +- **`rules.mjs` has a real opposed roll** (R-273), so the scenario-local ruling is deleted + and GM ESSENTIALS points at the game. + +**Open Questions at the foot carries the full list**, including the ones already settled, +and is the only place decisions are recorded — this block does not keep a second copy. **Source:** freely adapted from *A Hollow Tree* (Cathal Fitzpatrick, 2023, 23 min) — post-nuclear survivors walking north for clean ground, and a fast thing that wears @@ -58,13 +62,17 @@ premise (a walk north that never arrives) and the predator's method (imitation). - **Panic is live** — 0–5, up. Triggers marked per act. Act Four is where it bites. - **Coherence is the case.** The far side is a filed duplicate and it wants the agents written into it. Coherence loss is the clock on the whole scenario, not a side effect. -- **Opposed rolls here are a SCENARIO-LOCAL RULING, because the game does not have one.** - Two beats call for opposed POW×5 — the hollow man's swap in Act Three and rehearsal step - 5 — and `rules.mjs`, the single rule authority, defines no opposed roll at all. Until it - does, run it this way and say so at the table: **both sides roll; the better BAND wins - (critical beats special beats success); equal bands favour the agent; if both fail, - nothing happens and it may try again next round.** That uses only banding the game - already has. It is a local ruling, not a rule, and desk pass 2 is where it came from. +- **Opposed rolls are a REAL RULE now** (`opposedContestFor`, R-273), and the scenario-local + ruling this document used to carry is gone. Both sides roll; the better **band** wins; + **an equal band goes to whoever is being acted upon**, which in both of this scenario's + beats means the agent; and if neither side succeeds the contest is **unsettled**, which + is not the same as the defender winning. Each side takes its own difficulty, which is how + Act Two's OFFER gets priced in Act Four. + - **The tie-break is not a detail — it decides these beats.** At POW×5 100 against + Braithwaite's 60 the hollow man succeeds on everything but a 96–00 and still takes him + only **48%** of the time, because both sides usually succeed and the tie goes to the + person being reached for. If you remember one thing about opposed rolls here, remember + that resisting is much stronger than the raw numbers look. - **Standing Order 9 is live**, and for once it is not a costume rule: the duplicate is a 1962 valley plus sixty years of decay. Modern kit works and is exactly why the column will follow the agents anywhere. @@ -216,8 +224,26 @@ Advance one step per act, plus one whenever the agents spend Coherence on the fa | **2** | It has a posture and a limp right. Someone sees "a colleague" a field away | | **3** | It takes an NPC. The NPC's file is still good, so the copy holds | | **4** | The copy is fluent. It is inside the cordon and helping | -| **5** | It reaches for **the lowest POW in the room** — opposed POW×5, permanent. **Unless Ashcroft accepted THE OFFER in Act Two, in which case it takes him instead and he resists at Difficult.** GM ESSENTIALS has the resolution; Act Four beat 3 has the reasoning | -| **6** | It leaves for the south road wearing a warrant card, and this is a campaign | +| **5** | It reaches for **the lowest POW in the room** — opposed POW×5 (it is **75**, not 100), permanent. **Unless Ashcroft accepted THE OFFER in Act Two, in which case it takes him instead and he resists at Difficult.** GM ESSENTIALS has the resolution; Act Four beat 3 has the reasoning | +| **5 — unsettled** | **If neither side succeeds, the reach fails and the row is spent.** This is common enough to plan for: **10%** against Braithwaite, **14.5%** against Ashcroft if he accepted. It does not retry. See below — it is an outcome, not a gap | +| **6** | It goes south at dusk wearing whatever face it has. **Read the Close's "leave it filed" note: in a one-shot this lands tonight, not as a sequel** | + +**WHEN STEP 5 COMES TO NOTHING — and it will, about one time in seven against an Ashcroft +who accepted.** The rule returns *unsettled*, which is not the agent winning. Do not +re-roll it and do not quietly hand the understudy the win. **Play the failure, because it +is the most frightening thing the creature does all night:** + +> *Read aloud or paraphrase:* For a moment it is wearing the wrong face — not a stranger's, +> theirs, badly, like a coat on the wrong shoulders. Then it is a colleague again, and it +> apologises for the interruption. + +- **That is information, and it is the only time the party sees the mechanism fail.** They + now know what it is doing and cannot prove it to anybody, which is the scenario. +- **The clock still turns.** Step 5 is spent, step 6 comes, and it leaves with whatever it + already has. A thing that fails to take a Custodian goes south wearing the NPC from step + 3, and the party let it walk because they were watching each other. +- **Do not give it a second attempt.** The countdown fires once per step by design; the + creature has had thirty years of rehearsal and this was the performance. --- @@ -633,6 +659,13 @@ them. **Anyone who examines him properly finds this, with no roll.** - **Exception — if Ashcroft accepted THE OFFER in Act Two, it reaches for him instead**, because his file is already loose, and he resists **at Difficult** (85 → 42). That is what accepting cost him, and he was never told it. + - **What it cost, exactly.** Refusing, Ashcroft is taken **21.9%** of the time — the + safest person in the room. Accepting, **48.7%**. Accepting does not make him *a bit* + more vulnerable; **it moves him from the hardest file at the table to the easiest, past + Braithwaite's 37.6%**, and the countdown reaches for the easiest. The offer was never a + trap, it was a trade, and this is the half of it nobody quoted him. + - *Enumerated rather than sampled — all 10,000 roll pairs through `opposedContestFor`, + so these are exact and there is no seed involved. Reproduce with the rule itself.* - **The anchor clause does NOT transfer, and the table will assume it does.** A hollow man cannot swap with somebody holding an anchor that is genuinely theirs. **The understudy has no such limit on its sheet.** Ashcroft is not safe here, and if he was skipped in Act @@ -877,6 +910,11 @@ side of it because she has walked south for the first time in sixty-four years. - **Leave it filed.** Everyone lives. The peg is gone, the seam is open at dusk, and something over there will keep rehearsing for as long as the file exists. The agents will be the ones who come back for it, repeatedly, for the rest of their careers. + - **This is a one-shot, so that last sentence has to land tonight rather than promise a + campaign.** Do not end on "and you'll be back" — the table will never be back, and a + hook they cannot take reads as an unfinished scenario. End on the cost instead: + **name the next agent who will be sent**, from their own roster, and have Registry + thank them for a clean resolution. The horror is that the file is now correct. - **WHERE IT IS DONE, WHICH THE TABLE WILL ASK AND WHICH DECIDES THE SCENE.** Cancellation is a **Registry act on our side**. The entry is a record, the record is held here, and nothing said or done on the far side touches it. `[CUS: Anomaly Lore — the record is not @@ -1125,17 +1163,21 @@ twice.* ## Open questions for Tim -- Is this a **convention 4-hour** or a **campaign opener**? The cancellation ending is a - one-shot ending; the "leave it filed" branch is a campaign spine. -- Which convention slot, if any — Contingency 2027 has room. +- ~~Is this a **convention 4-hour** or a **campaign opener**?~~ **Resolved 13 September + 2026 — convention one-shot.** Both endings stay, but neither may be written as a hook: + see the Close, where "leave it filed" now has to land as an ending rather than as a + promise of sessions that will not happen. Design intent 5 is unchanged and was already + written for a one-shot. +- Which convention slot, if any — Contingency 2027 has room. **Still open**, and it is the + last thing between this and a print run. - ~~Does the party get told about RESERVE ENTRY at Registry?~~ **Resolved by casting** — it is Bhattacharya's private box, so a player decides it, not the GM. Say if you would rather take that back and fix it yourself. - How hard do you want the PC-replacement? Default here is NPC-first. -- **Does `rules.mjs` want an opposed roll?** It has none, and the hollow man's own tactics - text, `slice-text.mjs` and this scenario all ask for one. GM ESSENTIALS carries a - scenario-local ruling so the case is playable, but that is a patch over a system gap and - the gap is yours to decide on. If the rule lands, delete the local ruling. +- ~~**Does `rules.mjs` want an opposed roll?**~~ **Resolved 13 September 2026 — it has one** + (`opposedContestFor`, R-273). The scenario-local ruling is deleted and GM ESSENTIALS + points at the game. Both beats were priced against the real rule and the Act Four row + gained an *unsettled* outcome it did not previously have. - **Scent is unusable** (base 0, 1% floor). I moved Act Four's working test to Xenology (Baseline), which puts Braithwaite on the critical path. Worth deciding whether you want the whole scenario's one reliable test sitting on a single sheet. diff --git a/docs/scenarios/CLEAN_GROUND_HANDOUTS.html b/docs/scenarios/CLEAN_GROUND_HANDOUTS.html new file mode 100644 index 0000000..71fa5d1 --- /dev/null +++ b/docs/scenarios/CLEAN_GROUND_HANDOUTS.html @@ -0,0 +1,371 @@ + + + + +CLEAN GROUND — handout pack + + + + +
+

CLEAN GROUND — handout pack · print instructions

+ +
+ + +
+
CG-H01 · Act One
+
+
+ HM CORONER — NORTH NORTHUMBERLAND + PRELIMINARY NOTE +
+
+ Ref: NN/1174/26 + Not for release +
+ +
+
DECEASED
Unidentified male
+
FOUND
29 August, stone sheep pen, Sike Head march
+
BY
Mrs M. Swinburne, of Sike Head
+
+ +

Estimated age at death, from dentition and general condition: forty to forty-five + years.

+ +

Bone density, joint surfaces and vascular condition are those of a man in his + ninetieth year. I have written that sentence three times and I am content with it.

+ +

No dental record answers to this mouth. No fingerprint record answers to these + hands. Enquiries with the National Insurance index return nothing. He is not a missing + person anywhere I can reach.

+ +

Dressed in wool and cotton of good but old manufacture, mended repeatedly and + competently by hand. The boots have been resoled, by my count, nine times, and not by + a tradesman.

+ +

Lungs, turn-ups and hair carry a fine grey particulate which I am not equipped to + identify and have sent onward.

+ +

Cause of death: exhaustion, on a body that had no reserve left to draw on. There is + no injury. Nobody did this to him.

+ +

I would be glad to be told what this was, in due course, if anyone is ever able to + tell me.

+ +
+
J. A. Corbridge
+
Assistant Pathologist
+
+
+
+ + +
+
CG-H03a · Act Two
+
+ + + + + + + + + + + + + + + + +
DAYWEATHERWALKEDTO CLEAN GROUNDNOTES
Monlow cloud7 mi41 miGround softer.
Tuedry, cold8 mi41 miPassed the lorry.
Wedrain5 mi41 miRested at noon.
Thudry8 mi41 miThursday again.
Frilow cloud7 mi41 miAlec is tired.
Satrain6 mi41 miPassed the lorry.
Sundry, bright9 mi41 miGood day. Sang.
+
Counted this morning: forty-one.
+
+
+ + +
+
CG-H02 · Act Three
+
+
+ COMMITTEE ON CONTINGENT ARRANGEMENTS + SECRET +
+
+ Minute of the meeting of 26 October 1962 + Copy 3 of 4 +
+ +

RESERVE ENTRY (NORTHERN) — AUTHORISED

+ +
    +
  1. The Committee approved the entry of a reserve holding of ground in the northern + march, of eleven miles and a fraction, to be recorded twice in order that one record + may be drawn upon without disturbance to the other.
  2. + +
  3. The purpose of the reserve is to hold persons displaced by a general exchange, in + a place which is not on any list that an adversary may obtain. Members were reminded + that an unrecorded place cannot be targeted and cannot be requisitioned.
  4. + +
  5. The entry is pegged to the standing boundary oak at Sike Head, being the feature + to which the march was recorded before the survey of 1861 and therefore the older of + the two authorities. The Committee was satisfied that the tree is in good health.
  6. + +
  7. The Registrar drew the Committee's attention to the consequence of paragraph 2. + A place recorded as the place to which the displaced go is a place the record must + afterwards furnish with displaced persons. The Committee noted this and did not + regard it as an obstacle.
  8. + +
  9. The entry is to be reviewed at the rising of the emergency, and cancelled if it + is found not to have been drawn upon.
  10. +
+ +
+ Signed(illegible)
Chairman
+ Minuted byA.E.W.
Clerk (Junior)
+
+
+
+ + +
+
CG-H03b · Act Three
+
+ + + + + + + + + + + + + + + + +
DAYWEATHERWALKEDTO CLEAN GROUNDNOTES
Mondry8 mi41 miPassed the lorry.
Tuerain6 mi41 miAda's chest again.
Wedlow cloud7 mi41 miGround softer.
Thudry, cold8 mi41 miThursday again.
Fridry7 mi41 miPassed the lorry.
Satlow cloud6 mi41 miRested at noon.
Sunrain5 mi41 miNobody sang.
+
Counted this morning: fifty-three.
+
+
+ + + diff --git a/package.json b/package.json index 1f8c1f4..7a07153 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "play": "node tools/playthrough.mjs", "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", - "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-cited.mjs && bun tools/check-bestiary.mjs", + "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs" }, diff --git a/tools/check-handouts.mjs b/tools/check-handouts.mjs new file mode 100644 index 0000000..8a2ce10 --- /dev/null +++ b/tools/check-handouts.mjs @@ -0,0 +1,165 @@ +/** + * 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(/
]*>([\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(//g, "") + .replace(/>[^<]*<") + .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(//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(/