/** * The lobby screen — Phase 4 of `docs/architecture/multiplayer.md` (§12 steps 17-20), rebuilt * 2026-08-23 (Jesse's cleanup pass). * * Everything in `#lobby` (`play.html`) is owned here: the join-secret gate, the two doors (join a * game, create one), the read-only preview a player reads BEFORE taking a seat, and the seating * screen up to `Lobby.Start`. * * WHAT CHANGED, and why each one was worth changing: * - JOINING CAME FIRST. It used to be a heading below the whole create form — fifteen fields a * player who was handed a code has no use for. * - A SEAT SURVIVES A RELOAD. The token was held in a closure and only written to `localStorage` * at `Lobby.Start`, so a refresh before the host started orphaned the chair: the player could * not get back and the seat could not be freed. `onSeated` hands it to `main.ts` immediately. * - THERE IS A WAY OUT. `/api/lobby/leave` frees a seat, so a mis-join or a player who wanders off * no longer wedges a table that cannot start until every chair is taken. * - THE STREAM CAN FAIL OUT LOUD. `onmessage` was the only handler; a dropped connection left the * seating screen frozen and silent. * - THE RULES ARE VISIBLE TO EVERYONE, not just the host who typed them. * * 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 { closestPreset, configFromSettings, gameTypeLabel, preset, presetSettings, } from './presets.ts'; import type { GameType, PresetName } from './presets.ts'; import { rulesListHtml, settingsForm } from './settings-form.ts'; import { seatLabel } from '../sim/view.ts'; export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex; gameCode: string }; /** What the page must do with a seat this screen takes or gives up. `main.ts` owns the storage; this * module owns the moments. */ export type LobbyHandlers = { /** The game has begun — tear this screen down and build a `RemoteSession`. Called at most once. */ onReady: (r: LobbyReady) => void; /** A seat is now held. Called before the game starts, so a reload can come back to it. */ onSeated: (s: { token: string; gameId: string; gameCode: string }) => void; /** The seat is gone — left, removed, or the lobby closed under us. Forget the stored record. */ onLeft: (gameId?: string) => void; /** Every game this browser still holds a seat in — the page owns the storage, this screen only * draws it. */ known?: () => { gameId: string; gameCode: string; stage: 'lobby' | 'game' }[]; /** Re-enter one of them. */ rejoin?: (gameId: string) => void; /** Give one up for good: the token is the only proof of identity, so this cannot be undone. */ forget?: (gameId: string) => void; }; 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 }; type Preview = { gameCode: string; hostName: string; config: GameConfig; players: number; seated: { seat: number; who: string | null; bot: 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'; /** The name is not a credential; it is remembered for the same reason the secret is — nobody should * retype what they typed last time. */ const NAME_KEY = 'stationmaster-displayname'; const $ = (id: string): T => document.getElementById(id) as T; const has = (id: string): boolean => document.getElementById(id) !== null; /** * A server code turned into a sentence. * * `LOBBY_FULL` and `BAD_PLAYER_COUNT` used to be printed at the player exactly as the server said * them. The codes are the server's vocabulary, not the table's. */ function explain(code: unknown, fallback: string): string { const messages: Record = { LOBBY_FULL: 'That table is already full — every chair is taken.', ALREADY_STARTED: 'That game has already started.', BAD_PLAYER_COUNT: 'That table size cannot start a game — 2 to 4 players.', NOT_HOST: 'Only the host can do that.', NAME_TAKEN: 'Somebody at that table is already using that name — pick another.', 'bad or missing secret': 'That join secret was not accepted by this server.', // What a lobby answers once it has become a GAME — most often seen by a host pressing Start // twice, and by a tab left open on a lobby that started somewhere else. 'no such lobby': 'That game is no longer waiting to start — it has either begun or been closed.', 'no open lobby with that code': 'No game is waiting under that code. Check it, or ask for a new one — a game that has already started cannot be joined.', }; const key = typeof code === 'string' ? code : ''; return messages[key] ?? (key !== '' ? key : fallback); } 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 }; } async function getJson(path: string): Promise<{ status: number; body: Record }> { const res = await fetch(path); return { status: res.status, body: (await res.json().catch(() => ({}))) as Record }; } /** * Shows `#lobby` and drives it. `resume` re-enters the seating screen for a browser that already * holds a seat (a reload before the host started) rather than starting at the doors. */ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; gameId: string; gameCode: string }): void { // Nothing below exists on a page that is not `play.html` — and `main.ts` is imported by tests that // stub only part of the DOM. Bail rather than throwing through the module's caller. if (!has('lobby') || !has('lb-choice-section')) return; $('lobby').hidden = false; const form = settingsForm('lb-'); let source: EventSource | null = null; let done = false; /** * THE GAMES THIS BROWSER IS ALREADY IN. * * Rejoining is what the stored token is FOR, and until now the only thing that ever used one was a * bare page load — so a player who left a game, or who joined a second, had no way to get back to * the first. Drawn from the page's own storage (`main.ts`), never from this module. */ function renderKnown(): void { if (!has('lb-known')) return; const games = handlers.known?.() ?? []; $('lb-known').hidden = games.length === 0; if (games.length === 0) return; $('lb-known-list').innerHTML = games .map( (g) => `
${escapeHtml(g.gameCode || g.gameId.slice(0, 8))}` + `${g.stage === 'lobby' ? 'waiting to start' : 'in play'}` + `` + `
`, ) .join(''); for (const btn of Array.from($('lb-known-list').querySelectorAll('.lb-rejoin'))) { btn.onclick = () => handlers.rejoin?.(btn.dataset['game'] ?? ''); } for (const btn of Array.from($('lb-known-list').querySelectorAll('.lb-forget'))) { btn.onclick = () => { // Confirmed, because it is not recoverable from this browser: the token IS the identity // (`lobby-and-sessions.md` §1), and nothing else on this server will accept a claim to that // seat. const code = btn.previousElementSibling?.previousElementSibling?.textContent ?? 'that game'; if (!confirm(`Forget ${code}? This browser will not be able to rejoin it — your seat stays in the game, and only whoever runs the server could let you back in.`)) return; handlers.forget?.(btn.dataset['game'] ?? ''); renderKnown(); }; } } renderKnown(); // -- the join secret ------------------------------------------------------------------------ function showSecret(saved: boolean): void { $('lb-secret-saved').hidden = !saved; $('lb-secret-ask').hidden = saved; } const storedSecret = localStorage.getItem(SECRET_KEY) ?? ''; $('lb-secret').value = storedSecret; showSecret(storedSecret !== ''); $('lb-secret-change').onclick = () => showSecret(false); function secret(): string { const value = $('lb-secret').value; localStorage.setItem(SECRET_KEY, value); return value; } /** A rejected secret re-opens the field it is about — the error used to appear a screen away from * the box that caused it. */ function secretRejected(status: number): boolean { if (status !== 403) return false; showSecret(false); $('lb-secret').focus(); return true; } // -- display name --------------------------------------------------------------------------- const nameField = $('lb-name'); nameField.value = localStorage.getItem(NAME_KEY) ?? ''; function displayName(): string { const value = nameField.value.trim(); if (value !== '') localStorage.setItem(NAME_KEY, value); return value; } // -- the two doors -------------------------------------------------------------------------- function door(which: 'join' | 'create'): void { $('lb-join-panel').hidden = which !== 'join'; $('lb-create-panel').hidden = which !== 'create'; $('lb-door-join').classList.toggle('active', which === 'join'); $('lb-door-create').classList.toggle('active', which === 'create'); } $('lb-door-join').onclick = () => door('join'); $('lb-door-create').onclick = () => door('create'); // -- the create form ------------------------------------------------------------------------ /** The named type the form is currently measured against — and, for a Custom game, the type it is * scored as. Custom is only ever reached FROM one of these, so there is always an answer. */ let base: PresetName = 'coop'; let type: GameType = 'coop'; /** Once the host types a Revenue floor it is theirs; players and days stop re-deriving it. */ let floorTyped = false; const players = (): number => Number($('lb-players').value) || 4; const days = (): number => { const raw = Number($('lb-days').value); return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5; }; function typeRadios(): HTMLInputElement[] { return Array.from(document.querySelectorAll('input[name="lb-type"]')); } /** Reset every rule to a named type. Seed, players and days are parameters, and are left alone. */ function selectPreset(name: PresetName): void { base = name; type = name; floorTyped = false; const values = presetSettings(name, players(), days()); form.write(values, values); form.setEmployeeRotationAvailable(true); refresh(); } function refresh(): void { const differing = form.mark(base, players(), days()); if (differing.length > 0) type = 'custom'; else if (type === 'custom') type = base; for (const r of typeRadios()) r.checked = r.value === type; /** * NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person * who had just chosen it, and the row is already labelled and already carries its own * description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on * each row that actually differs, which is where a Custom game's differences can be acted on. */ // A Custom game is nobody's default: open the block that says how it differs. if (type === 'custom') $('lb-settings').open = true; } for (const r of typeRadios()) { // Solitaire is on this screen so the two screens read as one list, but there is nothing here to // deal it with. Dimmed and left to speak for itself: the heading says "Game type // (multi-player)", which is the explanation (Jesse, 2026-08-30). if (r.value === 'solitaire') markUnavailable(r); r.onchange = () => { if (!r.checked) return; if (r.value === 'custom') { // Clicking Custom keeps everything as it stands, and keeps the scoring of the type it came // from (Jesse, 2026-08-23) — it is only ever reached from one of the named types. type = 'custom'; refresh(); return; } selectPreset(r.value as PresetName); }; } form.onEdit((key) => { // The floor is derived until somebody sets it; ticking the condition off counts as setting it. if (key === 'minCombinedRevenue') floorTyped = true; type = 'custom'; refresh(); }); /** Players and days are parameters, not settings: they re-derive the floor and never make a game * Custom by themselves. */ function paramsChanged(): void { if (!floorTyped) { const values = form.read(); const want = presetSettings(base, players(), days()); form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want); } refresh(); } $('lb-players').onchange = paramsChanged; $('lb-days').oninput = paramsChanged; selectPreset('coop'); door('join'); // -- creating ------------------------------------------------------------------------------- $('lb-create').onclick = () => { const name = displayName(); if (name === '') { $('lb-create-err').textContent = 'Enter a display name first.'; return; } const asked = $('lb-seed').value.trim(); // Blank or unparseable both mean "surprise me", which is what leaving the box alone asks for. const seed = asked === '' || !Number.isFinite(Number(asked)) ? null : Math.trunc(Number(asked)); const config = configFromSettings(form.read(), preset(base).scoring, days(), preset(base).pvpCards); $('lb-create-err').textContent = ''; void postJson('/api/lobby/create', { secret: secret(), config, displayName: name, players: players(), seed, }).then(({ status, body }) => { if (status !== 200) { secretRejected(status); $('lb-create-err').textContent = explain(body['error'], 'Could not create the game.'); return; } enterSeating(body['gameId'] as string, body['token'] as string, body['gameCode'] as string); }); }; // -- joining -------------------------------------------------------------------------------- let previewed: Preview | null = null; function showPreview(p: Preview): void { previewed = p; $('lb-preview').hidden = false; $('lb-preview-code').textContent = p.gameCode; const near = closestPreset(p.config, p.players, p.config.days); const label = gameTypeLabel( near.differing.length === 0 ? near.name : 'custom', p.config.mode, ); $('lb-preview-type').textContent = near.differing.length === 0 ? label : `${label} · ${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(near.name).label}`; const taken = p.seated.filter((s) => s.who !== null || s.bot).length; const names = p.seated .map((s) => (s.bot ? 'a bot' : (s.who ?? 'empty'))) .join(', '); $('lb-preview-who').textContent = `Host: ${p.hostName} · ${taken} of ${p.players} seats taken — ${names}`; $('lb-preview-rules').innerHTML = rulesListHtml(p.config, p.players, p.config.days); } $('lb-look').onclick = () => { const code = $('lb-code').value.trim().toUpperCase(); $('lb-preview').hidden = true; if (code === '') { $('lb-join-err').textContent = 'Enter the game code you were given.'; return; } $('lb-join-err').textContent = ''; void getJson( `/api/lobby/preview?gameCode=${encodeURIComponent(code)}&secret=${encodeURIComponent(secret())}`, ).then(({ status, body }) => { if (status !== 200) { secretRejected(status); $('lb-join-err').textContent = explain(body['error'], 'Could not look up that game.'); return; } showPreview(body as unknown as Preview); }); }; $('lb-join').onclick = () => { const name = displayName(); const code = previewed?.gameCode ?? $('lb-code').value.trim().toUpperCase(); if (name === '') { $('lb-join-err').textContent = 'Enter a display name first.'; return; } $('lb-join-err').textContent = ''; void postJson('/api/lobby/join', { secret: secret(), gameCode: code, displayName: name }).then( ({ status, body }) => { if (status !== 200) { secretRejected(status); $('lb-join-err').textContent = explain(body['error'], 'Could not join that game.'); return; } enterSeating(body['gameId'] as string, body['token'] as string, code); }, ); }; // -- seating -------------------------------------------------------------------------------- function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void { $('lb-gamecode').textContent = lobby.gameCode; const isHost = lobby.hostToken === token; let html = ''; // Bots are numbered here exactly as `startLobby` numbers them at the moment the game starts, so // the table you set up is the table that appears on the board. let botNumber = 0; 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; if (occupant?.kind === 'bot') botNumber++; const who = occupant === null ? '— waiting —' : occupant.kind === 'bot' ? `Bot ${botNumber}` : `${escapeHtml(occupant.displayName)}${isYou ? ' (you)' : ''}${isSeatHost ? ' — host' : ''}`; let action = ''; if (isHost) { if (occupant === null) action = ``; else if (occupant.kind === 'bot') action = ``; else if (!isYou) action = ``; } html += `
Seat ${seatLabel(seat)}${who}${action}
`; } $('lb-seats').innerHTML = html; /** * The code is the whole invitation, so it has to leave this screen by some route other than * being read off it and retyped. Two buttons because they are two different acts: the CODE is * what you read aloud on a call and works at whatever address each player reaches the box by; * the LINK is what you paste into a chat, and only works for someone who can reach this address. * Neither carries the join secret — that is the door key, and it travels out of band. * * `navigator.clipboard` is unavailable on an insecure origin and can be refused outright, so a * failure says the code is there to be selected rather than silently doing nothing. */ const say = (m: string): void => { $('lb-copied').textContent = m; setTimeout(() => ($('lb-copied').textContent = ''), 4000); }; const copy = (text: string, ok: string): void => { void navigator.clipboard ?.writeText(text) .then(() => say(ok)) .catch(() => say('Could not copy — select the code above instead.')); }; $('lb-copy').onclick = () => copy(lobby.gameCode, 'Code copied.'); $('lb-copylink').onclick = () => copy( `${location.origin}${location.pathname}?lobby&code=${encodeURIComponent(lobby.gameCode)}`, 'Invite link copied — the join secret is not in it.', ); 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 }); } for (const btn of Array.from($('lb-seats').querySelectorAll('.lb-kick'))) { btn.onclick = () => { void postJson('/api/lobby/leave', { token, seat: Number(btn.dataset['seat']) }).then(({ status, body }) => { if (status !== 200) $('lb-start-note').textContent = explain(body['error'], 'Could not clear that seat.'); }); }; } // What everyone at the table is about to play — the host chose it, and until now nobody else // could see any of it. const near = closestPreset(lobby.config, lobby.seats.length, lobby.config.days); const label = gameTypeLabel(near.differing.length === 0 ? near.name : 'custom', lobby.config.mode); $('lb-seating-type').textContent = near.differing.length === 0 ? label : `${label} · ${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(near.name).label}`; $('lb-seating-rules').innerHTML = rulesListHtml(lobby.config, lobby.seats.length, lobby.config.days); const waiting = lobby.seats.filter((s) => s === null).length; const startBtn = $('lb-start'); startBtn.hidden = !isHost; startBtn.disabled = waiting > 0; startBtn.textContent = 'Start game'; $('lb-start-note').textContent = ''; const waitNote = $('lb-stream-note'); if (waitNote.dataset['reason'] !== 'stream') { waitNote.hidden = waiting === 0 && isHost; waitNote.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.'; waitNote.hidden = waitNote.textContent === ''; } startBtn.onclick = () => { // No busy state used to mean a second press posted a second start, whose 409 landed on screen // as a raw code. startBtn.disabled = true; startBtn.textContent = 'Starting…'; void postJson('/api/lobby/start', { token }).then(({ status, body }) => { if (status !== 200) { startBtn.disabled = false; startBtn.textContent = 'Start game'; $('lb-start-note').textContent = explain(body['error'], 'Could not start the game.'); } }); }; } function streamNote(message: string): void { const el = $('lb-stream-note'); el.dataset['reason'] = message === '' ? '' : 'stream'; el.textContent = message; el.hidden = message === ''; } function enterSeating(gameId: string, token: string, gameCode: string): void { handlers.onSeated({ token, gameId, gameCode }); $('lb-choice-section').hidden = true; $('lb-seating-section').hidden = false; $('lb-leave').onclick = () => { void postJson('/api/lobby/leave', { token }).then(() => { source?.close(); handlers.onLeft(gameId); $('lb-seating-section').hidden = true; $('lb-choice-section').hidden = false; renderKnown(); notice(''); }); }; source = new EventSource(`/api/lobby/stream?token=${encodeURIComponent(token)}`); source.onmessage = (ev: MessageEvent) => { streamNote(''); const push = JSON.parse(ev.data) as LobbyPush; if (push.started) { source?.close(); if (done) return; done = true; handlers.onReady({ token, gameId, seat: push.you, gameCode: push.lobby.gameCode }); return; } renderSeating(push.lobby, push.you, token); }; /** * A DEAD LOBBY AND A BLIP LOOK IDENTICAL HERE, so ask — the same shape `session.ts` already uses * for the game stream, which the lobby never got. * * Three answers matter. The game may have STARTED while we were disconnected (the `started` push * is sent once and then the connection closes, so a drop at the wrong moment loses it) — * `/api/session` knows, and we go straight in. The lobby may still be there, in which case * `EventSource` is already retrying and the note is all that is needed. Or it is gone, and * sitting on a frozen seating screen is the one thing that must not happen. */ source.onerror = () => { if (done) return; streamNote('Connection lost — retrying. You can leave and rejoin if this does not clear.'); void getJson(`/api/session?token=${encodeURIComponent(token)}`).then(({ status, body }) => { if (done) return; if (status === 200) { done = true; source?.close(); handlers.onReady({ token, gameId, seat: body['player'] as PlayerIndex, gameCode }); return; } void getJson( `/api/lobby/preview?gameCode=${encodeURIComponent(gameCode)}&secret=${encodeURIComponent(secret())}`, ).then(({ status: lobbyStatus }) => { if (done || lobbyStatus === 200) return; source?.close(); handlers.onLeft(gameId); $('lb-seating-section').hidden = true; $('lb-choice-section').hidden = false; renderKnown(); streamNote(''); notice('That game is no longer waiting on this server — it was ended, or the last player left.'); }); }); }; } if (resume) enterSeating(resume.gameId, resume.token, resume.gameCode); } /** The lobby-wide message slot: why you are looking at this screen, when it was not your own click. */ export function notice(message: string): void { const el = document.getElementById('lb-notice'); if (!el) return; el.textContent = message; el.hidden = message === ''; } /** Prefills the code from an invite link and opens the join door on it. */ export function prefillCode(code: string): void { const field = document.getElementById('lb-code') as HTMLInputElement | null; if (field) field.value = code.toUpperCase(); } /** * A CHOICE THAT CANNOT BE TAKEN HAS TO SAY SO. * * Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled` * on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads * as a broken control rather than an unavailable one. * * The dimming is the whole signal now. It used to append a reason to the row as well, and dropped * that in 2026-08-30 along with the same text on the solitaire screen: one heading naming which * game the screen deals says it once, where three dimmed rows each said it again. */ function markUnavailable(radio: HTMLInputElement): void { radio.disabled = true; radio.closest('label')?.classList.add('disabled'); } function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }