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
+51
View File
@@ -0,0 +1,51 @@
/**
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start.
*
* Run with: node src/server/index.ts
*
* Env-configured, no config file — matches how the rest of this project's dev-side tooling reads
* `process.env` directly (`scripts/build-web.ts`'s `BUILD_DIST_DIR`).
*/
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from './http.ts';
import { loadGame } from './persistence.ts';
import { resumeSession } from './session.ts';
import type { GameSession } from './session.ts';
const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
const joinSecret = process.env['JOIN_SECRET'];
const distDir = resolve(process.env['DIST_DIR'] ?? 'dist');
const dataDir = resolve(process.env['DATA_DIR'] ?? 'data');
if (!joinSecret) {
console.error('JOIN_SECRET must be set — a server-wide secret, passed out of band (D14).');
process.exit(1);
}
// `package.json`'s version IS `engineVersion` (§12 step 15) — the same reading `scripts/build-web.ts`'s
// `buildStamp()` already does, just from `src/server/` rather than the repo root script directory.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
let initialSession: GameSession | null = null;
const loaded = await loadGame(dataDir, engineVersion);
if (loaded.found && loaded.ok) {
initialSession = resumeSession(loaded.saved);
console.log(`Resumed a saved game from ${dataDir} (${loaded.saved.history.length} intents replayed).`);
} else if (loaded.found && !loaded.ok) {
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
// under. The file is left untouched: rolling the running version back would let it load again.
console.error(
`Refusing to load ${dataDir}/game.json: it was saved under engine version ` +
`${loaded.storedVersion}, this server is running ${engineVersion}. Starting with no active ` +
`game. The saved file has not been touched.`,
);
}
startServer({ port, bindAddress, joinSecret, distDir, dataDir, engineVersion, initialSession });
console.log(`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir}`);