Files
station-master-startos/startos/serverApi.ts
T
JesseandClaude Opus 5 81f9d06705 0.8.0.13:0 — bundle Station Master v0.8.0.13
Nine fixes from the Day 1-2 playtest of 0.8.0.12, almost all of them about what
the table can see. The one that mattered moved a car: the Division Yard chips
stayed clickable while a player's board was catching up on other people's turns,
so a click submitted a real intent against a position several moves stale. They
obey the catch-up queue now, and the yard counts beside them are read from the
board on screen rather than the live game. The district picker gained a button
for your own seat and is ordered west to east as the map draws it; the Fedora
passing is announced and logged; a collision names whose district it was and who
loses the 5 Revenue; and a Depot that cannot stock another passenger says which
car it is short of instead of the action silently vanishing.

Packaging is the submodule pin, the version and the words. No action, route,
file model or interface changed — the whole release is inside the bundled game.

GAMES IN PROGRESS RESUME NORMALLY, measured rather than assumed:
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is two ADDED lines — a
superintendentChanged variant on the GameEvent union, and the events.push that
emits it beside the actorChanged already there. Nothing was removed or edited:
check(), legal.ts and every predicate are untouched, so no once-legal move
became illegal, and events are derived by replaying a save rather than stored in
one, so widening the union cannot invalidate anything on disk. README,
instructions.md and the release notes in all five locales say so.

Two files come along that this release did not otherwise touch:
`prettier --write startos` reflowed a call in restoreSeat.ts and a return type
in serverApi.ts, formatting drift left by the 0.8.0.12 commit.

The 0.8.0.12 release it follows was deployed and verified at a table: a seat was
recovered end to end from a one-time claim code minted by the StartOS action,
which is what the previous commit said had not yet been exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 05:08:10 -04:00

141 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
}
}