v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted

Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.

Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).

Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.

Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.

Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.

docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:50:38 -04:00
parent f9c4d9fa92
commit c3c5cbfeec
52 changed files with 5282 additions and 420 deletions
+16 -5
View File
@@ -42,17 +42,28 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
| Grocer's Warehouse | Boxcar | Both | 1 | 1 | 1 | 3 | 3 |
| Oil Refinery | Tank car | Both | 1 | 1 | 1 | 4 | 3 |
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
| Freight House | Boxcar | Both | 1 | 1 | 1 | — | 6 |
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
outbound-only, inbound-only, and both.
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
and Freight Houses permit cars to move each direction" therefore names exactly those two.
**"Freight House" IS a card** (corrected v0.5.0) — a sixth industry, dealt 6 copies, one Laborer and
one slot each direction. An earlier pass here read §9.3/Appendix A's "Passenger Facilities and
Freight Houses permit cars to move each direction" as meaning "Freight House" was only ever a
*collective term* for the Grocer's Warehouse and the Oil Refinery, never a card of its own — that
reading was wrong; the engine deals it as a real sixth industry (`content.ts`'s `freightHouse`
profile) and this table follows the engine.
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
re-verified against the engine in this pass — only Grocer's Warehouse, Oil Refinery and Freight
House were. `mineTipple` and `powerPlant` in `content.ts` are also base 1/1/1, same as the three
corrected here, so these three rows and the "Throughput" reasoning below (built on the OLD
2–3-Laborer numbers) may be stale too. Flagged in TODO.md rather than silently rewritten — this
needs the same kind of decision Jesse made for Grocer's/Refinery/Freight House, not an assumption
that the same correction applies. -->
### Throughput — why these Laborer counts
+2 -2
View File
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 |
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
| **Freight House** | A sixth Freight Facility card (corrected v0.5.0 — an earlier pass here read it as a collective term for the Grocer's Warehouse and the Oil Refinery rather than a card of its own; it is dealt like any other industry). Permits both directions, the same as a Grocer's Warehouse or Oil Refinery. | §9.3, §12.5 |
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
+6 -4
View File
@@ -82,10 +82,12 @@ direction. `DivisionNode.gradeUp` records the direction a train travels when **c
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
climb, so turning the card around swaps which trains each card helps.
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
should become a real player choice when setup gains an interactive phase.
**Settled (v0.5.0): orientation is never a player choice, and this overrides the card's own printed
"Player sets orientation."** A Heavy Grade sits on the shared west-to-east Division chain, between
two players (§4.2) or beyond an end Division Point next to one — never inside a single player's own
district — so whichever direction climbs advantages one neighbour over the other, and no one player
has a fair claim to the decision. Orientation is **rolled** from the seed instead — deterministic,
and roughly even (51/49 east/west across 400 games) — identically for solitaire and multiplayer.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
+6 -4
View File
@@ -718,10 +718,12 @@ leave no way at all to speed up a slow facility. Section Gang is the sole worker
keeps that power scarce. Capacity modifiers are stronger than they sound, since banking room rather
than worker count is what limits output.
**Sub-decision — what a "Freight House" is.** §9.3 and Appendix A refer to Freight Houses, but §9's
facility list never defines one. It is **not a card**: it is the collective term for a freight
facility that both loads and unloads — under 10a, the Grocer's Warehouse and the Oil Refinery. This
reconciles every reference without adding a card type or disturbing Gap 4a's deck composition.
**Sub-decision — what a "Freight House" is. SUPERSEDED (v0.5.0).** §9.3 and Appendix A refer to
Freight Houses, but §9's facility list never defines one. This originally concluded it was **not a
card** — the collective term for a freight facility that both loads and unloads, under 10a the
Grocer's Warehouse and the Oil Refinery. That reading was wrong: it **is** a card, a sixth Freight
Facility dealt 6 copies (`content.ts`'s `freightHouse` profile), which the engine has implemented
all along. Left here for the reasoning; do not read this entry as current.
### 10e — Revenue/Day revalidated, and the Gap 6 targets revised
+7 -5
View File
@@ -452,9 +452,10 @@ Loading and unloading of Rolling Stock (passengers and freight) takes place in t
(§11). A Whistle Post is not a Passenger Facility, so a player who has not upgraded has no
passenger business at all.
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
Plants. Per-card values in §12.5.
- **Freight House:** **[Gap 10d]** not a facility type of its own, but the collective term for a
freight facility permitting both directions — the Grocer's Warehouse and the Oil Refinery.
Plants, Freight Houses. Per-card values in §12.5.
- **Freight House:** a Freight Facility card in its own right (corrected v0.5.0 — **[Gap 10d]**
previously read this as a collective term for the Grocer's Warehouse and the Oil Refinery rather
than a card of its own). Permits both directions, the same as those two.
- **Modifier Facilities:** Cards that, placed adjacent (on any of the nine nearby spots), increase a
passenger or freight facility's capacities. If two Facilities are adjacent to the Modifier, its
effects may only be used on one Facility per **Stage** **[Gap 7]**.
@@ -753,8 +754,9 @@ Modifier effects, and track geometries — is catalogued in
| Extra Platform | +1 green and +1 red slot (passenger) |
| Section Gang | +1 Laborer or +1 Porter |
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card (corrected v0.5.0 — previously
read as not a card, only a collective term for a facility that both loads and unloads). It permits
both directions, the same as the Grocer's Warehouse and the Oil Refinery.
---
+301
View File
@@ -0,0 +1,301 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
@@ -0,0 +1,714 @@
{
"seed": 116956197,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c27"
},
{
"type": "card.play",
"cardId": "c30"
},
{
"type": "card.play",
"cardId": "c4"
},
{
"type": "card.play",
"cardId": "c2"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c197",
"placement": {
"row": 0,
"col": -1
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.play",
"cardId": "c163",
"placement": {
"row": 1,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c41",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c127",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c183",
"placement": {
"row": 0,
"col": 2
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c150",
"placement": {
"row": 1,
"col": 2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c136",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c77",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c171",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c153",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c9"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": true
},
{
"type": "switch.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": 1,
"col": 0
},
"carIndex": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c162",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 1,
"col": 0
},
"carType": "boxcar"
},
{
"type": "laborer.startLoad",
"at": {
"row": 1,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 1,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c50",
"toSlot": 0
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false,
"via": {
"row": 0,
"col": 0
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 1
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray2",
"count": 3
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 3
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": -2
},
"reverse": true,
"via": {
"row": 0,
"col": 1
}
},
{
"type": "switch.move",
"trayId": "tray2",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.end"
}
],
"rules": {
"startingHand": "sixRandom",
"revenue": {
"passengerPerCoach": 1,
"freightPerLoad": 1,
"trainPerTransit": 0
}
}
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
# Station Master 0.5.0 — Test Plan
Written 2026-08-21, against an uncommitted working tree. Covers only the changes made in this
session's work — victory-condition unification, the New Train phase fix, and the multiplayer server
(Phases 2-3). Nothing here is committed yet; see the Coordination Note below before running any of it
against a tree that has since moved.
## Coordination note — shared working tree
Another thread is bug-fixing the 0.4.9 series **in this same uncommitted working tree**, concurrently.
`git status` shows changes I did not make in: `apply.ts`, `events.ts`, `track.ts`, `board-svg.ts`,
`docs/rules/*.md`, `package-lock.json`, `scripts/build-web.ts`, `.gitignore`, plus new
`docs/station-master-seed*.json` files still appearing as of this writing. **This plan does not cover
any of that** — it is scoped to the files below, which are the ones I actually touched.
Practical implications:
- Before running this plan, confirm the files under "Files this plan covers" still read the way this
document describes them — the other thread's commits could in principle touch the same functions
(`apply.ts`'s `check()` is used directly by `src/server/session.ts`, for instance).
- Run the full regression gate (`npm run typecheck && npm test`) fresh, not from a memory of an earlier
green run — both threads are editing live.
- The two bodies of work are unrelated in intent (0.4.9 patch fixes vs. 0.5.0 multiplayer groundwork)
but share a tree, so a clean split into separate commits before either is finalized is worth doing
deliberately rather than by accident.
### Files this plan covers
```
Engine: src/engine/advance.ts, src/engine/content.ts, src/engine/setup.ts, src/engine/state.ts
Web: src/web/game.ts, src/web/main.ts, src/web/play.html, src/web/session.ts
Sim: src/sim/view.ts, src/sim/frame-delta.ts (new), src/sim/compare.ts, src/sim/harness.ts,
src/sim/replay.ts
Server: src/server/ (new — index.ts, http.ts, session.ts, persistence.ts)
Tests: test/advance.test.ts, test/multiplayer.test.ts, test/web.test.ts, test/setup.test.ts,
test/redaction.test.ts (new), test/frame-delta.test.ts (new), test/server/ (new)
Docs: TODO.md, package.json (test script only)
```
---
## 0. Automated regression gate — run first, every time
```
npm run typecheck
npm test
```
Expected: typecheck clean, **all tests passing** (632 at the time this plan was written — the exact
number will drift as the other thread's work lands; what matters is zero failures). If either command
is red, stop and diagnose before doing anything below — a red gate means the manual steps are testing
against a broken build.
**If you only have time for one thing before the break ends, run this.** Everything else in this
document is either already covered by it, or is the manual/live verification that can't be — flagged
per-section below.
---
## 1. Regression baseline — solitaire must play exactly as before
The engine and dialog changes touch shared code paths solitaire also uses. None of this should have
changed solitaire's actual behavior.
| # | Test | Steps | Expected |
|---|---|---|---|
| 1.1 | Fresh deal | `npm run serve:web`, open `/play.html`, no URL params | A new random-seed solitaire game deals normally, board and hand render |
| 1.2 | Full game to completion | Play (or let a bot/harness play) a solitaire game to Day 5 | Game ends with a win/loss exactly as it would have before — `minCombinedRevenue` defaults to `collectiveRevenueFloor(1,5)=15`, not the old fixed `20`; a game scoring 15-19 that used to lose now wins (**intentional** — see §3, not a bug) |
| 1.3 | Save / restore | Play a few turns, reload the page (auto-restores from `localStorage`) | Game resumes exactly where it left off, house rules and victory dials intact |
| 1.4 | Save file download | Click "Save replay" mid-game | Downloads a small JSON; re-opening it in the replay viewer plays back identically |
| 1.5 | Undo | Play a few turns, click Undo repeatedly to the start | Each step back is clean; log never shows a move that "un-happened" |
| 1.6 | Existing published replays | Open each file under `public/replays/` in the replay viewer | All three still play back — `LEGACY_HOUSE_RULES` fallback for saves with no `houseRules` field is unaffected by this session's changes |
Automated coverage: `test/advance.test.ts`, `test/web.test.ts`, `test/session.test.ts` already assert
most of this at the unit level. §1 is about confirming nothing *visibly* changed for a solitaire
player, which only a live playthrough shows.
---
## 2. New Train phase car-placement rotation (engine fix)
**What changed:** the Superintendent used to place every car of every train alone, even in
competitive mode, against §7's written rule. Now the round rotates Superintendent-then-left, one car
per player, per `tray.consist.length`.
Automated: `test/multiplayer.test.ts`, "the New Train phase car-placement round rotates (§7, Gap 9)" —
asserts the exact actor sequence for 2p (wraps: 0,1,0) and 3p (no wrap: 0,1,2) against a real train
profile.
| # | Test | Steps | Expected |
|---|---|---|---|
| 2.1 | Manual multi-seat check | Drive a 3-4 player competitive game via the bot harness (`node src/sim/harness.ts`) or by hand through `src/server/`, and watch a Timetabled train get made up | Car-placement decisions visibly move between seats rather than one player filling the whole consist |
| 2.2 | Solitaire unaffected | Play any solitaire game where a train is made up | No visible change — one player, nothing to rotate, `tray.consist.length % 1 === 0` always |
Not covered, flagged in `TODO.md` (do not re-test, it's expected to be unreachable): `newTrain.passCar`
still has a dormant `check()`/`reduce()` gap, harmless because `trainNeedingCars` never offers a tray
with nothing suitable in the yard.
---
## 3. Victory conditions unified (`GameConfig` dials)
**What changed:** `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
`pvpCardsAllowed` replace the old `length`-preset/`target`/flat collision constant, across all three
modes. Automated coverage: `test/advance.test.ts`'s "victory conditions (§3, Gap 10e) — unified
2026-08-20" describe block (8 tests) covers every case below at the engine level already. This section
is for confirming the *dialog* actually produces the numbers the engine then honors.
| # | Test | Steps | Expected |
|---|---|---|---|
| 3.1 | `minCombinedRevenue` — loss | New Game, set it to a number higher than achievable, play to Day N | Loss, reason `revenueFloor`, regardless of individual score |
| 3.2 | `minCombinedRevenue` — win | Set it to 0, play to Day N with any score | Win — `0` genuinely disables the floor |
| 3.3 | `maxCollisionsPerDay` — **new solitaire behavior** | Set to 1, deliberately cause 1 collision (e.g. leave every A/D track occupied on an arrival) | Game ends **immediately**, mid-Stage, loss, reason `collisionFloor` — solitaire could never lose this way before this change |
| 3.4 | `maxCollisionsTotal` | Set `maxCollisionsPerDay=0` (off), `maxCollisionsTotal=2`, cause 2 collisions across different Days | Game ends on the 2nd collision, whichever Day it falls on |
| 3.5 | Both collision caps at 0 | Set both to 0, cause several collisions | Game does **not** end early — same as pre-change solitaire (collisions still cost Revenue, just don't end the game) |
| 3.6 | Mode radio defaults | Open New Game, click Competitive | `minCombinedRevenue` suggests `3 × 4 × days` (nominal 4-player assumption — no lobby yet to ask real seat count), `pvpCardsAllowed` checks and enables, Deal disables with a "needs a server" note |
| 3.7 | Mode radio — Co-op | Click Co-op | `pvpCardsAllowed` unchecks and **greys out** (forced off, not just defaulted off — no valid target when everyone's on one side), Deal disables |
| 3.8 | Mode radio — back to Solitaire | Click Solitaire again | Deal re-enables, `pvpCardsAllowed` unchecks and greys, all four dials show the numbers the *currently playing* game actually has (not the preset) |
| 3.9 | URL round-trip | Deal a game with non-default `days`/`minrev`/`colday`/`coltotal`, copy the URL, open it in a new tab | Identical settings — all four now travel via `?days=&minrev=&colday=&coltotal=` alongside the existing `?hand=&passenger=&freight=&transit=` |
**Not yet clicked through in a real browser** (no browser binary in the sandbox this was built in —
verified only via a simulated-DOM test harness, `test/web.test.ts`'s "the New Game dialog" describe
block, which drives the actual compiled bundle). §3.6-3.9 specifically should get a real click-through
before calling this done.
---
## 4. Multiplayer server — Phase 2 (session core)
New in this release: `src/server/` (session host, HTTP/SSE, static serving), `src/web/session.ts`'s
`createRemoteSession`, `src/sim/frame-delta.ts`. Automated: `test/server/session.test.ts` (12 cases,
pure logic, no sockets), `test/redaction.test.ts` (4 cases — the exhaustive "no seat sees another
seat's secrets" check), `test/frame-delta.test.ts` (5 cases).
| # | Test | Steps | Expected |
|---|---|---|---|
| 4.1 | Boot | `JOIN_SECRET=x PORT=8081 node src/server/index.ts` | Starts, logs the bind address and `dist/` path |
| 4.2 | Join secret gate | `curl -X POST localhost:8081/api/game` (no `?secret=`) | `403 bad or missing secret` |
| 4.3 | Create game | `POST /api/game?secret=x` with `{config, playerNames}` | `200 {ok:true, playerCount:N}`; a second call while a game exists → `409` |
| 4.4 | Per-seat SSE | Open `/api/stream?seat=0` and `?seat=1` for a 2-player game | Only the current actor's push has a non-null `menu`; the other seat's is `null` |
| 4.5 | Turn enforcement | `POST /api/intent` as the **non-acting** seat | `{ok:false, code:"NOT_YOUR_TURN"}`, no broadcast, no state change |
| 4.6 | Accept + broadcast | `POST /api/intent` as the acting seat, a legal intent | `{ok:true}`; **both** SSE streams receive a push with fresh narration |
| 4.7 | Idempotent resend | Submit the same `{seq, intent}` twice | Second call: `{ok:true}`, **no** new SSE push (already applied, silently ignored) |
| 4.8 | Illegal intent | Submit something `check()` would reject | `{ok:false, code:"<real RejectionCode>"}`; the rejection text does **not** appear in the *other* seat's narration (private feedback, §session.ts's design note) |
| 4.9 | Board delta | Submit an intent that doesn't move anything on the board (e.g. `localOps.choose`) | The next push's `frame.division`/`frame.cells` are `null` (unchanged-since-last-push-to-this-seat) |
| 4.10 | Heartbeat | Hold an SSE connection open >20s idle | A `: ping` comment line appears (EventSource ignores it; keeps a proxy from reaping the socket) |
| 4.11 | **Real browser — not yet done** | Two actual browser tabs, `?seat=0` and `?seat=1`, playing a shared game by clicking | Confirm the whole loop works through the actual UI, not just curl: turn indicator, disabled controls for the non-acting seat, board updates on both sides, a rejected action shows *something* sensible on screen rather than silently doing nothing |
§4.1-4.10 were run live against a real server process during this session (not just unit-tested) — see
the session transcript for the actual curl commands and captured output. §4.11 genuinely was not done;
no browser was available in the environment this was built in.
---
## 5. Multiplayer server — Phase 3 (persistence and resumption)
New: `src/server/persistence.ts`, `game.ts`'s `fromMultiplayerSave`, `session.ts`'s `exportSave`/
`resumeSession`/turn-timing tracking. Automated: `test/server/persistence.test.ts` (6 cases),
`test/server/session.test.ts`'s "turn timings" and "persistence hooks" describe blocks.
| # | Test | Steps | Expected |
|---|---|---|---|
| 5.1 | Immediate persistence | `POST /api/game`, then check `DATA_DIR/game.json` | Exists immediately, empty `history`, correct `engineVersion` (= `package.json`'s `version`) |
| 5.2 | History grows | Submit a few intents | `game.json`'s `history` array grows by one entry per accepted intent, in order |
| 5.3 | Turn timings recorded | Submit enough intents to cross a full turn (player/phase/day/stage change) | `DATA_DIR/turn-timings.json` gains an entry: `{player, phase, day, stage, startedAt, endedAt}`, `endedAt >= startedAt` |
| 5.4 | **Restart and resume** (the actual "done when" for Phase 3) | Kill the server process mid-game, restart it against the same `DATA_DIR` | Log line: `"Resumed a saved game... N intents replayed"`; reconnecting both `?seat=` streams shows the exact same Day/Stage/phase/whose-turn as before the kill, correct narration attribution ("Player X chose to...", not anonymous) |
| 5.5 | Version-mismatch refusal | Hand-edit `game.json`'s `engineVersion` to a bogus value, restart | Log line refusing to load, naming both versions; server starts with **no** active game (confirm via `POST /api/game` succeeding, not `409`ing); the file is **not** deleted or modified |
| 5.6 | Atomic writes, no stray temp files | After several intents, check `DATA_DIR` | Only `game.json`/`turn-timings.json` present — no leftover `.tmp` files from an interrupted write |
§5.1-5.6 were all run live during this session (kill-and-restart included) — see the transcript. This
is the most thoroughly live-verified section of the whole plan.
---
## 6. Things this session found but did *not* fix — verify they're still correctly deferred
These are logged in `TODO.md`, not bugs to chase here — listed so a tester doesn't rediscover them and
assume something regressed.
| Item | Where | Current state to confirm |
|---|---|---|
| `fromSave`'s replay loses "Player X" narration attribution | `src/web/game.ts` | Still present in `fromSave` (untouched, out of scope); **fixed** in the new `fromMultiplayerSave` — §5.4's narration check is what confirms the fix landed where it needed to |
| `newTrain.passCar`'s `check()`/`reduce()` gap | `src/engine/apply.ts` | Still dormant/unreachable — confirm no new caller has started exercising it (would only matter if the *other* thread's 0.4.9 work touches this area) |
| No real browser click-through | This session | §3.6-3.9, §4.11 — the two gaps a human still needs to close |
---
## Summary checklist
- [ ] §0 automated gate green
- [ ] §1 solitaire regression (six checks)
- [ ] §2 New Train rotation, manual multi-seat check
- [ ] §3 victory conditions, especially §3.6-3.9 (dialog, not yet browser-verified)
- [ ] §4 server core, especially §4.11 (real browser, not yet done)
- [ ] §5 persistence — already live-verified this session; worth a second independent run
- [ ] §6 confirm the three known-and-deferred items are still exactly as described