// Shared ranking and HTTP contract. Scores are reported by the player's browser, // not verified by a server emulator. No game bytes cross this boundary. export const MAX_SCORE = 99999999; export const MAX_BODY = 1024; export const MAX_ENTRIES = 1000; export const MAX_LIMIT = 100; export const RECEIPT_WINDOW = 4096; export const DIFFICULTIES = ['easy', 'normal', 'hard']; export const MODES = ['solo', 'co-op']; export const GAME_SPEEDS = [10, 11, 12, 13, 15, 20]; const FIELDS = ['runId', 'player', 'initials', 'score', 'difficulty', 'mode']; const OPTIONAL_FIELDS = ['handle', 'profileURL', 'gameSpeed']; const RUN_ID = /^(?:[a-f0-9]{32}|[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12})$/; const MINUTE = 60000; class RequestError extends Error { constructor(message, status = 400) { super(message); this.status = status; } } export const leaderboardJson = (body, status = 200, headers = {}) => Response.json(body, { status, headers: {'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers}, }); const reported = body => ({status: 'self-reported', receiptWindow: RECEIPT_WINDOW, ...body}); const sameRun = (a, b) => a.runId === b.runId && a.player === b.player; const rankOrder = (a, b) => b.score - a.score || a.submittedAt - b.submittedAt || (a.runId < b.runId ? -1 : a.runId > b.runId ? 1 : 0) || a.player - b.player; const publicEntry = ({runId, ...entry}) => entry; export function validateIdentity(value) { const identity = {}; if (Object.hasOwn(value, 'handle')) { if (typeof value.handle !== 'string') throw new RequestError('The player handle is invalid.'); const handle = value.handle.replace(/^@/, ''); if (!/^[A-Za-z0-9._-]{1,12}$/.test(handle)) throw new RequestError('The player handle is invalid.'); identity.handle = handle; } if (Object.hasOwn(value, 'profileURL')) { let profile; try { if (typeof value.profileURL !== 'string') throw Error('Invalid profile'); profile = new URL(value.profileURL); if (profile.protocol !== 'https:' || profile.username || profile.password || new TextEncoder().encode(profile.href).byteLength > 256) throw Error('Invalid profile'); } catch { throw new RequestError('The player profile URL is invalid.'); } identity.profileURL = profile.href; } return identity; } export function validateSubmission(value) { if (!value || typeof value !== 'object' || Array.isArray(value) || !FIELDS.every(key => Object.hasOwn(value, key)) || Object.keys(value).some(key => !FIELDS.includes(key) && !OPTIONAL_FIELDS.includes(key))) throw new RequestError('Send a completed score with runId, player, initials, score, difficulty and mode.'); if (typeof value.runId !== 'string' || !RUN_ID.test(value.runId) || ![1, 2].includes(value.player) || typeof value.initials !== 'string' || !/^[A-Z0-9]{3}$/.test(value.initials) || !Number.isSafeInteger(value.score) || value.score < 1 || value.score > MAX_SCORE || !DIFFICULTIES.includes(value.difficulty) || !MODES.includes(value.mode) || (Object.hasOwn(value, 'gameSpeed') && !GAME_SPEEDS.includes(value.gameSpeed))) throw new RequestError('The completed score has invalid fields.'); return {...Object.fromEntries(FIELDS.map(key => [key, value[key]])), ...validateIdentity(value), ...(Object.hasOwn(value, 'gameSpeed') ? {gameSpeed: value.gameSpeed} : {})}; } function checkedState(value) { if (value === undefined) return {version: 1, entries: [], receipts: []}; if (!value || value.version !== 1 || !Array.isArray(value.entries) || !Array.isArray(value.receipts) || value.entries.length > MAX_ENTRIES || value.receipts.length > RECEIPT_WINDOW) throw Error('Unreadable leaderboard storage'); const checkedEntry = entry => { const {submittedAt, ...submission} = entry; let canonical; try { canonical = validateSubmission(submission); } catch { throw Error('Unreadable leaderboard entry'); } if (!Number.isSafeInteger(submittedAt) || submittedAt < 0) throw Error('Unreadable leaderboard date'); return {...canonical, submittedAt}; }; return {version: 1, entries: value.entries.map(checkedEntry), receipts: value.receipts.map(checkedEntry)}; } // SQLite KV values have a 2 MB bound. Keep the bounded receipt window separate // from top scores, and commit both with one atomic put. Reads never migrate data. export function durableLeaderboardStorage(storage) { const receiptsKey = 'board-receipts-v1'; return { async read() { const state = await storage.get('board-v1'); if (state?.version !== 2) return state; if (state.receiptsKey !== receiptsKey) throw Error('Unreadable leaderboard storage'); return {version: 1, entries: state.entries, receipts: await storage.get(receiptsKey)}; }, write: state => storage.put({'board-v1': {version: 2, entries: state.entries, receiptsKey}, [receiptsKey]: state.receipts}), }; } // Every read and write shares a queue. This keeps concurrent local requests and // Durable Object events from accepting a duplicate or losing another score. export class Leaderboard { #tail = Promise.resolve(); #rates = new Map(); #globalRate = {at: 0, count: 0}; constructor(storage, now = Date.now) { this.storage = storage; this.now = now; } serial(operation) { const pending = this.#tail.then(operation); this.#tail = pending.catch(() => {}); return pending; } idle() { return this.#tail; } list({limit = 10, difficulty, mode} = {}) { return this.serial(async () => { const state = checkedState(await this.storage.read()); const rows = state.entries.filter(entry => (!difficulty || entry.difficulty === difficulty) && (!mode || entry.mode === mode)); rows.sort(rankOrder); return reported({entries: rows.slice(0, limit).map((entry, index) => ({rank: index + 1, ...publicEntry(entry)})), total: rows.length, limit}); }); } submit(submission, source = 'local') { const value = validateSubmission(submission); return this.serial(async () => { const state = checkedState(await this.storage.read()); const existing = state.receipts.find(entry => sameRun(entry, value)) ?? state.entries.find(entry => sameRun(entry, value)); if (existing) { if (!FIELDS.every(key => existing[key] === value[key]) || (existing.handle ?? existing.initials) !== (value.handle ?? value.initials) || existing.profileURL !== value.profileURL || (existing.gameSpeed ?? 10) !== (value.gameSpeed ?? 10)) throw new RequestError('This player already submitted a different score for this run.', 409); return reported({entry: publicEntry(existing), duplicate: true}); } const now = this.now(); this.#limit(source, now); const entry = {...value, submittedAt: now}; const next = {version: 1, entries: [...state.entries, entry].sort(rankOrder).slice(0, MAX_ENTRIES), receipts: [...state.receipts, entry].slice(-RECEIPT_WINDOW)}; // A successful response is sent only after the durable write completes. await this.storage.write(next); return reported({entry: publicEntry(entry), duplicate: false}); }); } #limit(source, now) { for (const [key, bucket] of this.#rates) if (now - bucket.at >= MINUTE) this.#rates.delete(key); if (now - this.#globalRate.at >= MINUTE) this.#globalRate = {at: now, count: 0}; const bucket = this.#rates.get(source) ?? {at: now, count: 0}; if (bucket.count >= 12 || this.#globalRate.count >= 120 || (!this.#rates.has(source) && this.#rates.size >= 512)) throw new RequestError('Too many score submissions. Try again in a minute.', 429); bucket.count++; this.#globalRate.count++; this.#rates.set(source, bucket); } } export async function readSubmission(request) { if (!/^application\/json(?:\s*;|$)/i.test(request.headers.get('Content-Type') ?? '')) throw new RequestError('Send the score as JSON.', 415); if (Number(request.headers.get('Content-Length')) > MAX_BODY) throw new RequestError('Score submission is too large.', 413); const reader = request.body?.getReader(); if (!reader) throw new RequestError('Send a completed score.'); const chunks = []; let length = 0; try { while (true) { const {value, done} = await reader.read(); if (done) break; length += value.byteLength; if (length > MAX_BODY) { await reader.cancel(); throw new RequestError('Score submission is too large.', 413); } chunks.push(value); } } finally { reader.releaseLock(); } const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } let parsed; try { parsed = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); } catch { throw new RequestError('Score submission is not valid JSON.'); } return validateSubmission(parsed); } export function leaderboardFailure(error) { if (error instanceof RequestError) return leaderboardJson({error: error.message}, error.status, error.status === 429 ? {'Retry-After': '60'} : {}); return leaderboardJson({error: 'The leaderboard is unavailable. Your score was not confirmed. Try again.'}, 503); } export async function leaderboardRequest(request, board, source) { try { const url = new URL(request.url); if (url.pathname !== '/api/leaderboard') return leaderboardJson({error: 'Leaderboard not found.'}, 404); if (request.method === 'GET') { const {searchParams} = url; if ([...searchParams.keys()].some(key => !['limit', 'difficulty', 'mode'].includes(key)) || [...searchParams.keys()].some(key => searchParams.getAll(key).length > 1)) throw new RequestError('Invalid leaderboard query.'); const limit = searchParams.has('limit') ? Number(searchParams.get('limit')) : 10; const difficulty = searchParams.get('difficulty'), mode = searchParams.get('mode'); if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT || (difficulty !== null && !DIFFICULTIES.includes(difficulty)) || (mode !== null && !MODES.includes(mode))) throw new RequestError('Invalid leaderboard query.'); return leaderboardJson(await board.list({limit, difficulty, mode})); } if (request.method !== 'POST') return leaderboardJson({error: 'Use GET or POST.'}, 405, {Allow: 'GET, POST'}); if (url.search) throw new RequestError('Score submissions do not accept query parameters.'); if (request.headers.get('Origin') !== url.origin) return leaderboardJson({error: 'Submit scores from the game screen.'}, 403); const result = await board.submit(await readSubmission(request), source); return leaderboardJson(result, result.duplicate ? 200 : 201); } catch (error) { return leaderboardFailure(error); } }