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
+98
View File
@@ -19,6 +19,104 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.0 — 2026-08-21
Multiplayer Phases 2 and 3 (`docs/architecture/multiplayer.md` §12): a real server exists now, one
game at a time, and it survives being restarted mid-game. Phases 0 and 1 shipped in v0.4.0; Phases
4–6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Also
folds in the three playtest fixes already released as v0.4.9b/c/d on the patch line, plus two rules
bugs and a victory-condition redesign found along the way.
### Phase 2 — server core, one game, no lobby
- `src/server/session.ts` — the game session host: pure logic, no sockets, built entirely on
`game.ts`'s existing `Game`/`submit`/`currentActor`/`actionMenu` rather than re-deriving intent
application or narration. **Found while building it:** `submit()` derives the acting player from
`currentActor(game)` and never checks who is actually calling it — harmless for `LocalSession`
(only one possible caller) but not safe for a server, so the session host now verifies `seat ===
currentActor(game)` itself before calling `submit`, rejecting with `NOT_YOUR_TURN` otherwise.
Idempotent resend (a repeated `seq`) and the illegal-intent path (checked via `check()` directly,
so a rejection never pollutes the shared narration log with text meant only for the submitter) are
both handled here.
- `src/server/http.ts` / `src/server/index.ts` — plain `node:http`, no framework: `POST /api/game`,
`GET /api/stream` (SSE, per-seat, 20s heartbeat, `id:` line per push), `POST /api/intent`, and
static serving of `dist/` so the server is same-origin with itself.
- `src/sim/frame-delta.ts` — the live per-seat board delta (`deltaFrame`/`applyDelta`), a smaller
replacement purpose-built for a single live push rather than reusing `replay.ts`'s `compress()`,
which interns strings across a whole recorded array with nothing here to intern against; only its
one-step-back "null if unchanged" idea carried over.
- **Found and fixed:** `actionMenu(game, seat)` only used `seat` for the `hand` field — everything
else came from `currentActor(game)` regardless of who asked, so a server computing every connected
seat's Menu would have handed the acting player's legal moves to a waiting seat, paired with the
wrong seat's cards. Fixed with a guard in `game.ts`; tested in `multiplayer.test.ts`.
- The redaction test (§7, `test/redaction.test.ts`) passed on the first run against the existing
`snapshot()`, confirming it was already correct rather than just apparently so.
- `src/web/session.ts` gained `createRemoteSession`; `main.ts`'s `start()` switches on `?seat=`
presence (one bundle, unchanged). Every `LocalSession`-only call site in `main.ts` now goes through
an `isLocal()` type guard instead of assuming.
- Verified two ways: `test/server/session.test.ts` exercises the session host directly, and a live
end-to-end smoke test (server started, a 2-player game created, two SSE streams opened, an intent
rejected from the non-acting seat, accepted from the acting seat and broadcast to both, a resent
`seq` producing no second push, the board correctly nulled on the second push). **Not verified: an
actual browser** — no browser binary in this environment, so `RemoteSession`'s DOM-facing code
compiled and typechecks but was never clicked through visually.
### Phase 3 — persistence and resumption
- `src/server/persistence.ts` — `game.json` (`{engineVersion, seed, config, playerNames, history,
status, createdAt}`) and `turn-timings.json`, both atomic-rewrite-then-rename.
- `game.ts` gained `fromMultiplayerSave`, `fromSave`'s multi-player sibling. **Found while testing
it:** `fromSave`'s replay loop calls `record(game, result.events)` without the `actor` argument
`submit()` always passes, so every replayed line loses its "Player X" attribution — invisible for
solitaire, immediately visible for multiplayer, where anonymous "Chose to…" lines are unreadable
the moment there is more than one seat. Fixed in the new function; `fromSave` itself still has the
gap, deliberately untouched here since it's used far more widely (undo, save/restore, the replay
viewer) and deserves its own pass.
- `session.ts` gained `exportSave()`, `resumeSession()`, and turn-timing tracking — a span (player,
phase, day, stage, start/end wall-clock) that closes and reopens whenever the acting player, phase,
Day or Stage changes, recorded entirely in the session host and never inside `history` (a replay
must reproduce a game from decisions alone).
- `index.ts` loads `game.json` on boot before starting the listener: version match → resumed and
replayed straight through; mismatch → refused explicitly and loudly, file left untouched, server
starts with no active game rather than replaying under the wrong rules.
- Verified live: server started against a fresh data directory, a 2-player game created, intents
submitted from both seats, **the server process killed and restarted**, both `?seat=` streams
reconnected and picked up exactly where they left off — same Day/Stage/phase, correct whose-turn,
correct narration attribution. Separately confirmed the version-mismatch path with a hand-edited
`engineVersion`.
- **Found and fixed an infrastructure bug along the way:** adding `test/server/` broke `npm test`'s
glob. `"test": "node --test test/**/*.test.ts"` relied on the shell passing the literal,
unexpanded pattern through whenever it matched no files at the shell level — the moment a
subdirectory existed, the shell expanded it to just that one file, and `npm test` silently ran only
the new suite. Fixed by listing both depths explicitly.
### Two rules bugs found while building this
- **New Train phase car-placement is one player's job even in competitive mode — it should be a
round.** §7 is explicit: starting with the Superintendent and working left, each player places one
car, and the round repeats until the consist is full. `newTrainPhase` never implemented the round —
`enterPhase` resets `actorOffset` to 0 on entry and nothing ever incremented it the way Local Ops
does — so the actor was always the Superintendent alone, for every car of every train, in every
mode. Fixed by reading the round position off `tray.consist.length`, which already counts
placements toward that tray and resets per train with no new state needed.
- **Victory conditions unified across solitaire, competitive and coop.** One shared, configurable
`GameConfig` set (`days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
`pvpCardsAllowed`) replaces the old fixed `LENGTH_PROFILES.target`, a dead `firstToTarget` victory
condition, and a flat collision-floor constant. Collision caps stay flat rather than scaling with
player count — Jesse's call: more players means more independent chances to collide, not a bigger
shared budget, so multiplayer is deliberately riskier than solitaire at the same default. One New
Game dialog now covers all three modes, with fields greyed out wherever a mode forces a value.
### The three v0.4.9b/c/d playtest fixes, folded in
Already shipped on the patch line — see 0.4.9d below for the full writeup of each. Summarized: a
switching train's crew badge failed to draw once it left the Office square (display only, game state
was never affected); unloading a freight car always took the westmost one regardless of which car
was picked; and a legal decision (`newTrain.startExtra`) could render with zero buttons, which was
indistinguishable from a hang. 635 tests, 0 failures.
---
## 0.4.9d — 2026-08-21
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress