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
+169
View File
@@ -0,0 +1,169 @@
/**
* 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;
const cap = lobby.config.mode === 'solitaire' ? 1 : 4;
let html = '';
for (let seat = 0; seat < cap; 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">— empty —</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 filled = lobby.seats.filter((s) => s !== null).length;
const noGaps = filled === lobby.seats.length;
const legalCount = lobby.config.mode === 'solitaire' ? filled === 1 : filled >= 2 && filled <= 4;
const startBtn = $<HTMLButtonElement>('lb-start');
startBtn.hidden = !isHost;
startBtn.disabled = !(noGaps && legalCount);
$('lb-start-note').textContent = isHost
? noGaps && legalCount
? ''
: 'Needs 2–4 seated players (human or bot), no empty seats in between.'
: '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;
}
void postJson('/api/lobby/create', { secret: secret(), config: defaultMultiplayerConfig(mode), displayName }).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);
});
};
}