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:
@@ -0,0 +1,181 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { MAX_ENTRIES_PER_POST, validateBatch } from './validate.ts'
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 5184)
|
||||
const DB_PATH = process.env.DB_PATH ?? './data/scores.db'
|
||||
/** Behind nginx the socket address is the proxy, so take the forwarded hop. */
|
||||
const TRUST_PROXY = process.env.TRUST_PROXY === '1'
|
||||
const BOARD_LIMIT = 50
|
||||
/** Rows kept on disk. The board only ever shows the top of this. */
|
||||
const KEEP_ROWS = 500
|
||||
const MAX_BODY_BYTES = 4096
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||
const db = new DatabaseSync(DB_PATH)
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS scores (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
assets INTEGER NOT NULL,
|
||||
days INTEGER NOT NULL,
|
||||
glasses INTEGER NOT NULL,
|
||||
seed INTEGER NOT NULL,
|
||||
broke INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS scores_rank
|
||||
ON scores (assets DESC, days ASC, created_at ASC);
|
||||
`)
|
||||
|
||||
const selectTop = db.prepare(
|
||||
`SELECT id, name, assets, days, glasses, broke, created_at
|
||||
FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ?`,
|
||||
)
|
||||
const insertScore = db.prepare(
|
||||
`INSERT INTO scores (name, assets, days, glasses, seed, broke, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
const prune = db.prepare(
|
||||
`DELETE FROM scores WHERE id NOT IN (
|
||||
SELECT id FROM scores ORDER BY assets DESC, days ASC, created_at ASC LIMIT ?
|
||||
)`,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- throttle
|
||||
|
||||
interface Bucket {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
const posts = new Map<string, Bucket>()
|
||||
const POST_WINDOW_MS = 60 * 60 * 1000
|
||||
const POST_LIMIT = 30
|
||||
|
||||
function overPostLimit(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const b = posts.get(ip)
|
||||
if (!b || now > b.resetAt) {
|
||||
posts.set(ip, { count: 1, resetAt: now + POST_WINDOW_MS })
|
||||
return false
|
||||
}
|
||||
b.count += 1
|
||||
return b.count > POST_LIMIT
|
||||
}
|
||||
|
||||
// Buckets are tiny, but a long-lived process should still not hoard them.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [ip, b] of posts) if (now > b.resetAt) posts.delete(ip)
|
||||
}, POST_WINDOW_MS).unref()
|
||||
|
||||
function clientIp(req: IncomingMessage): string {
|
||||
if (TRUST_PROXY) {
|
||||
const fwd = req.headers['x-forwarded-for']
|
||||
const first = (Array.isArray(fwd) ? fwd[0] : fwd)?.split(',')[0]?.trim()
|
||||
if (first) return first
|
||||
}
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ replies
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown) {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
// A public board with no cookies and no credentials.
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'access-control-max-age': '86400',
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
reject(new Error('body too large'))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- routes
|
||||
|
||||
const board = () =>
|
||||
(selectTop.all(BOARD_LIMIT) as Record<string, unknown>[]).map((r) => ({
|
||||
id: String(r.id),
|
||||
name: r.name as string,
|
||||
assets: r.assets as number,
|
||||
days: r.days as number,
|
||||
glasses: r.glasses as number,
|
||||
broke: r.broke === 1,
|
||||
at: r.created_at as number,
|
||||
}))
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
|
||||
const path = url.pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
if (req.method === 'OPTIONS') return send(res, 204, {})
|
||||
if (path === '/api/health') return send(res, 200, { ok: true })
|
||||
|
||||
if (path === '/api/scores' && req.method === 'GET') {
|
||||
return send(res, 200, { scores: board() })
|
||||
}
|
||||
|
||||
if (path === '/api/scores' && req.method === 'POST') {
|
||||
const ip = clientIp(req)
|
||||
if (overPostLimit(ip)) return send(res, 429, { error: 'too many submissions' })
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readBody(req))
|
||||
} catch {
|
||||
return send(res, 400, { error: 'invalid JSON' })
|
||||
}
|
||||
|
||||
const check = validateBatch(parsed)
|
||||
if (!check.ok) return send(res, 400, { error: check.why })
|
||||
|
||||
const now = Date.now()
|
||||
const ids: string[] = []
|
||||
for (const e of check.entries) {
|
||||
const info = insertScore.run(e.name, e.assets, e.days, e.glasses, e.seed, e.broke ? 1 : 0, now)
|
||||
ids.push(String(info.lastInsertRowid))
|
||||
}
|
||||
prune.run(KEEP_ROWS)
|
||||
|
||||
return send(res, 201, { ids, scores: board() })
|
||||
}
|
||||
|
||||
send(res, 404, { error: 'not found' })
|
||||
})
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[scores] listening on :${PORT}, db ${DB_PATH}, max ${MAX_ENTRIES_PER_POST}/post`)
|
||||
})
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(sig, () => {
|
||||
server.close(() => {
|
||||
db.close()
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Everything here treats the request body as hostile. A public leaderboard
|
||||
* with no accounts cannot prove a score is real - what it can do is refuse
|
||||
* anything impossible, keep names printable, and stop one client filling the
|
||||
* table on its own.
|
||||
*/
|
||||
|
||||
export interface Entry {
|
||||
name: string
|
||||
assets: number
|
||||
days: number
|
||||
glasses: number
|
||||
seed: number
|
||||
broke: boolean
|
||||
}
|
||||
|
||||
export const MAX_ENTRIES_PER_POST = 4
|
||||
export const NAME_MAX = 12
|
||||
|
||||
/** The simulation's best possible day is about $12.50; $25 leaves headroom. */
|
||||
const MAX_CENTS_PER_DAY = 2500
|
||||
const STARTING_ASSETS = 200
|
||||
const MAX_GLASSES_PER_DAY = 400
|
||||
const MAX_DAYS = 365
|
||||
|
||||
const isInt = (v: unknown, lo: number, hi: number): v is number =>
|
||||
typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi
|
||||
|
||||
/** Printable, uppercase, and short enough to fit the board. */
|
||||
export function cleanName(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return 'ANON'
|
||||
const cleaned = raw
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9 .'-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, NAME_MAX)
|
||||
return cleaned || 'ANON'
|
||||
}
|
||||
|
||||
export type Validated = { ok: true; entry: Entry } | { ok: false; why: string }
|
||||
|
||||
export function validateEntry(raw: unknown): Validated {
|
||||
if (typeof raw !== 'object' || raw === null) return { ok: false, why: 'not an object' }
|
||||
const e = raw as Record<string, unknown>
|
||||
|
||||
if (!isInt(e.days, 1, MAX_DAYS)) return { ok: false, why: 'days out of range' }
|
||||
if (!isInt(e.assets, 0, 10_000_00)) return { ok: false, why: 'assets out of range' }
|
||||
if (!isInt(e.glasses, 0, MAX_DAYS * MAX_GLASSES_PER_DAY))
|
||||
return { ok: false, why: 'glasses out of range' }
|
||||
if (!isInt(e.seed, 0, 9_999_999)) return { ok: false, why: 'seed out of range' }
|
||||
if (typeof e.broke !== 'boolean') return { ok: false, why: 'broke must be a boolean' }
|
||||
|
||||
// No stand can earn more than the simulation allows in the days it traded.
|
||||
if (e.assets > STARTING_ASSETS + e.days * MAX_CENTS_PER_DAY)
|
||||
return { ok: false, why: 'assets impossible for that many days' }
|
||||
if (e.glasses > e.days * MAX_GLASSES_PER_DAY)
|
||||
return { ok: false, why: 'glasses impossible for that many days' }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
entry: {
|
||||
name: cleanName(e.name),
|
||||
assets: e.assets,
|
||||
days: e.days,
|
||||
glasses: e.glasses,
|
||||
seed: e.seed,
|
||||
broke: e.broke,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBatch(raw: unknown): { ok: true; entries: Entry[] } | { ok: false; why: string } {
|
||||
const list = Array.isArray(raw) ? raw : (raw as { entries?: unknown })?.entries
|
||||
if (!Array.isArray(list)) return { ok: false, why: 'expected an array of entries' }
|
||||
if (list.length === 0) return { ok: false, why: 'no entries' }
|
||||
if (list.length > MAX_ENTRIES_PER_POST) return { ok: false, why: 'too many entries' }
|
||||
|
||||
const entries: Entry[] = []
|
||||
for (const item of list) {
|
||||
const v = validateEntry(item)
|
||||
if (!v.ok) return v
|
||||
entries.push(v.entry)
|
||||
}
|
||||
return { ok: true, entries }
|
||||
}
|
||||
Reference in New Issue
Block a user