import { storeJson } from './fileModels/store.json' import { uiPort } from './utils' import { T } from '@start9labs/start-sdk' import { i18n } from './i18n' /** * The one place this package talks to the game server. * * Everything the health check and the actions report — how many games are running, who is in them, * whose turn it is — exists only inside the running server. This package cannot work any of it out * for itself: the engine lives in the game repo, and answering "whose turn is it" means replaying * a game's whole intent history through it. So the server is asked, and these functions require * the daemon to be up. That is why the game actions declare `allowedStatuses: 'running'`. */ const base = `http://localhost:${uiPort}` export type GameRow = { gameId: string gameCode: string | null state: 'running' | 'lobby' playerCount: number playerNames: string[] createdAt: number /** * The seats a HUMAN holds a session token for — absent on a lobby. Bots never appear: the server * reads this from its session map rather than guessing from `playerNames` (Gitea#33). */ seatedPlayers?: number[] // Absent on a lobby — it has no game to be part-way through. lastMoveAt?: number status?: 'active' | 'finished' day?: number stage?: number phase?: string waitingOn?: { seat: number; name: string } | null } export type HealthCounts = { active: number; lobby: number } /** `null` when nothing answered — a server still replaying saved games has not bound its port yet. */ export async function fetchHealth(): Promise { try { const res = await fetch(`${base}/api/health`) if (!res.ok) return null const body = (await res.json()) as { games?: HealthCounts } return body.games ?? { active: 0, lobby: 0 } } catch { return null } } async function adminFetch( effects: T.Effects, path: string, init?: RequestInit, ): Promise { const adminSecret = await storeJson.read((s) => s.adminSecret).once() if (!adminSecret) { throw new Error( i18n( 'No admin secret is stored for this service, so its games cannot be managed.', ), ) } const res = await fetch(`${base}${path}`, { ...init, headers: { ...init?.headers, 'x-admin-secret': adminSecret }, }) if (!res.ok) { // 404 with a correct secret means the game is gone, not that the route is missing — the // routes themselves 404 only when the server has no admin secret at all, and it was given // one at install. const detail = res.status === 404 ? i18n('that game no longer exists') : `${i18n('the server answered')} ${res.status}` throw new Error(`${i18n('Could not reach the game server')} — ${detail}.`) } return res } export async function listGames(effects: T.Effects): Promise { const res = await adminFetch(effects, '/api/games') return ((await res.json()) as { games: GameRow[] }).games } export async function exportGame( effects: T.Effects, gameId: string, ): Promise { const res = await adminFetch(effects, `/api/games/${gameId}/save`) return ((await res.json()) as { save: unknown }).save } export async function deleteGame( effects: T.Effects, gameId: string, ): Promise<{ gameCode: string | null; save: unknown }> { const res = await adminFetch(effects, `/api/games/${gameId}`, { method: 'DELETE', }) return (await res.json()) as { gameCode: string | null; save: unknown } } /** * Mint a one-time code that puts one player back into their seat — Gitea#33. * * A session token is the only identity the game has, and it lives in one browser's local storage. * Lose it and the seat is unreachable: nothing else on the server will accept a claim to it. So the * administrator mints a code for a named seat, and the player opens a link carrying it. * * THE CODE IS NOT THE TOKEN. The game's own `lobby-and-sessions.md` §1 says to keep the token out of * URLs, and this code is going into one — so it is short-lived, single-use, and exchanged for the * real token by the page over a POST. `Content-Type` is set here because `adminFetch` spreads these * headers alongside the admin secret rather than assuming a body. */ export async function mintClaim( effects: T.Effects, gameId: string, player: number, ): Promise<{ code: string; expiresAt: number; player: number; displayName: string; gameCode: string | null }> { const res = await adminFetch(effects, `/api/games/${gameId}/claim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player }), }) return (await res.json()) as { code: string expiresAt: number player: number displayName: string gameCode: string | null } }