The toss is the server's ball — the real death loop

"still when I lost a single point and try to serve again every further tap
does hurt me more until I am dead". Reproduced tap-for-tap this time, and the
serveNum fix was a red herring — a real bug, but not this one.

tossBall never stamped ball.lastHit. After any rally the zombie touched last,
the freshly tossed ball still said lastHit 'O' — and the previous pass moved
the server behind the baseline, past the passing-shot line. So the moment the
toss went up, the game saw an opponent ball beyond your baseline: "its
passing shot ends the duel", point to them, blood. Every tap = a toss = an
instant lost point, faster the more he panicked. Two fixes composed into a
death loop; the stale field was harmless while serving happened inside the
court.

One stamp in tossBall — ball.lastHit = sv.side — fixes every caller. The duel
and the gore mode carried the same latent bug for anyone tossing deep behind
the line after a racket-zombie touch.

Verified with the exact field shape: lose a rally the zombie touched last,
then eight badly-timed panic taps — one honestly lost point, zero
toss-bleeds. Before the fix every single toss bled.

41 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 22:54:13 +02:00
1 parent f67411e330
commit 24be613c61
4 files changed
+52 -2

No files matched your search

+19 -1
View File
@@ -82,7 +82,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court
@@ -570,6 +570,24 @@ test('THE DEATH LOOP: a fault before a lost point must not arm second-serve fore
assert.equal(G.serveNum, 1, '...whoever won it');
});
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
// line (COURT.L + 0.3), so the moment the toss went up the game scored "its passing shot ends the
// duel": a point and blood per TAP. serveNum was a red herring; this was the loop Oliver hit twice.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT); // ride out the intro beat — it blocks serving
const hp0 = G.hp, pts0 = score.pts.slice();
ball.lastHit = 'O'; ball.active = false; // the zombie touched the last rally ball
assert.ok(P.z > COURT.L + 0.3, 'setup: the server stands behind the passing-shot line');
keyPressed('Space'); // the toss goes up
assert.equal(G.phase, 'toss', 'the toss is up');
for (let i = 0; i < 40; i++) update(DT); // two thirds of a second of flight
assert.equal(G.hp, hp0, 'no blood for tossing a ball');
assert.deepEqual(score.pts, pts0, 'and no point changes hands');
assert.equal(ball.lastHit, 'P', 'the toss is the server\'s ball');
});
test('every point starts with the receiver on its mark', () => {
startMatch();
const rz = oppOf();