/** * 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; /** * The seed the host asked for, or null for one picked at `Lobby.Start`. Chosen here rather than * at start because the same seed and the same settings deal the same railroad — which is only * useful if the person setting the game up can name it. */ seed: number | null; }; export type CreateResult = { lobby: Lobby; session: PlayerSession }; export type JoinResult = | { ok: true; lobby: Lobby; session: PlayerSession } | { ok: false; code: 'LOBBY_FULL' | 'ALREADY_STARTED' | 'NAME_TAKEN' }; /** `empty` when the last human has gone — the caller drops the lobby rather than leaving a table of * bots waiting for a host who no longer exists. */ export type LeaveResult = { lobby: Lobby; empty: boolean }; 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). */ /** * THE TABLE SIZE IS FIXED WHEN THE GAME IS CREATED, and `seats.length` is it. * * The host says how many are playing, so the seats array is built at full length with the host in * chair 0 and the rest empty. Nothing ever grows or shrinks it, which is what makes a gap * impossible to express rather than merely illegal — and that matters more than it looks: seats * used to be appended as people joined, so a bot dropped into a later chair padded the array with * a hole that silently blocked Start. It also removes any need to compact the seats at * `Lobby.Start`, and compaction would have shifted the `player` index every `PlayerSession` * already carries (`joinLobby` stamps it at join time, and `/api/stream` and `/api/intent` route * by it) — quietly handing a player somebody else's railroad. * * Knowing the count this early has one more consequence, and it is a bug fix: the config's * `minCombinedRevenue` is derived from the player count, and the lobby previously had to guess it * as 4 before anyone had sat down. */ export function createLobby( config: GameConfig, hostDisplayName: string, gameCode: string, players: number, seed: number | null = null, ): CreateResult { const gameId = randomUUID(); const token = randomUUID(); const session: PlayerSession = { token, gameId, player: 0, displayName: hostDisplayName }; const seats: LobbySeat[] = Array.from({ length: players }, (_, i) => i === 0 ? { kind: 'human', token, displayName: hostDisplayName } : null, ); const lobby: Lobby = { gameId, gameCode, hostToken: token, config, seats, joinOrder: [token], createdAt: Date.now(), seed, }; 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 { // The table was sized at creation, so joining takes an empty chair or none at all — there is no // longer an "append another seat" path for a late arrival to grow the game through. const seatIndex = lobby.seats.findIndex((s) => s === null); if (seatIndex < 0) return { ok: false, code: 'LOBBY_FULL' }; /** * TWO PLAYERS CANNOT SHARE A NAME (2026-08-23). * * The name is not decoration: it labels the district on the Division map, it is what the turn * chart means by "waiting on Jesse", and `record()` puts it in front of every line that player * causes. Two identical names make all three ambiguous, and there is no way to fix it once the * game starts — the names are locked into the session at `Lobby.Start`. Refused rather than * silently suffixed: a player should play under the name they chose, or be told to choose again. */ const wanted = displayName.trim().toLowerCase(); const clash = lobby.seats.some((s) => s?.kind === 'human' && s.displayName.trim().toLowerCase() === wanted); if (clash) return { ok: false, code: 'NAME_TAKEN' }; 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, }; } /** * GIVING UP A SEAT — a player leaving, or the host clearing somebody out of a chair. * * There was no way out of a lobby at all before 2026-08-23: a mis-join or a player who wandered off * wedged the table, because Start needs every chair filled and `setBotSeat` refuses to touch an * occupied human seat. One function serves both, since they differ only in whose seat is named, and * `http.ts` is what checks that a caller naming somebody else's seat is the host. * * Host rights move exactly as they do on a dropped connection (`reassignHost`), and the caller is * told when the last human has gone so the lobby can be dropped rather than left orphaned. */ export function leaveLobby(lobby: Lobby, token: string, seat?: PlayerIndex): LeaveResult { const index = seat === undefined ? lobby.seats.findIndex((s) => s?.kind === 'human' && s.token === token) : seat; const occupant = index >= 0 ? (lobby.seats[index] ?? null) : null; if (index < 0 || occupant?.kind !== 'human') return { lobby, empty: false }; const seats = [...lobby.seats]; seats[index] = null; const departing = occupant.token; const withoutThem: Lobby = { ...lobby, seats, joinOrder: lobby.joinOrder.filter((t) => t !== departing), }; const empty = !seats.some((s) => s?.kind === 'human'); return { lobby: reassignHost(withoutThem, departing), empty }; } /** 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]; // No padding: a seat outside the table the host chose is not a seat, and inventing one is how // the old array grew holes in it. if (seat < 0 || seat >= seats.length) return lobby; 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' }; // Every chair at the table must be taken. The size itself was validated at creation and cannot // have moved since, so this is only ever waiting on the last empty seat to fill. if (lobby.seats.some((s) => s === null) || !playerCountAllowed(lobby.config.mode, lobby.seats.length)) { return { ok: false, code: 'BAD_PLAYER_COUNT' }; } // Seat index IS player index — no compaction, because there is nothing to compact past. const taken = lobby.seats as Exclude[]; // Bots are numbered rather than all being called "Bot": two of them at one table are two // different railroads, and a map labelling both the same cannot say which is which. let botNumber = 0; const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`)); const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : [])); return { ok: true, playerNames, botSeats }; }