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

+43 -9
View File
@@ -268,6 +268,9 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
// a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
// so the bottom of the ladder cannot paint lines even when it punishes)
if (aim && aim.kill) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
@@ -384,6 +387,7 @@ function doHit(ch, quality, aim) {
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
if (ch.side !== 'P' && swingLog()) {
@@ -393,7 +397,7 @@ function doHit(ch, quality, aim) {
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
})();
const kind = soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
+ (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left
@@ -613,7 +617,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.spin = 1; ball.soft = false;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.spin = 1; ball.soft = false;
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
ball.pow = quality;
@@ -1650,6 +1654,25 @@ function logWhiff() {
...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him
}
// THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing
// while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the
// groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met
// the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the
// rally modes the swing now carries on for swingTail after its window and meets a ball arriving there
// at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is.
const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0;
// ...and the other half, without which the follow-through only made rallies endless: the CPU had NO
// way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past
// mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so
// the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a
// chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by
// difficulty. Rally modes, solo only.
function punishes(ch) {
if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false;
// the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler
const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0;
return Math.random() < k;
}
function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// Contact happens if the ball is inside reach while the swing window is open.
// Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue.
@@ -1663,7 +1686,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel());
const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball
if (!ball.active || G.phase !== 'rally') return;
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
@@ -1681,7 +1704,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// bounce frame (his v367 log, 8.98 s).
if (letBounce()) {
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() - 0.03) return;
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return;
}
// quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2;
@@ -1697,6 +1720,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
// finger is usually still down at contact, and the vector under it NOW is the aim the player
@@ -1708,7 +1732,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit).
const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0;
// Keep the old random sequence for classic and for foes without smart aim.
const smart = isAI && (G.diff || smartChance) && Math.random() < smartChance;
// THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence
const kill = isAI && punishes(ch);
const smart = kill || isAI && (G.diff || smartChance) && Math.random() < smartChance;
const aim = isAI
? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1))
: [ -1, 0, 1 ][Math.floor(Math.random() * 3)],
@@ -1717,7 +1743,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
const st = isAI ? styleOf(ch) : null;
const st = isAI && !kill ? styleOf(ch) : null;
if (st) {
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
@@ -1730,15 +1756,19 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
if (kill) q = Math.max(q, TUNE.punishQ);
const q0 = q; // (the log names whichever cap below took it down)
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
// ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this
// capped every low contact against him, his own included — invisible until a sitter got punished.
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
// That is the lob's real reward — rarely the winner itself, nearly always the point.
if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ);
if (kill && q >= TUNE.punishQ) aim.kill = true; // a put-away only if no cap took it down (a low volley is a block)
if (!isAI && swingLog()) {
G.swingSnaps = null; G.rallyNear = null;
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
@@ -2271,7 +2301,11 @@ export function update(dt) {
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
// ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
let rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
// ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
// won a point, and the CPU still had no way to take one
if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; }
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
// for a reason:
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
@@ -2368,7 +2402,7 @@ export function update(dt) {
// 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance
const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1)
* winLevel(); // the clock must outlive the widened window or the level's gift is dead time
if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing
}
expirePose(P, dt);