Gitea#33. A session token is the only identity the game has, and it lives in exactly one place the player controls: their browser's localStorage, scoped to the origin they joined at. Lose it — a cleared profile, a private window, a different browser — and the seat is unreachable while the game runs on and the session sits intact on disk. Reported from the table: of two humans in one game the host reloaded straight back in, the joiner met an empty lobby. Diagnosed before it was fixed, and two server-side theories of mine were retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the app deletes the secret or name, create and join both call persistSession, that game's sessions.json held both seats, and it resumed with 80 intents replayed. Both players used the same URL, so it was not a second origin either. The fix is a recovery link. An administrator mints a code for a named seat (admin-gated: deciding somebody lost a seat is a judgement no route can make); the player opens the link and the page trades the code for the token over a POST, then strips it from the address bar. The link never carries the token — lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is exactly what gets pasted into a chat. Single use, 30-minute expiry, held in memory because a restart dropping them is the right failure. server/claims.ts is a pure store, so single use, lazy expiry and one identical answer for unknown/spent/expired codes are tested rather than asserted. The admin game listing gained seatedPlayers — the seats a human holds a token for, read from the session map rather than guessed from player names — so the StartOS action can offer real players instead of bot chairs. No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
629 lines
28 KiB
TypeScript
629 lines
28 KiB
TypeScript
/**
|
|
* 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 $ = <T extends HTMLElement = HTMLElement>(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<string, string> = {
|
|
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);
|
|
}
|
|
|
|
/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */
|
|
export 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> };
|
|
}
|
|
|
|
async function getJson(path: string): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
const res = await fetch(path);
|
|
return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* 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) =>
|
|
`<div class="lb-known-row"><span class="code">${escapeHtml(g.gameCode || g.gameId.slice(0, 8))}</span>` +
|
|
`<span class="dim">${g.stage === 'lobby' ? 'waiting to start' : 'in play'}</span>` +
|
|
`<button class="lb-rejoin" data-game="${escapeHtml(g.gameId)}">Rejoin</button>` +
|
|
`<button class="lb-forget ghost" data-game="${escapeHtml(g.gameId)}">Forget</button></div>`,
|
|
)
|
|
.join('');
|
|
for (const btn of Array.from($('lb-known-list').querySelectorAll<HTMLButtonElement>('.lb-rejoin'))) {
|
|
btn.onclick = () => handlers.rejoin?.(btn.dataset['game'] ?? '');
|
|
}
|
|
for (const btn of Array.from($('lb-known-list').querySelectorAll<HTMLButtonElement>('.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) ?? '';
|
|
$<HTMLInputElement>('lb-secret').value = storedSecret;
|
|
showSecret(storedSecret !== '');
|
|
$<HTMLButtonElement>('lb-secret-change').onclick = () => showSecret(false);
|
|
|
|
function secret(): string {
|
|
const value = $<HTMLInputElement>('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);
|
|
$<HTMLInputElement>('lb-secret').focus();
|
|
return true;
|
|
}
|
|
|
|
// -- display name ---------------------------------------------------------------------------
|
|
|
|
const nameField = $<HTMLInputElement>('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');
|
|
}
|
|
$<HTMLButtonElement>('lb-door-join').onclick = () => door('join');
|
|
$<HTMLButtonElement>('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($<HTMLSelectElement>('lb-players').value) || 4;
|
|
const days = (): number => {
|
|
const raw = Number($<HTMLInputElement>('lb-days').value);
|
|
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
|
};
|
|
|
|
function typeRadios(): HTMLInputElement[] {
|
|
return Array.from(document.querySelectorAll<HTMLInputElement>('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') $<HTMLDetailsElement>('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();
|
|
}
|
|
$<HTMLSelectElement>('lb-players').onchange = paramsChanged;
|
|
$<HTMLInputElement>('lb-days').oninput = paramsChanged;
|
|
|
|
selectPreset('coop');
|
|
door('join');
|
|
|
|
// -- creating -------------------------------------------------------------------------------
|
|
|
|
$<HTMLButtonElement>('lb-create').onclick = () => {
|
|
const name = displayName();
|
|
if (name === '') {
|
|
$('lb-create-err').textContent = 'Enter a display name first.';
|
|
return;
|
|
}
|
|
const asked = $<HTMLInputElement>('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);
|
|
}
|
|
|
|
$<HTMLButtonElement>('lb-look').onclick = () => {
|
|
const code = $<HTMLInputElement>('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);
|
|
});
|
|
};
|
|
|
|
$<HTMLButtonElement>('lb-join').onclick = () => {
|
|
const name = displayName();
|
|
const code = previewed?.gameCode ?? $<HTMLInputElement>('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
|
|
? '<span class="dim">— waiting —</span>'
|
|
: occupant.kind === 'bot'
|
|
? `Bot ${botNumber}`
|
|
: `${escapeHtml(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>`;
|
|
else if (!isYou) action = `<button class="lb-kick" data-seat="${seat}">remove</button>`;
|
|
}
|
|
html += `<div class="lb-seat"><span class="dim">Seat ${seatLabel(seat)}</span><span class="who">${who}</span>${action}</div>`;
|
|
}
|
|
$('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.'));
|
|
};
|
|
$<HTMLButtonElement>('lb-copy').onclick = () => copy(lobby.gameCode, 'Code copied.');
|
|
$<HTMLButtonElement>('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<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 });
|
|
}
|
|
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.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 = $<HTMLButtonElement>('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;
|
|
|
|
$<HTMLButtonElement>('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<string>) => {
|
|
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, '>').replace(/"/g, '"');
|
|
}
|