// 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 — which was the whole of the reset until CLASSIC TENNIS arrived, a mode // with NO KILLS IN IT AT ALL. There, nothing ever cleared the flag, so the second and every later // match of a session was silently swallowed by the guard below. beginRun() is the honest reset and // gameplay.restartRun (the one "a new run starts here" moment there is) calls it. let banked = false; export function beginRun() { run = bag(); 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. // // `g` AND `s` ARE THE MODE'S OWN TWO HEADLINE NUMBERS, not literally games and sets: // gore — g = games survived, s = sets banked (what it has always been) // match — g = OPPONENTS FELLED, s = games played // `g` is the one everything ranks on (best, newBest, `longest`), so putting each mode's real // measure in that slot is what lets one record shape serve both without a second best written // somewhere else and left to drift — which is the mistake splatterBest already made once. // WHICH MODE WROTE THIS RUN. One character on the record, because the two modes do not share a // single currency: the gore mode counts kills, multi-kills and weapons; zombie tennis counts // opponents felled and has exactly one kill per opponent, by definition, always credited to 'bomb' // (matchGameWon's explode). Un-tagged records predate the split and ARE gore runs, which is why the // default is 'g' everywhere and no migration is needed. export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c'; const modeOf = r => r.m || MODE_GORE; export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) { const all = load(); const prev = all.filter(r => modeOf(r) === mode); // you are only ever compared against your own mode 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; // THE WORLD BOARD IS THE GORE MODE'S. It ranks kills in a run, and a match run has exactly one // kill per opponent felled — a five-body match would post a "5 kills" row next to a 300-kill // massacre and mean something completely different by it. No name prompt from a match. // ponytail: when zombie tennis earns a board of its own it ranks FELLED (with points won as the // tiebreak — felled alone is a small integer and would be a wall of ties), and that is a server // change, not this line. const newKillBest = mode === MODE_GORE && kills > bestKills; all.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode, k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w }); save(all); // ...the WHOLE record, not the filtered view — that would delete the other mode run = bag(); banked = true; last = { kills, games, newKillBest }; // `games`/`sets` here are THE BEST RUN'S, which is the only thing a caller wants back — the // caller already knows what the run it just handed over did. They used to be trailed by the // ENDED run's own `kills, games`, and the later key won: `G.best.games` was the run that had just // died, so the death screen printed "BEST 3 GAMES · 7 SETS" — this run's games, the record's sets. return { ...best, newBest, newKillBest }; } // ------------------------------------------------------------- aggregation ---- // `runs` is injectable so check() can assert against a fixture without touching localStorage. export function read(period, runs = load(), mode = MODE_GORE) { const own = runs.filter(r => modeOf(r) === mode); // ...and only ever one mode's runs at a time const rs = period === 'last' ? own.slice(-1) : period === '7d' ? own.filter(r => Date.now() - r.t < 7 * 864e5) : own; 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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. // ONE SCREEN, THREE MODES. The three do not share a currency — see the record-shape note in // endRun — so they do not share a layout either: gore counts bodies and weapons, zombie tennis // counts NAMED opponents felled, classic tennis counts matches won and lost. What they do share is // the furniture (the period tabs, the figure strip, the roster list, the footer), so this is one // function with three sets of words rather than three screens to keep in step. const roster = (el, rows, v, empty) => { const top = rows.length ? rows[0][1] : 1; el.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 `
${e.name}${n(c)}
${e.sub}
`; }).join('') : `

${empty}

`; }; export function drawStats(period, v, mode = MODE_GORE) { const d = read(period, undefined, mode); const $ = id => document.getElementById(id); $('stPeriod').textContent = period === 'last' ? 'LAST RUN' : period === '7d' ? '7 DAYS' : 'ALL TIME'; $('stTitle').textContent = mode === MODE_MATCH ? 'THE LADDER' : mode === MODE_CLASSIC ? 'THE RECORD' : 'BODY COUNT'; const figs = mode === MODE_MATCH ? [['FELLED', n(d.kills)], ['DEEPEST', n(d.longest)], ['RUNS', n(d.matches)]] : mode === MODE_CLASSIC ? [['PLAYED', n(d.matches)], ['WON', n(d.matches - d.deaths)], ['LOST', n(d.deaths)]] : [['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)]]; // the third figure is the RED one in gore and in classic (deaths, matches lost); in the match it // is just how many runs you have played, so nothing on that strip is bad news const badIdx = mode === MODE_MATCH ? -1 : 2; $('stFigs').innerHTML = figs.map(([k, val], i) => `
${k}${val}
`).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]); $('stZHdr').firstChild.textContent = mode === MODE_MATCH ? 'OPPONENTS FELLED' : 'BY OPPONENT'; roster($('stZ'), rows, v, mode === MODE_MATCH ? 'NOBODY HAS GONE DOWN YET.' : 'NOTHING YET. GO AND EARN A ROW.'); // THE WEAPON SHELF IS THE GORE MODE'S ALONE. Both tennis modes are played with plain balls (the // rack is hidden in both), so the section is hidden rather than printed empty — a shelf that says // NO WEAPONS FIRED for a mode that has none reads as something you failed to do. const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]); // style.display, not the `hidden` attribute: #stW carries a display rule of its own and any // declared display beats [hidden]'s, so the shelf stayed on screen under a mode with no weapons. const off = mode !== MODE_GORE ? 'none' : ''; $('stWHdr').style.display = off; $('stW').style.display = off; // ...and CLASSIC TENNIS has no roster either: nothing dies in it. A section printing that is a // line about something the mode does not do, which is worse than the section not being there. const noZ = mode === MODE_CLASSIC ? 'none' : ''; $('stZHdr').style.display = noZ; $('stZ').style.display = noZ; $('stW').innerHTML = ws.length ? ws.map(([id, c]) => `
${n(c)}
`).join('') : '

NO WEAPONS FIRED.

'; $('stFoot').innerHTML = mode === MODE_MATCH ? `DEEPEST RUN${d.longest} FELLED` : mode === MODE_CLASSIC ? `LONGEST MATCH${d.longest} GAME${d.longest === 1 ? '' : 'S'}` : `LONGEST RUN${d.longest} GAME${d.longest === 1 ? '' : 'S'}`; } // 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 = '

READING THE BOARD…

'; try { rows = await board(); } catch { el.innerHTML = '

THE BOARD IS UNREACHABLE.

'; return; } } if (!rows.length) { el.innerHTML = '

NOBODY IS ON THE BOARD YET. BE FIRST.

'; return; } const me = savedName(); el.innerHTML = rows.map((r, i) => `
${n(i + 1)}${esc(r.n)}` + `${n(r.k || 0)} · ${r.g || 0}G
`).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 } }, // a zombie-tennis run sitting in the same array: 4 opponents felled over 9 games. Its four // kills are 'r' bodies credited to the game-winning blast and must never reach a gore figure. { t: now - 500, g: 4, s: 9, d: 40, m: 'z', k: 4, mk: 0, dead: 1, z: { r: 4 }, w: { bomb: 4 } }, ]; 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}`); }; // THE MODE SPLIT. Un-tagged records are gore runs (that is what they were before the tag), so the // three above still answer every gore question exactly as they did — and the match run answers none // of them. 'last' is the last run OF THAT MODE, not the last run overall. const mz = read('all', fx, 'z'); eq(all.matches, 3, 'gore sees only gore runs'); eq(all.z.r || 0, 0, 'a felled opponent is not a shambler on the gore roster'); eq(mz.matches, 1, 'and the match view sees only its own'); eq(mz.longest, 4, 'the match measure is OPPONENTS FELLED, in the record`s ranking slot'); eq(read('last', fx, 'z').kills, 4, 'last-of-mode, not last-overall'); 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('hi'), 'BHIB', 'name strips markup'); eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12'); eq(tidyName('...'), '...', 'name keeps the allowed punctuation'); return 'ok'; }