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
+118
View File
@@ -0,0 +1,118 @@
/**
* §7's redaction test — "the single most important test in the plan."
*
* Everything else about `Frame` degrades gracefully; a redaction bug hands one player's hand to
* another and cannot be walked back once it has been seen. `test/multiplayer.test.ts`'s "the view
* shows one seat at a time" section already proves `snapshot(s, ..., viewer)` gives each seat its
* own hand, board and Revenue — spot-checks that today's code does the right thing. This is the
* different, exhaustive check: serialize a seat's whole `Frame` and assert none of some OTHER seat's
* actual secret data appears anywhere in it, so a future careless edit is caught rather than assumed
* safe. No server needed — `snapshot()` and a multi-player `GameState` are all this exercises.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** Plays a real multi-player game partway — enough for every seat to hold a real, distinct hand. */
function midGame(players: number, seed: number): GameState {
const s = createGame({
id: `redact-${players}`,
seed,
config,
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
});
const r = playGame(s, developerBot, pump, 400);
// A partial or finished game both exercise real hands — either is fine for this check.
void r;
return s;
}
describe('redaction — a seat\'s Frame never carries another seat\'s secrets', () => {
it('never contains another seat\'s actual hand-card ids', () => {
for (const players of [3, 4]) {
const s = midGame(players, 1000 + players);
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer));
for (let other = 0 as PlayerIndex; other < players; other++) {
if (other === viewer) continue;
for (const cardId of s.decks.hands.get(other) ?? []) {
assert.ok(
!serialized.includes(`"${cardId}"`),
`${players}p seed ${1000 + players}: seat ${viewer}'s Frame contains seat ${other}'s ` +
`hand card id "${cardId}"`,
);
}
}
}
}
});
it('never contains the Home Office deck\'s order or contents, only its count', () => {
for (const players of [3, 4]) {
const s = midGame(players, 2000 + players);
// The deck's own card ids are the thing that must never leak — distinct from any hand's ids,
// since a card once dealt is removed from `homeOffice` (state.ts).
const deckIds = new Set(s.decks.homeOffice);
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
const frame = snapshot(s, [], null, null, null, false, viewer);
assert.equal(frame.deck, s.decks.homeOffice.length, 'deck field is not a plain count');
const serialized = JSON.stringify(frame);
for (const cardId of deckIds) {
assert.ok(
!serialized.includes(`"${cardId}"`),
`${players}p seed ${2000 + players}: seat ${viewer}'s Frame contains a Home Office deck id "${cardId}"`,
);
}
}
}
});
it('never carries the seed or rngState — Frame has no field for either', () => {
// A structural guarantee, not a runtime one: confirmed here so a future field addition to Frame
// that reintroduces one of these is at least forced past a reader of this test, if not the type
// system directly (see Frame in src/sim/view.ts, which carries neither today).
const s = midGame(3, 3003);
const frame = snapshot(s, [], null, null, null, false, 0);
assert.ok(!('seed' in frame), 'Frame gained a seed field');
assert.ok(!('rngState' in frame), 'Frame gained an rngState field');
const serialized = JSON.stringify(frame);
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
});
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
// on everything else a Frame carries about shared state.
const s = midGame(3, 4004);
const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex));
for (const f of frames) {
assert.deepEqual(f.timetable, frames[0]!.timetable, 'the public timetable differs by seat');
assert.deepEqual(f.deck, frames[0]!.deck, 'the deck count differs by seat');
assert.deepEqual(
f.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
frames[0]!.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })),
'public standing (names, Revenue, hand COUNTS) differs by seat',
);
}
});
});