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
+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 };
}