These moved from the Coffey-Labs org to jcoffey-dev, where they belong. GitHub redirects a transferred repository, so nothing was broken by leaving them -- but a source link that lands via a redirect is still naming an owner that no longer holds the code, and the redirect only lasts as long as nobody creates a repository of the same name in the old place. Only the seven repositories that actually moved are rewritten. Every other Coffey-Labs URL is left alone: ihasmail, cairnobs and the rest are still there.
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
/**
|
|
* The leaderboard is one table 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 no longer ours. It is shared with the other games on the site
|
|
* -- see https://github.com/jcoffey-dev/games-scores -- which is why every
|
|
* call names the game. Nothing else about it leaks in here: rows come back in
|
|
* this game's own field names, so this file is otherwise what it always was.
|
|
*/
|
|
|
|
/** Which board on the shared service is ours. */
|
|
export const GAME = 'lemonade'
|
|
|
|
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
|
|
/** Closing assets, in whole cents. */
|
|
assets: number
|
|
days: number
|
|
glasses: number
|
|
at: number
|
|
broke: boolean
|
|
}
|
|
|
|
export type NewScore = Omit<Score, 'id' | 'at'>
|
|
|
|
/** Rows arrive over the network, so every field is checked before use. */
|
|
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.assets) &&
|
|
Number.isFinite(s.days) &&
|
|
Number.isFinite(s.glasses) &&
|
|
Number.isFinite(s.at) &&
|
|
typeof s.broke === 'boolean'
|
|
)
|
|
}
|
|
|
|
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 a whole table's worth of players 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),
|
|
}
|
|
}
|