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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd3d818893
commit
9f3a63174f
@@ -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:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
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
|
||||
```
|
||||
<!-- guards:end -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CLEAN GROUND — handout pack</title>
|
||||
<style>
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
CLEAN GROUND — print-ready handout pack. A4, four sheets, one per page.
|
||||
|
||||
Print from a browser with BACKGROUND GRAPHICS ON, scale 100%, margins
|
||||
"default". The cream of H02 and the ruled paper of H03 are backgrounds; with
|
||||
them off, H02 prints on white and the almanac loses its rules, which does not
|
||||
destroy either handout but does cost most of the effect.
|
||||
|
||||
THE ONE RULE THIS FILE EXISTS TO ENFORCE. H03a and H03b must be
|
||||
indistinguishable except for their dates and figures — that is the entire
|
||||
trick, and it dies the moment one of them is styled a hair differently from
|
||||
the other. So they are not two designs that happen to match: they share one
|
||||
class, `.almanac`, and every dimension they use comes from a variable defined
|
||||
once below. There is deliberately no `.almanac-a` or `#h03b` anywhere in this
|
||||
file. If you want to change the look of the almanac, change the variable and
|
||||
both move together; if you find yourself adding a selector that names only one
|
||||
of them, that is the bug, and `check-handouts` will fail the build for it.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* Body copy never goes below 11pt — convention halls are dim and eyes are
|
||||
tired. Everything here is at or above that. */
|
||||
--body: 11.5pt;
|
||||
--lead: 1.45;
|
||||
|
||||
/* The almanac's entire appearance, in one place, used by both pages. Swap the
|
||||
face here if a handwriting font is installed — it changes BOTH, which is the
|
||||
point. Keep it monospaced-by-metrics or the columns stop aligning, and the
|
||||
columns aligning IS the reveal. */
|
||||
--almanac-font: "Courier New", Courier, monospace;
|
||||
--almanac-size: 11.5pt;
|
||||
--almanac-lead: 26px; /* also the ruled-line pitch — one value, both uses */
|
||||
--almanac-ink: #1a2a4a; /* fountain-pen blue-black */
|
||||
--almanac-paper: #fbfaf3;
|
||||
--almanac-rule: #b9c6d8;
|
||||
--almanac-margin-rule: #d4a8a8;
|
||||
/* Where the red margin rule sits, measured from the sheet edge, and therefore
|
||||
where the writing has to start. Used by both the rule and the indent so they
|
||||
cannot drift apart. Sheet padding is 16mm, so the indent makes up the rest. */
|
||||
--almanac-margin-x: 26mm;
|
||||
}
|
||||
|
||||
@page { size: A4 portrait; margin: 16mm 15mm; }
|
||||
|
||||
html, body { margin: 0; padding: 0; background: #6e6e6e; }
|
||||
|
||||
body {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: var(--body);
|
||||
line-height: var(--lead);
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* One sheet = one A4 page. On screen they float on grey; in print they are the
|
||||
page itself. */
|
||||
.sheet {
|
||||
box-sizing: border-box;
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 10mm auto;
|
||||
padding: 18mm 16mm;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 14px rgba(0,0,0,.45);
|
||||
page-break-after: always;
|
||||
position: relative;
|
||||
}
|
||||
.sheet:last-of-type { page-break-after: auto; }
|
||||
|
||||
/* The slug is for the GM's pile, not the players. It is small, grey, and sits
|
||||
outside the fiction — every sheet says what it is and when it is given. */
|
||||
.slug {
|
||||
position: absolute; top: 6mm; right: 16mm;
|
||||
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 8pt; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
.slug b { color: #5a5a5a; letter-spacing: .12em; }
|
||||
|
||||
/* ── CG-H01 — coroner's note. White, official, dull on purpose. ───────────── */
|
||||
.official {
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: var(--body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.official .letterhead {
|
||||
border-bottom: 2.5pt solid #000;
|
||||
padding-bottom: 3mm; margin-bottom: 2mm;
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-weight: 700; letter-spacing: .04em; font-size: 11.5pt;
|
||||
}
|
||||
.official .letterhead .doctype { font-weight: 700; font-size: 11pt; }
|
||||
.official .refline {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 11pt; color: #333;
|
||||
border-bottom: .75pt solid #666;
|
||||
padding-bottom: 4mm; margin-bottom: 6mm;
|
||||
}
|
||||
.official dl { display: grid; grid-template-columns: 30mm 1fr; gap: 1.5mm 4mm; margin: 0 0 7mm; }
|
||||
.official dt { font-weight: 700; letter-spacing: .05em; font-size: 11pt; }
|
||||
.official dd { margin: 0; }
|
||||
.official p { margin: 0 0 4.5mm; max-width: 155mm; }
|
||||
.official .signoff { margin-top: 12mm; padding-left: 95mm; line-height: 1.35; }
|
||||
.official .signoff .name { font-family: Georgia, serif; font-style: italic; font-size: 14pt; }
|
||||
.official .signoff .role { font-size: 11pt; color: #333; }
|
||||
|
||||
/* ── CG-H02 — 1962 committee minute. Cream, typewriter, twice-copied. ─────── */
|
||||
.minute {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 11.5pt;
|
||||
line-height: 1.62;
|
||||
color: #241f1a;
|
||||
}
|
||||
/* The cream is on the SHEET so it reaches the paper edge in print. */
|
||||
.sheet--cream { background: #f3ecd9; }
|
||||
/* Photocopy grain: faint, uneven, and cheap. Two overlapping gradients rather
|
||||
than an image, so this file stays self-contained and prints the same anywhere. */
|
||||
.sheet--cream::before {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(92deg, rgba(0,0,0,.035) 0 1px, transparent 1px 4px),
|
||||
radial-gradient(120% 80% at 8% 4%, rgba(0,0,0,.07), transparent 55%),
|
||||
radial-gradient(90% 70% at 96% 98%, rgba(0,0,0,.05), transparent 60%);
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.minute > * { position: relative; }
|
||||
.minute .head {
|
||||
display: flex; justify-content: space-between;
|
||||
font-weight: 700; letter-spacing: .05em;
|
||||
}
|
||||
.minute .head .classification { letter-spacing: .3em; }
|
||||
.minute .subhead {
|
||||
display: flex; justify-content: space-between;
|
||||
border-bottom: 1pt solid #4a4038;
|
||||
padding-bottom: 3mm; margin-bottom: 7mm;
|
||||
}
|
||||
.minute h2 {
|
||||
font-family: inherit; font-size: 12.5pt; font-weight: 700;
|
||||
letter-spacing: .12em; text-align: center;
|
||||
margin: 0 0 9mm; text-decoration: underline;
|
||||
}
|
||||
.minute ol { margin: 0; padding: 0; list-style: none; counter-reset: para; }
|
||||
.minute ol li {
|
||||
counter-increment: para;
|
||||
display: grid; grid-template-columns: 9mm 1fr; gap: 3mm;
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
.minute ol li::before { content: counter(para) "."; font-weight: 700; }
|
||||
.minute .signoff {
|
||||
margin-top: 12mm; padding-left: 68mm;
|
||||
display: grid; grid-template-columns: 26mm 1fr; gap: 1mm 3mm;
|
||||
}
|
||||
.minute .signoff .lab { color: #3b332c; }
|
||||
|
||||
/* ── CG-H03a / CG-H03b — Ivy's almanac. ONE class, used twice. ────────────
|
||||
Every value comes from the variables above. Nothing below names a or b. */
|
||||
.sheet--almanac { background: var(--almanac-paper); }
|
||||
.almanac {
|
||||
font-family: var(--almanac-font);
|
||||
font-size: var(--almanac-size);
|
||||
color: var(--almanac-ink);
|
||||
line-height: var(--almanac-lead);
|
||||
/* Ruled paper: the pitch IS the line-height, so writing sits on the rules. */
|
||||
background:
|
||||
linear-gradient(to bottom, transparent calc(var(--almanac-lead) - 1px),
|
||||
var(--almanac-rule) calc(var(--almanac-lead) - 1px), var(--almanac-rule) var(--almanac-lead));
|
||||
background-size: 100% var(--almanac-lead);
|
||||
padding-top: 2px;
|
||||
/* 16mm of that is the sheet's own padding; this is the remainder. */
|
||||
padding-left: calc(var(--almanac-margin-x) - 16mm);
|
||||
}
|
||||
/* The red margin rule, down the left of the sheet, both pages alike. */
|
||||
.sheet--almanac::before {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: var(--almanac-margin-x);
|
||||
border-left: 1pt solid var(--almanac-margin-rule);
|
||||
}
|
||||
.almanac table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.almanac th, .almanac td {
|
||||
text-align: left; font-weight: 400; padding: 0;
|
||||
line-height: var(--almanac-lead); height: var(--almanac-lead);
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.almanac th { border-bottom: 1px solid var(--almanac-ink); }
|
||||
/* Fixed columns so both pages align to the millimetre — the identical "41 mi"
|
||||
column is the reveal and it only lands if it lines up across two sheets. */
|
||||
.almanac col.day { width: 10%; }
|
||||
.almanac col.weather { width: 22%; }
|
||||
.almanac col.walked { width: 12%; }
|
||||
.almanac col.clean { width: 24%; }
|
||||
.almanac col.notes { width: 32%; }
|
||||
.almanac .count { margin-top: calc(var(--almanac-lead) * 2); }
|
||||
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
.sheet { margin: 0; box-shadow: none; width: auto; min-height: 0; padding: 0; }
|
||||
.sheet--cream, .sheet--almanac { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.screen-only { display: none !important; }
|
||||
}
|
||||
|
||||
.screen-only {
|
||||
max-width: 210mm; margin: 10mm auto 0; padding: 6mm 8mm;
|
||||
background: #fffbe6; border: 1pt solid #d8cf9a;
|
||||
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 11pt;
|
||||
}
|
||||
.screen-only h1 { font-size: 12pt; margin: 0 0 3mm; letter-spacing: .05em; }
|
||||
.screen-only li { margin-bottom: 2mm; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="screen-only">
|
||||
<h1>CLEAN GROUND — handout pack · print instructions</h1>
|
||||
<ul>
|
||||
<li><b>A4 portrait, scale 100%, background graphics ON.</b> Without backgrounds, H02 prints white and the almanac loses its rules.</li>
|
||||
<li><b>Four sheets, one per page</b>, in handing order: H01 (Act One), H03a (Act Two), H02 (Act Three, depot), H03b (Act Three, laid beside H03a).</li>
|
||||
<li><b>H03a and H03b must come off the same printer, same tray, same session.</b> Two different papers and the trick dies before anyone reads a word.</li>
|
||||
<li><b>Staging:</b> H03a is given in Act Two and nothing is said about it. H03b goes down <i>flat on the table beside it</i> in Act Three. Do not explain. The table will do it out loud.</li>
|
||||
<li>Body text is 11.5pt throughout. Do not scale below 100% to fit.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ══ CG-H01 ══ Act One, once Braithwaite has looked ═══════════════════════ -->
|
||||
<section class="sheet">
|
||||
<div class="slug"><b>CG-H01</b> · Act One</div>
|
||||
<div class="official">
|
||||
<div class="letterhead">
|
||||
<span>HM CORONER — NORTH NORTHUMBERLAND</span>
|
||||
<span class="doctype">PRELIMINARY NOTE</span>
|
||||
</div>
|
||||
<div class="refline">
|
||||
<span>Ref: NN/1174/26</span>
|
||||
<span>Not for release</span>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<dt>DECEASED</dt><dd>Unidentified male</dd>
|
||||
<dt>FOUND</dt><dd>29 August, stone sheep pen, Sike Head march</dd>
|
||||
<dt>BY</dt><dd>Mrs M. Swinburne, of Sike Head</dd>
|
||||
</dl>
|
||||
|
||||
<p>Estimated age at death, from dentition and general condition: forty to forty-five
|
||||
years.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>Lungs, turn-ups and hair carry a fine grey particulate which I am not equipped to
|
||||
identify and have sent onward.</p>
|
||||
|
||||
<p>Cause of death: exhaustion, on a body that had no reserve left to draw on. There is
|
||||
no injury. Nobody did this to him.</p>
|
||||
|
||||
<p>I would be glad to be told what this was, in due course, if anyone is ever able to
|
||||
tell me.</p>
|
||||
|
||||
<div class="signoff">
|
||||
<div class="name">J. A. Corbridge</div>
|
||||
<div class="role">Assistant Pathologist</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CG-H03a ══ Act Two, when she decides to trust them ═══════════════════ -->
|
||||
<section class="sheet sheet--almanac">
|
||||
<div class="slug"><b>CG-H03a</b> · Act Two</div>
|
||||
<div class="almanac">
|
||||
<table>
|
||||
<colgroup>
|
||||
<col class="day"><col class="weather"><col class="walked"><col class="clean"><col class="notes">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>DAY</th><th>WEATHER</th><th>WALKED</th><th>TO CLEAN GROUND</th><th>NOTES</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Mon</td><td>low cloud</td><td>7 mi</td><td>41 mi</td><td>Ground softer.</td></tr>
|
||||
<tr><td>Tue</td><td>dry, cold</td><td>8 mi</td><td>41 mi</td><td>Passed the lorry.</td></tr>
|
||||
<tr><td>Wed</td><td>rain</td><td>5 mi</td><td>41 mi</td><td>Rested at noon.</td></tr>
|
||||
<tr><td>Thu</td><td>dry</td><td>8 mi</td><td>41 mi</td><td>Thursday again.</td></tr>
|
||||
<tr><td>Fri</td><td>low cloud</td><td>7 mi</td><td>41 mi</td><td>Alec is tired.</td></tr>
|
||||
<tr><td>Sat</td><td>rain</td><td>6 mi</td><td>41 mi</td><td>Passed the lorry.</td></tr>
|
||||
<tr><td>Sun</td><td>dry, bright</td><td>9 mi</td><td>41 mi</td><td>Good day. Sang.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="count">Counted this morning: forty-one.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CG-H02 ══ Act Three, in the buried depot ═════════════════════════════ -->
|
||||
<section class="sheet sheet--cream">
|
||||
<div class="slug"><b>CG-H02</b> · Act Three</div>
|
||||
<div class="minute">
|
||||
<div class="head">
|
||||
<span>COMMITTEE ON CONTINGENT ARRANGEMENTS</span>
|
||||
<span class="classification">SECRET</span>
|
||||
</div>
|
||||
<div class="subhead">
|
||||
<span>Minute of the meeting of 26 October 1962</span>
|
||||
<span>Copy 3 of 4</span>
|
||||
</div>
|
||||
|
||||
<h2>RESERVE ENTRY (NORTHERN) — AUTHORISED</h2>
|
||||
|
||||
<ol>
|
||||
<li>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.</li>
|
||||
|
||||
<li>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.</li>
|
||||
|
||||
<li>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.</li>
|
||||
|
||||
<li>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.</li>
|
||||
|
||||
<li>The entry is to be reviewed at the rising of the emergency, and cancelled if it
|
||||
is found not to have been drawn upon.</li>
|
||||
</ol>
|
||||
|
||||
<div class="signoff">
|
||||
<span class="lab">Signed</span><span>(illegible)<br>Chairman</span>
|
||||
<span class="lab">Minuted by</span><span>A.E.W.<br>Clerk (Junior)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CG-H03b ══ Act Three, and not before ═════════════════════════════════ -->
|
||||
<section class="sheet sheet--almanac">
|
||||
<div class="slug"><b>CG-H03b</b> · Act Three</div>
|
||||
<div class="almanac">
|
||||
<table>
|
||||
<colgroup>
|
||||
<col class="day"><col class="weather"><col class="walked"><col class="clean"><col class="notes">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>DAY</th><th>WEATHER</th><th>WALKED</th><th>TO CLEAN GROUND</th><th>NOTES</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Mon</td><td>dry</td><td>8 mi</td><td>41 mi</td><td>Passed the lorry.</td></tr>
|
||||
<tr><td>Tue</td><td>rain</td><td>6 mi</td><td>41 mi</td><td>Ada's chest again.</td></tr>
|
||||
<tr><td>Wed</td><td>low cloud</td><td>7 mi</td><td>41 mi</td><td>Ground softer.</td></tr>
|
||||
<tr><td>Thu</td><td>dry, cold</td><td>8 mi</td><td>41 mi</td><td>Thursday again.</td></tr>
|
||||
<tr><td>Fri</td><td>dry</td><td>7 mi</td><td>41 mi</td><td>Passed the lorry.</td></tr>
|
||||
<tr><td>Sat</td><td>low cloud</td><td>6 mi</td><td>41 mi</td><td>Rested at noon.</td></tr>
|
||||
<tr><td>Sun</td><td>rain</td><td>5 mi</td><td>41 mi</td><td>Nobody sang.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="count">Counted this morning: fifty-three.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(/<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`);
|
||||
@@ -37,7 +37,7 @@ const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs",
|
||||
"check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs",
|
||||
"check-rollable.mjs", "check-creatures.mjs", "check-anatomy.mjs",
|
||||
"check-lethality.mjs", "check-focus.mjs", "check-firstblood.mjs",
|
||||
"check-attackers.mjs", "check-fight-tail.mjs", "check-cited.mjs", "check-bestiary.mjs"];
|
||||
"check-attackers.mjs", "check-fight-tail.mjs", "check-cited.mjs", "check-handouts.mjs", "check-bestiary.mjs"];
|
||||
|
||||
/* The list above went stale the moment a guard was added without touching this file —
|
||||
which is what the comment above it already warned about, and which happened again
|
||||
@@ -107,9 +107,14 @@ if (!withGuards) { console.error("update-readme: guard markers missing"); proces
|
||||
/* The guard BLOCK was generated and the sentences introducing it were not, so the README
|
||||
listed nine guards while three places in the prose still said eight. Count words go
|
||||
stale the same way a hardcoded list does — the lesson two comments up, one layer out. */
|
||||
/* Ran out at sixteen, one guard after the comment above warned about hardcoded lists
|
||||
going stale. It fails loudly rather than silently, which is why this is an inconvenience
|
||||
and not a defect — but it will run out again, so it now reaches far enough ahead that
|
||||
the next person to hit it is unlikely to be working this year. */
|
||||
const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven",
|
||||
"eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
|
||||
"fifteen", "sixteen"];
|
||||
"fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty",
|
||||
"twenty-one", "twenty-two", "twenty-three", "twenty-four"];
|
||||
const word = COUNT_WORD[guardLines.length];
|
||||
if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); }
|
||||
const counted = withGuards.replace(/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen) guards\b/g,
|
||||
|
||||
Reference in New Issue
Block a user