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.
175 lines
7.3 KiB
TypeScript
175 lines
7.3 KiB
TypeScript
/**
|
|
* The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20).
|
|
*
|
|
* Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, creating or joining a
|
|
* game by code, and the seating screen up to `Lobby.Start`. `main.ts` calls `runLobby` once, at
|
|
* `start()`, only when there is no stored session to reconnect with — see `main.ts`'s own comment
|
|
* on why a stored `{token, gameId, seat}` skips this module entirely.
|
|
*
|
|
* MIRRORS SERVER TYPES RATHER THAN IMPORTING THEM, same choice `web/session.ts` already made for
|
|
* `Push`: this file must never depend on anything under `src/server/`, even at the type level, since
|
|
* it ships to the browser and the server does not.
|
|
*/
|
|
|
|
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
|
import { defaultMultiplayerConfig } from './game.ts';
|
|
|
|
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
|
|
|
|
type LobbySeat = { kind: 'human'; token: string; displayName: string } | { kind: 'bot' } | null;
|
|
type Lobby = {
|
|
gameId: string;
|
|
gameCode: string;
|
|
hostToken: string;
|
|
config: GameConfig;
|
|
seats: LobbySeat[];
|
|
joinOrder: string[];
|
|
createdAt: number;
|
|
};
|
|
type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean };
|
|
|
|
/** Per-origin, same reasoning `lobby-and-sessions.md` §1 gives for the session token itself — a
|
|
* secret typed at one address means nothing at another. */
|
|
const SECRET_KEY = 'stationmaster-joinsecret';
|
|
|
|
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
|
|
|
|
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
const res = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* Shows `#lobby`, drives it through creating or joining a game and then seating, and calls
|
|
* `onReady` exactly once — the instant `Lobby.Start` fires, from WHICHEVER browser tab started it.
|
|
* Never calls back more than once; the caller is expected to tear this screen down (`main.ts` hides
|
|
* `#lobby` and shows `#gameui`) as its very first action inside `onReady`.
|
|
*/
|
|
export function runLobby(onReady: (r: LobbyReady) => void): void {
|
|
$('lobby').hidden = false;
|
|
$<HTMLInputElement>('lb-secret').value = localStorage.getItem(SECRET_KEY) ?? '';
|
|
|
|
let source: EventSource | null = null;
|
|
|
|
function setError(id: string, message: string): void {
|
|
$(id).textContent = message;
|
|
}
|
|
|
|
function secret(): string {
|
|
const value = $<HTMLInputElement>('lb-secret').value;
|
|
localStorage.setItem(SECRET_KEY, value);
|
|
return value;
|
|
}
|
|
|
|
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
|
|
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`;
|
|
const isHost = lobby.hostToken === token;
|
|
|
|
let html = '';
|
|
for (let seat = 0; seat < lobby.seats.length; seat++) {
|
|
const occupant = lobby.seats[seat] ?? null;
|
|
const isYou = occupant?.kind === 'human' && occupant.token === token;
|
|
const isSeatHost = occupant?.kind === 'human' && occupant.token === lobby.hostToken;
|
|
const who =
|
|
occupant === null
|
|
? '<span class="dim">— waiting —</span>'
|
|
: occupant.kind === 'bot'
|
|
? 'Bot'
|
|
: `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`;
|
|
let action = '';
|
|
if (isHost) {
|
|
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
|
|
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
|
|
}
|
|
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`;
|
|
}
|
|
$('lb-seats').innerHTML = html;
|
|
|
|
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
|
|
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
|
|
}
|
|
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-remove'))) {
|
|
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: false });
|
|
}
|
|
|
|
const waiting = lobby.seats.filter((s) => s === null).length;
|
|
const startBtn = $<HTMLButtonElement>('lb-start');
|
|
startBtn.hidden = !isHost;
|
|
startBtn.disabled = waiting > 0;
|
|
$('lb-start-note').textContent = isHost
|
|
? waiting === 0
|
|
? ''
|
|
: `Waiting on ${waiting} more ${waiting === 1 ? 'player' : 'players'} — add a bot to any empty chair to start now.`
|
|
: 'Waiting for the host to start the game.';
|
|
startBtn.onclick = () => {
|
|
void postJson('/api/lobby/start', { token }).then(({ status, body }) => {
|
|
if (status !== 200) setError('lb-start-note', String(body['error'] ?? 'could not start'));
|
|
});
|
|
};
|
|
}
|
|
|
|
function enterSeating(gameId: string, token: string): void {
|
|
$('lb-choice-section').hidden = true;
|
|
$('lb-seating-section').hidden = false;
|
|
source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`);
|
|
source.onmessage = (ev: MessageEvent<string>) => {
|
|
const push = JSON.parse(ev.data) as LobbyPush;
|
|
if (push.started) {
|
|
source?.close();
|
|
onReady({ token, gameId, seat: push.you });
|
|
return;
|
|
}
|
|
renderSeating(push.lobby, push.you, token);
|
|
};
|
|
}
|
|
|
|
$<HTMLButtonElement>('lb-create').onclick = () => {
|
|
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
|
const mode = ($('lb-choice-section').querySelector<HTMLInputElement>('input[name="lb-mode"]:checked')?.value ??
|
|
'competitive') as 'competitive' | 'coop';
|
|
if (displayName === '') {
|
|
setError('lb-create-err', 'enter a display name first');
|
|
return;
|
|
}
|
|
const players = Number($<HTMLSelectElement>('lb-players').value) || 4;
|
|
// The real seat count reaches `defaultMultiplayerConfig`, so the combined-Revenue floor is
|
|
// sized for the table actually being played rather than for an assumed four.
|
|
void postJson('/api/lobby/create', {
|
|
secret: secret(),
|
|
config: defaultMultiplayerConfig(mode, players),
|
|
displayName,
|
|
players,
|
|
}).then(
|
|
({ status, body }) => {
|
|
if (status !== 200) {
|
|
setError('lb-create-err', String(body['error'] ?? 'could not create the game'));
|
|
return;
|
|
}
|
|
setError('lb-create-err', '');
|
|
enterSeating(body['gameId'] as string, body['token'] as string);
|
|
},
|
|
);
|
|
};
|
|
|
|
$<HTMLButtonElement>('lb-join').onclick = () => {
|
|
const displayName = $<HTMLInputElement>('lb-name').value.trim();
|
|
const gameCode = $<HTMLInputElement>('lb-code').value.trim();
|
|
if (displayName === '' || gameCode === '') {
|
|
setError('lb-join-err', 'enter a display name and a game code');
|
|
return;
|
|
}
|
|
void postJson('/api/lobby/join', { secret: secret(), gameCode, displayName }).then(({ status, body }) => {
|
|
if (status !== 200) {
|
|
setError('lb-join-err', String(body['error'] ?? 'could not join that game'));
|
|
return;
|
|
}
|
|
setError('lb-join-err', '');
|
|
enterSeating(body['gameId'] as string, body['token'] as string);
|
|
});
|
|
};
|
|
}
|