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:
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user