Let an early tap be a weak ball, and let the CPU put a sitter away

Step 4 (b), Oliver's pick. v372 measured why the ladder could not climb:
nearly every lost point was the player's own early whiff, the CPU barely
missed, and the auto-run reached everything, so no opponent dial decided
a point.

- The follow-through: a groundstroke swing meets a bounced ball up to
  60 ms after its window at q 0.05. The early whiff edge goes from ~70 ms
  back to ~130 ms, where the volley window had it before step 1.
- A sitter (a player ball under q 0.5) is put away with a chance that is
  the ladder (0.2 at the bottom of the Climb, 0.85 at the Champion, by
  difficulty in classic): clean, at the line, bounded by the rung's width.
- The player is on his heels for 0.5 s after a put-away of his own sitter.
  Without it the auto-run covered the whole court and no put-away won.
- Fixed a step-2 regression this surfaced: the drop's dig-out cap fired on
  every slice drive, so every low contact against the slicer was a sitter.

Measured at level 26: the Climb now slopes from 95% of points at the
Shambler to 28-39% at the Champion. The net rushers are still easier than
their neighbours. The log names put-aways. Three new tests; v373.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 18:25:49 +02:00
1 parent 5dd5d71c33
commit 4406aeafdb
8 files changed
+231 -31

No files matched your search

+113 -11
View File
@@ -911,7 +911,7 @@ test('league 5 has no speed dip below league 4, including serve and wrong-footed
G.match.opp = n; G.match.surprised = surprised; G.match.expected = false;
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = serve;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
return Math.hypot(zb.vx, zb.vz);
@@ -946,7 +946,7 @@ test('league 5 sustains strong returns and hunts the open court; league 4 stays
G.match.surprised = G.match.expected = false;
P.x = i % 2 ? 3 : -3; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, // a good ball: no put-away
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue; // actual flubs remain part of the opponent
@@ -1012,7 +1012,7 @@ test('match low contact: scoop rising balls, reject falling balls and contact ac
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,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, 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}`);
@@ -1076,7 +1076,7 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
oppOf().foe = cfg.matchFoe(11); // the BODY plays the style, not the rung number
for (let i = 0; i < 2.2 * 60; i++) update(DT);
const hits = [];
let prev = ball.lastHit;
let prev = ball.lastHit, putAway = false;
for (let i = 0; i < 120 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 11; // keep playing: the shape is the subject
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
@@ -1085,11 +1085,14 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2) keyPressed('Space');
}
const b0 = ball.bounces;
const b0 = ball.bounces, kill = putAway;
update(DT);
if (ball.active && ball.lastHit !== prev && !G.wasServe) {
const ch = ball.lastHit === 'P' ? P : oppOf();
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT });
// the answer to a PUT-AWAY (step 4) is a defence, not the rally's shape: a man caught on his heels
// takes it where he can, in the air included
if (ch) hits.push({ back: Math.abs(ch.z - COURT.netZ) >= TUNE.netFrom, volley: b0 === 0, t: i * DT, kill });
putAway = ball.heels > 0;
}
prev = ball.active ? ball.lastHit : null;
}
@@ -1098,7 +1101,7 @@ test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not
// the autopilot mistimes into short balls and a body is MEANT to come in on those, so the share of
// net contacts follows its timing; the back-court volley share below is the guard against the old T
assert.ok(back.length / hits.length >= 0.5, `most of the rally is played from the back: ${back.length}/${hits.length}`);
const volleys = back.filter(h => h.volley).length;
const volleys = back.filter(h => h.volley && !h.kill).length;
assert.ok(volleys / back.length <= 0.1, `a baseliner lets it bounce: ${volleys}/${back.length} back-court volleys`);
const gaps = [];
for (let k = 1; k < hits.length; k++) if (hits[k].back && hits[k - 1].back && hits[k].t - hits[k - 1].t < 3)
@@ -1143,6 +1146,105 @@ test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobo
} finally { TUNE.matchFlub = flub; }
});
test('THE FOLLOW-THROUGH: a tap ~80 ms early on a baseline ball is met weakly, not whiffed', () => {
// Step 4 (b), 2026-10-14. An early tap starts the swing while the ball is in the air, the swing waits
// for the bounce, and the groundstroke window had run out by then: a whiff at ~70 ms early, while a
// tap 120 ms LATE still met it. swingTail lets the swing meet it at q 0.05 — a sitter, not a miss.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
const run = () => {
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
for (let i = 0; i < 2.2 * 60; i++) update(DT);
let armed = false, incoming = 0, met = 0;
for (let i = 0; i < 90 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) {
const c = ringCue();
if (c && c.eta - c.sweet <= 5 / 60 + 1e-6 && c.eta - c.sweet > 4 / 60) { armed = true; incoming++; keyPressed('Space'); }
}
const before = ball.lastHit;
update(DT);
if (armed && before === 'O' && ball.lastHit === 'P') { met++; armed = false; }
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
}
endMatch();
return { incoming, met };
};
const tail = TUNE.swingTail;
try {
const on = run();
assert.ok(on.incoming >= 20, 'a real sample: ' + on.incoming);
assert.ok(on.met / on.incoming >= 0.8, `an ~80 ms early tap is met: ${on.met}/${on.incoming}`);
TUNE.swingTail = 0;
const off = run();
assert.ok(off.met / off.incoming < 0.6, `...where the old edge whiffed it: ${off.met}/${off.incoming}`);
} finally { TUNE.autoAssist = assist; TUNE.swingTail = tail; }
});
test('THE SITTER IS PUNISHED: the put-away chance climbs the ladder, goes for the line, and finds him on his heels', () => {
// Step 4 (b): before it, the CPU had no way to win a point — a q 0.05 floater came back like a drive
// and every point it took was the player's own whiff, which no opponent dial touches.
startMatch(); G.interlude.introT = 0;
const zb = oppOf();
const share = (n, pow) => {
G.match.opp = n; zb.foe = cfg.matchFoe(n);
let hits = 0, kills = 0, wide = 0, heels = 0;
for (let i = 0; i < 1200; i++) {
G.phase = 'rally'; G.wasServe = false;
G.match.surprised = G.match.expected = false;
P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow, rx: null, spin: 1, soft: false,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
hits++;
if (ball.heels > 0) {
kills++; heels++;
const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g;
if (Math.abs(ball.x + ball.vx * t) > 2.9) wide++;
assert.ok(ball.pow >= TUNE.punishQ, 'a put-away is struck clean');
}
ball.active = false;
}
return { hits, kills: kills / hits, wide: kills ? wide / kills : 0 };
};
const champ = share(cfg.LADDER, 0.1), first = share(1, 0.1), good = share(cfg.LADDER, 0.9);
assert.ok(Math.abs(champ.kills - TUNE.punishMax) < 0.06, `the champion puts a sitter away ${champ.kills.toFixed(2)}`);
assert.ok(first.kills < champ.kills - 0.3 && first.kills > 0.1, `the first rung does it rarely ${first.kills.toFixed(2)}`);
assert.equal(good.kills, 0, 'a good ball is never put away');
assert.ok(champ.wide > 0.9, `the champion's put-away goes for the line ${champ.wide.toFixed(2)}`);
endMatch();
});
test('a low SLICE drive is not a dug-out drop: the scoop cap is for soft backspin balls only', () => {
// Step 2's slicer hits every rally ball at spin 0.8, and the drop's dig-out cap read `spin < 1`, so every
// low contact against him came back at lowVolleyQ — found when step 4 started punishing sitters.
startMatch(); G.interlude.introT = 0;
const zb = oppOf(); G.match.opp = 6; zb.foe = cfg.matchFoe(6);
const flub = TUNE.matchFlub; TUNE.matchFlub = 0;
try {
for (const soft of [false, true]) {
let n = 0, capped = 0;
for (let i = 0; i < 300; i++) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, pow: 1, rx: null, spin: 0.8, soft,
x: 0, z: 2.4, y: 0.3, vx: 0, vy: 0.5, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
n++; if (ball.pow <= TUNE.lowVolleyQ + 1e-9) capped++;
ball.active = false;
}
assert.ok(n > 200, 'returns: ' + n);
if (soft) assert.equal(capped, n, 'a dug-out drop is still a weak reply');
else assert.equal(capped, 0, 'a low slice drive is an ordinary ball');
}
} finally { TUNE.matchFlub = flub; endMatch(); }
});
test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => {
// Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a
// groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms
@@ -1302,7 +1404,7 @@ function styleReturns(rung, pz, aiZ = 2) {
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = pz; P.swingT = -1;
zb.x = 0; zb.z = aiZ; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false,
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null, spin: 1, soft: false, pow: 1, // no sitter: no put-away
x: 0, z: aiZ + 0.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;
@@ -1505,7 +1607,7 @@ test('rally depth: deep by default, mistiming lands it SHORTER, never longer', (
G.phase = 'rally'; G.wasServe = false; G.match.surprised = G.match.expected = false;
P.x = 0; P.z = 24; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1, rx: null,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1, rx: null,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O' || ball.soft) continue; // a style's moonball has its own depth (and tests)
@@ -1532,7 +1634,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
G.match.surprised = G.match.expected = false;
zb.foe = cfg.matchFoe(n); zb.x = -3; zb.z = 2; zb.swingCool = 999;
G.phase = 'rally'; G.wasServe = false;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 0,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 0,
x: 3, z: 5, y: 2, vx: 0, vy: 4, vz: -8 });
update(DT);
const chase = Math.hypot(zb.vx, zb.vz);
@@ -1543,7 +1645,7 @@ test('The Climb opens briskly, converges at opponent eight, and leaves cup and t
G.phase = 'rally'; G.wasServe = false;
P.x = 0; P.z = 23; P.swingT = -1;
zb.x = 0; zb.z = 2; zb.swingT = -1; zb.swingCool = 0; zb.serveHold = 0;
Object.assign(ball, { active: true, lastHit: 'P', bounces: 1,
Object.assign(ball, { active: true, lastHit: 'P', pow: 1, bounces: 1,
x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 });
update(DT);
if (ball.lastHit !== 'O') continue;