The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
711 lines
32 KiB
TypeScript
711 lines
32 KiB
TypeScript
/**
|
|
* 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<string, GameSession>;
|
|
initialLobbies: Map<string, Lobby>;
|
|
initialSessions: Map<string, PlayerSession>;
|
|
};
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>(); // 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<string, Map<PlayerIndex, ServerResponse>>();
|
|
const gameEventIds = new Map<string, Map<PlayerIndex, number>>();
|
|
const lobbyConnections = new Map<string, Map<string, ServerResponse>>();
|
|
/** Which seats of a game have ever held a connection in THIS process — see `presenceOfOthers`. */
|
|
const everConnected = new Map<string, Set<PlayerIndex>>();
|
|
|
|
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<PlayerIndex, number>();
|
|
const id = (ids.get(seat) ?? 0) + 1;
|
|
ids.set(seat, id);
|
|
gameEventIds.set(gameId, ids);
|
|
return id;
|
|
}
|
|
|
|
function broadcastGame(gameId: string, pushes: Map<PlayerIndex, Push>): 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<Push['presence']> {
|
|
const conns = gameConnections.get(gameId);
|
|
const ever = everConnected.get(gameId) ?? new Set<PlayerIndex>();
|
|
const out: NonNullable<Push['presence']> = [];
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
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<string, ServerResponse>();
|
|
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/<id>`).
|
|
*/
|
|
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<PlayerIndex, ServerResponse>();
|
|
conns.set(seat, res);
|
|
gameConnections.set(gameId, conns);
|
|
const ever = everConnected.get(gameId) ?? new Set<PlayerIndex>();
|
|
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);
|
|
})().catch((err: unknown) => {
|
|
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
|
});
|
|
});
|
|
|
|
server.listen(opts.port, opts.bindAddress);
|
|
}
|