Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.
The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.
That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.
And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.
/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.
Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.
SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.
index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.
Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.
Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
197 lines
9.4 KiB
TypeScript
197 lines
9.4 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;
|
|
};
|
|
|
|
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). */
|
|
/**
|
|
* 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,
|
|
): 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(),
|
|
};
|
|
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' };
|
|
|
|
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];
|
|
// 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>[];
|
|
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : 'Bot'));
|
|
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
|
|
return { ok: true, playerNames, botSeats };
|
|
}
|