Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
40 kB · 1070 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071/** * 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<Response> { 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<Session | null> { // 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<Session | null> | null = null;
export function currentSession(): Promise<Session | null> { 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<string> { 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/<rkey> */ uri: string; cid: string; rkey: string;}
async function camoWrite(path: string, body: unknown): Promise<WrittenCamo> { const response = await call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const answer = (await response .json() .catch(() => ({}))) as Partial<WrittenCamo> & { 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<WrittenCamo> { 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<WrittenCamo> { 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<void> { 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/<rkey> */ 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<WrittenFlare> { 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<WrittenFlare> & { 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<void> { 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<string> { 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<ApiVersion> { 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<Record<string, string>> { 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<string, string>;}
/** * 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<ApiVersion | null> | null = null;export function apiVersion(): Promise<ApiVersion | null> { pending ??= fetchVersion().catch(() => { pending = null; return null; }); return pending;}
export async function logout(): Promise<void> { 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<void> { 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<DailyStatus> { 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<string, Blob>, ownCamo?: Blob,): Promise<RequestInit> { if (!botCamo?.size && !ownCamo) return { method: "POST" }; const rolled: Record<string, string> = {}; for (const [slot, blob] of botCamo ?? []) rolled[slot] = await toBase64(blob); const body: { botCamo?: Record<string, string>; 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<string, Blob>, ownCamo?: Blob,): Promise<DailyStart> { 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<Scenario[]> | null = null;
/** The scenarios a match can be launched into. */export function fetchScenarios(): Promise<Scenario[]> { 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<Opponent[]> { 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<SuggestedOpponent[]> { 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<string> { 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<MatchSummary[]> { 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<PlayerMatches | null> { 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<MatchStatus> { 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<string> { 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<string, Blob>, ownCamo?: Blob,): Promise<string> { 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(); }, };}