v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection

A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.

The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.

Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.

Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.

Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.

Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.

Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
This commit is contained in:
Jesse
2026-08-21 05:25:47 -04:00
parent c3c5cbfeec
commit e76bd77099
17 changed files with 1420 additions and 125 deletions
+278 -64
View File
@@ -1,14 +1,20 @@
/**
* The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12).
* 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`; this file is deliberately thin — routing, the join-secret
* gate, SSE mechanics, and static file serving for the built client (`dist/`, D16: the server serves
* the client, which is what makes same-origin work with no CORS).
* 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';
@@ -19,23 +25,43 @@ import { extname, join, normalize } from 'node:path';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { appendTiming, writeGame } from './persistence.ts';
import {
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 every `/api/*` route. */
/** 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 `game.json`/`turn-timings.json` live (Phase 3). */
/** 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;
/** Already reconstructed by `index.ts`'s load-on-start, or `null` for a fresh server. */
initialSession: GameSession | null;
/** 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> = {
@@ -82,92 +108,272 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
}
}
/**
* 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 {
let session: GameSession | null = opts.initialSession;
// One open SSE response per seat — a second connection from the same seat replaces the first
// rather than fanning out to both (Phase 2 has no concept of "the same seat from two tabs").
const connections = new Map<PlayerIndex, ServerResponse>();
const eventIds = new Map<PlayerIndex, number>();
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);
function checkSecret(url: URL, res: ServerResponse): boolean {
if (url.searchParams.get('secret') === opts.joinSecret) return true;
sendJson(res, 403, { error: 'bad or missing secret' });
return false;
// 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 writeSse(seat: PlayerIndex, push: Push): void {
const res = connections.get(seat);
if (!res) return; // that seat is not currently connected — Phase 4's reconnect story, not this one
const id = (eventIds.get(seat) ?? 0) + 1;
eventIds.set(seat, id);
res.write(`id: ${id}\ndata: ${JSON.stringify(push)}\n\n`);
function 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 broadcast(pushes: Map<PlayerIndex, Push>): void {
for (const [seat, push] of pushes) writeSse(seat, push);
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'}`);
if (url.pathname === '/api/game' && req.method === 'POST') {
if (!checkSecret(url, res)) return;
if (session) {
sendJson(res, 409, { error: 'a game already exists on this server' });
// -- 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;
}
const body = (await readJson(req)) as { config?: GameConfig; playerNames?: string[]; seed?: number };
if (!body.config || !Array.isArray(body.playerNames) || body.playerNames.length < 1) {
sendJson(res, 400, { error: 'expected { config, playerNames }' });
if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, config, displayName }' });
return;
}
session = createSession(body.seed ?? Math.floor(Math.random() * 1e9), body.config, body.playerNames);
// Persisted immediately, empty history and all — a crash one second after creation should
// still resume as "the game exists, day 1, nobody has moved" rather than vanish entirely.
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
sendJson(res, 200, { ok: true, playerCount: session.playerCount });
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/stream' && req.method === 'GET') {
if (!checkSecret(url, res)) return;
if (!session) {
sendJson(res, 404, { error: 'no game yet' });
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;
}
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
sendJson(res, 400, { error: 'bad or missing ?seat=' });
if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') {
sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' });
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
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));
}
});
connections.set(seat, res);
writeSse(seat, session.connect(seat));
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);
if (connections.get(seat) === res) connections.delete(seat);
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') {
if (!checkSecret(url, res)) return;
if (!session) {
sendJson(res, 404, { error: 'no game yet' });
return;
}
const seat = Number(url.searchParams.get('seat')) as PlayerIndex;
if (!Number.isInteger(seat) || seat < 0 || seat >= session.playerCount) {
sendJson(res, 400, { error: 'bad or missing ?seat=' });
// 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 };
@@ -175,15 +381,23 @@ export function startServer(opts: ServerOptions): void {
sendJson(res, 400, { error: 'expected { seq, intent }' });
return;
}
const result = session.intent(seat, body.seq, body.intent);
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).
await writeGame(opts.dataDir, session.exportSave(), opts.engineVersion);
if (result.timing) await appendTiming(opts.dataDir, result.timing);
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) broadcast(result.pushes);
if (result.accepted) broadcastGame(ps.gameId, result.pushes);
return;
}
+48 -17
View File
@@ -1,5 +1,6 @@
/**
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start.
* Process bootstrap — Phase 2 §12 step 8, extended for Phase 3 (§12 steps 14-15) load-on-start and
* Phase 4 (§12 steps 17-20) to resume every saved game and lobby, not just one.
*
* Run with: node src/server/index.ts
*
@@ -11,9 +12,10 @@ import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from './http.ts';
import { loadGame } from './persistence.ts';
import { gameDir, loadGame, readIndex, readLobby, readSessions } from './persistence.ts';
import { resumeSession } from './session.ts';
import type { GameSession } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const port = Number(process.env['PORT'] ?? 8081);
const bindAddress = process.env['BIND_ADDRESS'] ?? '0.0.0.0';
@@ -31,21 +33,50 @@ if (!joinSecret) {
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const engineVersion = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
let initialSession: GameSession | null = null;
const initialGames = new Map<string, GameSession>();
const initialLobbies = new Map<string, Lobby>();
const initialSessions = new Map<string, PlayerSession>();
const loaded = await loadGame(dataDir, engineVersion);
if (loaded.found && loaded.ok) {
initialSession = resumeSession(loaded.saved);
console.log(`Resumed a saved game from ${dataDir} (${loaded.saved.history.length} intents replayed).`);
} else if (loaded.found && !loaded.ok) {
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
// under. The file is left untouched: rolling the running version back would let it load again.
console.error(
`Refusing to load ${dataDir}/game.json: it was saved under engine version ` +
`${loaded.storedVersion}, this server is running ${engineVersion}. Starting with no active ` +
`game. The saved file has not been touched.`,
);
const index = await readIndex(dataDir);
for (const entry of index) {
const sessions = await readSessions(dataDir, entry.gameId);
for (const s of sessions) initialSessions.set(s.token, s);
if (entry.status === 'lobby') {
const lobby = await readLobby(dataDir, entry.gameId);
if (lobby) initialLobbies.set(entry.gameId, lobby);
continue;
}
const loaded = await loadGame(gameDir(dataDir, entry.gameId), engineVersion);
if (loaded.found && loaded.ok) {
initialGames.set(entry.gameId, resumeSession(loaded.saved));
console.log(`Resumed ${entry.gameId} (${entry.gameCode}) — ${loaded.saved.history.length} intents replayed.`);
} else if (loaded.found && !loaded.ok) {
// Refused explicitly (§12 step 15) — never silently replayed under rules it wasn't recorded
// under. The file is left untouched: rolling the running version back would let it load again.
console.error(
`Refusing to resume ${entry.gameId} (${entry.gameCode}): saved under engine version ` +
`${loaded.storedVersion}, this server is running ${engineVersion}. Left untouched, and ` +
`will not appear as an active game until the version matches again.`,
);
}
// `entry.status === 'finished'` games are not resumed into memory at all — nothing plays them
// forward, and their files stay on disk for post-game replay (`lobby-and-sessions.md` §6).
}
startServer({ port, bindAddress, joinSecret, distDir, dataDir, engineVersion, initialSession });
console.log(`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir}`);
startServer({
port,
bindAddress,
joinSecret,
distDir,
dataDir,
engineVersion,
initialGames,
initialLobbies,
initialSessions,
});
console.log(
`Station Master multiplayer server on ${bindAddress}:${port}, serving ${distDir} — ` +
`${initialGames.size} game(s) and ${initialLobbies.size} lobby(ies) resumed.`,
);
+167
View File
@@ -0,0 +1,167 @@
/**
* The lobby — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), fully specified in
* `docs/architecture/lobby-and-sessions.md`.
*
* Pure logic, no sockets, no filesystem, no in-memory registry — same split `session.ts` already
* draws (`http.ts` wires connections and persistence to this; `persistence.ts` is the only thing
* that touches disk). A `Lobby` exists only BEFORE `Lobby.Start`: once the config locks and the
* `GameSession` is built, this module is done with that game — everything after is `session.ts`.
*
* WHY A SEPARATE FILE FROM `session.ts`. `session.ts` is already the "running game" module and is
* large; a lobby's concerns — seating, host rights, the join secret's door versus a game code's
* discovery, config locking — share almost no code with running a Stage clock, and mixing them would
* make both harder to read for no reuse gained.
*/
import { randomUUID } from 'node:crypto';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
/** `lobby-and-sessions.md` §1 — names the PLAYER, not the seat. Issued once, at join, and never
* reissued: it is what makes reconnection work, so it must outlive the connection that first used
* it. */
export type PlayerSession = {
token: string;
gameId: string;
player: PlayerIndex;
displayName: string;
};
export type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
/** A game that has not started. `hostToken` ends its meaning at `Lobby.Start` — nothing here
* survives into the running game except the seat list and the locked `config`. */
export type Lobby = {
gameId: string;
gameCode: string;
hostToken: string;
config: GameConfig;
seats: LobbySeat[];
/** Tokens in the order they joined — human seats only, host first — so a departed host's
* replacement is unambiguous (`lobby-and-sessions.md` §2: "earliest-joined remaining player"). */
joinOrder: string[];
createdAt: number;
};
export type CreateResult = { lobby: Lobby; session: PlayerSession };
export type JoinResult = { ok: true; lobby: Lobby; session: PlayerSession } | { ok: false; code: 'LOBBY_FULL' | 'ALREADY_STARTED' };
export type StartResult = { ok: true; playerNames: string[]; botSeats: PlayerIndex[] } | { ok: false; code: 'NOT_HOST' | 'BAD_PLAYER_COUNT' };
/**
* `lobby-and-sessions.md` §2 — short and speakable, not an account name and not a UUID. A small
* fixed word list rather than a dictionary: this is read aloud across a table or a call, not typed
* from memory, so a handful of unambiguous railroad words serve better than a large random one.
*/
const CODE_WORDS = [
'RAIL', 'YARD', 'DEPOT', 'COAL', 'MAIL', 'CABOOSE', 'SIGNAL', 'FREIGHT',
'TRESTLE', 'HOPPER', 'TENDER', 'SIDING', 'GRADE', 'SPUR', 'WHISTLE', 'TROLLEY',
];
function randomCode(): string {
const word = CODE_WORDS[Math.floor(Math.random() * CODE_WORDS.length)]!;
const digits = Math.floor(Math.random() * 10_000)
.toString()
.padStart(4, '0');
return `${word}-${digits}`;
}
/** Collision-checked against whatever codes the caller already knows about — `http.ts` holds the
* live registry, so the check lives there rather than this module owning a set of its own. */
export function freshGameCode(taken: (code: string) => boolean): string {
let code = randomCode();
while (taken(code)) code = randomCode();
return code;
}
/** `lobby-and-sessions.md` §2 — solitaire is exactly 1, competitive/coop are 2-4. Not a rules
* limit: the engine will build a Division for any seat count. This is the lobby judgment, sized to
* what has actually been played and tested (`test/multiplayer.test.ts` exercises 2, 3 and 4). */
export function playerCountAllowed(mode: GameConfig['mode'], count: number): boolean {
return mode === 'solitaire' ? count === 1 : count >= 2 && count <= 4;
}
/** The creating player is the host and takes seat 0 (`lobby-and-sessions.md` §2). */
export function createLobby(config: GameConfig, hostDisplayName: string, gameCode: string): CreateResult {
const gameId = randomUUID();
const token = randomUUID();
const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName };
const lobby: Lobby = {
gameId,
gameCode,
hostToken: token,
config,
seats: [{ kind: 'human', token, displayName: hostDisplayName }],
joinOrder: [token],
createdAt: Date.now(),
};
return { lobby, session };
}
/** Joins the first empty seat, or extends the seat list if every seat so far is filled and the
* mode's cap allows one more (`lobby-and-sessions.md` §2's 2-4 is enforced at `Lobby.Start`, not
* here — a 5th join before anyone starts is refused outright since there is no seat it could ever
* play in, but the running count is checked against `playerCountAllowed` at every join too, so a
* lobby can never grow the seats array past what could legally start). */
export function joinLobby(lobby: Lobby, displayName: string): JoinResult {
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
const empty = lobby.seats.findIndex((s) => s === null);
const seatIndex = empty >= 0 ? empty : lobby.seats.length;
if (seatIndex >= cap) return { ok: false, code: 'LOBBY_FULL' };
const token = randomUUID();
const session: PlayerSession = { token, gameId: lobby.gameId, player: seatIndex, displayName };
const seats = [...lobby.seats];
seats[seatIndex] = { kind: 'human', token, displayName };
return {
ok: true,
lobby: { ...lobby, seats, joinOrder: [...lobby.joinOrder, token] },
session,
};
}
/** Host-only in effect (`http.ts` checks the caller's token against `hostToken` before calling
* this) — marks an empty seat as bot-filled, or clears one back to empty. Never touches an occupied
* human seat; the host removes a person by them leaving, not by overwriting their seat. */
export function setBotSeat(lobby: Lobby, seat: PlayerIndex, filled: boolean): Lobby {
const seats = [...lobby.seats];
while (seats.length <= seat) seats.push(null);
if (filled) {
if (seats[seat] !== null) return lobby;
seats[seat] = { kind: 'bot' };
} else {
if (seats[seat]?.kind !== 'bot') return lobby;
seats[seat] = null;
}
return { ...lobby, seats };
}
/**
* The host's connection closed before `Lobby.Start`. `lobby-and-sessions.md` §2: rights pass to the
* earliest-joined remaining player — remaining means still occupying a seat, not necessarily still
* connected, since a momentary drop should not also cost the NEXT person the host chair. Returns
* the lobby unchanged if the departing token was not the host, or if no other human seat exists.
*/
export function reassignHost(lobby: Lobby, departingToken: string): Lobby {
if (lobby.hostToken !== departingToken) return lobby;
const stillSeated = new Set(
lobby.seats.flatMap((s) => (s?.kind === 'human' ? [s.token] : [])),
);
const next = lobby.joinOrder.find((t) => t !== departingToken && stillSeated.has(t));
return next ? { ...lobby, hostToken: next } : lobby;
}
/**
* Locks the config, checks the player count, and hands back exactly what `session.ts`'s
* `createSession` needs — this function does not call it, so `lobby.ts` never depends on
* `session.ts` (the dependency runs the other way: `http.ts` calls both).
*/
export function startLobby(lobby: Lobby, callerToken: string): StartResult {
if (callerToken !== lobby.hostToken) return { ok: false, code: 'NOT_HOST' };
const filled = lobby.seats.filter((s) => s !== null);
if (filled.length !== lobby.seats.length || !playerCountAllowed(lobby.config.mode, filled.length)) {
return { ok: false, code: 'BAD_PLAYER_COUNT' };
}
const playerNames = filled.map((s) => (s!.kind === 'human' ? s.displayName : 'Bot'));
const botSeats = filled.flatMap((s, i) => (s!.kind === 'bot' ? [i as PlayerIndex] : []));
return { ok: true, playerNames, botSeats };
}
+97 -10
View File
@@ -1,22 +1,26 @@
/**
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15;
* `lobby-and-sessions.md` §6 specifies the exact shape and reasoning).
* Persistence — Phase 3 of `docs/architecture/multiplayer.md` (§12 steps 14-15), extended for
* Phase 4 (§12 steps 17-20) to more than one game (`lobby-and-sessions.md` §6 specifies both
* shapes and the reasoning behind them).
*
* One game per process (Phase 2's scope, unchanged) — two files in `DATA_DIR`, no index and no
* `gameId`, both deferred to Phase 4's multi-game generalization same as the server core deferred
* them. Every write is a full-file atomic rewrite (write to `.tmp`, `rename` over the real path)
* rather than true on-disk appending: §6 says the storage mechanism is genuinely open as long as the
* LOGICAL history is never rewritten or reordered, which a full rewrite of an always-growing array
* satisfies — and at the measured scale (~350 intents, a few hundred bytes per game) there is nothing
* to optimize yet.
* ONE DIRECTORY PER GAME (`games/<gameId>/`), plus one top-level `index.json` naming every game so
* `index.ts` can find and resume them all on boot without scanning the filesystem. Every write is
* still a full-file atomic rewrite (write to `.tmp`, `rename` over the real path) rather than true
* on-disk appending — §6 says the storage mechanism is genuinely open as long as the LOGICAL history
* is never rewritten or reordered, which a full rewrite of an always-growing array satisfies, and at
* the measured scale (~350 intents, a few hundred bytes per game) there is nothing to optimize yet.
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { SavedGame, TurnTiming } from './session.ts';
import type { Lobby, PlayerSession } from './lobby.ts';
const GAME_FILE = 'game.json';
const TIMINGS_FILE = 'turn-timings.json';
const LOBBY_FILE = 'lobby.json';
const SESSIONS_FILE = 'sessions.json';
const INDEX_FILE = 'index.json';
type PersistedGame = SavedGame & { engineVersion: string };
@@ -67,3 +71,86 @@ export async function appendTiming(dataDir: string, timing: TurnTiming): Promise
existing.push(timing);
await atomicWrite(path, JSON.stringify(existing, null, 1));
}
// ---------------------------------------------------------------------------
// Phase 4 — more than one game
// ---------------------------------------------------------------------------
export type GameIndexEntry = { gameId: string; gameCode: string; status: 'lobby' | 'active' | 'finished' };
/** Where one game's own files live. Every function below takes a `gameId` and joins it under here
* itself; `writeGame`/`loadGame`/`appendTiming` above do not, and take a directory directly, so a
* caller wanting one specific game's `game.json` passes `gameDir(dataDir, gameId)` to those. */
export function gameDir(dataDir: string, gameId: string): string {
return join(dataDir, 'games', gameId);
}
export async function readIndex(dataDir: string): Promise<GameIndexEntry[]> {
try {
return JSON.parse(await readFile(join(dataDir, INDEX_FILE), 'utf8')) as GameIndexEntry[];
} catch {
return [];
}
}
async function writeIndex(dataDir: string, entries: GameIndexEntry[]): Promise<void> {
await mkdir(dataDir, { recursive: true });
await atomicWrite(join(dataDir, INDEX_FILE), JSON.stringify(entries, null, 1));
}
/**
* Adds or updates exactly one game's row, by `gameId` — every caller that changes one game's status
* (created, started, finished) wants precisely this, not "replace the whole index", which would
* silently drop every other game's row the moment two writes happened close together.
*/
export async function upsertIndexEntry(dataDir: string, entry: GameIndexEntry): Promise<void> {
const entries = await readIndex(dataDir);
const i = entries.findIndex((e) => e.gameId === entry.gameId);
if (i >= 0) entries[i] = entry;
else entries.push(entry);
await writeIndex(dataDir, entries);
}
export async function writeLobby(dataDir: string, lobby: Lobby): Promise<void> {
const dir = gameDir(dataDir, lobby.gameId);
await mkdir(dir, { recursive: true });
await atomicWrite(join(dir, LOBBY_FILE), JSON.stringify(lobby, null, 1));
}
export async function readLobby(dataDir: string, gameId: string): Promise<Lobby | null> {
try {
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), LOBBY_FILE), 'utf8')) as Lobby;
} catch {
return null;
}
}
/**
* Removed once a game starts — a `Lobby` and a running `SavedGame` are mutually exclusive for one
* `gameId`, and leaving the file behind would let a restart resurrect a lobby for a game already
* under way. Missing already is not an error; `Lobby.Start` calls this exactly once, in the same
* request that writes `game.json`.
*/
export async function deleteLobby(dataDir: string, gameId: string): Promise<void> {
try {
await unlink(join(gameDir(dataDir, gameId), LOBBY_FILE));
} catch {
// Already gone — nothing to do.
}
}
/** Session tokens for one game — `lobby-and-sessions.md` §6: persisted so a token still works after
* a server restart, the same reason `game.json` itself is persisted. */
export async function writeSessions(dataDir: string, gameId: string, sessions: PlayerSession[]): Promise<void> {
const dir = gameDir(dataDir, gameId);
await mkdir(dir, { recursive: true });
await atomicWrite(join(dir, SESSIONS_FILE), JSON.stringify(sessions, null, 1));
}
export async function readSessions(dataDir: string, gameId: string): Promise<PlayerSession[]> {
try {
return JSON.parse(await readFile(join(gameDir(dataDir, gameId), SESSIONS_FILE), 'utf8')) as PlayerSession[];
} catch {
return [];
}
}
+76 -5
View File
@@ -19,6 +19,7 @@
*/
import { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
@@ -27,13 +28,28 @@ import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = {
frame: FrameDelta;
/**
* Absent on a presence-only push (below) — there is no board delta to send when nothing about the
* GAME changed, and computing one just to say "unchanged" would mean tracking a last-sent frame
* for a seat that may not even be in this game's connection table (a lobby-stage notice has none).
*/
frame?: FrameDelta;
/** Non-null only for the seat that may currently act — never guess otherwise (`game.ts`'s `actionMenu` guards this too, but the host must still only compute/send it for the actor). */
menu: Menu | null;
/** Narration since the LAST push to this specific seat, not the whole game's log. */
lines: { text: string; tone: string }[];
/**
* Connection news about ANOTHER seat — never this push's own recipient. `lobby-and-sessions.md`
* §5: a disconnect is server-layer news about a connection, not a `GameEvent`, so it must not go
* through the engine or the shared narration log (which must stay replayable from a seed). Built
* and broadcast entirely by `http.ts`, which already owns the connection table; `session.ts` never
* sets this field itself — every `Push` `session.ts` builds carries a real `frame` and no
* `presence`, and `http.ts`'s presence notices carry no `frame` and no `menu`.
*/
presence?: { seat: PlayerIndex; connected: boolean };
};
/**
@@ -59,6 +75,13 @@ export type SavedGame = {
history: Intent[];
status: 'active' | 'finished';
createdAt: number;
/**
* Seats `developerBot` plays for, filled at `Lobby.Start` (D8 — bots fill EMPTY seats at lobby
* time only, never mid-game). Persisted so a bot seat is still a bot after a server restart —
* `resumeSession` has no other way to know, and re-deriving it from `playerNames` would mean
* guessing from a display name rather than reading a fact.
*/
botSeats: PlayerIndex[];
};
export type IntentResult =
@@ -67,6 +90,7 @@ export type IntentResult =
export type GameSession = {
readonly playerCount: number;
isBot(seat: PlayerIndex): boolean;
/** A new SSE connection (or a reconnect) for `seat` — always a full Frame, never a delta. */
connect(seat: PlayerIndex): Push;
intent(seat: PlayerIndex, seq: number, i: Intent): IntentResult;
@@ -76,7 +100,12 @@ export type GameSession = {
type OpenSpan = { player: PlayerIndex; phase: string; day: number; stage: number; startedAt: number };
function buildSession(game: Game, playerNames: string[], createdAt: number): GameSession {
function buildSession(
game: Game,
playerNames: string[],
createdAt: number,
botSeats: Set<PlayerIndex>,
): GameSession {
const lastSeq = new Map<PlayerIndex, number>();
const lastFrame = new Map<PlayerIndex, Frame>();
const sentLines = new Map<PlayerIndex, number>();
@@ -137,8 +166,40 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
return closed;
}
/**
* PLAY EVERY BOT SEAT FORWARD until a human is due, or the game ends.
*
* `developerBot` (D8, `lobby-and-sessions.md` §2) fills EMPTY seats at lobby time only — it never
* takes over for a disconnected human, so this loop only ever touches seats `botSeats` named at
* `Lobby.Start`. Uses `submit` directly rather than the public `intent()` path: a bot is not a
* client with a `seq` to dedupe against, and its choice is by construction always legal (`check`
* would only ever confirm what `legalActions` already promised).
*
* Intermediate bot-turn spans are swept through `settleTiming()` but their result is discarded —
* `lobby-and-sessions.md` §5's turn clock exists to learn how long HUMANS take, and a bot decides
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
* on a bot's turn) and once after every accepted human intent.
*/
function driveBots(): void {
let guard = 0;
for (;;) {
const actor = currentActor(game);
if (actor === null || !botSeats.has(actor)) return;
if (++guard > 10_000) throw new Error(`driveBots: probable infinite loop at seat ${actor}`);
const options = legalActions(game.state, actor);
if (options.length === 0) return;
const choice = developerBot.choose(game.state, actor, options);
const ok = submit(game, choice);
/* c8 ignore next -- `options` came from `legalActions`, so `choice` is always legal. */
if (!ok) throw new Error(`driveBots: developerBot chose an illegal action for seat ${actor}`);
settleTiming();
}
}
driveBots();
return {
playerCount: playerNames.length,
isBot: (seat) => botSeats.has(seat),
connect(seat) {
// A (re)connect always starts from a clean slate — no cache to trust across a lost connection
@@ -169,6 +230,10 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
lastSeq.set(seat, seq);
const timing = settleTiming();
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
// in one push regardless of how many turns that took.
driveBots();
return { accepted: true, pushes: pushesForAll(), timing };
},
@@ -180,13 +245,19 @@ function buildSession(game: Game, playerNames: string[], createdAt: number): Gam
history: [...game.history],
status: game.state.status === 'finished' ? 'finished' : 'active',
createdAt,
botSeats: [...botSeats],
};
},
};
}
export function createSession(seed: number, config: GameConfig, playerNames: string[]): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now());
export function createSession(
seed: number,
config: GameConfig,
playerNames: string[],
botSeats: PlayerIndex[] = [],
): GameSession {
return buildSession(newMultiplayerGame(seed, config, playerNames), playerNames, Date.now(), new Set(botSeats));
}
/**
@@ -196,5 +267,5 @@ export function createSession(seed: number, config: GameConfig, playerNames: str
*/
export function resumeSession(saved: SavedGame): GameSession {
const game = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
return buildSession(game, saved.playerNames, saved.createdAt);
return buildSession(game, saved.playerNames, saved.createdAt, new Set(saved.botSeats));
}