The receiver stands where the serve can actually go

The trick: hard serve, slight slide, and the first ten opponents died where
they stood. Measured headless — that serve was returned 0 times out of 24 at
EVERY rung. Three things were wrong and all three were the same thing: he
waited on the tennis mark 1.3 m off the centre line (covering the T, conceding
the whole corner), he took no first step, and he chased the ball's LIVE
position, which during a serve is still up by the server's baseline.

Now: he waits on the bisector of the serves he has to cover, takes a
returner's first step (serve-scoped — the rally is untouched), and runs at the
bounce instead of at the ball. The slid serve comes back from rung one; a
flush serve into the very corner of the box still aces the bottom of the
ladder, which is the reward placement is supposed to earn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 20:29:17 +02:00
1 parent ece27827e5
commit 86b8275f5d
6 files changed
+77 -10

No files matched your search

+29 -7
View File
@@ -365,7 +365,10 @@ export function sliceCarom(tgt, speed) {
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and
// so can never fail the old `quality > 0.999` test once you have pulled far enough.
function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// exported for the headless suite only: it is the one honest way to serve without a thumb, and a
// serve-defence test that fakes its own ball is testing a ball the game never hits (it did, once —
// the fake was a flat rocket that skidded out the back and nothing could have returned it).
export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const toFar = sv.side === 'P';
// {sx, hard} is the TENNIS strike aim (see keyPressed's toss branch): sx sways the target in
// SCREEN direction — flick right, serve right — and hard buys pace and depth, paid for in
@@ -1010,8 +1013,16 @@ function matchFallWillPlay() {
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
}
// How fast this body chases a ball. ONE definition, because racketAI's legs and the two
// out-of-reach tests that end a rally early must never disagree about what it can get to.
// THE RETURN DASH is the returner's first step: a serve is the one ball in the game that is
// hit before he is moving at all, and against a hard one into the corner the walk-up simply
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
const rzChaseSpeed = rz => TUNE.racketSpeed
* (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1);
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
@@ -1279,16 +1290,27 @@ function racketAI(rz, 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
if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; }
const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
// 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
// across once it was past him. Measured headless: a flush, hard serve into the corner of the
// box was returned 0 times out of 40 at every rung of the ladder — Oliver's "you can kill the
// first ten of them the moment you serve". He reads the BOUNCE now and runs at that instead.
// 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 aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
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,
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
// 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.
const spd = TUNE.racketSpeed * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer)
* (rz.foe?.speed || 1) : 1);
const step = Math.min(spd * dt, d);
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
const step = Math.min(rzChaseSpeed(rz) * 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';