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
+33
View File
@@ -33,6 +33,9 @@ header button:hover{border-color:#4d6fa8}
stops inviting the press. */
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d}
.zoom{display:inline-flex;align-items:center;gap:4px}
.zoom button{padding:3px 9px;line-height:1}
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
@@ -221,6 +224,12 @@ ul.blocked li{padding:2px 0}
to keep the header on one line; the tooltip spells it out. -->
<span class="dim" id="houserules" title="">—</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
rendered SVG's own pixel dimensions; the existing scrollbar keeps doing the panning. -->
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
</span>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
@@ -309,6 +318,15 @@ ul.blocked li{padding:2px 0}
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
<h3>Game type</h3>
<p class="ng-note">Picking a type just sets the fields below to that type's defaults — every number stays yours to change afterward. Solitaire is the only type that can be dealt today; Competitive and Co-op need a server (coming soon).</p>
<label class="ng-radio"><input type="radio" name="ng-mode" value="solitaire" checked>
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. Everything below is real today.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="competitive">
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-mode" value="coop">
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
<h3>Seed</h3>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
@@ -332,7 +350,22 @@ ul.blocked li{padding:2px 0}
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
<h3>Victory conditions</h3>
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off. The combined-Revenue suggestion updates for Competitive/Co-op — it assumes a 4-player table until there's a lobby to ask who's actually seated.</p>
<label class="ng-num"><span>Days</span>
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
<input id="ng-minrev" type="number" min="0" step="1" value="15"></label>
<label class="ng-num"><span>Collisions in one Day that end the game</span>
<input id="ng-colday" type="number" min="0" step="1" value="3"></label>
<label class="ng-num"><span>Collisions across the whole game that end it</span>
<input id="ng-coltotal" type="number" min="0" step="1" value="5"></label>
<label class="ng-num"><span>Allow the opponent-directed cards</span>
<input id="ng-pvp" type="checkbox"></label>
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
<menu class="ng-buttons">
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Multiplayer needs a server — coming soon.</span>
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>