Give the rally its length back: deep balls, baseliners, approaches off short balls

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-13 12:55:05 +02:00
1 parent f33d46366a
commit 4e7c816a25
8 files changed
+292 -29

No files matched your search

+53 -4
View File
@@ -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.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=360"></script>
<script type="module" src="src/main.js?v=361"></script>
</body>
</html>
+18
View File
@@ -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
+93 -16
View File
@@ -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).
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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:
+110 -2
View File
@@ -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();
+13 -3
View File
@@ -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);