Files
splatter-tennis/stats.js
T
wuwiandClaude Opus 5 3291246425 Put a real world board behind the RANKINGS button
Ranked on KILLS IN A SINGLE RUN. Games survived is the same information at a
tenth the resolution — most players land between 5 and 20, so that board is a
wall of ties broken by who got there first. Games ride along as the tiebreak
and as a column.

The image is python:3.12-alpine running server.py now, not nginx. The board
needs one process that can write, and everything the nginx conf did here — the
no-cache headers and the manifest MIME type — is a few lines of stdlib. /data
must be a persistent volume or every deploy resets the ladder to empty.

A name belongs to whoever took it first: the client mints a random id once and
the row remembers it, so a stranger cannot overwrite your score. The id never
leaves the server. That is the whole security model and it is enough.

The prompt to sign the board appears on exactly one occasion — a run that beat
your own best kill count, on the tap that leaves the death screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:03:17 +02:00

258 lines
14 KiB
JavaScript

// stats.js — what the game remembers about you, and the two menu screens that read it back.
//
// Everything the BODY COUNT screen shows comes out of ONE localStorage key: an array of per-run
// records, newest last. ALL TIME / LAST MATCH / 7 DAYS are three filters over that array rather
// than three stored counters, so a period can be added later without a migration and without a
// second write path that can drift out of step with the first.
//
// The live run is a module-local bag, not a field on G. G is deep-cloned into PRISTINE at load and
// restored wholesale by restartRun(), so a counter living there would be reset by machinery that
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
// first time the reset order changes. endRun() is the only thing that empties this bag.
import { WEAPONS, ENEMIES } from './config.js';
const KEY = 'splatterRuns';
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
// that is ~50 KB against a 5 MB quota, and 300 deaths is months of play. If anyone ever passes it,
// the upgrade is a second key holding rolled-up lifetime totals that the cap never touches — not a
// bigger cap, which just moves the cliff.
const CAP = 300;
// A run in progress. `z` is kills by enemy row key, `w` is kills by weapon id.
const bag = () => ({ k: 0, mk: 0, z: {}, w: {} });
let run = bag();
// Has the CURRENT run already been written? An emptied bag is not enough to answer that: the games
// count lives on G and survives the flush, so a second endRun would sail past a "nothing happened"
// guard and bank a duplicate with zero kills. `pagehide` really does fire more than once — the
// bfcache dismisses and restores the same page — which is exactly how this was caught.
// Cleared by the next kill, so a fresh run re-arms itself with no reset call to forget.
let banked = false;
// ---------------------------------------------------------------- storage ----
// Private-mode Safari throws on both read and write. Stats are a nicety; a menu that will not open
// is not. Every path through this file degrades to "no stats" and never to an exception.
function load() {
try {
const a = JSON.parse(localStorage.getItem(KEY) || 'null');
if (Array.isArray(a)) return a;
return migrate();
} catch { return []; }
}
// The only number the game used to keep was localStorage.splatterBest = {games, sets}, written
// inside killPlayer. Rather than orphan it, it comes across as a single synthetic run the first
// time this module looks — so an existing player's best survives the upgrade instead of resetting
// to zero on the device they actually play on.
function migrate() {
try {
const b = JSON.parse(localStorage.splatterBest || 'null');
if (!b || !b.games) return [];
const seed = [{ t: Date.now(), g: b.games, s: b.sets || 0, d: 0, k: 0, mk: 0, dead: 1, z: {}, w: {} }];
localStorage.setItem(KEY, JSON.stringify(seed));
localStorage.removeItem('splatterBest'); // one source of truth from here on
return seed;
} catch { return []; }
}
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(-CAP))); } catch {} };
// ------------------------------------------------------------- collection ----
// One kill. `variant` is the zombie's own char; 'a' and 'b' are one shambler in two shirts and fold
// together, and a chapter boss arrives already keyed 'B0'..'B5' by design (see zombies.killZombie).
export function tally(variant, weapon) {
const z = variant === 'b' ? 'a' : variant;
banked = false; // something happened: this is a live run again
run.k++;
run.z[z] = (run.z[z] || 0) + 1;
run.w[weapon] = (run.w[weapon] || 0) + 1;
}
// A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a
// massacre of six is one multi-kill and not five.
export function multiKill() { run.mk++; }
// The run is over: fold it into the record and hand back the best run so far. `dead` separates a
// death from a tab closed mid-match — both are worth recording, only one is a death.
// Idempotent: a run with nothing in it writes nothing, so death-then-close cannot double-count and
// closing the tab at the menu does not invent an empty match.
export function endRun(games, sets, secs, dead = true) {
const prev = load();
const best = prev.reduce((b, r) => r.g > b.games ? { games: r.g, sets: r.s } : b, { games: 0, sets: 0 });
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
// the world board ranks — so it is the one that earns you a name-entry prompt.
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
const kills = run.k;
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
const newBest = games > best.games;
const newKillBest = kills > bestKills;
prev.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0),
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
save(prev);
run = bag(); banked = true;
last = { kills, games, newKillBest };
return { ...best, newBest, newKillBest, kills, games };
}
// ------------------------------------------------------------- aggregation ----
// `runs` is injectable so check() can assert against a fixture without touching localStorage.
export function read(period, runs = load()) {
const rs = period === 'last' ? runs.slice(-1)
: period === '7d' ? runs.filter(r => Date.now() - r.t < 7 * 864e5)
: runs;
const out = { kills: 0, multi: 0, deaths: 0, longest: 0, matches: rs.length, z: {}, w: {} };
for (const r of rs) {
out.kills += r.k || 0; out.multi += r.mk || 0; out.deaths += r.dead || 0;
out.longest = Math.max(out.longest, r.g || 0);
for (const k in r.z) out.z[k] = (out.z[k] || 0) + r.z[k];
for (const k in r.w) out.w[k] = (out.w[k] || 0) + r.w[k];
}
return out;
}
// the two figures the splash carries
export function total() {
const a = read('all');
return { kills: a.kills, best: a.longest };
}
// ------------------------------------------------------------ leaderboard ----
// The world board, ranked on KILLS IN A SINGLE RUN — the game's own currency, and fine-grained
// enough that the order means something (games survived is a small integer and would be a wall of
// ties). Games ride along as the tiebreak and as a column.
// Two routes, no auth. See server.py: the board is a wall for writing your name on.
const NAME_KEY = 'splatterName', ID_KEY = 'splatterId';
export const savedName = () => { try { return localStorage.getItem(NAME_KEY) || ''; } catch { return ''; } };
// Your claim on your name. Minted once, never shown, never leaves this device except to prove a
// submission is yours — see server.py. It is not a login and it protects nothing but the row: lose
// your browser storage and you lose the name, which is what having no accounts costs.
function myId() {
try {
let v = localStorage.getItem(ID_KEY);
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
.map(b => b.toString(36).padStart(2, '0')).join(''); localStorage.setItem(ID_KEY, v); }
return v;
} catch { return ''; } // private mode: no claim, and the first stranger to use the name keeps it
}
// The run just flushed, held here rather than on G — G is restored from PRISTINE by restartRun(),
// and the name prompt has to survive exactly that. Consumed on read so it prompts once.
let last = null;
export function lastRun() { const r = last; last = null; return r; }
export async function board() {
const r = await fetch('api/scores', { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || [];
}
// The SAME normalisation server.py's clean() applies. Both ends have to agree or nothing lines up:
// the server stored OLIVER, the client remembered "oliver", and the board then failed to highlight
// the row you had just written. Normalising here also means the prefill shows what will really
// appear on the board.
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
export async function submit(rawName, kills, games) {
const name = tidyName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
if (!r.ok) throw new Error('submit ' + r.status);
return r.json(); // {ok, rank, scores}
}
const n = x => x.toLocaleString('en-GB');
// Names come off the wire and are written by other people. server.py whitelists them down to
// [A-Z0-9 ._-] so nothing dangerous can survive the round trip — this is the second lock on the
// same door, because the alternative is trusting a remote string into innerHTML.
const esc = s => String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null;
// ----------------------------------------------------------------- screens ----
// Both screens are built here rather than in main.js: they are the only readers of the shapes
// above, and main.js is the boot file, not a view layer.
export function drawStats(period, v) {
const d = read(period);
document.getElementById('stPeriod').textContent =
period === 'last' ? 'LAST MATCH' : period === '7d' ? '7 DAYS' : 'ALL TIME';
document.getElementById('stFigs').innerHTML = [
['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)],
].map(([k, val], i) => `<div class="fig${i === 2 ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
// sorted by count, and only bodies you have actually met — an empty roster of everything would
// read as a checklist of things to go and do, which this screen is not
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
const top = rows.length ? rows[0][1] : 1;
document.getElementById('stZ').innerHTML = rows.length ? rows.map(([k, c]) => {
// a key with no row is a save written by an older build: name it off the key, stripped, because
// this is the one string on the screen that does not come from our own tables
const e = ENEMIES[k] || { name: k.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || '?', spr: 'z_a1', sub: '' };
return `<div class="zrow${e.boss ? ' boss' : ''}">
<img src="assets/${e.spr}.png?v=${v}" alt="">
<div class="zb"><b>${e.name}</b><span>${n(c)}</span>
<div class="pb"><s style="width:${Math.max(3, c / top * 100)}%"></s></div>
<i>${e.sub}</i></div></div>`;
}).join('') : '<p class="none">NOTHING YET. GO AND EARN A ROW.</p>';
const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]);
document.getElementById('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
`<div class="wtile"><img src="assets/${wIcon(id)}.png?v=${v}" alt=""><b>${n(c)}</b></div>`).join('')
: '<p class="none">NO WEAPONS FIRED.</p>';
document.getElementById('stFoot').innerHTML =
`<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
}
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
// the server being down must never be indistinguishable from nobody having played.
export async function drawRanks(rows) {
const el = document.getElementById('rkList');
if (!rows) {
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
try { rows = await board(); }
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
}
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
const me = savedName();
el.innerHTML = rows.map((r, i) =>
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
// The one runnable check: the period filter and the aggregation, against a fixture. `read` takes an
// injected array precisely so this needs no localStorage and cannot disturb a real save.
export function check() {
const day = 864e5, now = Date.now();
const fx = [
{ t: now - 9 * day, g: 3, s: 0, d: 60, k: 10, mk: 1, dead: 1, z: { a: 8, g: 2 }, w: { ball: 10 } },
{ t: now - 2 * day, g: 7, s: 1, d: 90, k: 5, mk: 0, dead: 1, z: { g: 5 }, w: { bomb: 5 } },
{ t: now - 1000, g: 1, s: 0, d: 10, k: 1, mk: 2, dead: 0, z: { a: 1 }, w: { ball: 1 } },
];
const all = read('all', fx), wk = read('7d', fx), last = read('last', fx);
const eq = (got, want, what) => { if (got !== want) throw new Error(`${what}: ${got} !== ${want}`); };
eq(all.kills, 16, 'all kills');
eq(all.deaths, 2, 'all deaths'); // the abandoned run is recorded but is not a death
eq(all.longest, 7, 'all longest');
eq(all.z.a, 9, 'all shamblers');
eq(wk.kills, 6, '7d kills'); // the 9-day-old run is outside the window
eq(wk.z.a, 1, '7d shamblers');
eq(last.kills, 1, 'last kills');
eq(last.multi, 2, 'last multi');
eq(Object.entries(all.z).sort((a, b) => b[1] - a[1])[0][0], 'a', 'sort');
// tidyName has to agree with server.py's clean() or the board cannot highlight your own row —
// it is the one rule duplicated across two languages, so it is the one worth pinning down.
eq(tidyName(' oliver '), 'OLIVER', 'name upper+trim');
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
return 'ok';
}