buildStamp()'s no-git fallback was the literal "nogit", and the .s9pk Dockerfile copies the tree in without .git — so git rev-parse fails on every packaged build. That string is also the cache-bust key every module URL carries, so v0.7.4, v0.7.5 and v0.7.6 all published ./web/main.js?v=nogit, byte-identical, and returning browsers refetched nothing. v0.7.5's setup screen and v0.7.6's door fix were both correct and neither arrived. The fallback is now the package version plus the build timestamp, always distinct. And serveStatic sent no Cache-Control at all, which is the other half — a cached play.html pins a player to the whole build it names. A request carrying ?v= is now immutable for a year; everything else is no-cache. ?v= rather than "not HTML" because build-web.ts tags the modules and nothing else. Neither half is sufficient alone. 864 tests pass, two new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
737 lines
34 KiB
TypeScript
737 lines
34 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,
|
|
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
|
buildTagged = false,
|
|
): 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');
|
|
/**
|
|
* 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<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, 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<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, url.searchParams.has('v'));
|
|
})().catch((err: unknown) => {
|
|
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
|
});
|
|
});
|
|
|
|
server.listen(opts.port, opts.bindAddress);
|
|
}
|