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

+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();