diff --git a/PROJECT.md b/PROJECT.md index eeaaeb9..4b1d2c0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5445,6 +5445,27 @@ Oliver: *"bitte bau nochmal die Funktion ein die protokolliert warum ich einen B the bounce will land inside the swing window. Otherwise it digs the ball out as a half-volley, capped below the tape. His 48.14 s MISS was exactly this. +### "passed you" called before the ball reached him (2026-10-14, v367) — a step-1 regression +Oliver's v366 log vs THE BRUTE, first loss: +1. A 29 m/s topspin drive landed 3.7 m to his left. +2. He reached it at the edge of his reach (q 0.09). +3. The Brute came in behind the weak reply and hit 3 m to his left. +4. LOST "passed you", with no swing at all. + +The match awards the CPU the point the moment its ball crosses `COURT.L + 0.3`. That line was +written when the player stood INSIDE his baseline. Since step 1 he waits up to `rallyBehind` (2.2 m) +BEHIND it, deepest against heavy topspin. A ball passing ~1.5 m beside him only enters his reach about +0.5 m in front of him (the reach ellipse is shallow off-centre), so the point was called before the +ball was ever playable. A late-but-legal tap was cut off the same way. **In the match, "past you" now +means past your reach:** `max(L + 0.3, P.z + reach·matchReach + 0.3)`. The duel keeps the old line; +classic was never affected (it calls the second bounce). Test: a deep, slightly wide topspin ball +against a player held 1.8 m behind his line. Without the fix it reproduces his exact log line +(`LOST passed you`); with it he returns it. The "never swung, it came within X m" annotation is pinned +in the same test. + +The swing log's HIT line now also says which way the timing missed: `late 40ms` (the ball was already +there when the window opened) or `early 40ms`. That was the question his first loss could not answer. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 89e89ea..cf525f2 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/gameplay.js b/src/gameplay.js index 2cb73bd..1f11f14 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1705,7 +1705,10 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : ''; const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels' : ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop'; - logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, + // ...and WHICH WAY the timing missed: contact before the window's centre means the ball was + // already there — the swing came LATE; after it, the swing came EARLY + const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`; + logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); } doHit(ch, q, aim); @@ -2496,8 +2499,13 @@ export function update(dt) { } else if (ball.bounces === 1) pointTo(ball.lastHit, null); } } - // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM - if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { + // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM. + // IN THE MATCH "past you" means past your REACH (2026-10-14, Oliver's v366 log): since step 1 a + // baseliner waits up to rallyBehind BEHIND his line, so the old line — 0.3 m behind the baseline — + // called the point while the ball was still on its way to his racket. Every deep topspin ball the + // auto-run met deep was a "passed you" with no swing. The duel keeps the old line. + const passZ = G.match ? Math.max(COURT.L + 0.3, P.z + TUNE.reach * TUNE.matchReach + 0.3) : COURT.L + 0.3; + if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > passZ) { ball.active = false; if (G.interlude) resolveInterlude('O', 'passed_player'); // sudden death: its passing shot ends the duel else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way diff --git a/test.js b/test.js index 48faa11..10b0b93 100644 --- a/test.js +++ b/test.js @@ -1397,6 +1397,46 @@ test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and wh } finally { TUNE.autoAssist = assist; } }); +test('a deep ball the player waits for BEHIND his baseline is still his to hit — not "passed you"', () => { + // Oliver's v366 log vs THE BRUTE: "LOST passed you" with no swing. The match called the point the + // moment its ball crossed 0.3 m behind the baseline — but since step 1 the player waits up to + // rallyBehind behind it for a deep, kicking ball, so the ball was taken away on its way to him. + const flub = TUNE.matchFlub; TUNE.matchFlub = 0; + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; + const zb = oppOf(); zb.swingCool = 999; + G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0; + Object.assign(P, { x: 0, z: COURT.L + 1.8, swingT: -1, tgt: null, vx: 0, vz: 0 }); + TUNE.autoAssist = 0; TUNE.matchAssist = 0; // he holds his deep mark: the ball comes to him + const v = solveShot(0, 1, 1, 1.25, COURT.L - 2.2, 27, TUNE.gravity * 1.6); // deep, a little wide, heavy topspin + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1.15, soft: false, + x: 0, y: 1, z: 1, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity * 1.6 }); + let crossed = false, lift = -1; + for (let i = 0; i < 3 * 60 && ball.active && ball.lastHit === 'O'; i++) { + if (lift < 0 && ringCue()?.gold) lift = i + 6; + if (i === lift) keyPressed('Space', 0.1); + update(DT); + if (ball.z > COURT.L + 0.3) crossed = true; + } + assert.ok(crossed, 'the ball really did cross the old line'); + assert.equal(ball.lastHit, 'P', 'and he returned it: ' + JSON.stringify(G.missLog.map(e => e.result + ' ' + e.reason))); + // ...while a ball that truly gets past him is still called, and says whether he swung + TUNE.autoAssist = assist; TUNE.matchAssist = 1.15; + freshSplatter(); startMatch(); G.interlude.introT = 0; + oppOf().swingCool = 999; + G.phase = 'rally'; G.wasServe = false; G.missLog.length = 0; + Object.assign(P, { x: 0, z: 23, swingT: -1 }); + const w = solveShot(0, 1, 1, 3.9, COURT.L - 3, 28); // wide, past him + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: false, + x: 0, y: 1, z: 1, vx: w.vx, vy: w.vy, vz: w.vz, g: TUNE.gravity }); + TUNE.autoAssist = 0; TUNE.matchAssist = 0; // a statue: it must go by + for (let i = 0; i < 3 * 60 && G.phase === 'rally'; i++) update(DT); + const lost = G.missLog.find(e => e.result === 'LOST'); + assert.ok(lost && /never swung, it came within/.test(lost.reason), 'the verdict says how close it came: ' + (lost && lost.reason)); + } finally { TUNE.matchFlub = flub; TUNE.autoAssist = assist; TUNE.matchAssist = 1.15; } +}); + test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => { // the only out a rally ball may find is wide — depth must never put one past the baseline startMatch(); G.interlude.introT = 0;