Files
long-range-scan/src/game/highscores.ts
T
jcoffey-dev d623315517 The remaster: a bridge, two bands, and a real modem
Adds the cel-shaded skin, the score and the sound, and puts the skin
machinery back so the toggle under the frame has somewhere to go.

The remaster is a bridge rather than a paper column: the ship down the left
as meters and pips, a cel-shaded sensor grid in the middle, the log down the
right. Both panels are devices -- break the short range sensors and the
sector grid goes dark, break the computer and the chart does, and the chart
draws what has been scanned rather than the galaxy as it is. Scans are drawn
instead of printed, so the log gets the sentences and stops being eight rows
of ASCII squeezed into a strip.

One theme, two arrangements, so switching skin re-scores the piece rather
than changing the record: brass, strings and timpani for the remaster, the
same eight bars as a three-channel chiptune for 1971. Written in the idiom
rather than quoted from it, for the same reason the game is not called what
you expect -- NOTICE.md now argues both. Effects are the ship's: warp, beams,
torpedo, hit, explode, klaxon, dock, and the print head on paper only.

The boot dials in for real: Bell 103 at the real frequencies, which is what a
110-baud acoustic coupler sounded like and not the modem noise everybody
remembers. The synth engine is the siblings', unforked.

Tests cover the music the only way a test can: a mistyped note name does not
throw, it silently removes a voice, so every step of every track is checked
against the parser.
2026-09-08 23:45:25 -07:00

93 lines
2.9 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://github.com/Coffey-Labs/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
/** Days still on the orders when the patrol ended. */
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),
}
}