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:
1 parent
f33d46366a
commit
4e7c816a25
8 files changed
+292
-29
No files matched your search
+53
-4
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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);
|
||||
Reference in new issue
Block a user