Six things found playing the StartOS build, all of them the game telling you what it already knew. The lobby's Start button did not look disabled when it was. The reported symptom was "it says it's waiting for a player but Start is enabled" — it wasn't: the note and the disabled assignment are two lines apart in the same block. The page had only `header button:disabled` and `#actions button:disabled`, and #lb-start is in neither, so a disabled button kept its normal face AND still lit up under the cursor from the generic button:hover. It advertised a click it would refuse. The rule is generic now. The game code was rendered as "— code TRESTLE-5109" in dim text beside a heading, reading like a reference number rather than the thing you have to send somebody. It is a labelled block at 22px with a Copy button, and a clipboard refusal says the code can be selected instead of failing silently. The blurb under it was also WRONG — it claimed the chairs were "in the order everyone joined", which stopped being true in v0.4.1 when the §4.4 D12 started deciding. It now says what actually happens. Every Office on the Division map was labelled with its tier, which every other player's Office also has, so four districts read identically and "where does Bob sit" had no answer on the one map showing where trains are. The owner's name takes the headline and the tier moves beside the A/D count. Amber marks whose move it is — the same "happening here" the action panel uses — and "(you)" is spelled out on the reader's own district, because colour alone cannot say which of four railroads is yours. Turn colour wins over the you-colour when both apply: whose turn it is changes every few seconds, which railroad is yours never does. Under the map, the chain in words with the roll behind it: "West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)". state.openingRolls has been kept for exactly this since v0.4.1 and nothing had displayed it. It also answers "is the host always at the eastern end" outright — no. Alice there is the host, rolled lowest, and sits at the western end. Supporting: Frame gained viewer and viewerSeat. Every private field on it was already scoped to one player, but nothing said which player, so a page could draw a railroad without being able to say whose it was — harmless in solitaire, the first question at four seats. Frame also gained openingRolls. Bots are Bot 1 / Bot 2 rather than all Bot, since two of them are two different railroads. The standalone replay gets all of it: players, actor and viewer are not delta'd keys in compress, so they ride whole on every frame and replay.ts passes the same roster. Verified: 673 tests pass (668 + 5). The new ones were mutation-checked — removing the (you) suffix, never applying the turn mark, and reinstating the pre-v0.4.1 identity seating each fail the suite. The seating test deliberately asserts across six seeds that the eastern end is NOT always player 0, which is the claim it exists to defend.
200 lines
9.6 KiB
TypeScript
200 lines
9.6 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>[];
|
|
// 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 };
|
|
}
|