Shared leaderboard, a games hub, AGPL, and two new street conditions
The high score table moves out of the browser and into a service, so every player is on one board. It is a Node process with SQLite, no build step and no native modules - node:sqlite ships with the runtime and Node runs the TypeScript directly. Everything in a request is treated as hostile: names forced to a printable subset, every number range-checked, and scores refused if they could not have happened in the days claimed. Bodies capped, submissions rate limited per address. It still cannot prove a score is real, and server/README.md says so plainly rather than implying otherwise. The game degrades properly without it: the board says it cannot reach town, posting happens behind the closing standings, and play is untouched. Deployment is three containers behind the host's nginx - the hub at /, the game at /lemonade/, the scores service at /api/ - so the game keeps its own container and a second game is just another service. Licensing: AGPL-3.0-or-later, Affero because the leaderboard is a network service. NOTICE.md credits Bob Jamison and Charlie Kellner, records that this is clean-room work, and is honest about the one thing it is not: some on-screen wording is quoted from the original and cannot be licensed by us. The street can now get better as well as worse. The summer fair brings the town out and lifts what they will pay; a rival on the next corner takes a share and lingers. Both show in the art and in the crowd. Over 500 seasons a player who reads the briefing survives every time and finishes around $25.80; one who ignores it goes broke 70% of the time.
This commit is contained in:
+48
-53
@@ -1,6 +1,14 @@
|
||||
export const HIGH_SCORE_KEY = 'lemonade.highscores.v1'
|
||||
const KEY = HIGH_SCORE_KEY
|
||||
export const MAX_SCORES = 10
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
|
||||
const TIMEOUT_MS = 8000
|
||||
|
||||
export const MAX_SCORES = 50
|
||||
|
||||
export interface Score {
|
||||
id: string
|
||||
@@ -9,15 +17,14 @@ export interface Score {
|
||||
assets: number
|
||||
days: number
|
||||
glasses: number
|
||||
seed: number
|
||||
/** Epoch milliseconds, for the tie-break and the date column. */
|
||||
at: number
|
||||
broke: boolean
|
||||
}
|
||||
|
||||
export type NewScore = Omit<Score, 'id' | 'at'>
|
||||
|
||||
const isScore = (v: unknown): v is Score => {
|
||||
/** 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 (
|
||||
@@ -26,58 +33,46 @@ const isScore = (v: unknown): v is Score => {
|
||||
Number.isFinite(s.assets) &&
|
||||
Number.isFinite(s.days) &&
|
||||
Number.isFinite(s.glasses) &&
|
||||
Number.isFinite(s.seed) &&
|
||||
Number.isFinite(s.at) &&
|
||||
typeof s.broke === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
const rank = (a: Score, b: Score) => b.assets - a.assets || a.days - b.days || a.at - b.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything already in storage was written by someone who can edit it freely,
|
||||
* so every field is checked before it is trusted.
|
||||
*/
|
||||
export function loadScores(): Score[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter(isScore)
|
||||
.map((s) => ({ ...s, name: s.name.slice(0, 12).toUpperCase() }))
|
||||
.sort(rank)
|
||||
.slice(0, MAX_SCORES)
|
||||
} catch {
|
||||
// Private windows, blocked site data, or corrupt JSON: play without a table.
|
||||
return []
|
||||
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'))
|
||||
}
|
||||
|
||||
/** 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({ 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),
|
||||
}
|
||||
}
|
||||
|
||||
function persist(scores: Score[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(scores))
|
||||
} catch {
|
||||
// Nothing to do - the run still shows in the table until the tab closes.
|
||||
}
|
||||
}
|
||||
|
||||
const newId = () =>
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
/**
|
||||
* Returns the saved table and the ids of the entries this call added. The
|
||||
* table only ever loses a row by being pushed past MAX_SCORES by a better
|
||||
* one - there is no way to clear it.
|
||||
*/
|
||||
export function addScores(entries: NewScore[]): { table: Score[]; added: string[] } {
|
||||
const at = Date.now()
|
||||
const fresh: Score[] = entries.map((e) => ({ ...e, id: newId(), at }))
|
||||
const table = [...loadScores(), ...fresh].sort(rank).slice(0, MAX_SCORES)
|
||||
persist(table)
|
||||
const kept = new Set(table.map((s) => s.id))
|
||||
return { table, added: fresh.filter((s) => kept.has(s.id)).map((s) => s.id) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user