Files
long-range-scan/src/game/highscores.ts
T
jcoffey-dev 2605e3364d Point links at git.coffeylabs.org after the move from GitHub
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:09:24 -07:00

99 lines
3.1 KiB
TypeScript

/**
* One board, shared by everybody, held by the scores service rather than the
* browser. Nothing about it is trusted on the way in: the server decides what
* is plausible, and the client re-checks the shape of whatever comes back
* before putting it on screen.
*
* The service is shared with the other games on the site rather than being one
* of ours -- see https://git.coffeylabs.org/jcoffey-dev/games-scores. That is why
* every call names the game. Nothing else about it leaks in here: the rows
* come back in this game's own field names, so this file would be identical if
* the board were ours alone.
*/
/** Which board on the shared service is ours. */
export const GAME = 'scan'
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
const TIMEOUT_MS = 8000
export const MAX_SCORES = 50
export interface Score {
id: string
name: string
/** Raiders destroyed. The rank, before any tie-break. */
killed: number
/**
* Whole days still on the orders when the patrol ended.
*
* The game counts days to a tenth and the board ranks on integers, so this
* is the floor of what the report screen shows. A tie-break finer than a
* day would be measuring the dice rather than the captain.
*/
daysLeft: number
/** Torpedoes still in the racks. */
torpedoes: number
/** How it ended, in the game's own words. */
ending: string | null
at: number
}
export type NewScore = Omit<Score, 'id' | 'at'>
function isScore(v: unknown): v is Score {
if (typeof v !== 'object' || v === null) return false
const s = v as Record<string, unknown>
return (
typeof s.id === 'string' &&
typeof s.name === 'string' &&
Number.isFinite(s.killed) &&
Number.isFinite(s.daysLeft) &&
Number.isFinite(s.torpedoes) &&
(s.ending === null || typeof s.ending === 'string') &&
Number.isFinite(s.at)
)
}
const parseBoard = (body: unknown): Score[] => {
const rows = (body as { scores?: unknown })?.scores
if (!Array.isArray(rows)) return []
return rows
.filter(isScore)
.map((s) => ({ ...s, name: s.name.slice(0, 12) }))
.slice(0, MAX_SCORES)
}
async function call(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(`${BASE}${path}`, {
...init,
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'content-type': 'application/json', ...init?.headers },
})
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const why = (body as { error?: string })?.error
throw new Error(why ?? `scores service returned ${res.status}`)
}
return body
}
export async function fetchScores(): Promise<Score[]> {
return parseBoard(await call(`/scores?game=${GAME}`))
}
/** Posts every captain in the party at once and returns the new board. */
export async function submitScores(
entries: NewScore[],
): Promise<{ ids: string[]; scores: Score[] }> {
const body = await call('/scores', {
method: 'POST',
body: JSON.stringify({ game: GAME, entries }),
})
const ids = (body as { ids?: unknown })?.ids
return {
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
scores: parseBoard(body),
}
}