From 24be613c6199eb0f7fdd6aed9d0321196e4665d5 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 22:54:13 +0200 Subject: [PATCH] =?UTF-8?q?The=20toss=20is=20the=20server's=20ball=20?= =?UTF-8?q?=E2=80=94=20the=20real=20death=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- PROJECT.md | 23 +++++++++++++++++++++++ index.html | 2 +- src/gameplay.js | 9 +++++++++ test.js | 20 +++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 153a180..cfa56df 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -889,6 +889,29 @@ again and again until i die"*. seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge. +**SIXTEENTH PASS — the REAL death loop, and my serveNum fix was a red herring.** Oliver, again: +*"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 before touching anything, and the previous diagnosis +was wrong — serveNum was A bug, but not THE bug. + +**`tossBall` never stamped `ball.lastHit`.** After any rally the zombie touched last, the freshly +tossed ball still said `lastHit: 'O'` — and the fourteenth pass moved the server BEHIND the baseline +(z 24.3, past the passing-shot line at COURT.L + 0.3). 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 of my 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` — the toss is the server's ball) fixes every +caller. **The duel and the gore mode carried the same latent bug** for anyone tossing from deep +behind the line after a racket-zombie touch; it never fired only because splatter serving used to +happen from inside the court. Verified in the browser 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. + +**The lesson for this file: when a report survives a fix, the fix was wrong — reproduce before +re-diagnosing.** The serveNum reset was real and stays, but it was found by reading code, not by +reproducing the report, and the actual loop needed the exact field conditions (a zombie touch, the +new serve position, panic-speed taps) to show itself. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 974b25e..aa575a5 100644 --- a/index.html +++ b/index.html @@ -400,6 +400,6 @@ - + diff --git a/src/gameplay.js b/src/gameplay.js index 10ef51d..fbf71c0 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -192,6 +192,15 @@ function tossBall(sv) { // keeps the reach he was drawn with. ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0; ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; + // THE TOSS IS THE SERVER'S BALL. lastHit was left stale from the previous rally, and if the + // OPPONENT had touched it last, the freshly tossed ball read as *their* ball in flight — which + // armed every rule that judges an opponent ball, most fatally the passing-shot test: the match + // serves from BEHIND the baseline (z 24.3 > the test's 24.07), so the moment you tossed after any + // rally they had touched, "its passing shot ends the duel" fired, the point went to them and drew + // blood. That was Oliver's death loop: every tap = a toss = an instant lost point, faster the + // more he panicked. One stamp here fixes every caller — the duel and the gore mode carried the + // same latent bug for anyone tossing from deep behind the line. + ball.lastHit = sv.side; G.phase = 'toss'; } export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 diff --git a/test.js b/test.js index c6ce2a5..766771a 100644 --- a/test.js +++ b/test.js @@ -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();