/** * NEON SURVIVORS — Leaderboard Worker (Cloudflare Workers + KV) * * Deploy: * 1. wrangler kv:namespace create LB * 2. paste the returned id into wrangler.toml * 3. wrangler deploy * * Endpoints: * GET /top?scope=daily:20260822|stage:frostcave:2&n=10 * POST /submit { name, time, kills, gold, lv, stageId, grade, endless, daily, win, day, sig } * * `sig` is a lightweight checksum (same algorithm as the client) that deters * casual tampering. True server authority would require session validation. */ const SALT = 'NSv1-lb-salt-v1'; async function lbSig(obj) { const data = new TextEncoder().encode(SALT + JSON.stringify(obj)); let h = 2166136261 >>> 0; const view = data; for (let i = 0; i < view.length; i++) { h ^= view[i]; h = Math.imul(h, 16777619) >>> 0; } // second avalanche pass so short payloads spread h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16; return ('0000000' + h.toString(36)).slice(-8); } function jsonRes(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,OPTIONS' } }); } export default { async fetch(req, env) { const url = new URL(req.url); if (req.method === 'OPTIONS') return new Response(null, { status: 204 }); if (url.pathname === '/top' && req.method === 'GET') { const scope = (url.searchParams.get('scope') || '').slice(0, 48); const n = Math.min(50, Math.max(1, parseInt(url.searchParams.get('n') || '10', 10))); if (!scope) return jsonRes({ ok: false, error: 'scope' }, 400); const raw = await env.LB.get('b:' + scope); const rows = raw ? JSON.parse(raw) : []; return jsonRes({ ok: true, scope, rows: rows.slice(0, n) }); } if (url.pathname === '/submit' && req.method === 'POST') { let b; try { b = await req.json(); } catch (e) { return jsonRes({ ok: false, error: 'json' }, 400); } const time = Math.floor(+b.time || 0); const entry = { n: String(b.name || 'Anon').slice(0, 16), t: time, k: Math.max(0, Math.min(999999, Math.floor(+b.kills || 0))), g: Math.max(0, Math.min(9999999, Math.floor(+b.gold || 0))), lv: Math.max(1, Math.min(200, Math.floor(+b.lv || 1))), w: b.win ? 1 : 0, st: String(b.stageId || '').slice(0, 24), gr: Math.max(0, Math.min(3, +b.grade | 0)) }; // scope: daily board uses UTC day; stage boards keyed by stage+grade const day = new Date().toISOString().slice(0, 10).replace(/-/g, ''); let scope; if (b.daily) scope = 'daily:' + day; else if (time >= 60) scope = 'stage:' + (entry.st || 'graveyard') + ':' + entry.gr; else return jsonRes({ ok: false, error: 'too_short' }, 400); // verify checksum over the signed fields only const payload = { name: b.name, time, kills: entry.k, gold: entry.g, lv: entry.lv, stageId: b.stageId, grade: entry.gr, endless: !!b.endless, daily: !!b.daily, win: !!b.win, day }; if ((await lbSig(payload)) !== b.sig) return jsonRes({ ok: false, error: 'sig' }, 403); if (time < 30 || time > 86400) return jsonRes({ ok: false, error: 'range' }, 400); const key = 'b:' + scope; const raw = await env.LB.get(key); const rows = raw ? JSON.parse(raw) : []; rows.push(entry); rows.sort((a, z) => z.t - a.t || z.k - a.k); await env.LB.put(key, JSON.stringify(rows.slice(0, 100))); return jsonRes({ ok: true, rank: rows.findIndex(r => r === entry) + 1 }); } return jsonRes({ ok: false, error: 'route' }, 404); } };