The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
/**
|
|
* 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<LobbySeat, null>[];
|
|
// 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 };
|
|
}
|