Long Range Scan: the 1971 galaxy, on the terminal it was written for

An independent rebuild of the 1971 starship patrol game. Eight by eight
quadrants of eight by eight sectors, three digits a quadrant on the chart,
courses round a nine-point compass, and a stardate clock that is the real
opponent.

The name and the nouns are ours and NOTICE.md says why: the mechanics are
free to rebuild, the trademarks are not. Raiders rather than the enemy from
the television programme, beams rather than the energy weapon, and every
sentence the machine prints written for this project rather than borrowed
from a listing.

This is the 1971 skin only -- paper, ink and a print head. The remaster and
the synth come next, and the tokens and the structured transcript are already
shaped for them.
This commit is contained in:
2026-09-08 23:26:22 -07:00
commit 593bbd888f
31 changed files with 7262 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* 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),
}
}