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" → both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE"
against "REMATCH — THEIR SERVE". 146 tests. 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 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 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 4. **Retune the escalation** against the new shape, with `tools/rally-sim.mjs` and the step-1 test as the
gauge. 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 # OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks. 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, 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. Diagnosed and measured; a four-step plan is awaiting Oliver's go. reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
See the section of that name just above. Fallback tag `rally-baseline-v360`. 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 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. 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>
</div> </div>
<span id="recLamp" hidden>REC</span> <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> </body>
</html> </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 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 // send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is. // 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 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 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 // 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)); const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound); 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; // THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; // 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 // 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 // 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 // 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.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; 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 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.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts 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); perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; 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.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts 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 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) export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist)
* legLevel(); * 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) { function intercept(side) {
const ch = side === 'O' ? O : P; const ch = side === 'O' ? O : P;
// G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the // 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; let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01; const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = HOME[side]; const home = rallyFeel() ? rallyHome(ch) : HOME[side];
let fallback = null, deepest = null, anyHit = null; 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++) { for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; 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 // 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 // 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 // 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) { 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 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; 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 if (reachable && !fallback) fallback = pt; // low but arrivable
deepest = pt; // last resort: run at the deepest hittable point 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 ---- // ---------------------------------------------------------- miss diagnostics ----
@@ -1449,6 +1508,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const dx = ball.x - ch.x, dz = ball.z - ch.z; 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 (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 (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 // quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2; const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (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. // 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; 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 (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') { if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null; 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 }); 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 : G.match ? TUNE.aiRead
// the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible) // the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible)
: TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill(); : 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 // 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. // 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 aimZ = recovering ? home.z : c ? c.z : netX !== null ? rz.z
const tz = Math.max(1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ)); : 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); const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) { if (d > 0.15) {
// THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard, // 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 }; const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 };
if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0 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 && 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)) { && (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, // ...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. // which across a game is an opponent that beats itself.
@@ -1976,7 +2053,7 @@ export function update(dt) {
// who never walked back. // who never walked back.
const tgt = chasing ? intercept('P') const tgt = chasing ? intercept('P')
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z } : 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); 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 if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d); 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 // 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 // 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). // 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) if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35)
G.aiTgt = fresh; G.aiTgt = fresh;
const tgt = G.aiTgt; const tgt = G.aiTgt;
@@ -2123,7 +2200,7 @@ export function update(dt) {
if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) { if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) {
// same radius trySwing uses for contact, so an armed swing actually connects // 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 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) { if (near) {
// rubber band: every game HE has taken makes him flub more — the opening match must not // 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). // 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 // the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
export function netApplyShot(m) { export function netApplyShot(m) {
const s = mirror(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.x = s.x; ball.y = m.y; ball.z = s.z;
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz; ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
ball.g = m.g; ball.pow = m.pow; 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.wasServe = !!m.serve; // arms the receiver's own service-box call
G.phase = 'rally'; G.swung = false; G.rallyNear = null; 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 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; 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"). // ...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 // 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 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 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, 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 // 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 // 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 // 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 const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() { 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; G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below: // (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) ---------------------------------------------- // ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js'); const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state; 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 { 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'); 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 // 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'); assert.deepEqual([rz.x, rz.z], [3, -0.5], 'serve follow-through holds its mark');
rz.serveHold = 0; rz.serveHold = 0;
update(DT); 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); const recoverySpeed = Math.hypot(rz.vx, rz.vz);
ball.lastHit = 'P'; ball.vz = -1; ball.lastHit = 'P'; ball.vz = -1;
update(DT); 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'); 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', () => { test('The Climb opens briskly, converges at opponent eight, and leaves cup and training intact', () => {
startMatch(); G.interlude.introT = 0; startMatch(); G.interlude.introT = 0;
const zb = oppOf(); const zb = oppOf();
+13 -3
View File
@@ -84,7 +84,7 @@ const SECS = Number(process.env.SECS || 240);
TUNE.autoAssist = 1.15; TUNE.autoAssist = 1.15;
const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0; 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 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) { for (const rung of RUNGS) {
restartRun(); restartRun();
prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo(); 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 speeds = [];
const hits = []; // {side, z(dist from own baseline), bounced, t} 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 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++) { for (let i = 0; i < SECS * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.modal = null; G.modalQ.length = 0;
if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; } if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; }
@@ -124,10 +125,18 @@ for (const rung of RUNGS) {
if (ch && !G.wasServe) { if (ch && !G.wasServe) {
const fromNet = Math.abs(ch.z - COURT.netZ); const fromNet = Math.abs(ch.z - COURT.netZ);
speeds.push({ side: ball.lastHit, v: Math.hypot(ball.vx, ball.vz) }); 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 }); hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y });
} }
lastHitT = t; rally++; 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 (G.phase !== 'rally' && rally) { rallies.push(rally); rally = 0; lastHitT = null; }
if (ball.active && prevB === 0 && ball.bounces === 1 && !G.wasServe) { if (ball.active && prevB === 0 && ball.bounces === 1 && !G.wasServe) {
const fromBase = ball.lastHit === 'P' ? ball.z : COURT.L - ball.z; 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), 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) }); '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) }); 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);