// 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) => `
${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]); 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 `
${e.name}${n(c)}
${e.sub}
`; }).join('') : '

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]) => `
${n(c)}
`).join('') : '

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 => `
#${n(x.r)}${x.name}${n(x.k)}
`).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'); eq(rank(0) > rank(5000), true, 'rank improves with kills'); return 'ok'; }