diff --git a/PROJECT.md b/PROJECT.md index 89d85eb..86ff629 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -4963,6 +4963,29 @@ download-request feedback and no console errors. The in-app browser did not expo completion event; the exporter branches are tested with platform stubs, real Safari export is the next phone check. New file is under src/, already covered by Docker's COPY. +## Session log: PHONE-RECORDED AI FOOTWORK (2026-09-07, v352) + +Oliver exported a v351 iPhone playtest successfully: final-league cup 5–0 games / 20–3 points, +then six Climb opponents 24–1 on points. All eight Champion winners were second bounces, not +early awards. Seventeen of the cup's eighteen second-bounce winners had no sampled post-bounce +frame above the AI's 24 cm contact floor. Six Champion winners had timing quality <= 0.1. +The AI held behind the service line, then could not scoop the low bounce even when it arrived. +On its own serve it also stood still until Oliver's return. This was footwork/contact, not stale +difficulty settings. Oliver's constraint: **harder, still winnable**. + +Solo match opponents now read first-bounce depth from launch, approach as far as 1.2 m behind +the net and can scoop a rising low ball under the same height rule as the player. They cannot +strike across the net. During their own live flight they recover towards HOME.O at half chase +speed, after the serve pose finishes. Lateral anticipation, wrong-footing, wounds, shot quality, +speed, reach and mistake dials stay as before; gore and classic behavior stay as before. + +Validation: 142 tests, default seed plus 1, 42, 1234 and 20260907. Recorded cup points 21/22 +are returned at 30/60/120 Hz; tests also pin rising vs falling contact, net separation, serve/dead +ball recovery guards and a passing-shot winner against the approaching Champion. Isolated final +shot simulations from the recording (20 seeds each, not a deterministic full-match replay) now +return all six soft Champion winners; wide winners against earlier foes and surprise misses remain. +Phone balance still needs Oliver's next recording; no further numeric difficulty increase here. + # 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 7d26484..de9d636 100644 --- a/index.html +++ b/index.html @@ -1107,6 +1107,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/gameplay.js b/src/gameplay.js index cca6ab4..9e94927 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1488,10 +1488,15 @@ function racketAI(rz, dt) { rz.swingCool -= dt; const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 && (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now + // Solo tennis recovers during its own flight, at half chase speed. The serve pose finishes + // first; dead balls and the gore racket keep their existing hold. + const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored + && !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0 + && !(rz.serveHold > 0); // ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on // the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben // die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue. - if (!incoming) { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; return; } + if (!incoming && !recovering) { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; return; } // ANTICIPATION. He used to run at the ball's LIVE position, and against a serve that is a // trap: for the first third of the flight the ball is still up by YOUR baseline, so he ran // deep and straight while the ball was already leaving sideways, and only started sliding @@ -1501,7 +1506,8 @@ function racketAI(rz, dt) { // aiRead is how much of the read he gets: 1 would be a machine that is never wrong-footed, and // being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied // to every flight — the same lag was there in a rally, just hidden by the shorter distances. - const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket + const pred = incoming && ((G.match && ball.bounces === 0) || (ball.y > 0.35 && ball.vy < 3)) + ? predictLanding() : null; // THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly // — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure // pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung @@ -1513,9 +1519,11 @@ function racketAI(rz, dt) { : G.match ? TUNE.aiRead // the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible) : TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill(); - const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x; - const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z; - const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court + const aimX = recovering ? HOME.O.x : pred ? ball.x + (pred.x - ball.x) * read : ball.x; + // Read the landing depth immediately in a match, including rising returns. Blending with + // the ball's current depth would send every receiver towards the net before it arrives. + const aimZ = recovering ? HOME.O.z : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z; + const tz = Math.max(1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); if (d > 0.15) { // THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard, @@ -1523,7 +1531,7 @@ function racketAI(rz, dt) { // 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a // whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase. // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) - const step = Math.min(rzChaseSpeed(rz) * dt, d); + const step = Math.min(rzChaseSpeed(rz) * (recovering ? 0.5 : 1) * dt, d); rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; if (rz.pose !== 'swing') rz.pose = 'run'; @@ -1547,9 +1555,10 @@ function racketAI(rz, dt) { // as far, lost its head and missed exactly as often. Both dials read the same racketStage the // SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound. const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 }; - if (rz.swingCool <= 0 && rz.swingT < 0 + if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0 && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach - && ball.y < CONTACT.maxY && ball.y > CONTACT.minY) { + && ball.y < CONTACT.maxY + && (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : ball.y > CONTACT.minY)) { // ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away, // which across a game is an opponent that beats itself. const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, matchStep()) * (rz.foe?.flub || 1) * wnd.flub diff --git a/test.js b/test.js index d700e03..1931724 100644 --- a/test.js +++ b/test.js @@ -969,6 +969,94 @@ test('league 5 sustains strong returns and hunts the open court; league 4 stays endMatch(); }); +test('phone winners: the Champion approaches and returns the recorded short, low balls', () => { + // Cup points 21/22 from Oliver's v351 phone log. Both were untouched second-bounce winners: + // one landed near the net, the other bounced below the AI's old 24 cm contact floor. + const cases = [ + { opponent: [1.626, 5.485], player: [-1.035, 14.522], hp: 3, + pos: [-0.535, 1.194, 14.222], velocity: [1.467, 1.063, -7.611], g: 9.614 }, + { opponent: [2.676, 5.485], player: [-2.749, 19.533], hp: 2, + pos: [-2.249, 0.404, 19.233], velocity: [0.597, 3.678, -14.681], g: 9.698 }, + ]; + const flub = TUNE.matchFlub; + TUNE.matchFlub = 0; // isolate movement/contact; reliability and surprise rates have their own tests + try { + for (const hz of [30, 60, 120]) for (const c of cases) { + freshSplatter(); prof.prof.lg = 5; prof.prof.xp = prof.xpFor(29); + startMatch(); G.interlude.introT = 0; G.match.opp = 18; + const rz = oppOf(); + Object.assign(rz, { x: c.opponent[0], z: c.opponent[1], hp: c.hp, hpMax: 8, + foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 }); + Object.assign(P, { x: c.player[0], z: c.player[1], swingT: -1 }); + G.phase = 'rally'; G.wasServe = false; + Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.05, + x: c.pos[0], y: c.pos[1], z: c.pos[2], vx: c.velocity[0], vy: c.velocity[1], vz: c.velocity[2], g: c.g }); + for (let i = 0; i < hz * 3 && ball.active && ball.lastHit === 'P' && G.phase === 'rally'; i++) { + update(1 / hz); + assert.ok(rz.z <= COURT.netZ - 1.2, 'the approach stays on its own side'); + } + assert.equal(ball.lastHit, 'O', `${hz}Hz: the reachable phone winner is returned`); + assert.deepEqual(score.pts, [0, 0], 'the rally continues instead of gifting a point'); + } + } finally { TUNE.matchFlub = flub; } +}); + +test('match low contact: scoop rising balls, reject falling balls and contact across the net', () => { + const flub = TUNE.matchFlub; + TUNE.matchFlub = 0; + try { + for (const [z, y, vy, returns] of [[5, 0.12, 1.5, true], [5, 0.12, -1, false], + [COURT.netZ + 0.1, 1, 0, false]]) { + freshSplatter(); startMatch(); G.interlude.introT = 0; + const rz = oppOf(); + Object.assign(rz, { x: 0, z: Math.min(z, COURT.netZ - 1.2), swingT: -1, swingCool: 0 }); + G.phase = 'rally'; G.wasServe = false; + Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, + x: 0, y, z, vx: 0, vy, vz: -1, g: TUNE.gravity }); + update(DT); + assert.equal(ball.lastHit === 'O', returns, `contact at z=${z}, y=${y}, vy=${vy}`); + } + } finally { TUNE.matchFlub = flub; } +}); + +test('match recovery: finish the serve, recover during the outbound rally, hold on dead balls', () => { + startMatch(); G.interlude.introT = 0; + const rz = oppOf(); + Object.assign(rz, { x: 3, z: -0.5, serveHold: 0.4 }); + G.phase = 'rally'; + Object.assign(ball, { active: true, lastHit: 'O', bounces: 0, + x: 0, y: 2, z: 14, vx: 0, vy: 0, vz: 1 }); + update(DT); + assert.deepEqual([rz.x, rz.z], [3, -0.5], 'serve follow-through holds its mark'); + rz.serveHold = 0; + update(DT); + assert.ok(rz.x < 3 && rz.z > -0.5, 'recovers towards centre and into court before the return'); + const recoverySpeed = Math.hypot(rz.vx, rz.vz); + ball.lastHit = 'P'; ball.vz = -1; + update(DT); + assert.ok(Math.abs(Math.hypot(rz.vx, rz.vz) - recoverySpeed * 2) < 1e-9, + 'recovery uses half chase speed, leaving room for wrong-footing'); + for (const dead of ['inactive', 'scored', 'pretoss']) { + ball.lastHit = 'O'; ball.vz = 1; ball.active = dead !== 'inactive'; ball.scored = dead === 'scored'; + G.phase = dead === 'pretoss' ? 'pretoss' : 'rally'; + const pos = [rz.x, rz.z]; + update(DT); + assert.deepEqual([rz.x, rz.z], pos, `${dead}: no recovery outside a live flight`); + assert.deepEqual([rz.vx, rz.vz], [0, 0], 'no stale sprint trail'); + } +}); + +test('the approaching Champion can still be passed into the open back corner', () => { + startMatch(); G.interlude.introT = 0; G.match.opp = 18; + const rz = oppOf(); + Object.assign(rz, { x: 3, z: 10, foe: cfg.matchFoe(18), swingT: -1, swingCool: 0 }); + G.phase = 'rally'; G.wasServe = false; + Object.assign(ball, { active: true, lastHit: 'P', bounces: 0, pow: 0.8, + x: -3.5, y: 0.3, z: 1.5, vx: 0, vy: -2, vz: -4, g: TUNE.gravity }); + for (let i = 0; i < 60 && score.pts[0] === 0; i++) update(DT); + assert.deepEqual(score.pts, [1, 0], 'a legal passing shot beats a Champion stranded at the net'); +}); + test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => { startMatch(); G.interlude.introT = 0; const zb = oppOf();