A player who has lost their browser storage can be put back in their seat (Gitea#33 in the game repo). The session token is the only identity the game has and it lives in one browser's localStorage, scoped to the origin joined at, so a cleared profile, a private window or a different browser locks a player out of a game that is still running with their session still on disk. Seen at a real table: of two humans in one game, the host reloaded straight back in and the joiner met an empty lobby. New action, restore-seat: pick a seat from a dropdown of the players actually holding a session — bots never appear, since seatedPlayers is read from the server's session map rather than guessed from display names — and get back a link built from this interface's own address. serverApi gained mintClaim; the dictionary gained 57-64 in all five locales. THE LINK CARRIES A CODE, NOT THE TOKEN. lobby-and-sessions.md §1 says to keep the token out of URLs so it is not shoulder-surfed or pasted into a chat, and a recovery link is exactly what gets pasted into a chat. The code is single-use, expires in 30 minutes, and the page trades it for the real token over a POST. Minting is admin-gated because deciding that somebody has lost a seat is a judgement no route can make safely; spending needs no secret, because the player following the link holds none. GAMES IN PROGRESS RESUME NORMALLY, measured rather than assumed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is EMPTY. The release is the server's HTTP surface, the client and the docs. The codes live in memory and are deliberately not persisted, so nothing new reaches the volume and there is nothing to migrate. README, instructions.md and the release notes in all five locales say so. NOT YET EXERCISED END TO END: the two routes and the action are typechecked and the claim store is unit-tested, but nothing has run them against a live server. This build is what makes that possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
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<HealthCounts | null> {
|
|
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<Response> {
|
|
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<GameRow[]> {
|
|
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<unknown> {
|
|
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
|
|
}
|
|
}
|