From 66fdad3d74169b7e0803b9fe55dc0619b43ceaf5 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 12 Aug 2026 08:58:04 +0200 Subject: [PATCH] A match run is a different kind of run, and stops polluting the gore stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two modes share no currency: gore counts kills, multi-kills and weapons; zombie tennis counts opponents felled and has exactly one kill per opponent, always credited to 'bomb' (matchGameWon's explode). Every one of those was landing in splatterRuns as a gore run, on the roster as THE CLUB PRO, and on the KILLS world board — where a five-body match would post a row next to a 300-kill massacre and mean something else entirely by it. One character on the record fixes all of it: `m`, absent meaning gore (which is what every existing record is, so no migration). read() takes a mode and so does endRun, which now compares you only against your own mode and refuses a name prompt from a match. The record's two headline slots carry each MODE's two headline numbers — gore g=games s=sets, match g=felled s=games — so `g` stays the thing everything ranks on and the match best is derived from the record instead of living in a second key that drifts. That retires the matchBest key from the last commit. Found on the way, pre-existing and visible: endRun returned `{...best, kills, games}` and the later key won, so G.best.games was the run that had just died. The gore death screen has been printing "BEST GAMES · SETS". The return now carries the best and nothing else. And stats.check() — the module's own fixture check — was exported and called by nothing. Wired into the suite, with the mode split added to it. Co-Authored-By: Claude Opus 5 --- index.html | 2 +- src/main.js | 6 ++++- src/render.js | 4 ++-- src/stats.js | 61 +++++++++++++++++++++++++++++++++++++++++--------- src/zombies.js | 24 +++++++++----------- test.js | 21 ++++++++++++----- 6 files changed, 84 insertions(+), 34 deletions(-) diff --git a/index.html b/index.html index 884fdf9..b293e22 100644 --- a/index.html +++ b/index.html @@ -416,6 +416,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/main.js b/src/main.js index 93408ed..96dc220 100644 --- a/src/main.js +++ b/src/main.js @@ -317,7 +317,11 @@ addEventListener('keydown', e => { // one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and // no-ops on an empty run, so closing the tab at the menu writes nothing. addEventListener('pagehide', () => { - if (G.mode === 'splatter') stats.endRun(G.runGames, G.sets[0], G.splatT, false); + if (G.mode !== 'splatter') return; + // ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers — + // see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run. + if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH); + else stats.endRun(G.runGames, G.sets[0], G.splatT, false); }); // eaten: any tap or key returns to the title — but only once the death animation has played out, so diff --git a/src/render.js b/src/render.js index 0f7282e..ea3c146 100644 --- a/src/render.js +++ b/src/render.js @@ -2104,9 +2104,9 @@ export function render() { shadowText(m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.', W / 2, H / 2 - 44, UI.bad); ctx.font = F(9); shadowText(m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') - + ' · ' + (score.games[0] + score.games[1]) + ' GAMES' + + ' · ' + m.games + ' GAMES' : 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS', W / 2, H / 2 - 14, UI.cream); - if (m ? m.newBest : G.newBest) { // a fresh record crows; otherwise show the mark to beat + if (G.newBest) { // a fresh record crows; otherwise show the mark to beat const pulse = 0.55 + 0.45 * Math.abs(Math.sin(G.eatenT * 4)); ctx.font = B(20); shadowText('★ NEW BEST ★', W / 2, H / 2 + 2, hexA(UI.warm, pulse)); diff --git a/src/stats.js b/src/stats.js index 81b4151..08ab5b1 100644 --- a/src/stats.js +++ b/src/stats.js @@ -76,8 +76,24 @@ export function multiKill() { run.mk++; } // 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(); +// +// `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'; +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 @@ -86,21 +102,32 @@ export function endRun(games, sets, secs, dead = true) { 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); + // 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 }; - return { ...best, newBest, newKillBest, kills, games }; + // `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()) { - const rs = period === 'last' ? runs.slice(-1) - : period === '7d' ? runs.filter(r => Date.now() - r.t < 7 * 864e5) - : runs; +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; @@ -235,9 +262,21 @@ export function check() { { 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'); diff --git a/src/zombies.js b/src/zombies.js index 1d45b73..c664d00 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -4,12 +4,12 @@ // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; -import { G, P, ball, sendBoy, setupPoint } from './state.js'; +import { G, P, ball, score, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one -import { tally, multiKill, endRun } from './stats.js'; +import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -300,19 +300,17 @@ export function killPlayer() { // splatterBest read/write here: the best is now derived from the record rather than stored twice // and left to drift. Timing is safe — restartRun() only fires on the tap AFTER death, so // runGames/sets/splatT are all still intact at this point. - const b = endRun(G.runGames, G.sets[0], G.splatT); - G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets // THE MATCH MEASURES SOMETHING ELSE. Blood-only scoring (model A) means a lost game costs nothing // but the blood already spent, so "games survived" is not the achievement — OPPONENTS FELLED is. - // Kept on G.match (which survives to the death screen; restartRun is what clears it) with its own - // localStorage best, the pattern drillBest already set. Games ride along as the length of the run. - if (G.match) { - const felled = G.match.opp - 1; - let best = 0; - try { best = Number(localStorage.getItem('matchBest')) || 0; - if (felled > best) localStorage.setItem('matchBest', String(felled)); } catch {} - G.match.felled = felled; G.match.best = best; G.match.newBest = felled > best; - } + // It goes through the SAME endRun with the mode tagged and its own two headline numbers in the + // record's two headline slots (see the shape note there), so the best is derived from the record + // rather than stored a second time in its own key and left to drift. + const m = G.match; + const felled = m ? m.opp - 1 : 0, gamesPlayed = score.games[0] + score.games[1]; + const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH) + : endRun(G.runGames, G.sets[0], G.splatT); + G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets + if (m) { m.felled = felled; m.games = gamesPlayed; m.best = b.games; } // ...the death screen's words } // One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways. diff --git a/test.js b/test.js index 700c302..fb58a3c 100644 --- a/test.js +++ b/test.js @@ -91,6 +91,7 @@ const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { TUNE, bossArt, COURT } = await import('./src/config.js'); +const { read: readStats, check: statsCheck } = await import('./src/stats.js'); const DT = 1 / 60; function freshSplatter() { @@ -750,26 +751,34 @@ test('regicide: killing the body mid-game wins the game outright', () => { assert.ok(oppOf(), 'and the next opponent is on'); }); -test('the match measures OPPONENTS FELLED, with its own best', () => { - localStorage.removeItem('matchBest'); +test('the match measures OPPONENTS FELLED, banked as its own kind of run', () => { + localStorage.removeItem('splatterRuns'); startMatch(); for (let k = 0; k < 8; k++) resolveInterlude('P'); // two games = two bodies down assert.equal(G.match.opp, 3, 'on the third rung'); G.hp = 1; resolveInterlude('O'); assert.equal(G.mode, 'eaten', 'bled out'); assert.equal(G.match.felled, 2, 'the summary counts bodies, not games'); - assert.ok(G.match.newBest, 'first run of the day is a record'); - assert.equal(localStorage.getItem('matchBest'), '2', '...and it is written down'); + assert.ok(G.newBest, 'first run of the day is a record'); // a shorter second run must NOT overwrite the mark, and must know what it is chasing G.mode = 'splatter'; endMatch(); startMatch(); for (let k = 0; k < 4; k++) resolveInterlude('P'); G.hp = 1; resolveInterlude('O'); assert.equal(G.match.felled, 1); assert.equal(G.match.best, 2, 'the mark to beat is shown'); - assert.ok(!G.match.newBest, 'and one body does not beat two'); - assert.equal(localStorage.getItem('matchBest'), '2', 'the record survives a worse run'); + assert.ok(!G.newBest, 'and one body does not beat two'); + // ...and none of it lands in the gore mode's stats, which is where it all used to go: a felled + // opponent is a kill credited to 'bomb', and three of them are not a massacre. + const runs = JSON.parse(localStorage.getItem('splatterRuns')); + assert.ok(runs.every(r => r.m === 'z'), 'both runs are tagged as match runs'); + assert.equal(readStats('all').kills, 0, 'the gore screen shows nothing from a match'); + assert.equal(readStats('all', undefined, 'z').longest, 2, '...and the match view knows the best'); G.mode = 'splatter'; endMatch(); }); +// stats.js ships its own fixture-based check — the period filter, the mode split and the name +// normalisation that has to agree with server.py. It was exported and never called by anything. +test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); }); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report