Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js)
100 lines
3.7 KiB
JavaScript
100 lines
3.7 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
};
|