/** * The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12), * extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game. * * 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` and `lobby.ts`; this file is deliberately thin — routing, * the join-secret gate on the DOOR (create/join), token resolution once a player is through it, 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). * * TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token * already proves "I am the player who was in this game," which is the only identity claim `/api/stream` * and `/api/intent` need — the join secret's job ends at the lobby door. */ 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, deleteGame, deleteLobby, gameDir, readIndex, removeIndexEntry, upsertIndexEntry, writeGame, writeLobby, writeSessions, } from './persistence.ts'; import { createSession } from './session.ts'; import type { GameSession, Push } from './session.ts'; import { createLobby, leaveLobby, freshGameCode, playerCountAllowed, joinLobby, reassignHost, setBotSeat, startLobby, } from './lobby.ts'; import type { Lobby, PlayerSession } from './lobby.ts'; export type ServerOptions = { port: number; bindAddress: string; /** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door; * once a player is through it and holds a token, the token alone authenticates them. */ joinSecret: string; /** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */ distDir: string; /** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */ dataDir: string; /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ engineVersion: string; /** * Gates the administrative routes — listing, exporting and deleting games — and is DELIBERATELY * not the join secret. Every player holds that one, so gating a delete with it would let anyone * at the table destroy anyone else's game. This is held by whoever runs the server and nobody * else. When it is unset the admin routes do not exist at all (404, the same answer as any other * unknown path), so a server that was never given one cannot be administered by guessing. */ adminSecret?: string | undefined; /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */ initialGames: Map; initialLobbies: Map; initialSessions: Map; }; const MIME: Record = { '.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 { 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, /** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */ buildTagged = false, ): Promise { 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'); /** * ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE. * * Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to * the pages as much as the modules. The pages are the one thing that CANNOT be versioned in * their own URL — a player types the address or follows a bookmark — so a cached `play.html` * pins that player to the entire build it names, including every `?v=` tag inside it. That is * half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts` * publishing `?v=nogit` on every packaged release was the other half, and neither is enough on * its own. * * `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the * script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay * manifest — has no way to announce a change, so a year of `immutable` on one would outlive * several releases of whatever it holds. */ res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size, 'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache', }); createReadStream(full).pipe(res); } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); } } /** * What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta * mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this). * * `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is * closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream * simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry. */ type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean }; /** What `/api/lobby/preview` answers with — everything a player weighing a join needs, and nothing * that would spoil the game. THE SEED IS NOT IN IT: it decides every shuffle and every roll. */ type LobbyPreview = { gameCode: string; hostName: string; config: GameConfig; players: number; seated: { seat: number; who: string | null; bot: boolean }[]; }; export function startServer(opts: ServerOptions): void { const games = opts.initialGames; const lobbies = opts.initialLobbies; const sessions = opts.initialSessions; const gameCodes = new Map(); // gameCode -> gameId, for /api/lobby/join for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); // One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a // lobby still being seated — a second connection from the same seat/token replaces the first // rather than fanning out to both (no concept yet of "the same seat from two tabs"). const gameConnections = new Map>(); const gameEventIds = new Map>(); const lobbyConnections = new Map>(); /** Which seats of a game have ever held a connection in THIS process — see `presenceOfOthers`. */ const everConnected = new Map>(); function writeSse(res: ServerResponse, id: number, data: unknown): void { res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`); } function nextEventId(gameId: string, seat: PlayerIndex): number { const ids = gameEventIds.get(gameId) ?? new Map(); const id = (ids.get(seat) ?? 0) + 1; ids.set(seat, id); gameEventIds.set(gameId, ids); return id; } function broadcastGame(gameId: string, pushes: Map): void { const conns = gameConnections.get(gameId); if (!conns) return; for (const [seat, push] of pushes) { const res = conns.get(seat); if (res) writeSse(res, nextEventId(gameId, seat), push); } } /** * Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go * through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently * connected seat of the same game as a presence-only push (an empty board delta, no menu, no new * lines) rather than inventing a second SSE event type — one message shape for the client to parse. */ function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void { const conns = gameConnections.get(gameId); if (!conns) return; for (const [other, res] of conns) { if (other === seat) continue; const push: Push = { menu: null, lines: [], presence: [{ seat, connected, seen: true }] }; writeSse(res, nextEventId(gameId, other), push); } } /** * EVERY OTHER SEAT'S STATE, for a client that has just connected. * * `broadcastPresence` only ever reports a CHANGE, so a player arriving at a table where two people * had not opened the game yet was told nothing about them at all — and "is everyone here?" is the * question at the moment a game starts. `seen` separates a seat that was here and dropped from one * that has never connected; it is remembered only for as long as this process runs, so after a * restart every absent seat reads as "not here yet", which is the more cautious of the two. */ function presenceOfOthers( gameId: string, seat: PlayerIndex, session: GameSession, ): NonNullable { const conns = gameConnections.get(gameId); const ever = everConnected.get(gameId) ?? new Set(); const out: NonNullable = []; for (let other = 0 as PlayerIndex; other < session.playerCount; other++) { // A bot holds no connection and never will, so reporting it would put "waiting on Bot 1 — not // here yet" on every screen for the whole game. Found by playing a real 3-seat game. if (other === seat || session.isBot(other)) continue; out.push({ seat: other, connected: conns?.has(other) === true, seen: ever.has(other) }); } return out; } function broadcastLobby(gameId: string): void { const lobby = lobbies.get(gameId); const conns = lobbyConnections.get(gameId); if (!lobby || !conns) return; for (const [token, res] of conns) { const ps = sessions.get(token); if (!ps) continue; writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); } } async function persistLobby(lobby: Lobby): Promise { lobbies.set(lobby.gameId, lobby); gameCodes.set(lobby.gameCode, lobby.gameId); await writeLobby(opts.dataDir, lobby); await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' }); } async function persistSession(ps: PlayerSession): Promise { sessions.set(ps.token, ps); const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId); await writeSessions(opts.dataDir, ps.gameId, all); } const server = createServer((req, res) => { void (async () => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); // -- Is anyone home? -------------------------------------------------------------------- /** * THE ONE ROUTE THAT EXISTS TO BE FAILED. * * The same `dist/` is served two ways: by this server, and as a plain static upload with no * server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way * — one client, mode decided at runtime (D4) — so the page cannot know from its own build * which it is, and every other route here answers a 404 for a path it does not have, exactly * as a static host would. Nothing distinguished them until this did. * * Unauthenticated on purpose: it says only that a Station Master server is answering, which * is what the client is about to offer the player anyway. It reveals no game and no seat. */ if (url.pathname === '/api/health' && req.method === 'GET') { // `summary()` rather than `exportSave()`: this is polled on a timer, and the save copies // every intent of every game to answer a question about none of them. let active = 0; for (const g of games.values()) if (g.summary().status === 'active') active++; sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion, games: { active, lobby: lobbies.size }, }); return; } // -- Administration: listing, exporting and deleting games ------------------------------ if (url.pathname === '/api/games' || url.pathname.startsWith('/api/games/')) { // Unset means the routes are not here — indistinguishable from any other unknown path, so // nothing advertises an administrative surface to someone probing for one. if (!opts.adminSecret) { await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v')); return; } if (req.headers['x-admin-secret'] !== opts.adminSecret) { sendJson(res, 403, { error: 'bad or missing admin secret' }); return; } const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode])); if (url.pathname === '/api/games' && req.method === 'GET') { const running = [...games.entries()].map(([gameId, g]) => ({ gameId, gameCode: codes.get(gameId) ?? null, state: 'running' as const, ...g.summary(), })); // A lobby has no game to summarize yet — it is reported as what it is, so an // administrator sees a table that never started rather than nothing at all. const waiting = [...lobbies.values()].map((l) => ({ gameId: l.gameId, gameCode: l.gameCode, state: 'lobby' as const, playerCount: l.seats.length, playerNames: l.seats.map((seat) => seat === null ? '(empty)' : seat.kind === 'bot' ? 'Bot' : seat.displayName, ), createdAt: l.createdAt, })); sendJson(res, 200, { games: [...running, ...waiting] }); return; } const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname); const gameId = match?.[1]; if (!gameId) { sendJson(res, 404, { error: 'no such route' }); return; } if (match?.[2] && req.method === 'GET') { const session = games.get(gameId); if (!session) { sendJson(res, 404, { error: 'no such game' }); return; } sendJson(res, 200, { gameCode: codes.get(gameId) ?? null, save: session.exportSave() }); return; } if (req.method === 'DELETE') { const session = games.get(gameId); const lobby = lobbies.get(gameId); if (!session && !lobby) { sendJson(res, 404, { error: 'no such game' }); return; } // The save goes back with the deletion, so a game can never be destroyed without its // record being handed to whoever destroyed it — the intents ARE the game (D5), so this // is the whole thing, replayable later, not a summary of it. const save = session?.exportSave() ?? null; // Everyone watching is told the game is gone before its files are, rather than being // left on a stream that will never push again. for (const [, watcher] of gameConnections.get(gameId) ?? []) watcher.end(); gameConnections.delete(gameId); for (const [, watcher] of lobbyConnections.get(gameId) ?? []) watcher.end(); lobbyConnections.delete(gameId); games.delete(gameId); lobbies.delete(gameId); gameEventIds.delete(gameId); const code = lobby?.gameCode ?? codes.get(gameId); if (code) gameCodes.delete(code); for (const [token, ps] of [...sessions]) if (ps.gameId === gameId) sessions.delete(token); await removeIndexEntry(opts.dataDir, gameId); await deleteGame(opts.dataDir, gameId); sendJson(res, 200, { ok: true, gameCode: code ?? null, save }); return; } sendJson(res, 405, { error: 'method not allowed' }); return; } // -- Lobby: creating and joining (the door — join-secret gated) -------------------------- if (url.pathname === '/api/lobby/create' && req.method === 'POST') { const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string; players?: number; seed?: number | null; }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { sendJson(res, 400, { error: 'expected { secret, config, displayName, players }' }); return; } // The table size is the host's to choose and is fixed from here on, so it is validated at // the door rather than at Start — `createLobby` builds the seats array from it. const players = body.players ?? 0; if (!Number.isInteger(players) || !playerCountAllowed(body.config.mode, players)) { sendJson(res, 400, { error: 'BAD_PLAYER_COUNT' }); return; } const gameCode = freshGameCode((code) => gameCodes.has(code)); const seed = typeof body.seed === 'number' && Number.isFinite(body.seed) ? Math.trunc(body.seed) : null; const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode, players, seed); await persistLobby(lobby); await persistSession(session); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); return; } if (url.pathname === '/api/lobby/join' && req.method === 'POST') { const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') { sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' }); return; } const gameId = gameCodes.get(body.gameCode.trim().toUpperCase()); const lobby = gameId ? lobbies.get(gameId) : undefined; if (!lobby) { // A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a // typo gets, which tells a latecomer "that game is gone" without leaking which case it was. sendJson(res, 404, { error: 'no open lobby with that code' }); return; } const result = joinLobby(lobby, body.displayName.trim()); if (!result.ok) { sendJson(res, 409, { error: result.code }); return; } await persistLobby(result.lobby); await persistSession(result.session); broadcastLobby(lobby.gameId); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: result.session.token, player: result.session.player, }); return; } // -- Lobby: seating, once inside (token-authenticated) ------------------------------------ if (url.pathname === '/api/lobby/bot' && req.method === 'POST') { const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean }; const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } if (lobby.hostToken !== ps.token) { sendJson(res, 403, { error: 'NOT_HOST' }); return; } if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') { sendJson(res, 400, { error: 'expected { token, seat, filled }' }); return; } const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled); await persistLobby(updated); broadcastLobby(lobby.gameId); sendJson(res, 200, { ok: true }); return; } /** * GIVING UP A SEAT — the player's own, or (host only) somebody else's. * * There was no door out of a lobby before this: a mis-join or a player who wandered off left a * chair that could not be freed, and a table that cannot start until every chair is taken. * The host's "remove" and a player's "Leave" are the same act from opposite ends, so they are * one route — `seat` names somebody else's chair and is refused to anyone but the host. */ if (url.pathname === '/api/lobby/leave' && req.method === 'POST') { const body = (await readJson(req)) as { token?: string; seat?: number }; const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } const seat = typeof body.seat === 'number' ? (body.seat as PlayerIndex) : undefined; if (seat !== undefined && seat !== ps.player && lobby.hostToken !== ps.token) { sendJson(res, 403, { error: 'NOT_HOST' }); return; } const result = leaveLobby(lobby, ps.token, seat); if (result.empty) { // Nobody human is left to start it. Everything about this lobby goes, including the code, // so it cannot be joined into a game that will never begin. lobbies.delete(lobby.gameId); gameCodes.delete(lobby.gameCode); for (const [, watcher] of lobbyConnections.get(lobby.gameId) ?? []) watcher.end(); lobbyConnections.delete(lobby.gameId); await deleteLobby(opts.dataDir, lobby.gameId); // The row goes with the lobby rather than being marked: a game that never started is not a // game an administrator has any use for a record of. await removeIndexEntry(opts.dataDir, lobby.gameId); sendJson(res, 200, { ok: true, closed: true }); return; } await persistLobby(result.lobby); broadcastLobby(lobby.gameId); sendJson(res, 200, { ok: true }); return; } /** * WHAT AM I ABOUT TO JOIN? Read-only, takes no seat, and gated by the same join secret the * door itself is. * * A player used to have to take a chair before they could see a single rule of the game they * were sitting down to — and until 2026-08-23 there was then no way back out of it. */ if (url.pathname === '/api/lobby/preview' && req.method === 'GET') { if (url.searchParams.get('secret') !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } const code = (url.searchParams.get('gameCode') ?? '').trim().toUpperCase(); const gameId = gameCodes.get(code); const lobby = gameId ? lobbies.get(gameId) : undefined; if (!lobby) { sendJson(res, 404, { error: 'no open lobby with that code' }); return; } const hostSeat = lobby.seats.find((seat) => seat?.kind === 'human' && seat.token === lobby.hostToken); const preview: LobbyPreview = { gameCode: lobby.gameCode, hostName: hostSeat?.kind === 'human' ? hostSeat.displayName : 'unknown', config: lobby.config, players: lobby.seats.length, seated: lobby.seats.map((seat, i) => ({ seat: i, who: seat?.kind === 'human' ? seat.displayName : null, bot: seat?.kind === 'bot', })), }; sendJson(res, 200, preview); return; } if (url.pathname === '/api/lobby/start' && req.method === 'POST') { const body = (await readJson(req)) as { token?: string }; const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } const result = startLobby(lobby, ps.token); if (!result.ok) { sendJson(res, 409, { error: result.code }); return; } // The host's seed if they named one; otherwise a fresh random deal. const session = createSession( lobby.seed ?? Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats, ); games.set(lobby.gameId, session); lobbies.delete(lobby.gameId); // Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game // forward from here. `started: true` on one last message, THEN close, is what lets a // still-open lobby tab tell "the game began" apart from a network hiccup `EventSource` // would otherwise silently retry through. for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) { const watcherPs = sessions.get(watcherToken); if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush); watcherRes.end(); } lobbyConnections.delete(lobby.gameId); await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion); await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' }); await deleteLobby(opts.dataDir, lobby.gameId); sendJson(res, 200, { ok: true }); return; } if (url.pathname === '/api/lobby/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); const conns = lobbyConnections.get(lobby.gameId) ?? new Map(); conns.set(token, res); lobbyConnections.set(lobby.gameId, conns); writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); req.on('close', () => { clearInterval(heartbeat); const live = lobbyConnections.get(lobby.gameId); if (live?.get(token) === res) live.delete(token); // `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player // if the host's connection closes before start. `lobbies.get` again, not the captured // `lobby`, because it may have changed (another join, another bot toggle) since connect. const current = lobbies.get(lobby.gameId); if (current && current.hostToken === token) { void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId)); } }); return; } // -- The running game (token-authenticated) ------------------------------------------------ /** * IS THIS TOKEN STILL GOOD FOR ANYTHING? * * A browser remembers its session in `localStorage` and re-enters the game on the next load * without asking, which is what makes reconnection seamless — and what leaves it stranded * when the game is gone. `EventSource` cannot report a status code and retries a 404 * silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a * game legitimately disappears under a player: an engine-version bump refuses to resume it * (D7), and an administrator ends it (`DELETE /api/games/`). */ if (url.pathname === '/api/session' && req.method === 'GET') { const ps = sessions.get(url.searchParams.get('token') ?? ''); const live = ps ? games.get(ps.gameId) : undefined; if (!ps || !live) { sendJson(res, 404, { error: 'no such game' }); return; } sendJson(res, 200, { gameId: ps.gameId, player: ps.player }); return; } if (url.pathname === '/api/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const session = ps ? games.get(ps.gameId) : undefined; if (!ps || !session) { sendJson(res, 404, { error: 'no such game' }); return; } const { gameId, player: seat } = ps; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); const conns = gameConnections.get(gameId) ?? new Map(); conns.set(seat, res); gameConnections.set(gameId, conns); const ever = everConnected.get(gameId) ?? new Set(); ever.add(seat); everConnected.set(gameId, ever); // The board, and who else is at the table — the second half used to be missing entirely. const first = session.connect(seat); first.presence = presenceOfOthers(gameId, seat, session); writeSse(res, nextEventId(gameId, seat), first); broadcastPresence(gameId, seat, true); // 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); const live = gameConnections.get(gameId); if (live?.get(seat) === res) live.delete(seat); broadcastPresence(gameId, seat, false); }); return; } if (url.pathname === '/api/intent' && req.method === 'POST') { // Token comes from the QUERY STRING, matching `/api/stream` and matching what // `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`) // — the body carries only what changes per call, `{ seq, intent }`. const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const session = ps ? games.get(ps.gameId) : undefined; if (!ps || !session) { sendJson(res, 404, { error: 'no such game' }); 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(ps.player, 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). const dir = gameDir(opts.dataDir, ps.gameId); await writeGame(dir, session.exportSave(), opts.engineVersion); if (result.timing) await appendTiming(dir, result.timing); if (session.exportSave().status === 'finished') { // `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be // carried forward here rather than left blank — `gameCodes` is the only place still // holding it once a lobby's own record is gone. const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? ''; await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' }); } } sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code }); if (result.accepted) broadcastGame(ps.gameId, result.pushes); return; } await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v')); })().catch((err: unknown) => { sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' }); }); }); server.listen(opts.port, opts.bindAddress); }