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.
93 lines
2.9 KiB
TypeScript
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),
|
|
}
|
|
}
|