Fix match AI short-ball contact and recovery

This commit is contained in:
wuwi committed 2026-09-07 10:53:14 +02:00
1 parent fe64242090
commit a399e4929c
4 files changed
+129 -9

No files matched your search

+23
View File
@@ -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.
+1 -1
View File
@@ -1107,6 +1107,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=351"></script>
<script type="module" src="src/main.js?v=352"></script>
</body>
</html>
+17 -8
View File
@@ -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
+88
View File
@@ -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();