// 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 }); if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false }; } const newBest = games > best.games; prev.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), k: run.k, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w }); save(prev); run = bag(); banked = true; return { ...best, newBest }; } // ------------------------------------------------------------- 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 }; } // ponytail: a PLACEHOLDER rank until the leaderboard task lands. Deterministic in your own kill // count, so it never reshuffles between opens, and shaped like a real ladder — steep at the bottom, // flattening out. It means nothing and is not stored anywhere. export const rank = kills => Math.max(1, Math.ceil(48000 * Math.exp(-kills / 900))); const n = x => x.toLocaleString('en-GB'); 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) => `
NOTHING YET. GO AND EARN A ROW.
'; 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]) => `NO WEAPONS FIRED.
'; document.getElementById('stFoot').innerHTML = `LONGEST RUN${d.longest} GAME${d.longest === 1 ? '' : 'S'}`; } // ponytail: the ladder is FAKE — filler names around your own entry, seeded off your kill count so // the table is stable between opens. It exists so the button is not a dead end before the real // leaderboard task; delete this whole function and the #ranks stage when that lands. const FILLER = ['DEUCE_COURT', 'NETCORD', 'ADVANTAGE', 'BREAKPOINT', 'LOVEGAME', 'TIEBREAK', 'DROPSHOT', 'BASELINE', 'ACEHOLE', 'TOPSPIN', 'LETCALL', 'SMASHER']; export function drawRanks() { const { kills } = total(), me = rank(kills); const rows = []; for (let i = -4; i <= 4; i++) { if (i === 0) { rows.push({ r: me, name: 'YOU', k: kills, me: true }); continue; } const r = Math.max(1, me + i * (1 + (me % 7))); if (rows.some(x => x.r === r) || r === me) continue; // deterministic in the rank itself, so the same neighbours come back every time rows.push({ r, name: FILLER[r % FILLER.length] + (r % 97), k: Math.max(0, Math.round(kills - i * 37)) }); } rows.sort((a, b) => a.r - b.r); document.getElementById('rkList').innerHTML = rows.map(x => `