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:
co-authored by
Claude Opus 5
parent
7ade60e21f
commit
819996faa2
@@ -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
|
||||
|
||||
Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule
|
||||
|
||||
@@ -74,8 +74,8 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
|
||||
## Sections
|
||||
|
||||
1. **Play it at a table** — #39 #35 #42a #40 #32
|
||||
2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #78 #75
|
||||
1. **Play it at a table** — #39 #35 #42a #40
|
||||
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
|
||||
4. **The screen** — #44 #81 #33 #36
|
||||
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
|
||||
|
||||
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
|
||||
waits behind a release; this waits behind an afternoon.
|
||||
running on `phoenix.local` — and the items below name the ones no person has met at a board.
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -148,8 +149,16 @@ cheaper.
|
||||
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**.
|
||||
|
||||
- [ ] **#78** — The redaction test is more done than the plan suggests, but the remaining gap is real
|
||||
and is Gitea#20 step 1's starting point. See **Reference · #78**.
|
||||
- [ ] **#91** — **Narration is still outside the redaction net, and the two known leaks in it are
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
#### #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
|
||||
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
|
||||
a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its
|
||||
own hand, board, Revenue and impediments — traced `snapshot()` itself
|
||||
(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a
|
||||
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
|
||||
is all spot-checks, though — "this seat's Frame has the right hand length." What's still
|
||||
missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it
|
||||
contains none of another seat's actual card ids and no deck order, so a future careless edit
|
||||
is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()`
|
||||
and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now,
|
||||
2026-08-20.
|
||||
`Frame` and asserts no other seat's card ids or deck order appear in it — and every one of those
|
||||
tests passes `[]` for the log. So the shared narration has never been checked at all, while
|
||||
`game.log` is ONE list and `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat
|
||||
filter whatsoever. Every line written there reaches every player.
|
||||
|
||||
**Two leaks found and closed in v0.7.9.2**, both discovered while planning Gitea#20 and both live in
|
||||
multiplayer with or without that display: the **seed**, announced in the opening line of every
|
||||
multiplayer game, and the **name of a card drawn blind** from the Home Office deck. The tests for
|
||||
them are in `redaction.test.ts` now, so narration is no longer entirely unchecked — but two specific
|
||||
strings are not a net.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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
|
||||
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;
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
> 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.
|
||||
>
|
||||
> **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
|
||||
> ask the design. **Do not use its numbers.**
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9.1",
|
||||
"version": "0.7.9.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
@@ -13,7 +13,8 @@
|
||||
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"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": {
|
||||
"@types/node": "^26.1.2",
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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
|
||||
* 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
|
||||
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
|
||||
* what the cards say.
|
||||
* still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
|
||||
* 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 },
|
||||
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
||||
|
||||
+34
-2
@@ -333,7 +333,21 @@ export function newMultiplayerGame(seed: number, config: GameConfig, 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 };
|
||||
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);
|
||||
return game;
|
||||
}
|
||||
@@ -1149,10 +1163,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
cardName: (id) => cardName(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
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
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 });
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -17,7 +17,8 @@ import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.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 = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user