The "Play multiplayer" door on index.html had sat disabled, labelled
"Coming soon", since before the server existed — Phases 2 through 4 built
a working lobby and nothing ever linked to it. Loading the site landed on
the same solitaire splash whether a real multiplayer server was behind it
or not, with no visible way in. Found packaging Phase 6 for StartOS.
The door is now a live link to ./play.html?lobby, and main.ts's start()
routes ?lobby straight to the lobby screen — the same showScreen('lobby');
runLobby(beginRemote) the in-game Multiplayer button already used —
instead of dealing a solitaire game first.
GET /api/health is new, and exists to be failed. The same dist/ ships both
served by src/server/ and uploaded as flat files by deploy-web.ts, and the
bundle is identical either way (D4), so the page cannot know from its own
build which it is; every other route 404s an unknown path exactly as a
static host does, so nothing distinguished them. The splash probes it on
load and closes the door when nothing names itself in reply.
The door starts open and only ever closes, deliberately: a wrong "no
server" is the bug above again — invisible, and it strands a player who
does have one — while a wrong "there is one" costs a click and a lobby
that says it cannot connect. The reply must name itself rather than merely
return 200, or a host answering every path with its index page would pass.
Verified: tsc clean; 659 tests pass (656 + 3); /api/health exercised live
against a running server — 200 with the right body, unauthenticated, while
an unknown path and a wrong method both still 404, which is what makes the
probe discriminate at all.
The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
431 lines
20 KiB
TypeScript
431 lines
20 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,
|
|
deleteLobby,
|
|
gameDir,
|
|
upsertIndexEntry,
|
|
writeGame,
|
|
writeLobby,
|
|
writeSessions,
|
|
} from './persistence.ts';
|
|
import { createSession } from './session.ts';
|
|
import type { GameSession, Push } from './session.ts';
|
|
import {
|
|
createLobby,
|
|
freshGameCode,
|
|
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;
|
|
/** 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 };
|
|
|
|
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>>();
|
|
|
|
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 } };
|
|
writeSse(res, nextEventId(gameId, other), push);
|
|
}
|
|
}
|
|
|
|
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') {
|
|
sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion });
|
|
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 };
|
|
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 }' });
|
|
return;
|
|
}
|
|
const gameCode = freshGameCode((code) => gameCodes.has(code));
|
|
const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode);
|
|
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, 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
const session = createSession(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) ------------------------------------------------
|
|
|
|
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);
|
|
writeSse(res, nextEventId(gameId, seat), session.connect(seat));
|
|
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);
|
|
}
|