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:
1 parent
8011186cda
commit
66fdad3d74
6 files changed
+84
-34
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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));
|
||||
|
||||
+50
-11
@@ -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');
|
||||
|
||||
+11
-13
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user