A match run is a different kind of run, and stops polluting the gore stats

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 <this run> GAMES · <the record>
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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 08:58:04 +02:00
1 parent 8011186cda
commit 66fdad3d74
6 files changed
+84 -34

No files matched your search

+50 -11
View File
@@ -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');