/** * The only thing this site talks to. * * Every call sends credentials, because the session lives in a cookie set by * the API's own origin — the browser will not attach it otherwise. Nothing * here ever sees an OAuth token; that is the point of the split. */ // The loopback default is for development only. A production build should be // told its API origin; unset, it falls back to same-origin, never to loopback // — a public page fetching 127.0.0.1 trips Chrome's Local Network Access // prompt on every visitor. // // The optional chaining below (`import.meta.env?.`) is a no-op in a real // build or dev server — Vite always provides `import.meta.env` there — but // is what lets `node --test` import this module at all: // lobby-client.test.mjs drives the lobby functions below directly against a // mocked fetch/WebSocket, and `import.meta.env` does not exist under plain // Node, so an unguarded read here would throw before the module finished // loading. const API_ORIGIN = ( import.meta.env?.VITE_API_ORIGIN ?? (import.meta.env?.DEV ? "http://127.0.0.1:3000" : "") ).replace(/\/+$/, ""); export interface Session { did: string; handle: string | null; /** * The record key of the camo to launch with when a match names none. * * A setting on the account here, not a record in the player's repository: * the camo is theirs and lives with them, and this is only the site * remembering which one to reach for first. Null until they say. */ defaultCamo?: string | null; } export class ApiError extends Error {} async function call(path: string, init?: RequestInit): Promise { try { return await fetch(`${API_ORIGIN}${path}`, { credentials: "include", ...init, }); } catch { // fetch only rejects for transport-level failures, which in practice means // the API is not running — the first thing to go wrong in development. throw new ApiError( `Cannot reach the control plane${API_ORIGIN ? ` at ${API_ORIGIN}` : ""}.`, ); } } /** The signed-in account, or null. Never throws for "not signed in". */ export async function fetchSession(): Promise { // Dead code in a production build: `import.meta.env.DEV` is a literal false // there, so the bundler drops the branch and the import with it. // `import.meta.env.DEV` is a literal false in a production build, so this // block and the dynamic import inside it are eliminated outright - which is // what keeps the browser OAuth client out of the shipped bundle. Guarding on // a constant imported from another module was not enough: the import stayed // reachable and Rollup emitted the chunk anyway. if (import.meta.env.DEV) { const { devAuthEnabled, devSession } = await import("./devauth"); if (devAuthEnabled) return devSession(); } const response = await call("/api/session"); if (response.status === 401) return null; if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); return (await response.json()) as Session; } /** * The session, fetched once and shared for the life of the page. * * Two things want it now and they run independently: the masthead's account * control, which the layout mounts on every page, and the router, which reads * it again on every screen change. Neither knows about the other, and without * this the app page asked twice on the way to its first screen. * * Not a cache of a signed-in state that could go stale on its own: a session * ends because someone signed out here, and logout() clears this. */ let session: Promise | null = null; export function currentSession(): Promise { session ??= fetchSession().catch((err: unknown) => { // A failed read must not be remembered, or one blip means the page // believes nobody is signed in until it is reloaded. session = null; throw err; }); return session; } /** Returns the authorization server URL to send the browser to. */ export async function startLogin(handle: string): Promise { if (import.meta.env.DEV) { const { devAuthEnabled, devSignIn } = await import("./devauth"); // Navigates away itself rather than handing back a URL, so this never // returns and the caller's assign() is unreachable. if (devAuthEnabled) return devSignIn(handle); } const response = await call("/api/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ handle }), }); const body = (await response.json().catch(() => ({}))) as { redirectUrl?: string; message?: string; }; if (!response.ok || !body.redirectUrl) { throw new ApiError(body.message ?? "Login could not be started."); } return body.redirectUrl; } // --- camo ------------------------------------------------------------------ // // Writes only. A camo record and its blob are public, so the site reads a // collection straight from the player's PDS (pds.ts) and asks us for nothing. // The writes come here because the browser has no token: the OAuth session is // on the API, and the repository write is made there. export interface WrittenCamo { /** at://did:plc:…/blue.lance.camo/ */ uri: string; cid: string; rkey: string; } async function camoWrite(path: string, body: unknown): Promise { const response = await call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const answer = (await response .json() .catch(() => ({}))) as Partial & { message?: string; }; if (!response.ok || !answer.uri) { throw new ApiError(answer.message ?? "The camo could not be saved."); } return { uri: answer.uri, cid: answer.cid ?? "", rkey: answer.rkey ?? "" }; } /** Writes a new camo to the signed-in player's own repository. */ export async function saveCamo( name: string, png: Blob, source: unknown, ): Promise { await refuseInDevAuth(); return camoWrite("/api/camo", { name, png: await toBase64(png), source: source ?? undefined, }); } /** Renames a camo, replaces its picture, or both. */ export async function updateCamo( rkey: string, changes: { name?: string; png?: Blob; source?: unknown; swapRecord?: string; }, ): Promise { await refuseInDevAuth(); return camoWrite(`/api/camo/${encodeURIComponent(rkey)}`, { name: changes.name, png: changes.png ? await toBase64(changes.png) : undefined, source: changes.source, swapRecord: changes.swapRecord, }); } /** Deletes a camo from the signed-in player's own repository. */ export async function deleteCamo(rkey: string): Promise { await refuseInDevAuth(); const response = await call(`/api/camo/${encodeURIComponent(rkey)}`, { method: "DELETE", }); if (!response.ok && response.status !== 404) { const body = (await response.json().catch(() => ({}))) as { message?: string; }; throw new ApiError(body.message ?? "The camo could not be deleted."); } } // --- flare ----------------------------------------------------------------- // // The same write the match screen's flare button makes, reached from the // account menu's "Report an issue". The API appends nothing here — a match // footer needs a matchId, and a report from the site names none. export interface WrittenFlare { /** at://did:plc:…/app.userinput.discussion/ */ uri: string; rkey: string; } /** * Files a report to the lance.blue board on userinput.app, written to the * signed-in player's own repository as a discussion. */ export async function sendFlare( title: string, body: string, tags: string[] = [], ): Promise { await refuseInDevAuth(); const response = await call("/api/flare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title, body, tags }), }); const answer = (await response .json() .catch(() => ({}))) as Partial & { message?: string }; if (!response.ok || !answer.uri) { throw new ApiError(answer.message ?? "The report could not be sent."); } return { uri: answer.uri, rkey: answer.rkey ?? "" }; } /** * Local development auth keeps its tokens in this tab, so the API has no * session for this DID and nothing on the server to write with. Saying so * beats a 401 that looks like a bug. */ async function refuseInDevAuth(): Promise { if (import.meta.env.DEV) { const { devAuthEnabled } = await import("./devauth"); if (devAuthEnabled) { throw new ApiError( "Local auth cannot write: the write happens on the API, which has no session for you.", ); } } } /** The payload of a `data:` URL, which is the base64 the API wants. */ function toBase64(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const url = String(reader.result); const comma = url.indexOf(","); if (comma < 0) reject(new ApiError("The camo could not be read.")); else resolve(url.slice(comma + 1)); }; reader.onerror = () => reject(new ApiError("The camo could not be read.")); reader.readAsDataURL(blob); }); } export interface ApiVersion { version: string; build: string | null; arena: string | null; /** Which unit library the API loaded, or null where it loaded none. */ library: { helm: string | null; megamek: string } | null; } /** What the API is running — for the footer's debug line. */ export async function fetchVersion(): Promise { const response = await call("/api/version"); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); return (await response.json()) as ApiVersion; } /** * Which picture each of these designs is drawn with. * * Paths relative to `data/images/units/`, keyed by the names that resolved; * a name this library has never heard of is simply absent. The caller turns * a path into an address — see `unit-art.ts`, which also holds the fallback * for when this cannot be reached at all. */ export async function fetchUnitArt( names: string[], ): Promise> { const query = names.map((n) => `name=${encodeURIComponent(n)}`).join("&"); const response = await call(`/api/units/art?${query}`); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); return (await response.json()) as Record; } /** * The same answer, fetched once per page load and shared. * * Two callers want it now — the footer's debug line and `megamek.ts`, which * reads the helm release off it — and neither should cost the other a * request. Memoised here rather than in either caller, so a third one is * free too. A failed fetch is not cached: it resolves to null and the next * caller asks again. */ let pending: Promise | null = null; export function apiVersion(): Promise { pending ??= fetchVersion().catch(() => { pending = null; return null; }); return pending; } export async function logout(): Promise { if (import.meta.env.DEV) { const { devAuthEnabled, devSignOut } = await import("./devauth"); if (devAuthEnabled) { await devSignOut(); session = null; return; } } const response = await call("/api/logout", { method: "POST" }); if (!response.ok) throw new ApiError("Sign out failed."); // Whoever asked for this is about to redraw against the session; it has to // be read again rather than answered from the one that just ended. session = null; } export interface MatchStatus { status: string; url?: string; } /** Starts a bot match; resolves to its id. */ /** * Remembers which camo to launch with when a match names none. Null clears it. */ export async function setDefaultCamo(rkey: string | null): Promise { const response = await call("/api/account/camo", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ rkey }), }); if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { message?: string; }; throw new ApiError(body.message ?? "That could not be saved."); } // The session is memoized for the life of the page and carries this field, // so without touching it the editor keeps marking the old camo and the // challenge screen keeps pre-selecting it until a reload. Patched rather // than dropped: this call is the only thing that changed, and dropping it // would send every later reader back to the API for a value we have. const known = session; if (known) { session = known.then((s) => (s ? { ...s, defaultCamo: rkey } : s)); } } export interface ScenarioSlot { /** The faction name, which is also the launch manifest's slot key. */ name: string; /** * The MegaMek team this faction fights on, when its file sets one. * * Usually null. A scenario that declares no `Team_` line is a free-for-all — * one team per faction — which is a real answer rather than a missing one. */ team?: number | null; /** The deployment zone, MegaMek's own spelling: `N`, `SW`, `Any`. */ deploy?: string; /** The units this slot fields, in the order its file lists them. */ units: ScenarioUnit[]; } /** One machine and the pilot the scenario puts in it. */ export interface ScenarioUnit { /** Chassis and model, as the file writes it. */ name: string; /** The pilot's name. Scenario files name their crews. */ pilot?: string; /** Gunnery and piloting, the two numbers BV 2.0 multiplies by. */ gunnery?: number; piloting?: number; /** * What this machine is worth with this crew, as MegaMek computed it. * * The value as written. A lobby can reseat any slot and nothing recomputes * it, so it answers what the fight is worth on paper. */ bv?: number; } export interface Scenario { /** Path inside the arena image's scenario library. */ path: string; name: string; description: string; slots: ScenarioSlot[]; } /** What the control plane knows about one daily challenge. */ export interface DailyStatus { /** The challenge this is about, so a late answer cannot be read as another's. */ slug: string; /** How many players have started it. Attempts, not finishers. */ attempts: number; /** * Whether the signed-in player has used their attempt, and what they got. * Both null for a stranger, and `score` is null for an attempt that is * still running or that ended without a result. */ attempted: boolean; score: number | null; } /** * The live half of the daily challenge card. * * Everything else on that card is bundled with the page; this is the one * figure that has to be asked for, and the card is written so that losing it * costs the count and nothing else. */ export async function dailyStatus(slug: string): Promise { const response = await call(`/api/daily/${encodeURIComponent(slug)}`); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); return (await response.json()) as DailyStatus; } /** A daily challenge that has been started. */ export interface DailyStart { matchId: string; /** * Whether this one counts. False for a challenge whose day has passed: it * is playable, it spends no attempt, and it is not ranked. The server * decides it against its own clock, so this is what actually happened, not * what the card guessed. */ scored: boolean; } /** * Take on a challenge: the API claims the player's one attempt and launches * the fight against the bot in a single call. * * Not the lobby's openLobby/deployLobby pair. A daily has no seats to arrange * — both are fixed, and the scenario is compiled into the API rather than * being in the catalog — so there is nothing for a lobby row to hold. 401 is * signed out and 409 is an attempt already spent; both arrive as the server's * own message. */ /** * A launch request, with whatever camo the browser rolled for the bot forces. * * Nothing rolled means no body at all, which is exactly the request these two * endpoints took before they could carry one — so an older client, or a player * who never pressed Shuffle, is unchanged. */ async function launchBody( botCamo?: Map, ownCamo?: Blob, ): Promise { if (!botCamo?.size && !ownCamo) return { method: "POST" }; const rolled: Record = {}; for (const [slot, blob] of botCamo ?? []) rolled[slot] = await toBase64(blob); const body: { botCamo?: Record; camo?: string } = {}; if (Object.keys(rolled).length > 0) body.botCamo = rolled; if (ownCamo) body.camo = await toBase64(ownCamo); return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }; } export async function startDaily( slug: string, botCamo?: Map, ownCamo?: Blob, ): Promise { const response = await call( `/api/daily/${encodeURIComponent(slug)}`, await launchBody(botCamo, ownCamo), ); const body = (await response.json().catch(() => ({}))) as { matchId?: string; scored?: boolean; message?: string; }; if (!response.ok || !body.matchId) { throw new ApiError(body.message ?? "The challenge could not be started."); } return { matchId: body.matchId, scored: body.scored === true }; } /** The catalog is static for the life of a deploy, so one fetch per page. */ let scenarios: Promise | null = null; /** The scenarios a match can be launched into. */ export function fetchScenarios(): Promise { scenarios ??= (async () => { const response = await call("/api/scenarios"); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); const body = (await response.json()) as { scenarios: Scenario[] }; return body.scenarios; })().catch((err: unknown) => { // A failed read must not be remembered, or one blip means an empty // scenario picker until the page reloads. scenarios = null; throw err; }); return scenarios; } export interface Opponent { did: string; handle: string | null; /** When the latest match against them started, unix seconds. */ lastMatchAt: number; } /** Everyone the signed-in player has shared a match with, latest first. */ export async function recentOpponents(): Promise { const response = await call("/api/opponents"); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); const body = (await response.json()) as { opponents: Opponent[] }; return body.opponents; } export interface SuggestedOpponent { did: string; handle: string | null; } /** * Bluesky accounts the player follows who have played lance.blue before, * and who they have never faced — new discovery, separate from who they * have already fought. */ export async function suggestedOpponents(): Promise { const response = await call("/api/opponents/suggested"); if (!response.ok) throw new ApiError(`Unexpected response (${response.status}).`); const body = (await response.json()) as { suggested: SuggestedOpponent[] }; return body.suggested; } /** * One slot of a launch: taken by the signed-in player, by a bot, or by * another player named by handle. The API resolves and verifies the name — * this side never sends a DID it decided on for someone else. */ export interface SeatPlan { slot: string; control: "self" | "bot" | "player"; /** For `player` seats: the handle to resolve. */ handle?: string; /** * For the `self` seat: which of the player's own camo to fight in, as the * record key of a `blue.lance.camo` in their own repository — a key rather * than a picture, so what gets painted is what they published. The three * cases are distinct: `undefined` means "whatever my default is", `null` * means "no camo this match", which a player has to be able to say * without clearing their default. */ camo?: string | null; /** * For `bot` seats: a rolled picture, sent once and kept nowhere. An image * and not a key because the bot has no account and nothing published, so * there is no record to name. */ botCamo?: Blob; } /** * Starts a match: a scenario from the catalog, one entry per slot. Resolves * to the match id. */ export async function createMatch( scenario: string, seats: SeatPlan[], ): Promise { const players = await Promise.all( seats.map(async (seat) => ({ slot: seat.slot, control: seat.control, ...(seat.handle === undefined ? {} : { handle: seat.handle }), ...(seat.camo === undefined ? {} : { camo: seat.camo ?? "" }), ...(seat.botCamo ? { botCamo: await toBase64(seat.botCamo) } : {}), })), ); const response = await call("/api/matches", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scenario, players }), }); const body = (await response.json().catch(() => ({}))) as { matchId?: string; message?: string; }; if (!response.ok || !body.matchId) { throw new ApiError(body.message ?? "The match could not be started."); } return body.matchId; } /** One seat of a listed match, as the API stored it at launch. */ export interface MatchPlayer { slot: string; control: string; did: string | null; handle: string | null; /** * The team a lobby put this seat on, or null when nobody overrode the * scenario's own answer. Null is not "no team" — see `setTeam`. */ team: number | null; } export interface MatchSummary { matchId: string; status: string; createdAt: number; url: string | null; /** The scenario path the match launched with; null on older matches. */ scenario?: string | null; /** Who was in it; absent or empty on matches that predate seats. */ players?: MatchPlayer[]; } /** * A match nobody is going back into. Named by the two statuses that are an end * rather than by the ones that are not: a status this file has never heard of * is something in progress until it says otherwise, which keeps an unknown one * on the screen with a way in rather than filed away as history. * * Here rather than in screens/match-list.ts, where it was written: the * masthead needs the same rule now, and account.ts is mounted by every page of * the site including the blog's. Importing the screen from there would drag * the waiting screen, the scenario catalog and the DOM helpers onto every blog * page behind one predicate. This module is one both already import. */ export function isFinished(match: MatchSummary): boolean { return match.status === "over" || match.status === "failed"; } /** The signed-in player's matches, newest first. */ export async function listMatches(): Promise { const response = await call("/api/matches"); if (!response.ok) throw new ApiError("Your matches could not be loaded."); const body = (await response.json()) as { matches: MatchSummary[] }; return body.matches; } /** Whose matches these are, resolved by the API from the address. */ export interface PlayerMatches { did: string; handle: string | null; matches: MatchSummary[]; } /** * Any player's matches, newest first, by handle or DID. * * No session needed — a match is public, and so is the report at the end of * one. What a session still changes is the way *in*: a row carries a link * into a running match only when the caller is the player whose row it is. * * `null` for an actor nobody resolves, which is the page's not-found rather * than an error: the address was typed or pasted, and the account is what is * missing. */ export async function listPlayerMatches( actor: string, ): Promise { const response = await call( `/api/profile/${encodeURIComponent(actor)}/matches`, ); if (response.status === 404) return null; if (!response.ok) throw new ApiError("The matches could not be loaded."); return (await response.json()) as PlayerMatches; } export async function matchStatus(id: string): Promise { const response = await call(`/api/matches/${encodeURIComponent(id)}`); if (!response.ok) throw new ApiError("The match status could not be read."); return (await response.json()) as MatchStatus; } /** The match lives on the API's origin; the session cookie only travels there. */ export function matchUrl(path: string): string { return `${API_ORIGIN}${path}`; } // --- lobby ------------------------------------------------------------- // // A parked match row, filled in before it becomes a real one: a scenario // choice and seats, kept in sync over a WebSocket every connected browser // shares (`matches::lobby` on the API side — see its module doc for the // wire protocol this is written against), which also carries chat — the one // thing on this channel a player authors rather than picks from a fixed // set. Opening a lobby and deploying it are plain HTTP calls in the same // ApiError-throwing shape as everything above; everything in between — a // scenario change, a seat claimed, a seat handed to the bot, a line said — // travels over the socket `connectLobby` opens, not a call here. /** Opens a new lobby starting on `scenario`; resolves to its match id. */ export async function openLobby(scenario: string): Promise { const response = await call("/api/matches/lobby", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scenario }), }); const body = (await response.json().catch(() => ({}))) as { matchId?: string; message?: string; }; if (!response.ok || !body.matchId) { throw new ApiError(body.message ?? "The lobby could not be opened."); } return body.matchId; } /** * Deploys a fully-seated lobby into a real, launched match; resolves to the * new match's id. * * The API sends a real `message` on every failure that is not a bare * "you're not allowed here" (400: no scenario yet, or an unclaimed slot; * 503: launching not configured; 500: the launch itself failed) — those are * passed straight through rather than collapsed into one generic line. A * 401/404/403/409 carries no body at all (no session, no such lobby, not * this caller's to deploy, already deployed by someone else), so those fall * back to the same default `openLobby` and `createMatch` use for a body * that parsed to nothing. */ export async function deployLobby( matchId: string, botCamo?: Map, ownCamo?: Blob, ): Promise { const response = await call( `/api/matches/${encodeURIComponent(matchId)}/deploy`, await launchBody(botCamo, ownCamo), ); const body = (await response.json().catch(() => ({}))) as { matchId?: string; message?: string; }; if (!response.ok || !body.matchId) { throw new ApiError(body.message ?? "The match could not be deployed."); } return body.matchId; } /** ws(s):// for whatever http(s):// origin the API calls travel to — same * host, same cookie, just a different scheme for the upgrade. */ function httpToWs(origin: string): string { return origin.replace(/^http/, "ws"); } /** * The origin a lobby socket connects to: `API_ORIGIN` itself when one is * configured (production, or a dev build pointed at a real API), and * otherwise the page's own origin — what an empty `API_ORIGIN` means in * production (same-origin), and what the loopback dev fallback would be * wrong for. `window` is checked rather than assumed: there is none under * `node --test`, where `API_ORIGIN` is also empty (see the note by its * declaration above), and this must not throw there either. */ function wsOrigin(): string { if (API_ORIGIN) return API_ORIGIN; return typeof window === "undefined" ? "" : window.location.origin; } /** * One thing said in a lobby, exactly as the API's `ChatLine` sends it. Never * stored anywhere — a restart of the API takes the whole backlog with it, * and this client keeps nothing beyond whatever is still in the DOM. */ export interface ChatLine { /** Who said it — every connection on a lobby's channel is signed in. */ did: string; /** The speaker's verified handle, or null when they have none to show. */ handle: string | null; text: string; /** Unix seconds — present so a replayed backlog line and one said just * now are told apart, though a client that only ever appends in arrival * order (see `onChat`) has no other use for it. */ at: number; } /** * Somebody with a socket open on this lobby, seated or not. * * Connecting needs only a session, so a lobby has spectators by * construction — who is *here* and who is *playing* are different lists. */ export interface Watcher { did: string; /** Their verified handle, or null when they have none to show. */ handle: string | null; } /** How many characters `sendChat` will let through — mirrors the API's own * `CHAT_MAX_CHARS`, so a client finds out before sending, not after. The * API still enforces it; this is only for the input's own max-length. */ export const CHAT_MAX_CHARS = 500; /** * One connected lobby socket's outgoing half: the seven requests a * connected browser may send. None of the first four report why an * attempt failed — see `connectLobby` — only whether the next `seats` * snapshot reflects it; `sendChat` is silently refused the same way on a * blank or over-length line. `kickSeat` adds a fifth silent-refusal reason * on top of that — see its own doc. */ export interface LobbySocket { /** Proposes a scenario change. */ setScenario(scenario: string): void; /** Claims `slot` as the caller's own session identity. */ claimSeat(slot: string): void; /** Assigns `slot` to the bot. */ assignBot(slot: string): void; /** * Invites `handle` — who need not be connected to this lobby, or to * anything, right now — into `slot`. Resolved on the API side, which * means this can take a real network round trip before the seat map * moves; there is nothing in this shape to await, and, like the other * resolvers, no way to tell a slow resolution from a refused one except * whether the next `onSeats` reflects it. */ inviteHandle(slot: string, handle: string): void; /** * Removes whoever holds `slot` — human or bot — freeing it for someone * else to claim. Only the lobby's own opener may do this; the API checks * that server-side on every attempt, since this client has no reliable * way to know in advance whether the caller is the opener (see * `screens/lobby.ts`'s module doc). A non-owner's call is refused the * same silent way as a bad slot on the other resolvers — nothing here * reports it, the only signal is whether the next `onSeats` still shows * the seat. */ kickSeat(slot: string): void; /** * Puts `slot` on `team`, or hands the question back to the scenario file * (`null`). The seat's holder may move their own seat; the lobby's owner * may move any, which is the only way a bot seat moves at all. Refused * the same silent way as the other resolvers — the next `onSeats` is the * only answer. */ setTeam(slot: string, team: number | null): void; /** * Hands the lobby to a seated player. The host's own to give, and the way * a host who has to leave does not take the lobby with them. */ makeHost(did: string): void; /** Says `text` to everyone connected to this lobby, as the caller's own * session identity. Anyone connected may call this, seated or not. */ sendChat(text: string): void; /** * Says the lineup as it stands is one the caller will fight, or takes * that back — never anyone else's, the same as a seat claim. The API * drops every readiness in the lobby on any seat or scenario change, so * this is a statement about what is on screen right now and nothing * more; the next `onReady` is what says whether it took. */ setReady(ready: boolean): void; close(): void; } /** * What a lobby socket's incoming half is told — one callback per event the * API's tagged `LobbyEvent` may carry. */ export interface LobbyCallbacks { /** * The socket's own handshake finished — the browser's `WebSocket` moved * from `CONNECTING` to `OPEN`. Fired at most once, before anything the * server sends: the API checks the session and the lobby's existence * ahead of `ws.on_upgrade` (see `routes.rs`'s `lobby_live`), so this only * fires for a socket the server actually accepted, never for a bad or * expired id. A caller that wants to say something the moment the * connection is real, ahead of the scenario/seats snapshot or any chat, * belongs here rather than piggybacking on `onScenario`. */ onOpen(): void; /** * Whose lobby this is, by DID. Sent once, before anything else, and never * again — it cannot change. The owner is the only identity the API lets * deploy, and the one player never asked to press Ready: pressing Deploy * is their readiness. */ onOwner(did: string): void; /** The lobby's current scenario, sent on connect and after every change. */ onScenario(scenario: string): void; /** The full seat map, sent on connect and after every successful claim. */ onSeats(seats: MatchPlayer[]): void; /** The slots the host is holding open for somebody, sorted. A full * snapshot, sent on connect and after every change. * * Nothing in this client sets one: the lobby's Hold control went with the * seat control's redesign, and there is no `setLock` sender any more. This * is what a lock set by another client, or by an older one, reads as here * — the API still keeps and broadcasts them. */ onLocked(slots: string[]): void; /** * Everyone with a socket open, sorted by DID — a full snapshot, sent on * every arrival and departure, this connection's own arrival included. * That first one is how a fresh connection learns who is already here. */ onPresence(here: Watcher[]): void; /** * Everyone who has pressed Ready, by DID — a full snapshot like the seat * map, sent on connect and after every change, and emptied by every seat * or scenario change (the API sends the empty snapshot itself, right * after the `seats` one that caused it). A DID here need not hold a seat: * anyone connected may press it, and the API only ever gates a deploy on * the readiness of seats. */ onReady(dids: string[]): void; /** * The lobby resolved into a real match. The one event that ends the * socket's useful life — the row it was about is deleted server-side * right after this fires. */ onDeployed(matchId: string): void; /** * One chat line — the backlog on connect (oldest first), then every line * said afterward, one event each. There is no separate "history" message * and no way to tell a backlog line from a live one except `at`: a caller * that just appends each one it is told about, in the order it is told, * ends up with the whole scrollback in the right order for free. */ onChat(line: ChatLine): void; /** * The socket closed, for any reason: a clean `close()` from this same * client leaving the screen, the server ending the connection, or the * upgrade itself being refused (no session, no such lobby) — the * WebSocket API gives no way to tell those apart from here, only that the * connection is over. Fired at most once. A caller that closed the socket * itself already knows why and can ignore this; one that did not (a * lobby.ts join gone stale, say) is what this exists for. */ onClosed(): void; } /** * Opens a lobby's live channel and wires it to `callbacks`. * * No message ever says why a `claimSeat`, `botSeat`, `invite` or `kick` * request was a no-op — an unknown slot, one already taken, a handle that * did not resolve, a DID already seated elsewhere in this lobby, or — for * `kick` alone — a caller who is not this lobby's owner — that is the API's * own deliberate posture (see `matches::lobby`'s module doc), not something * to work around here. The only signal a caller gets is whatever the next * `seats` snapshot looks like — for `invite` specifically, that can arrive * well after the request did, since resolving a handle is a real network * round trip on the API side. A chat line that was blank or over * `CHAT_MAX_CHARS` is refused the same silent way. */ export function connectLobby( matchId: string, callbacks: LobbyCallbacks, ): LobbySocket { const url = `${httpToWs(wsOrigin())}/api/matches/${encodeURIComponent(matchId)}/live`; const socket = new WebSocket(url); socket.addEventListener("open", () => { callbacks.onOpen(); }); socket.addEventListener("message", (event) => { let data: unknown; try { data = JSON.parse(String(event.data)); } catch { return; } const message = data as { type?: string }; switch (message.type) { case "owner": callbacks.onOwner((message as { owner: string }).owner); break; case "scenario": callbacks.onScenario((message as { scenario: string }).scenario); break; case "seats": callbacks.onSeats((message as { seats: MatchPlayer[] }).seats); break; case "locked": callbacks.onLocked((message as { locked: string[] }).locked); break; case "presence": callbacks.onPresence((message as { here: Watcher[] }).here); break; case "ready": callbacks.onReady((message as { ready: string[] }).ready); break; case "deployed": callbacks.onDeployed((message as { matchId: string }).matchId); break; case "chat": // Flattened onto the tag on the wire, same as the API sends it — // the message itself, minus the tag, is already a ChatLine. callbacks.onChat(message as unknown as ChatLine); break; // An unknown or missing tag is ignored, not a reason to drop the // socket — the same forward-compatible posture the rest of this // client takes with a response shape it was not expecting. } }); socket.addEventListener("close", () => { callbacks.onClosed(); }); function send(request: unknown): void { // Silently dropped rather than thrown: WebSocket.send() throws on a // socket that is not yet OPEN, and a click that lands before the // handshake finishes is a timing accident, not a reason to crash the // screen that made it. if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(request)); } } return { setScenario(scenario) { send({ type: "scenario", scenario }); }, claimSeat(slot) { send({ type: "claimSeat", slot }); }, assignBot(slot) { send({ type: "botSeat", slot }); }, inviteHandle(slot, handle) { send({ type: "invite", slot, handle }); }, kickSeat(slot) { send({ type: "kick", slot }); }, sendChat(text) { send({ type: "chat", text }); }, setTeam(slot, team) { send({ type: "setTeam", slot, team }); }, makeHost(did) { send({ type: "makeHost", did }); }, setReady(ready) { send({ type: "setReady", ready }); }, close() { socket.close(); }, }; }