From 4e7c816a2565710904d73c0ced452083173b30e9 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 13 Oct 2026 12:55:05 +0200 Subject: [PATCH] Give the rally its length back: deep balls, baseliners, approaches off short balls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every match had collapsed into serve, both at the net, reflex volleys at ~0.7 s per shot. The rally ball landed on the service line by definition, the match CPU stood on the bounce and volleyed 90-100% of everything, and the auto-run chased the earliest contact. Rally modes only (classic + every match; gore and the duel untouched): rally balls land deep and mistiming only shortens them (no new long outs); each shot latches the receiver's plan as it leaves the racket — from the back he lets it bounce and meets it falling, up to 2.2 m behind the line, a short ball pulls him in and he follows it to the net, nobody approaches a man already there, and a volley below the tape is a weak block. Measured: contacts 10.5-13.7 m from the net, back-court volleys 0-8%, 0.8-1.0 s per baseline shot. Three new tests pin the shape; v361. Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 57 ++++++++++++++++++++-- index.html | 2 +- src/config.js | 18 +++++++ src/gameplay.js | 109 +++++++++++++++++++++++++++++++++++------- src/net.js | 3 +- src/state.js | 4 +- test.js | 112 +++++++++++++++++++++++++++++++++++++++++++- tools/rally-sim.mjs | 16 +++++-- 8 files changed, 292 insertions(+), 29 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 8536788..1a9aae5 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5127,7 +5127,7 @@ replay button"*. Both are netplay-only by construction, which is why neither had → both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE" against "REMATCH — THEIR SERVE". 146 tests. -## THE RALLY GOES FLAT — diagnosis (2026-10-13, UNDER DISCUSSION, nothing built) +## THE RALLY GOES FLAT — diagnosis, plan, and step 1 (2026-10-13, STEP 1 SHIPPED v361, phone verdict pending) Oliver: *"jedes Spiel läuft am Ende gleich ab (vor allem in höheren Levels): Aufschlag, dann schnell vor ans Netz (Spieler und Opponent), von wo aus ein sehr schneller Ballwechsel passiert und durch eine @@ -5214,13 +5214,62 @@ the bot. That is expected: once the geometry is fixed, what is missing is the to 4. **Retune the escalation** against the new shape, with `tools/rally-sim.mjs` and the step-1 test as the gauge. +**Oliver's answers (2026-10-13): "a) ja b) ja c) erstmal nur kurze bälle — go".** (a) Step 1 first, in +both modes, then a phone test. (b) The down-flick becomes the soft ball (short = drop, long = lob), which +is APPROVED and is step 3. (c) The player goes to the net ONLY on a short ball for now. Following an +attacking up-flick in was offered and deferred. + +### Step 1, as shipped (v361): THE COURT HAS ITS LENGTH +All dials are in TUNE's block of that name. Rally modes only (`rallyFeel()`); gore's prologue and the duel +are byte-identical. +- **Depth**: `rallyMid` 2.8 / `rallyDeep` 1.9 m inside the line. Timing only SHORTENS the ball: + `(1-q)·shortK·(0.5..1.5)`. Pinned: a rally ball never lands nearer than ~1.4 m to the line. (solveShot + aims at ty 0.08, so the real bounce sits a few dm past the mark.) The down-flick keeps the old + `SHOT_DEPTH.short` until step 3. +- **The plan, `ball.rx`, latched once per shot** (every strike clears it; `rxPlan` sets it on the + receiver's first planning frame). `net` = the receiver stood inside `netFrom` (7 m) when the shot left; + `short` = it lands more than `shortBall` (4.9 m) inside his line and is not a serve. The latch is + load-bearing: re-reading position every frame flipped a man running in for a short ball into a volleyer + the moment he crossed `netFrom`. +- **From the back you let it bounce.** `intercept` meets the ball between its bounce and the next, at the + top or falling through `rallyHit`+0.3, up to `rallyBehind` (2.2 m) behind the line. A short ball is met + the first moment it is up off the grass. The old earliest-contact candidates are only the fallback. + `racketAI` does the same in closed form (`bounceContact`), keeps THE READ's blend on x, and is now + allowed behind its own baseline (the old `>= 1.5` clamp). `letBounce()` refuses a below-the-tape + pick-up on the full from the back, for both CPUs (in their near-checks, or the cooldown eats the + bounce) and for the player (in `trySwing`, where his swing simply waits for the bounce). +- **The approach**: a short ball taken sets `ball.approach`, and the striker's recovery (`rallyHome`) + heads for `netStand` (3.2 m) while it flies. Inside `netFrom` you stay at the net; otherwise you + recover `rallyBack` (0.6 m) behind your line. **Nobody approaches a man already at the net** + (`ball.from`, the striker's distance): that ball is a pass. Without this rule, both bodies ended up at + the net at a quarter of a second per shot, which was the old bug in a new place. +- **A net man facing a lob** (the ball will clear his racket height at his depth) turns and runs it down + as a baseliner. Nothing hits a lob yet; it is groundwork for steps 2 and 3. +- **A volley below the tape** is capped at `lowVolleyQ` 0.3: soft, short, little width. This is the + net's cost. + +**Measured after** (`tools/rally-sim.mjs`, same bot): both sides now meet the ball 10.5-13.7 m from the +net, back-court volleys are 0-8%, and a baseline exchange takes 0.8-1.0 s per shot (it was 0.7 s at the +T). Approaches happen off short balls only, mostly the opponent's at the high rungs, because the bot +mistimes. Classic shows the same shape. Suite 150, plus three new tests: the rally is played from the +baselines (back-court volley share, time per shot), short means approach / deep means stay back / never +approach a net man, and depth never goes long. Fuzzed over 8 seeds. + +**What step 1 did NOT fix, and it is the next lever: THE LEGS.** `rzChaseSpeed` at the Climb's rungs is +about 3.4 m/s (1), 5.6 (6), 8.8 (12) and **15.7 m/s (18, faster than a sprinter)**, against the player's +~6.5. Oliver's hunch that "the opponents get very fast" was literally true. Under the new geometry a +late opponent at the net covers every pass, so the bot loses ~95% of the points where the Champion +comes in. That is step 4 (and the passing/lob game of steps 2-3); it is deliberately not mixed into +step 1, so its phone verdict reads one change. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. -0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converges on serve, both at the net, - reflex volleys, won by a wrong-foot. Diagnosed and measured; a four-step plan is awaiting Oliver's go. - See the section of that name just above. Fallback tag `rally-baseline-v360`. +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. Next up: step 3 (the soft ball, approved), step 2 (opponent styles), and step 4 (the legs: + the Champion runs 15.7 m/s). See the section of that name 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 e47d5d8..355a142 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 f12c7bc..b9dbc94 100644 --- a/src/config.js +++ b/src/config.js @@ -74,6 +74,24 @@ export const TUNE = { smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not // send the ball longer — solveShot still lands it on the same spot — it // gets there flatter and sooner, which is what a smash is. + // THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT"). RALLY MODES ONLY + // (classic + every match). Gore's tennis prologue and the duel keep SHOT_DEPTH and HOME. The rally + // ball used to land ON the service line (SHOT_DEPTH.mid 5.4) and both sides met it as early as + // they could, so every point became a volley exchange at the T at ~0.7 s per shot. + rallyMid: 2.8, // m inside the receiver's baseline a plain rally ball aims for (gore: 5.4) + rallyDeep: 1.9, // ...and a forward flick (gore: 3.0). Mistiming only ever SHORTENS these, + // so depth adds no long out + shortK: 3.0, // m of depth a quality-0 ball loses (rolled x0.5..1.5): a mistimed ball + // sits up SHORT, which is the ball a real opponent attacks + shortBall: 4.9, // m from the receiver's baseline past which a landing is SHORT: he runs + // in and takes it on the rise (serves never count) + netFrom: 7.0, // m from the net inside which a body is AT THE NET: it volleys, and it + // recovers to netStand instead of the baseline + netStand: 3.2, // m from the net a net player recovers to + rallyBack: 0.6, // m behind the baseline a baseliner recovers to + rallyBehind: 2.2, // m behind the baseline he will drop back to let a deep ball come down + rallyHit: 1.0, // m: a baseliner meets the ball falling through this height + lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up aimError: 2.4, // m of scatter at quality 0 aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the // ball further OUT (the chosen out-risk keeps its exact shape) but only diff --git a/src/gameplay.js b/src/gameplay.js index 284f63d..19976a2 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -243,8 +243,14 @@ 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); } - const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; - tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; + // 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). + const deep = rallyFeel() && aim.az <= 0; + const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep) + : aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid; + const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8; + tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz; // A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for // depth and nothing else — the gesture had a direction with no consequence behind it. It buys // PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot @@ -315,7 +321,12 @@ function doHit(ch, quality, aim) { } ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; - ball.lastHit = ch.side; ball.bounces = 0; + // ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies). + // Never against a man already at the net — that ball is a PASS, and two volleyers is the old + // reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net. + 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 G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.kills = 0; // fresh shot: multi-kill streak restarts @@ -531,7 +542,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.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.from = Math.abs(sv.z - COURT.netZ); ball.pow = quality; ball.kills = 0; // fresh serve: multi-kill streak restarts ball.praise = null; // ...and any word a spent ball never got to pop dies with it @@ -1346,6 +1357,43 @@ export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.pee export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist) * legLevel(); +// THE COURT HAS ITS LENGTH. How the receiver plays THIS shot, decided once as it leaves the racket +// (every strike clears ball.rx): at the net he volleys; from the back he lets it bounce, and a SHORT +// landing pulls him in to take it on the rise. Latched, so a man running in for a short ball does not +// turn volleyer the moment he crosses netFrom. Rally modes only — the callers gate it. +function rxPlan(ch) { + if (!ball.rx) { + const land = ball.bounces === 0 ? predictLanding() : null; + const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0; + ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom, short: !G.wasServe && depth > TUNE.shortBall }; + } + return ball.rx; +} +// ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape +const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net; +// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back +// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither. +// The one exception is the APPROACH: having taken a short ball, he follows it in. +const rallyHome = ch => ({ x: 0, z: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || ball.approach === ch.side + ? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand) + : ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack }); +// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a +// bounce that never gets that high. A SHORT ball is met on the RISE instead, moving in. Closed form +// off predictLanding, for racketAI (intercept walks the flight for itself). +function bounceContact(short) { + const g = ball.g || TUNE.gravity; + let { x, y, z, vx, vy, vz } = ball; + if (ball.bounces === 0) { + const p = predictLanding(); if (!p) return null; + x = p.x; z = p.z; y = 0; + vy = -(vy - g * p.t) * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; + } + const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex); + const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y))); + const t = Math.max(0, (short && vy > 0 ? vy - r : vy + r) / g); + return { x: x + vx * t, z: z + vz * t }; +} + function intercept(side) { const ch = side === 'O' ? O : P; // G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the @@ -1354,11 +1402,22 @@ function intercept(side) { let { x, y, z, vx, vy, vz } = ball; const g = ball.g || TUNE.gravity, dt = 0.01; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; - const home = HOME[side]; - let fallback = null, deepest = null, anyHit = null; + const home = rallyFeel() ? rallyHome(ch) : HOME[side]; + const plan = rallyFeel() ? rxPlan(ch) : null, back = plan && !plan.net; // a baseliner lets it bounce + let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces; for (let i = 0; i < 300; i++) { vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; - if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } + if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; nb++; } + // FROM THE BACK: meet it between its bounce and the next one — at the top or falling through + // rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is + // up off the grass. Only if nothing like that is reachable do the old candidates below apply. + if (back && nb === 1 && i > 5 && y > 0.45 && y < TUNE.rallyHit + (plan.short ? 0.6 : 0.3)) { + const inB = side === 'P' ? z <= COURT.L + TUNE.rallyBehind : z >= -TUNE.rallyBehind; + if (inB && Math.hypot(x - ch.x, z - ch.z) / spd <= i * dt) { + if (plan.short || vy < 1) return { x, z }; + rise ||= { x, z }; + } + } // THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the // near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the // legal contact height by then) and the point went to the zombie. Seven of seven recorded @@ -1371,12 +1430,12 @@ function intercept(side) { if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; - if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time + if (y > 0.7 && reachable && !back) return pt; // waist-high AND arrivable in time if (reachable && !fallback) fallback = pt; // low but arrivable deepest = pt; // last resort: run at the deepest hittable point } } - return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better + return rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better } // ---------------------------------------------------------- miss diagnostics ---- @@ -1449,6 +1508,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const dx = ball.x - ch.x, dz = ball.z - ch.z; if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable + if (letBounce()) return; // planned from the back: the swing waits for the bounce instead of a weak pick-up // quality: how centered in the window the contact lands const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); @@ -1475,6 +1535,9 @@ 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)); + // 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. + if (rallyFeel() && ball.bounces === 0 && ball.y < COURT.netH) q = Math.min(q, TUNE.lowVolleyQ); if (!isAI && G.mode === 'tennis') { G.swingSnaps = null; G.rallyNear = null; logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 }); @@ -1521,11 +1584,25 @@ function racketAI(rz, dt) { : G.match ? TUNE.aiRead // the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible) : TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill(); - const aimX = recovering ? HOME.O.x : pred ? ball.x + (pred.x - ball.x) * read : ball.x; + // THE COURT HAS ITS LENGTH (match only): from the back he waits BEHIND the bounce for the ball to + // come down — standing ON it made him volley 90-100% of everything from the T — and a short ball + // pulls him in. At the net he holds his depth on the ball's line, unless it will clear his racket: + // that is a lob, and he turns and runs it down like a baseliner. + const plan = G.match && incoming ? rxPlan(rz) : null; + if (plan && plan.net && ball.vz < 0) { + const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity; + if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) plan.net = false; + } + const c = plan && !plan.net ? bounceContact(plan.short) : null; + const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null; + const tgtX = c ? c.x : netX ?? (pred && pred.x); + const home = G.match ? rallyHome(rz) : HOME.O; + const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x; // Read the landing depth immediately in a match, including rising returns. Blending with // the ball's current depth would send every receiver towards the net before it arrives. - const aimZ = recovering ? HOME.O.z : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z; - const tz = Math.max(1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); + const aimZ = recovering ? home.z : c ? c.z : netX !== null ? rz.z + : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z; + const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); 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, @@ -1559,7 +1636,7 @@ function racketAI(rz, dt) { const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 }; if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0 && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach - && ball.y < CONTACT.maxY + && ball.y < CONTACT.maxY && !letBounce() && (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : ball.y > CONTACT.minY)) { // ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away, // which across a game is an opponent that beats itself. @@ -1976,7 +2053,7 @@ export function update(dt) { // who never walked back. const tgt = chasing ? intercept('P') : G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z } - : HOME.P; + : rallyFeel() ? rallyHome(P) : HOME.P; const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it P.x += dx / d * Math.min(rate, d); @@ -2105,7 +2182,7 @@ export function update(dt) { // the flight shortens the earliest-reachable point jumps around its solution — each jump // flips his run direction, faster and faster. He commits to the stored point unless the // fresh solve moves it meaningfully (or the chase resets between shots). - const fresh = incoming ? intercept('O') : HOME.O; + const fresh = incoming ? intercept('O') : rallyFeel() ? rallyHome(O) : HOME.O; if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35) G.aiTgt = fresh; const tgt = G.aiTgt; @@ -2123,7 +2200,7 @@ export function update(dt) { if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) { // same radius trySwing uses for contact, so an armed swing actually connects const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach - && ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0; + && ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0 && !letBounce(); if (near) { // rubber band: every game HE has taken makes him flub more — the opening match must not // be a wall a new player grinds against for rounds (see aiFlubLoss in config). diff --git a/src/net.js b/src/net.js index b1721c9..725d382 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.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; 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; @@ -84,6 +84,7 @@ export function netApplyShot(m) { G.wasServe = !!m.serve; // arms the receiver's own service-box call G.phase = 'rally'; G.swung = false; G.rallyNear = null; O.x = s.x; O.z = s.z; // the strike happened where the ball says it did + ball.from = Math.abs(O.z - COURT.netZ); // ...which is what the receiver's approach rule reads O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0; // ...AND IT MAKES A NOISE (Oliver, 2026-09-11: "der ballsound des gegners wird nicht abgespielt"). // Every other opponent in the game is simulated locally and cracks through gameplay's own strike diff --git a/src/state.js b/src/state.js index 37d4a86..7c29b78 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, collected: false, kills: 0, bomb: false, flame: false, + active: false, lastHit: 'P', bounces: 0, rx: null, approach: null, 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 @@ -205,7 +205,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; + G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; 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 cb3d9c7..571bff7 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); +const { solveShot, update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick, netAgain } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -1030,7 +1030,8 @@ test('match recovery: finish the serve, recover during the outbound rally, hold 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'); + // THE COURT HAS ITS LENGTH: the baseliner's spot is BEHIND the line now (rallyBack), not 2.5 m in + assert.ok(rz.x < 3 && rz.z < -0.5 && rz.z >= -TUNE.rallyBack, 'recovers towards centre, to its spot behind the baseline'); const recoverySpeed = Math.hypot(rz.vx, rz.vz); ball.lastHit = 'P'; ball.vz = -1; update(DT); @@ -1057,6 +1058,113 @@ test('the approaching Champion can still be passed into the open back corner', ( assert.deepEqual(score.pts, [1, 0], 'a legal passing shot beats a Champion stranded at the net'); }); +// ---- THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT") ---------------------- +// Oliver: "Aufschlag, dann schnell vor ans Netz ... ein sehr schneller Ballwechsel" — every match had +// become a volley exchange at the T. Measured: the rally ball landed ON the service line, the +// opponent stood on the bounce and volleyed 90-100% of everything from opponent 6 up, and the +// auto-run chased the earliest contact, ~0.7 s per shot. These pin the shape that replaced it. + +test('THE COURT HAS ITS LENGTH: a match rally is played from the baselines, not volleyed at the T', () => { + // the reachability soak's autopilot, asserting the SHAPE of the rally rather than a win rate + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; G.match.opp = 12; + for (let i = 0; i < 2.2 * 60; i++) update(DT); + const hits = []; + let prev = ball.lastHit; + for (let i = 0; i < 120 * 60; i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; // keep playing: the shape is the subject + 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') { + 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; + 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 }); + } + prev = ball.active ? ball.lastHit : null; + } + const back = hits.filter(h => h.back); + assert.ok(hits.length >= 40, 'a real rally sample: ' + hits.length + ' contacts'); + assert.ok(back.length / hits.length >= 0.6, `most of the rally is played from the back: ${back.length}/${hits.length}`); + const volleys = back.filter(h => h.volley).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) + gaps.push(hits[k].t - hits[k - 1].t); + gaps.sort((a, b) => a - b); + assert.ok(gaps[gaps.length >> 1] >= 0.75, 'a baseline exchange leaves time to play: median ' + + gaps[gaps.length >> 1].toFixed(2) + ' s per shot'); + } finally { TUNE.autoAssist = assist; } +}); + +test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobody approaches a man at the net', () => { + const flub = TUNE.matchFlub; TUNE.matchFlub = 0; + // the opponent's answer to one ball from the player's baseline, landing `depth` m inside its line + const play = (depth, pace, from) => { + freshSplatter(); startMatch(); G.interlude.introT = 0; + const rz = oppOf(); + Object.assign(rz, { x: 0, z: -TUNE.rallyBack, swingT: -1, swingCool: 0, serveHold: 0 }); + Object.assign(P, { x: 0, z: 24, swingT: -1 }); + G.phase = 'rally'; G.wasServe = false; + const v = solveShot(0, 1, 22.5, 0.5, depth, pace); + Object.assign(ball, { active: true, scored: false, lastHit: 'P', bounces: 0, pow: 0.5, rx: null, from, + x: 0, y: 1, z: 22.5, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); + let bounced = null, at = null; + for (let i = 0; i < 4 * 60 && ball.lastHit === 'P' && ball.active; i++) { bounced = ball.bounces; update(DT); } + assert.equal(ball.lastHit, 'O', `depth ${depth}: the opponent returns it`); + at = rz.z; + for (let i = 0; i < 30; i++) update(DT); // its own ball is in the air: where does it go now? + return { bounced, at, then: rz.z, approach: ball.approach }; + }; + try { + const deep = play(2.4, 24, 12); + assert.equal(deep.bounced, 1, 'a deep ball is played after the bounce'); + assert.ok(deep.at < 1, 'from the baseline, not the T: met at z ' + deep.at.toFixed(2)); + assert.equal(deep.approach, null, 'and it is no approach'); + assert.ok(deep.then < 1, 'he stays back: ' + deep.then.toFixed(2)); + const short = play(6.4, 15, 12); + assert.equal(short.approach, 'O', 'a short ball taken is an approach'); + assert.ok(short.at > 2, 'he ran in for it: met at z ' + short.at.toFixed(2)); + assert.ok(short.then > short.at + 0.5, 'and follows it in: ' + short.at.toFixed(2) + ' -> ' + short.then.toFixed(2)); + const pass = play(6.4, 15, 3); + assert.equal(pass.approach, null, 'against a man at the net the same ball is a pass, never an approach'); + } finally { TUNE.matchFlub = flub; } +}); + +test('rally depth: deep by default, mistiming lands it SHORTER, never longer', () => { + // the only out a rally ball may find is wide — depth must never put one past the baseline + startMatch(); G.interlude.introT = 0; + const zb = oppOf(); + const sample = n => { + G.match.opp = n; zb.foe = cfg.matchFoe(n); + const depths = []; + for (let i = 0; i < 400; i++) { + 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, + x: 0, z: 2.4, y: 1.2, vx: 0, vy: 0, vz: -1 }); + update(DT); + if (ball.lastHit !== 'O') continue; + const t = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * ball.g * ball.y)) / ball.g; + depths.push(COURT.L - (ball.z + ball.vz * t)); + ball.active = false; + } + return depths; + }; + const weak = sample(1), strong = sample(18); + // solveShot aims at ty 0.08 (the ball on the grass), so the true bounce is a few dm past the mark + for (const d of [...weak, ...strong]) assert.ok(d >= TUNE.rallyDeep - 0.5, 'never long, never near the line: ' + d.toFixed(2)); + const mean = a => a.reduce((s, x) => s + x, 0) / a.length; + assert.ok(mean(weak) > mean(strong) + 0.4, `weaker timing sits up shorter: ${mean(weak).toFixed(2)} vs ${mean(strong).toFixed(2)} m inside`); + endMatch(); +}); + test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => { startMatch(); G.interlude.introT = 0; const zb = oppOf(); diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index f4cf8ae..54f1729 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -84,7 +84,7 @@ const SECS = Number(process.env.SECS || 240); TUNE.autoAssist = 1.15; 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; }; -const rows = [], land = []; +const rows = [], land = [], pts = []; for (const rung of RUNGS) { restartRun(); prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo(); @@ -98,7 +98,8 @@ for (const rung of RUNGS) { const speeds = []; const hits = []; // {side, z(dist from own baseline), bounced, t} let prevLast = ball.lastHit, prevB = ball.bounces, rally = 0, rallies = [], lastHitT = null, t = 0; - let swipe = null; + let swipe = null, pts0 = [...score.pts], net = { P: false, O: false }; + const won = { P: 0, O: 0, Pnet: 0, PnetWon: 0, Onet: 0, OnetWon: 0 }; for (let i = 0; i < SECS * 60; i++) { G.modal = null; G.modalQ.length = 0; if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; } @@ -124,10 +125,18 @@ for (const rung of RUNGS) { if (ch && !G.wasServe) { const fromNet = Math.abs(ch.z - COURT.netZ); speeds.push({ side: ball.lastHit, v: Math.hypot(ball.vx, ball.vz) }); + if (fromNet < 6.4) net[ball.lastHit] = true; hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y }); } lastHitT = t; rally++; } + const d0 = score.pts[0] - pts0[0], d1 = score.pts[1] - pts0[1]; + if (d0 > 0 || d1 > 0) { + const w = d0 > 0 ? 'P' : 'O'; won[w]++; + for (const sd of ['P', 'O']) if (net[sd]) { won[sd + 'net']++; if (w === sd) won[sd + 'netWon']++; } + } + if (d0 || d1) net = { P: false, O: false }; + pts0 = [...score.pts]; if (G.phase !== 'rally' && rally) { rallies.push(rally); rally = 0; lastHitT = null; } if (ball.active && prevB === 0 && ball.bounces === 1 && !G.wasServe) { const fromBase = ball.lastHit === 'P' ? ball.z : COURT.L - ball.z; @@ -151,6 +160,7 @@ for (const rung of RUNGS) { land.push({ rung, hitter: side, bounces: l.length, medLandFromBaseline: +med(l).toFixed(1), 'deep<3m%': pct(l, x => x < 3), 'svcbox>5.5m%': pct(l, x => x > 5.5), medPace: +med(v).toFixed(1) }); } + pts.push({ rung, Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' }); 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) }); } -console.table(rows); console.table(land); +console.table(rows); console.table(land); console.table(pts);