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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user