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
|
## 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
> 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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
* 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
@@ -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 });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user