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
+1
-1
@@ -416,6 +416,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=177"></script>
|
||||
<script type="module" src="src/main.js?v=178"></script>
|
||||
</body>
|
||||
</html>
|
||||
+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.
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user