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
+197
View File
@@ -0,0 +1,197 @@
/**
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12).
*
* Plain `node:http`, no framework: the project has zero runtime dependencies
* (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than
* reaching for a bundler — this matches that everywhere-else choice rather than introducing the
* first framework dependency for one route table.
*
* All the game logic lives in `session.ts`; this file is deliberately thin — routing, the join-secret
* gate, SSE mechanics, and static file serving for the built client (`dist/`, D16: the server serves
* the client, which is what makes same-origin work with no CORS).
*/
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { appendTiming, writeGame } from './persistence.ts';
import { createSession } from './session.ts';
import type { GameSession, Push } from './session.ts';
export type ServerOptions = {
port: number;
bindAddress: string;
/** D14 — a server-wide secret, passed out of band. Gates every `/api/*` route. */
joinSecret: string;
/** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */
distDir: string;
/** Where `game.json`/`turn-timings.json` live (Phase 3). */
dataDir: string;
/** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */
engineVersion: string;
/** Already reconstructed by `index.ts`'s load-on-start, or `null` for a fresh server. */
initialSession: GameSession | null;
};
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
const HEARTBEAT_MS = 20_000;
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString('utf8');
return text.trim() === '' ? {} : JSON.parse(text);
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const text = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
}
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
const rel = urlPath === '/' ? '/index.html' : urlPath;
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
const full = join(distDir, normalize(rel));
if (!full.startsWith(distDir)) {
sendJson(res, 400, { error: 'bad path' });
return;
}
try {
const info = await stat(full);
if (!info.isFile()) throw new Error('not a file');
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
createReadStream(full).pipe(res);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
}
}
export function startServer(opts: ServerOptions): void {
let session: GameSession | null = opts.initialSession;
// One open SSE response per seat — a second connection from the same seat replaces the first
// rather than fanning out to both (Phase 2 has no concept of "the same seat from two tabs").
const connections = new Map<PlayerIndex, ServerResponse>();
const eventIds = new Map<PlayerIndex, number>();
function checkSecret(url: URL, res: ServerResponse): boolean {
if (url.searchParams.get('secret') === opts.joinSecret) return true;
sendJson(res, 403, { error: 'bad or missing secret' });
return false;
}
function writeSse(seat: PlayerIndex, push: Push): void {
const res = connections.get(seat);
if (!res) return; // that seat is not currently connected — Phase 4's reconnect story, not this one
const id = (eventIds.get(seat) ?? 0) + 1;
eventIds.set(seat, id);
res.write(`id: ${id}\ndata: ${JSON.stringify(push)}\n\n`);
}
function broadcast(pushes: Map<PlayerIndex, Push>): void {
for (const [seat, push] of pushes) writeSse(seat, push);
}
const server = createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
if (url.pathname === '/api/game' && req.method === 'POST') {
if (!checkSecret(url, res)) return;
if (session) {
sendJson(res, 409, { error: 'a game already exists on this server' });
return;
}
const body = (await readJson(req)) as { config?: GameConfig; playerNames?: string[]; seed?: number };
if (!body.config || !Array.isArray(body.playerNames) || body.playerNames.length < 1) {
sendJson(res, 400, { error: 'expected { config, playerNames }' });
return;
}
session = createSession(body.seed ?? Math.floor(Math.random() * 1e9), body.config, body.playerNames);
// Persisted immediately, empty history and all — a crash one second after creation should
// still resume as "the game exists, day 1, nobody has moved" rather than vanish entirely.
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
sendJson(res, 200, { ok: true, playerCount: session.playerCount });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') {
if (!checkSecret(url, res)) return;
if (!session) {
sendJson(res, 404, { error: 'no game yet' });
return;
}
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
sendJson(res, 400, { error: 'bad or missing ?seat=' });
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
connections.set(seat, res);
writeSse(seat, session.connect(seat));
// Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a
// silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a
// real event (EventSource ignores lines starting with `:`), so it costs the client nothing.
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
req.on('close', () => {
clearInterval(heartbeat);
if (connections.get(seat) === res) connections.delete(seat);
});
return;
}
if (url.pathname === '/api/intent' && req.method === 'POST') {
if (!checkSecret(url, res)) return;
if (!session) {
sendJson(res, 404, { error: 'no game yet' });
return;
}
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
sendJson(res, 400, { error: 'bad or missing ?seat=' });
return;
}
const body = (await readJson(req)) as { seq?: number; intent?: Intent };
if (typeof body.seq !== 'number' || !body.intent) {
sendJson(res, 400, { error: 'expected { seq, intent }' });
return;
}
const result = session.intent(seat, body.seq, body.intent);
if (result.accepted) {
// Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at
// this scale, not just "applied in memory" (§12 step 14).
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
if (result.timing) await appendTiming(opts.dataDir, result.timing);
}
sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code });
if (result.accepted) broadcast(result.pushes);
return;
}
await serveStatic(opts.distDir, url.pathname, res);
})().catch((err: unknown) => {
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
});
});
server.listen(opts.port, opts.bindAddress);
}