The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
645 lines
29 KiB
TypeScript
645 lines
29 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;
|
|
/**
|
|
* THE TWO DOORS COME BACK EVERY TIME THIS SCREEN DOES.
|
|
*
|
|
* `enterSeating` hides this section, and until v0.8.1.0 the only things that put it back were the
|
|
* lobby's OWN leave paths. Leaving a running game is a different route — `main.ts`'s Leave game
|
|
* button calls `runLobby` again — so the section stayed hidden from the previous seating and the
|
|
* screen came back holding nothing but "Games you are in". Reported 2026-09-22: "I see the games
|
|
* that I'm in, and I have the ability to rejoin or forget. But what if I want to join a different
|
|
* game or start a new one? How do I get back there?" There was no way: the doors were on the
|
|
* page, display:none, with no control that could reveal them.
|
|
*
|
|
* Reset here rather than in the leave handler, because this function is the one thing every route
|
|
* onto the lobby goes through — which is exactly why the two that did it themselves missed a third.
|
|
*/
|
|
$('lb-choice-section').hidden = false;
|
|
if (has('lb-seating-section')) $('lb-seating-section').hidden = true;
|
|
|
|
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, 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, 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, '"');
|
|
}
|