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