/** * 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 $ = (id: string): T => document.getElementById(id) as T; async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record }> { 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 }; } /** * 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; $('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 = $('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 ? '— waiting —' : occupant.kind === 'bot' ? 'Bot' : `${occupant.displayName}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`; let action = ''; if (isHost) { if (occupant === null) action = ``; else if (occupant.kind === 'bot') action = ``; } html += `
Seat ${seat}${who}${action}
`; } $('lb-seats').innerHTML = html; for (const btn of Array.from($('lb-seats').querySelectorAll('.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('.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 = $('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) => { 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); }; } $('lb-create').onclick = () => { const displayName = $('lb-name').value.trim(); const mode = ($('lb-choice-section').querySelector('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($('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); }, ); }; $('lb-join').onclick = () => { const displayName = $('lb-name').value.trim(); const gameCode = $('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); }); }; }