From 4406aeafdb97bc01b6acc817d09b8cd2b3d1c317 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 14 Oct 2026 18:25:49 +0200 Subject: [PATCH] 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 --- PROJECT.md | 49 +++++++++++++++-- index.html | 2 +- src/config.js | 15 ++++-- src/gameplay.js | 52 +++++++++++++++---- src/net.js | 2 +- src/state.js | 4 +- test.js | 124 ++++++++++++++++++++++++++++++++++++++++---- tools/rally-sim.mjs | 14 ++++- 8 files changed, 231 insertions(+), 31 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index e8f3d1d..491fe19 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5585,6 +5585,48 @@ sein, dass durch die geänderte Mechanik alles etwas schwerer geworden ist"*. - (c) **Widen the early whiff edge only** (a smaller tail), keeping the error race: undoes most of the "harder" without changing the model. +### Step 4 (b), Oliver's pick: the follow-through, and a CPU that can WIN a point (2026-10-14, v373) +Oliver: *"b"* (options above). Two halves, each useless alone (both were measured alone above): +- **THE FOLLOW-THROUGH** (`TUNE.swingTail` 0.06 s, rally modes, player, bounced balls only). A swing + meets a ball arriving up to 60 ms after its window closed 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); past it, it is still a whiff. The + log says `q=0.05 · early 115ms`. Volleys keep their window: a tail on balls in the air raised the + back-court volley share. +- **THE SITTER IS PUNISHED** (`gameplay.punishes`): a player ball under `punishBelow` (0.5) is answered + with a put-away at a chance from `punishMin` 0.2 to `punishMax` 0.85 along the Climb's own progress + (`matchStep`, so its brisk bottom counts), classic by `DIFFS[].punish` (0.2/0.5/0.8). A put-away is + struck at `punishQ` 0.95, skips the style's soft shots, aims at `punishX` 3.5 m (the line; the rung's + width dial still bounds it, so the bottom of the ladder punishes without painting lines), and only + counts if no cap took it down (a low volley stays a block). The log says `THEM PUT-AWAY of your weak ball`. +- **...AND HE IS ON HIS HEELS** (`ball.heels`, `punishRead` 0.5 s): the auto-run stands for that long on a + put-away of his own sitter. This is the half that makes it work. The auto-run started on the strike's + frame with a perfect read, ~7 m/s plus a 1.75 m reach, and covered the whole court: a q 0.95 put-away + to the open court won 0 of 21, at the line 0-2 of 20. Read 0.25 s: ~5%; 0.4: ~20%; 0.55: ~70%. + **Feel risk:** he visibly stands still for half a second. If that reads as a bug on the phone, the + alternatives are a slowed (not frozen) run, or a shorter read with the put-away hit harder. +- **A step-2 regression it surfaced:** the drop's dig-out cap read `ball.spin < 1`, and the slicer hits + EVERY rally ball at spin 0.8, so every low contact against him (both sides) came back at `lowVolleyQ`. + Now `ball.soft && spin < 1`. THE SELLER went from 35% to 47-54% and normal rally lengths. +- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %): + + | rung | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | + |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| + | σ40 | 95 | 88 | 88 | 87 | 75 | 58 | 52 | 45 | 54 | 48 | 47 | 77 | 42 | 46 | 64 | 40 | 65 | 39 | + | σ60 | 95 | 88 | 83 | 78 | 62 | 55 | 53 | 43 | 47 | 46 | 36 | 61 | 30 | 43 | 57 | 28 | 55 | 28 | + + The ladder slopes now, down to the Champion at 28-39%. Classic: easy ~80, medium ~57, hard ~30 (σ60, level 1). +- **Still open:** + - (1) **The net rushers** (Umpire, Club Pro, Director) are 15-25 points easier than their neighbours. + They win only ~35-55% of their net points, because the bot ALWAYS passes or lobs a net man, which is + their tag's answer played perfectly. The phone decides whether a human does that; if so, the dial is + their split-step read (`netReact`) or their put-away from the net. + - (2) The bottom three rungs are ~90%. That is easy for level 26, but the Climb is a marathon. +- Tests (167, nine seeds): the follow-through meets an ~80 ms early tap (and the old edge did not); the + put-away share climbs the ladder, goes for the line, never answers a good ball; a low slice drive is not + a dug-out drop. The baseline-shape test excludes the answer to a put-away (a man on his heels takes it in + the air legitimately), and synthetic returns in the suite now carry `pow: 1` (a stale `pow` from the + previous test read as a sitter). + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. @@ -5592,9 +5634,10 @@ Nothing here is broken; these are known gaps and unverified risks. 0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net, reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the - opponent styles (v364) followed; the swing-log fixes ran to v371. **Step 4 (v372) is BLOCKED on a - design decision**: the ladder cannot climb while points are decided by the player's whiffs. See "Step - 4, measured first" above for options (a)/(b)/(c). Fallback tag `rally-baseline-v360`. + opponent styles (v364) followed; the swing-log fixes ran to v371. Step 4: v372 measured why the ladder + could not climb (points decided by the player's whiffs); Oliver picked (b), shipped v373 (the follow- + through, put-aways, on his heels). Awaiting the phone verdict on v373, especially the half-second + flat-footed read and the net rushers. See "Step 4 (b)" above. Fallback tag `rally-baseline-v360`. 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. diff --git a/index.html b/index.html index 6d2cb83..6f7dfa5 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 20c6371..9cc60b6 100644 --- a/src/config.js +++ b/src/config.js @@ -153,6 +153,15 @@ export const TUNE = { // screen: a short swipe is a safe half-court ball, and only a committed // one goes hunting the line, which is what makes going OUT a chosen risk. perfectMs: 45, // |offset from window center| for PERFECT + swingTail: 0.06, // s: rally modes, a groundstroke swing still meets a ball arriving this long after + // its window closes — the follow-through, q 0.05 (a weak short ball, not a whiff). + // 0 = the old edge: a tap ~70 ms early whiffed (step 4, see gameplay.trySwing) + punishBelow: 0.5, // a player ball under this q is a SITTER the CPU may put away (gameplay.punishes) + punishMin: 0.2, // ...the chance it does at the bottom of the ladder + punishMax: 0.85, // ...and at the CHAMPION (classic: DIFFS[].punish) + punishQ: 0.95, // the put-away's quality, played to the open court... + punishX: 3.5, // ...at this x, inside the singles line (4.1), bounded by the rung's width dial + punishRead: 0.5, // s the player's auto-run stands flat-footed on a put-away of his own sitter qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step. // 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past // PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing) @@ -919,9 +928,9 @@ export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }] export const DIFFS = [ // EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD // taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy - { id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 }, - { id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 }, - { id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 }, + { id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48, punish: 0.2 }, + { id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66, punish: 0.5 }, + { id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80, punish: 0.8 }, ]; // THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's diff --git a/src/gameplay.js b/src/gameplay.js index f8d80c6..ed2b5d3 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -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); diff --git a/src/net.js b/src/net.js index 8f9aa1f..b9ff89b 100644 --- a/src/net.js +++ b/src/net.js @@ -75,7 +75,7 @@ export function netSendShot() { // the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here export function netApplyShot(m) { const s = mirror(m); - ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.approach = null; + ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null; ball.x = s.x; ball.y = m.y; ball.z = s.z; ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz; ball.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike diff --git a/src/state.js b/src/state.js index 61efb4f..4a7ebe4 100644 --- a/src/state.js +++ b/src/state.js @@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, - active: false, lastHit: 'P', bounces: 0, rx: null, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false, + active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false, // NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the // striker's phone that is mid-roll, a whole court away from the call, and it read as the game // loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring @@ -206,7 +206,7 @@ export const other = s => s === 'P' ? 'O' : 'P'; export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export function setupPoint() { - G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.approach = null; + G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null; G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing // (the MATCH falls through to the tennis branch below: diff --git a/test.js b/test.js index d69effd..9efa822 100644 --- a/test.js +++ b/test.js @@ -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; diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index a0fbdc8..17ca762 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -84,6 +84,7 @@ const DT = 1 / 60; const RUNGS = (process.env.RUNGS || '1,6,12,18').split(',').map(Number); const SECS = Number(process.env.SECS || 240); TUNE.autoAssist = 1.15; +for (const [k, v] of Object.entries(process.env)) if (k.startsWith('T_')) TUNE[k.slice(2)] = Number(v); // T_punishRead=0.4 etc. const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0; const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; }; // BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a @@ -92,7 +93,7 @@ const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[M const HUMAN = (process.env.BOT || 'human') === 'human'; const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7; const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); -const probe = []; +const probe = [], kills = []; let kOpen = null, kpow = 1; const bot = { armed: false, fire: -1, jit: null, swipe: null }; const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n; return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; }; @@ -172,6 +173,16 @@ for (const rung of RUNGS) { update(DT); if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; } if (ball.lastHit !== 'O') G.swipe = null; + // KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went, + // how far the player had to go, how long he had, and whether he got it back + if (process.env.KILL) { + if (lh === 'P' && ball.lastHit === 'O' && kpow < 0.5 && ball.pow >= 0.95) { + const L = gp.predictLanding(); kills.push({ px: +P.x.toFixed(1), pz: +P.z.toFixed(1), lx: L && +L.x.toFixed(1), lz: L && +L.z.toFixed(1), tl: L && +L.t.toFixed(2), v: +Math.hypot(ball.vx, ball.vz).toFixed(1), got: null }); kOpen = kills[kills.length - 1]; + } + if (kOpen && ball.lastHit === 'P' && lh === 'O') { kOpen.got = +ball.pow.toFixed(2); kOpen = null; } + if (kOpen && (!ball.active || G.phase !== 'rally')) { kOpen.got = 'LOST'; kOpen.gap = +(G.rallyNear ? G.rallyNear.d : -1).toFixed(2); kOpen = null; } + if (ball.lastHit === 'P') kpow = ball.pow; + } const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r'); if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) { const ch = ball.lastHit === 'P' ? P : rz; @@ -221,6 +232,7 @@ for (const rung of RUNGS) { rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) }); } if (!process.env.SUMMARY) { console.table(rows); console.table(land); } +if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); } if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); } if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); } console.table(pts);