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.
714 lines
9.6 KiB
JSON
714 lines
9.6 KiB
JSON
{
|
|
"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
|
|
}
|
|
}
|
|
} |