v0.7.9.2 — two things the table could hear that only one seat should

Both leaks were found while planning the common board (Gitea#20 step 1),
and both are live multiplayer bugs with or without that display, so they
are fixed now rather than with 0.8.0.

`game.log` is one shared list and `linesSince(seat)` slices it with no
per-seat filter, so every line reaches every player. It carried the SEED
in the opening line of each multiplayer game — the whole future of the
deal — and the NAME OF A CARD DRAWN BLIND from the face-down Home Office
deck. Solitaire deliberately keeps both: a one-seat table has nobody to
leak to, the seed is what a bug report quotes, and a player's own history
naming their own draw is the record. A Department slot is face up and
stays named. The drawer still learns their card through `justDrawn`,
which already goes to that seat alone.

Neither was found by a test. Every test in `redaction.test.ts` passes an
empty log, so the whole of narration has sat outside the redaction net
since the net was built. Both now have tests there; TODO #91 carries what
is still owed and supersedes #78, which described a gap that had already
been closed and never mentioned this one.

`docs/rules/` had no current description of the game, and `content.ts`
named `card-reference.md` as the file that carries what the cards say —
a file whose own banner says not to use its numbers, describing the
v0.4.5 deck where 3/4 is a Mail-Express with three coaches. Every file in
that directory is a deliberate historical record, so none of them is
rewritten. `as-built.md` is new and GENERATED from the same catalogues
the engine instantiates from, with a test that re-runs the generator and
fails when the checked-in file disagrees. A hand-written replacement
would have drifted the same way, for the same reason.

TODO.md: #32 closed — the playtest migration note did its job and the
jump is made; the durable fact it carried is kept. #78 retired in favour
of #91. The "play it at a table" section now records that 0.7.4-0.7.9
were test-run without change requests, and that more testing comes at the
end of the 0.7.9 series.

897 tests pass, up from 891.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
This commit is contained in:
Jesse.Markowitz
2026-09-07 12:09:25 -04:00
co-authored by Claude Opus 5
parent 7ade60e21f
commit 819996faa2
10 changed files with 717 additions and 41 deletions
+65
View File
@@ -19,6 +19,71 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.7.9.2 — 2026-09-07
Two multiplayer information leaks, and the documentation problem that let a wrong table sit in
`docs/rules/` for several releases with the code pointing at it.
### The shared narration log was telling every seat things only one seat should know
Both found while planning the public common board (Gitea#20 step 1), and **both are live multiplayer
bugs with or without that display** — which is why they are fixed here rather than waiting for
0.8.0.
`game.log` is ONE list. `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat
filter at all, so every line written there reaches every player. Two things were being written into
it that should never have left the seat that caused them:
**The seed, in the opening line of every multiplayer game.** `competitive · 3 players · seed 4242`
handed each player the entire future of the deal — every card order, every die roll.
**The name of a card drawn blind from the Home Office deck.** `Player Cy drew Red Flags from the
Home Office deck`, to the whole table, from a face-down deck.
**Solitaire deliberately keeps both, and that is the rule rather than an exception.** A one-seat
table has nobody to leak to; the seed in the log is what a bug report quotes — both of the issues
fixed in 0.7.9.1 opened by naming it — and a solo player's own history naming their own draw is the
record, not a leak. The rule is *do not tell the OTHER seats*, not *write less down*. A Department
slot stays named for the same reason: those piles are face up, a discard goes onto one precisely so
a rival can take it, so the card was public before it was drawn.
The drawing seat still learns what it got. `justDrawn` is the owner-only channel and `session.ts`
already sends it to that seat alone, so hiding the name from the shared log costs the drawer nothing.
The seed remains in `game.seed`, in every save and in the lobby record, so nothing administrative or
replayable loses it.
**These were not found by a test. They were found by reading a plan.** Every test in
`redaction.test.ts` passes `[]` for the narration log, so the whole of it has sat outside the
redaction net since the net was built. Tests for both now live there, but two strings are not a net —
TODO #91 carries what is still owed, and supersedes #78, which described a gap that had already been
closed and never mentioned this one.
### `docs/rules/` had no current description of the game, and `content.ts` pointed at a superseded one
`content.ts` named `docs/rules/card-reference.md` as "the place that now carries what the cards say".
That file opens with its own banner — **"⚠ SUPERSEDED… Do not use its numbers"** — and describes the
v0.4.5 deck: twelve numbered trains, `3 / 4 | Mail-Express | 3 coaches, no caboose`, against a
`content.ts` whose train 3 is the Express, two freight cars, `oneFreightPerLocation`. The code was
sending readers to a table it had itself replaced.
Every file in `docs/rules/` turns out to be a historical record and says so: `rules-v0.1.md` is a
faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker, `rules-v0.2.md`
and `card-reference.md` both carry superseded banners. **So the fix is not to rewrite one of them** —
the record is worth more intact than patched, and there was simply no current reference at all.
`docs/rules/as-built.md` is new and is **generated** — trains, Mainline cards, Offices, freight
facilities, modifiers and track, emitted from the same exported catalogues the engine instantiates
from, by `scripts/build-card-reference.ts` (`npm run build:cards`).
`test/card-reference.test.ts` re-runs the generator and asserts the checked-in file matches, so
changing a card face without regenerating turns the suite red.
**A hand-written replacement would have drifted exactly the same way**, and for the same reason:
nothing fails when a table falls behind a constant. That is the whole lesson of the file it replaces.
897 tests pass, up from 891.
---
## 0.7.9.1 — 2026-09-07 ## 0.7.9.1 — 2026-09-07
Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule
+86 -34
View File
@@ -74,8 +74,8 @@ Not items. Things that are true of every change, and that have gone wrong when s
## Sections ## Sections
1. **Play it at a table** — #39 #35 #42a #40 #32 1. **Play it at a table** — #39 #35 #42a #40
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #78 #75 2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #91 #75
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79 3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
4. **The screen** — #44 #81 #33 #36 4. **The screen** — #44 #81 #33 #36
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52 5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
@@ -94,8 +94,14 @@ ruling or a lesson).
## Play it at a table ## Play it at a table
The largest gap in the project, and none of it is a coding gap. Features are shipped, packed, The largest gap in the project, and none of it is a coding gap. Features are shipped, packed,
running on `phoenix.local` — and no person has met them at a board. Everything else in this file running on `phoenix.local` — and the items below name the ones no person has met at a board.
waits behind a release; this waits behind an afternoon. Everything else in this file waits behind a release; this waits behind an afternoon.
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
specific paths below have not been exercised at a table. **More testing is planned at the end of the
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag - [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end, hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
@@ -118,11 +124,6 @@ waits behind a release; this waits behind an afternoon.
in a release note rather than code, and worth knowing when a bug report arrives with a save that in a release note rather than code, and worth knowing when a bug report arrives with a save that
will not load. See **Reference · #40**. will not load. See **Reference · #40**.
- [ ] **#32** — **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same
shape as #40 for the playtest line; the saves attached to Gitea#15 and #17 are among them.
`PLAYTEST-0.7.4.md` at the repo root is the note drafted for this and leads with it. See
**Reference · #32**.
--- ---
## The common board, and watching play happen — Gitea#20 ## The common board, and watching play happen — Gitea#20
@@ -148,8 +149,16 @@ cheaper.
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
rather than under The screen. See **Reference · #18**. rather than under The screen. See **Reference · #18**.
- [ ] **#78** — The redaction test is more done than the plan suggests, but the remaining gap is real - [ ] **#91** — **Narration is still outside the redaction net, and the two known leaks in it are
and is Gitea#20 step 1's starting point. See **Reference · #78**. fixed but the net is not.** `game.log` is one shared list that `linesSince` slices with no
per-seat filter, so anything written into it reaches every player. The seed and the blind-draw
card name were closed in v0.7.9.2; what has NOT been done is the systematic check the plan
asks for — serialise the log alongside the Frame and search it for every opponent's card ids
AND display names, objective names, `justDrawn` for the wrong seat, and private decision data,
across a fresh game, a pending decision, the Superintendent acting, Employee Rotation, a
reconnect and a finished game. **This is Gitea#20 step 1's starting point and its acceptance
bar** — the plan is explicit that passing redaction tests alone is insufficient and that every
public property needs an allow-list review. See **Reference · #91**.
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the - [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**. idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
@@ -453,14 +462,6 @@ the move and leaves the file untouched — and `WHISTLE-4086` did survive on `ph
is "may not" rather than "will not". Worth a line wherever the build is announced, and worth is "may not" rather than "will not". Worth a line wherever the build is announced, and worth
knowing when a bug report arrives with a save that will not load. knowing when a bug report arrives with a save that will not load.
#### #32 — Tell the 0.4.9 playtesters their saves are dead, before they find out.
**Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same shape as #40
but for the playtest line: the deck change shipped as v0.4.9h, so every save filed before it —
including the ones attached to Gitea#15 and #17 — stops replaying at its first `card.play`. They
fail safe and the files are kept, but nobody has been told. `PLAYTEST-0.7.4.md` (untracked, at
the repo root) is the note drafted for this and leads with it.
### The common board, and watching play happen — Gitea#20 ### The common board, and watching play happen — Gitea#20
#### #13 — I CANNOT SEE WHAT THE OTHER PLAYERS DID — BOTS INCLUDED. #### #13 — I CANNOT SEE WHAT THE OTHER PLAYERS DID — BOTS INCLUDED.
@@ -582,22 +583,41 @@ of enforced waiting per Stage, forty-eight per Day, and a player who has seen it
will want it off. Whatever this becomes probably needs a speed control, or to scale with whether will want it off. Whatever this becomes probably needs a speed control, or to scale with whether
anything actually happened in the phase. anything actually happened in the phase.
#### #78 — The redaction test (multiplayer.md §7) is more done than the plan sugg… #### #91 — NARRATION IS OUTSIDE THE REDACTION NET.
**The redaction test (multiplayer.md §7) is more done than the plan suggests, but the **NARRATION IS OUTSIDE THE REDACTION NET.** `test/redaction.test.ts` serialises a seat's whole
exhaustive check is still missing.** `test/multiplayer.test.ts`'s "the view shows one seat at `Frame` and asserts no other seat's card ids or deck order appear in it — and every one of those
a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its tests passes `[]` for the log. So the shared narration has never been checked at all, while
own hand, board, Revenue and impediments — traced `snapshot()` itself `game.log` is ONE list and `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a filter whatsoever. Every line written there reaches every player.
count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`,
`rngState` or card-id-dictionary field for anything to leak through by accident. What exists **Two leaks found and closed in v0.7.9.2**, both discovered while planning Gitea#20 and both live in
is all spot-checks, though — "this seat's Frame has the right hand length." What's still multiplayer with or without that display: the **seed**, announced in the opening line of every
missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it multiplayer game, and the **name of a card drawn blind** from the Home Office deck. The tests for
contains none of another seat's actual card ids and no deck order, so a future careless edit them are in `redaction.test.ts` now, so narration is no longer entirely unchecked — but two specific
is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()` strings are not a net.
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
2026-08-20. **Solitaire deliberately keeps both**, and that is the rule to apply to anything found next: a
one-seat table has nobody to leak to, the seed in the log is what a bug report quotes, and a solo
player's own history naming their own draw is the record. The rule is "do not tell the OTHER seats",
not "write less down".
**What is still owed** is the plan's own list (`docs/plans/jitsi-common-board.md`, Step 1 § Tests):
serialise the public frame *and* the player pushes and search for every opponent hand-card id **and
display name**, objective ids and names, `justDrawn` for the wrong player, seed values and seed
narration, and private decision/menu data — across a newly created game, a blind draw, a pending
decision, the Superintendent acting, Employee Rotation before and after ownership changes, a
reconnect push, and a finished game. **And the plan's acceptance bar is not the tests**: it requires
an allow-list review of every public property, on the grounds that passing redaction tests alone is
insufficient. That is the right bar — the two leaks above would have passed any test nobody thought
to write.
**Supersedes #78**, which said the exhaustive Frame check was "still missing… held for now,
2026-08-20". It is not missing: `test/redaction.test.ts` exists and does exactly what #78 described
— serialise a seat's Frame, assert no other seat's card ids and no deck order. #78 was written
before that file and was never revisited, so it read as live work for two weeks after it was done.
**The gap that is actually real is the log, which #78 never mentioned.**
#### #75 — Let the game join a call and talk to the table. #### #75 — Let the game join a call and talk to the table.
@@ -1729,11 +1749,43 @@ re-verified turn out to have been re-verified against a premise rather than agai
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group. each group.
### Shipped through v0.7.9.1, from the queue ### Shipped through v0.7.9.2, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson; Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
the numbers stay so cross-references above and below still resolve. the numbers stay so cross-references above and below still resolve.
32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done
2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07:
"a temporary document to help some of the playtesters out on making the big jump, but that is no
longer needed." The jump is made, so the note is retired rather than committed. **The durable
fact, which is why this entry stays:** a save written by v0.4.9h does not replay on 0.7.x — the
deck changed, so it stops at its first `card.play` — and that includes the saves attached to
Gitea#15 and #17. It fails safe, naming the move and leaving the file untouched, so a bug report
arriving with a save that will not load is this and not a new fault. #40 is the same shape on the
main line and is still open.
92. ~~**Two multiplayer information leaks in the shared narration log.**~~ — done 2026-09-07 in
v0.7.9.2, found while planning Gitea#20 step 1. The **seed** was announced in the opening line of
every multiplayer game and a **blind Home Office draw named the card** — and `linesSince(seat)`
slices one shared `game.log` with no per-seat filter, so both went to every player. **Ruling:
solitaire keeps both**, because a one-seat table has nobody to leak to, the seed is what a bug
report quotes, and a solo player's history naming their own draw is the record. A Department
slot is face up and stays named for the same reason. **Worth knowing:** these were not found by
a test, they were found by reading the plan — every redaction test passes `[]` for the log, so
the whole of narration was unchecked. #91 is what remains.
93. ~~**`docs/rules/` had no current description of the game, and the code pointed at a superseded
one.**~~ — done 2026-09-07 in v0.7.9.2. `content.ts` named `card-reference.md` as "the place
that now carries what the cards say" while that file's own banner said not to use its numbers;
it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts`
whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every
file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new
`as-built.md` is GENERATED from the same catalogues the engine instantiates from, by
`scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts`
re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a
hand-written replacement would have drifted the same way and for the same reason — nothing fails
when a table falls behind a constant. Generate it or check it; do not retype it.
89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in 89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in
v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the
collision rules want; the map wanted "which printed box, left to right" and used the same number, collision rules want; the map wanted "which printed box, left to right" and used the same number,
+177
View File
@@ -0,0 +1,177 @@
# Station Master — the cards as built
> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by
> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file
> and the code disagree.
This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything
else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)
transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in
them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and
[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the
reasoning; read this for the numbers.
The engine instantiates from the same constants this is emitted from, so a disagreement between
this page and the game is a bug in the generator, not a stale table.
---
## Trains
12 timetabled and 10 Extras, 22 in all.
Odd numbers run west, even run east; a pair shares a class and is the same card face in two
directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no
train with a caboose carries more than three revenue cars.
### Timetabled
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
### Extras
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
---
## Mainline cards
A card is divided into **regions**, and a train advances one region per Stage — so the regions a
card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
part of the road all change the entry point rather than the card's length.
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |
| --- | ---: | ---: | --- | :---: | :---: |
| Plains | 1 | 0 | — | — | — |
| Curves | 2 | 0 | — | — | — |
| Hilly | 2 | 0 | 1 / 0 | — | — |
| Heavy Grade | 3 | 0 | — | — | — |
| Double Track | 1 | 0 | — | yes | — |
| Uncontrolled Siding | 2 | 1 | — | — | — |
| Tunnel | 2 | 0 | — | — | — |
| Trestle | 1 | 0 | — | — | — |
| Interchange | 2 | 1 | — | — | yes |
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
the Division and are not dealt. What each card does, in the words the game uses on screen:
- **Plains** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear.
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here.
---
## Office cards
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
to Porters rather than one more.
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red | In deck |
| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |
| Whistle Post | — | — | 1 | 0 | 0 | 0 | — |
| Depot | yes | yes | 2 | 1 | 1 | 1 | 4 |
| Station | yes | yes | 3 | 2 | 2 | 2 | 2 |
| Terminal | yes | yes | 4 | 3 | 3 | 3 | 1 |
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
supply of 8.
---
## Freight facilities
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
build one end of a chain or the other, never both, which is what forces traffic to run between
districts rather than in circles inside one. No two of the same industry may share an Office Area,
and that rule is enforced for every kind rather than repeated in each row.
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with | Copies |
| --- | --- | --- | ---: | ---: | ---: | --- | ---: |
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse | 2 |
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant | 2 |
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant | 1 |
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery | 2 |
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse | 1 |
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House | 1 |
---
## Modifier cards
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
is not one.
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters | Copies |
| --- | --- | ---: | ---: | ---: | ---: | ---: |
| Waiting area | any Passenger Facility | 1 | — | — | 1 | 3 |
| Restaurant | any Passenger Facility | 1 | — | — | 1 | 2 |
| Hotel | any Passenger Facility | 1 | — | — | 1 | 1 |
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — | 2 |
| Railroad Express Agency | Freight House | 1 | — | 1 | — | 1 |
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — | 2 |
| Prep Plant | Mine Tipple | 1 | — | 1 | — | 1 |
| Coal Piles | Mine Tipple | 1 | — | 1 | — | 1 |
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — | 1 |
| Pipelines | Refinery | 1 | — | 1 | — | 1 |
| Oil Depot | Refinery | 1 | — | 1 | — | 1 |
| Viscosity breakers | Refinery | 1 | — | 1 | — | 1 |
| Transmission lines | Power Plant | — | — | 1 | — | 1 |
| Rotary Dumps | Power Plant | — | — | 1 | — | 1 |
| Steam Turbines | Power Plant | — | — | 1 | — | 1 |
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — | 2 |
| Local small groceries | Grocer's Warehouse | — | — | 1 | — | 1 |
---
## Track cards
Track is **in the Home Office deck** and is drawn and played like any other card — not a separate
per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout
may be run through but not stopped on.
| Track | Geometry | Hand | Operational rail | Move cost | In deck |
| --- | --- | --- | :---: | ---: | ---: |
| Straight track | straight | none | yes | 1 | 16 |
| Curved track (right) | curved | right | yes | 1 | 8 |
| Curved track (left) | curved | left | yes | 1 | 8 |
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | — |
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | — |
| Turnout (right) | turnout | right | — | 1 | 8 |
| Turnout (left) | turnout | left | — | 1 | 8 |
48 track cards are dealt in total. Rows showing no copies are shapes the engine
understands but the deck does not currently print.
+5
View File
@@ -4,6 +4,11 @@
> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed > 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed
> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison. > in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison.
> >
> **For what the cards say today, read [`as-built.md`](as-built.md)** — generated from
> `content.ts` and checked against it by the test suite, so it cannot fall behind the way this file
> did. For several releases `content.ts` named *this* page as the current reference while the banner
> here said otherwise, and a reader following the code landed on the v0.4.5 deck.
>
> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to > Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to
> ask the design. **Do not use its numbers.** > ask the design. **Do not use its numbers.**
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.7.9.1", "version": "0.7.9.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
@@ -13,7 +13,8 @@
"test": "node --test test/*.test.ts test/**/*.test.ts", "test": "node --test test/*.test.ts test/**/*.test.ts",
"build:web": "node scripts/build-web.ts", "build:web": "node scripts/build-web.ts",
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1", "serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
"deploy:web": "node scripts/deploy-web.ts" "deploy:web": "node scripts/deploy-web.ts",
"build:cards": "node scripts/build-card-reference.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
+212
View File
@@ -0,0 +1,212 @@
/**
* Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them.
*
* WHY THIS IS GENERATED RATHER THAN WRITTEN.
*
* Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a
* faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker,
* `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the
* game as built, and none of them should be edited to — the record is worth more intact than
* patched.
*
* So there was no current reference at all, and `content.ts` spent several releases pointing at
* `card-reference.md` as "the place that now carries what the cards say" while that file's own
* banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5
* deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train
* 3 is the Express with two freight cars and a per-location freight rule.
*
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing
* fails when a table falls behind a constant. So the reference is emitted from the same exported
* catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator
* and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red
* until the doc is regenerated — which is the only mechanism this project has found that keeps a
* document honest.
*
* `npm run build:cards` writes it. Nothing at runtime reads it; it is for people.
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ALL_TRAINS, EXTRA_TRAINS, INDUSTRY_PROFILES, LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_PROFILES,
MODIFIER_PROFILES, OFFICE_PROFILES, TIMETABLED_TRAINS, TRACK_CARDS, TRACK_IN_DECK,
WHISTLE_POST_SUPPLY, consistSize, mainlineDescription,
} from '../src/engine/content.ts';
import type { ConsistSpec, TrainProfile, TrainRules } from '../src/engine/content.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Title Case a camelCase key, so `oneFreightPerLocation` reads as a rule rather than an identifier. */
const words = (k: string): string => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
const consistOf = (c: ConsistSpec): string => {
const parts: string[] = [];
if (c.freight > 0) {
const kinds = c.freightTypes ? c.freightTypes.join(' or ') : 'freight';
parts.push(`${c.freight} ${kinds}${c.freight === 1 ? '' : c.freightTypes ? 's' : ''}`);
}
if (c.coach > 0) parts.push(`${c.coach} coach${c.coach === 1 ? '' : 'es'}`);
if (c.caboose > 0) parts.push(`${c.caboose} caboose`);
if (!parts.length) return 'engine only';
const n = consistSize(c);
// "Empties only" qualifies the whole consist rather than adding to it, so it reads after the count.
return `${parts.join(' + ')} (${n} piece${n === 1 ? '' : 's'})${c.emptiesOnly ? ', empties only' : ''}`;
};
const rulesOf = (r: TrainRules): string => {
const out: string[] = [];
for (const [k, v] of Object.entries(r)) {
if (k === 'note' || v === false || v === undefined) continue;
out.push(words(k));
}
if (typeof r.note === 'string') out.push(`*"${r.note}"*`);
return out.length ? out.join('; ') : '—';
};
const trainRow = (t: TrainProfile): string =>
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
const lines: string[] = [];
const w = (s = ''): void => void lines.push(s);
w('# Station Master — the cards as built');
w();
w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by');
w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file');
w('> and the code disagree.');
w();
w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything');
w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)');
w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in');
w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and');
w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the');
w('reasoning; read this for the numbers.');
w();
w('The engine instantiates from the same constants this is emitted from, so a disagreement between');
w('this page and the game is a bug in the generator, not a stale table.');
w();
w('---');
w();
w('## Trains');
w();
w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`);
w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two');
w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no');
w('train with a caboose carries more than three revenue cars.');
w();
w('### Timetabled');
w();
w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |');
for (const t of TIMETABLED_TRAINS) w(trainRow(t));
w();
w('### Extras');
w();
w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |');
for (const t of EXTRA_TRAINS) w(trainRow(t));
w();
w('---');
w();
w('## Mainline cards');
w();
w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a');
w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast');
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
w('part of the road all change the entry point rather than the card\'s length.');
w();
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
w('| --- | ---: | ---: | --- | :---: | :---: |');
for (const m of MAINLINE_PROFILES) {
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
w(`| ${m.name} | ${m.regions} | ${m.defaultStart} | ${ss} | ${m.trainsMayPass ? 'yes' : '—'} | ${m.sortsCars ? 'yes' : '—'} |`);
}
w();
w(`The Mainline deck is ${MAINLINE_DECK.length} cards; the two Division Points are the fixed ends of`);
w('the Division and are not dealt. What each card does, in the words the game uses on screen:');
w();
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
w();
w('---');
w();
w('## Office cards');
w();
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
w('to Porters rather than one more.');
w();
w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red | In deck |');
w('| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |');
for (const o of OFFICE_PROFILES) {
w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` +
`${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} | ${o.copiesInDeck || '—'} |`);
}
w();
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
w(`supply of ${LIMITS_SUPPLY}.`);
w();
w('---');
w();
w('## Freight facilities');
w();
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
w('districts rather than in circles inside one. No two of the same industry may share an Office Area,');
w('and that rule is enforced for every kind rather than repeated in each row.');
w();
w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with | Copies |');
w('| --- | --- | --- | ---: | ---: | ---: | --- | ---: |');
for (const f of INDUSTRY_PROFILES) {
const lo = f.lockouts.length
? f.lockouts.map((k) => INDUSTRY_PROFILES.find((p) => p.kind === k)?.name ?? k).join(', ')
: '—';
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} | ${f.copies} |`);
}
w();
w('---');
w();
w('## Modifier cards');
w();
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
w('is not one.');
w();
w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters | Copies |');
w('| --- | --- | ---: | ---: | ---: | ---: | ---: |');
for (const m of MODIFIER_PROFILES) {
const hosts = m.hosts
.map((h) => (h === 'office' ? 'any Passenger Facility' : INDUSTRY_PROFILES.find((p) => p.kind === h)?.name ?? h))
.join(', ');
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} | ${m.copies} |`);
}
w();
w('---');
w();
w('## Track cards');
w();
w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate');
w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout');
w('may be run through but not stopped on.');
w();
w('| Track | Geometry | Hand | Operational rail | Move cost | In deck |');
w('| --- | --- | --- | :---: | ---: | ---: |');
for (const t of TRACK_CARDS) {
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck || '—'} |`);
}
w();
w(`${TRACK_IN_DECK} track cards are dealt in total. Rows showing no copies are shapes the engine`);
w('understands but the deck does not currently print.');
w();
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);
console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);
+5 -2
View File
@@ -481,8 +481,11 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited * COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card * drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it * faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries * still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
* what the cards say. * the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with
* `test/card-reference.test.ts` failing if the two disagree. This comment used to name
* `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
* numbers; the code sent readers to a table it had itself superseded.
*/ */
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 }, ...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }), { terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
+34 -2
View File
@@ -333,7 +333,21 @@ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames }); const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
game.log.push({ text: 'Game Begins', tone: 'start' }); game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' }); /**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
*
* `game.log` is one shared list and `linesSince(seat)` (`server/session.ts`) slices it with no
* per-seat filter, so every line here reaches every player. Announcing the seed therefore handed
* each of them the whole future of the deal — every card order, every die — in the opening line
* of the game. Found while planning the public common board; it is a multiplayer leak with or
* without that display, which is why it is fixed here rather than waiting for it.
*
* `newGame` still records it, deliberately: a solitaire table has nobody to leak to, and the seed
* in the log is what a bug report quotes. The rule is "do not tell the OTHER seats", not "write
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
* lobby record, so nothing administrative or replayable loses it.
*/
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' });
drain(game); drain(game);
return game; return game;
} }
@@ -1149,10 +1163,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
cardName: (id) => cardName(game.state, id), cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id), trainName: (id) => trainName(game.state, id),
}); });
/**
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
*
* Everybody at the table sees a hand go to the Home Office deck, so the draw itself belongs in
* the shared log. The card's NAME does not: the deck is face down, and this log goes to every
* seat unfiltered, so naming it told three opponents exactly what the fourth was holding.
*
* A DEPARTMENT SLOT IS NOT THE SAME and stays named. Those piles are face up — a discard goes
* onto one precisely so a rival can take it — so the card was public before it was drawn, and
* hiding it would lose real information for no gain.
*
* The drawing seat still learns what it got. `justDrawn` below is the owner-only channel and
* `session.ts` sends it to that seat alone, so this costs the drawer nothing. Solitaire keeps
* the name for the same reason it keeps the seed: a one-seat table has nobody to leak to, and
* a solo player's history naming their own draw is the record rather than a leak.
*/
const blindDraw = e.type === 'cardDrawn' && e.source === 'homeOffice' && game.state.players.length > 1;
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
// one seat. Only events the player caused are attributed; the Division running itself is not. // one seat. Only events the player caused are attributed; the Division running itself is not.
const mine = who !== null && 'player' in e; const mine = who !== null && 'player' in e;
const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text; const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
game.log.push({ text, tone: mine ? 'act' : n.tone }); game.log.push({ text, tone: mine ? 'act' : n.tone });
} }
+54
View File
@@ -0,0 +1,54 @@
/**
* The card reference must not drift from the cards.
*
* `docs/rules/card-reference.md` spent several releases describing the v0.4.5 deck — twelve numbered
* trains, "3 / 4 Mail-Express, 3 coaches" — while `content.ts` had train 3 as the Express with two
* freight cars and a per-location freight rule. Worse, `content.ts` named that file as "the place
* that now carries what the cards say", so the code sent readers to a table its own banner told them
* not to trust. Nothing failed, because nothing checked.
*
* `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates
* from, and this re-runs the generator and compares. Change a card face without regenerating and
* this goes red — which is the whole point: a document nothing verifies is a document that will be
* wrong, and this project's own history is the evidence.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const doc = join(root, 'docs/rules/as-built.md');
describe('docs/rules/as-built.md is generated, and current', () => {
it('matches what the generator emits from content.ts today', () => {
const before = readFileSync(doc, 'utf8');
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
const after = readFileSync(doc, 'utf8');
assert.equal(
after,
before,
'the checked-in card reference is stale — run `npm run build:cards` and commit the result',
);
});
it('carries the current train catalogue, not the v0.4.5 deck', () => {
// The specific drift that went unnoticed for several releases, asserted by name so a future
// regeneration against an old content.ts cannot quietly reintroduce it.
const md = readFileSync(doc, 'utf8');
assert.match(md, /Crack Limited/);
assert.match(md, /\| 3 \| Express \|/);
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
});
it('says it is generated, so nobody edits it by hand', () => {
const md = readFileSync(doc, 'utf8');
assert.match(md, /Generated from `src\/engine\/content\.ts`/);
assert.match(md, /Do not edit by/);
});
});
+76 -1
View File
@@ -17,7 +17,8 @@ import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts'; import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts'; import { cardName, snapshot } from '../src/sim/view.ts';
import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
const config: GameConfig = { const config: GameConfig = {
mode: 'competitive', mode: 'competitive',
@@ -139,3 +140,77 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
} }
}); });
}); });
/**
* THE OTHER HALF OF §7, AND THE HALF THAT WAS NEVER LOOKED AT.
*
* Every test above serializes a `Frame`, and every one of them passes `[]` for the narration log —
* so the entire shared log has sat outside the redaction net since the net was built. It is not a
* hypothetical hole: `game.log` is ONE list, and `linesSince(seat)` (`server/session.ts`) slices it
* with no per-seat filter at all, so every line written into it reaches every player.
*
* Two things were being written into it that should never have left the seat that caused them, both
* found while planning the public common board (Gitea#20 step 1) and both live in multiplayer today,
* with or without that display:
*
* 1. the SEED, announced in the opening line of every multiplayer game — which hands every player
* the whole future of the deal;
* 2. the NAME OF A CARD DRAWN BLIND from the Home Office deck.
*
* SOLITAIRE IS DELIBERATELY LEFT ALONE in both cases. There is nobody to leak to at a one-seat
* table, the seed in the log is what a bug report quotes, and a solo player's own history naming
* the card they drew is the record, not a leak. The rule is "do not tell the OTHER seats", not
* "write less down" — so both checks below assert the solitaire text is still there.
*/
describe('redaction — the shared narration log never carries a seat\'s secrets', () => {
const names = ['Ann', 'Bob', 'Cy'];
it('never announces the seed to the table (Gitea#20 step 1)', () => {
const g = newMultiplayerGame(550943578, config, names);
const log = g.log.map((l) => l.text).join('\n');
assert.ok(
!/550943578/.test(log),
`the seed was announced to every seat:\n${log}`,
);
// The opening line must still say what the game IS — the leak is the number, not the line.
assert.match(log, /Game Begins/);
assert.match(log, /3 players/);
});
it('still tells a solitaire player their own seed — there is nobody to leak it to', () => {
const g = newGame(550943578);
const log = g.log.map((l) => l.text).join('\n');
assert.match(log, /550943578/, 'a solo game stopped recording the seed its bug reports quote');
});
it('never names a card drawn blind from the Home Office deck (Gitea#20 step 1)', () => {
const g = newMultiplayerGame(4242, config, names);
// Drive to the first Home Office draw any seat makes, and note what it actually drew.
let drawn: string | null = null;
for (let i = 0; i < 400 && drawn === null; i++) {
const actor = g.state.clock.currentActor;
if (actor === null) break;
const before = g.log.length;
if (!submit(g, { type: 'localOps.choose', option: 'draw' }, actor as PlayerIndex)) continue;
if (!submit(g, { type: 'draw.fromHomeOffice' }, actor as PlayerIndex)) continue;
drawn = g.justDrawn;
void before;
}
assert.ok(drawn, 'no seat ever drew from the Home Office deck');
const name = cardName(g.state, drawn!);
const log = g.log.map((l) => l.text).join('\n');
assert.ok(
!log.includes(name),
`a blind draw named "${name}" to the whole table:\n${log.split('\n').slice(-6).join('\n')}`,
);
// The draw itself is public — everyone saw a hand go to the deck. Only WHICH card is not.
assert.match(log, /Home Office/i);
// And the drawing seat still learns what it got: `justDrawn` is the owner-only channel, and
// `session.ts` sends it to that seat alone.
assert.equal(g.justDrawn, drawn);
});
});