diff --git a/PROJECT.md b/PROJECT.md index 491fe19..bf90189 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5627,6 +5627,55 @@ Oliver: *"b"* (options above). Two halves, each useless alone (both were measure the air legitimately), and synthetic returns in the suite now carry `pow: 1` (a stale `pow` from the previous test read as a sitter). +### The player's way to the net and back, and the SMASH (2026-10-14, v374) +Oliver: *"Steuerung ans Netz gehen, wie machen wir das?"* Offered: (1) a long up-flick is the attack and he +follows it in, (2) a separate up-swipe while his own ball flies, (3) automatic after a strong ball (breaks +the one-meaning rule). He picked **(1)**, asked *"wie kommt man wieder zurück?"*, and added *"wenn man einen +Lob vom Gegner als Volley annimmt, soll man derb schmettern"*. +- **The gestures, by LENGTH only** (never context — the 2026-08-22 lob rule): + - up, short (< `attackMin` 90 px): the deep ball, you stay; + - up, long: **the attack**, the deep ball and you follow it in (`ball.approach = 'P'`), even onto a + man at the net — it is his call; + - down, short: the drop, you stay (a drop volley keeps the net); + - down, long: **the lob, and you go back behind it** (`ball.retreat`, `rallyHome`). That is the way back. + It is also automatic when you are lobbed and cannot get under it, and when the point ends. + Keys: up = attack, down = lob, as before. Both of your own runs go at full pace, and while the attack + flies `P.rushing` makes the reply a net man's plan from wherever he has got to (the CPU's rule). +- **The net has a price, as the CPU's does.** + - At the net (or rushing) the player split-steps for `netReact` (0.22 s, `ball.heels`) before he reads + the pass. + - The CPU, facing a net man and not lobbing, plays the PASS at the line away from him (`aim.pass`, + `punishX`, still bounded by the rung's width). + - Measured without both: an attacker won 78-95% of his net points. His volley reach is ~2.5 m and he + read instantly. +- **THE SMASH** (rally modes, player): + - A ball taken OUT OF THE AIR at `smashMinY` 1.9 m or higher, up to `smashMaxY` 2.5 (above + `CONTACT.maxY` 2.3, but under the ~2.8 m a CPU lob clears a net man by). + - Extra pace `smashPace` 0.8 × q on top of the forward flick's. Aimed at the line (`punishX`) on the + flicked side, else away from the body. The opponent reads it for `smashRead` 0.3 s. It pops "SMASH!", + and the log says `HIT q=1 SMASH`. + - Aimed deep down the middle (the first cut), it came back to the baseliner and won ~40%. At the line it + wins ~80%. The ring shows it (`ringCue`'s volley band is the smash band). +- **Under the lob, or not — DECIDED ONCE** (`plan.over`, latched with the plan). When the player at the net + is lobbed, `intercept` looks for the first point the lob comes down into the smash band that he reaches + at `smashRun` 0.25 of his legs. Found: he goes there and smashes. Not found: he turns and runs it down, + and whatever he meets on that run (in the air included) is a capped defensive ball, not a smash. + - Re-asked every frame, his backpedal brought the overhead into reach on the way, and every lob was + smashed. + - Measured (attacker, rungs 7/14/18, 3 seeds): smashRun 0.35 → 131 smashes for 141 lobs; 0.25 → 62 for + 130, won 82%; 0.18 → 49. +- **Measured** (Climb, level 26, σ60, 8 seeds; `ATTACK=0.3` = the bot plays 30% of its baseline balls as + the attack): the base ladder is unchanged within the noise. Attacking is a real choice: + - better against the pushers and touch players (Steward 49 → 60, Mascot 42 → 49, the bottom rungs); + - worse against the big hitters, slicers and rushers (Roller 66 → 57, Seller 43 → 37, Club Pro 53 → 47); + - level against the Champion (33 / 28). + Points get short (4-6 shots). +- Tests (170, nine seeds): the long up-flick follows the ball in and the short one stays; the long + down-flick from the net goes back and a drop volley holds the net; a short lob is smashed hard into the + open court and a deep lob is not (blocked back if reached). +- `tools/rally-sim.mjs`: `ATTACK=0.3`, `NETX=1` (attacks, lobs faced at the net, smashes, smashes won), and + `T_=` sets any TUNE dial for a sweep. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. @@ -5637,7 +5686,9 @@ Nothing here is broken; these are known gaps and unverified risks. opponent styles (v364) followed; the swing-log fixes ran to v371. Step 4: v372 measured why the ladder could not climb (points decided by the player's whiffs); Oliver picked (b), shipped v373 (the follow- through, put-aways, on his heels). Awaiting the phone verdict on v373, especially the half-second - flat-footed read and the net rushers. See "Step 4 (b)" above. Fallback tag `rally-baseline-v360`. + flat-footed read and the net rushers. See "Step 4 (b)" above. + v374 adds the player's own net game: long up-flick = attack and follow in, long down-flick = lob and + go back, and the SMASH. See "The player's way to the net and back" above. Fallback tag `rally-baseline-v360`. 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. diff --git a/index.html b/index.html index 6f7dfa5..5efbaf7 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 9cc60b6..b17f4ae 100644 --- a/src/config.js +++ b/src/config.js @@ -107,6 +107,16 @@ export const TUNE = { softErr: 0.8, // ...and how far toward it at quality 0 lobTurn: 0.6, // leg speed of an opponent lobbed at the net: he turns and backpedals netReact: 0.22, // s an opponent at the net stands reading the pass before he moves + attackMin: 90, // px: an UP-flick this long is the ATTACK — the deep ball and you follow it to the + // net (shorter: the deep ball, you stay). Mirror of dropMax: a long DOWN-flick is + // the lob and takes you back to the baseline. One meaning each, wherever you stand + smashMinY: 1.9, // m: a ball the player takes OUT OF THE AIR at least this high is a SMASH... + smashMaxY: 2.5, // ...up to this high (above CONTACT.maxY: the overhead reaches higher than a volley — + // but under the 2.8 m the opponent's lob clears a net man by, or he smashes it standing) + smashPace: 0.8, // ...extra pace on top of the forward flick's, scaled by timing + smashRead: 0.3, + smashRun: 0.25, // share of his legs a lobbed net man may need to get UNDER the lob for the smash — + // a short lob is smashed, a deep one is not (he turns and runs it down, lobTurn) // s the opponent stands reading a smash before he moves softRead: 0.35, // s any opponent stands reading a soft ball (drop, lob) — they are disguised 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 diff --git a/src/gameplay.js b/src/gameplay.js index ed2b5d3..bd4f48b 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -270,7 +270,13 @@ function doHit(ch, quality, aim) { } // a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it, // so the bottom of the ladder cannot paint lines even when it punishes) - if (aim && aim.kill) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3; + if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3; + // ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else + // away from the body across the net. Down the middle, deep, it came straight back to the baseliner + if (ch.side === 'P' && aim && aim.smash) { + const foe = G.match ? zombies.find(z => z.variant === 'r') : O; + tx = (sway ? Math.sign(sway) : foe && foe.x > 0 ? -1 : 1) * TUNE.punishX + err() * 0.3; + } // THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a // mistimed ball sits up short, which is the ball a real opponent attacks — never length, so // depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next). @@ -304,6 +310,8 @@ function doHit(ch, quality, aim) { // still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather // than longer. A smash you can hit out on a whim would just be the bug above wearing a hat. if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality; + // ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard + if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality; aimedLane = true; } tx = Math.max(-5, Math.min(5, tx)); @@ -385,9 +393,20 @@ function doHit(ch, quality, aim) { // 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; + // THE PLAYER'S OWN WAY IN AND OUT (2026-10-14, Oliver: "Steuerung ans Netz gehen"). A LONG up-flick + // is the attack: the deep ball, and he follows it in — even onto a man at the net, it is his call. + // A long DOWN-flick is the lob, and he goes back behind it. Gesture length only, never context: the + // 2026-08-22 lob taught that a gesture whose meaning depends on where the opponent stands is noise. + const longFlick = aim && (aim.mag === undefined || aim.mag >= TUNE.attackMin); + if (ch.side === 'P' && rallyFeel() && aim && aim.az < 0 && !aim.smash && longFlick) ball.approach = 'P'; + ball.retreat = ch.side === 'P' && rallyFeel() && soft !== null && soft >= 1 ? 'P' : null; + ball.smash = !!(aim && aim.smash); ball.from = Math.abs(ch.z - COURT.netZ); ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run) + // ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's + // net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable + if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact); // THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it — // was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point. if (ch.side !== 'P' && swingLog()) { @@ -617,7 +636,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp); ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; - ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.spin = 1; ball.soft = false; + ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; ball.spin = 1; ball.soft = false; // a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ); ball.pow = quality; @@ -1465,7 +1484,7 @@ const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && // ...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 +const rallyHome = ch => ({ x: 0, z: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || 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 @@ -1502,7 +1521,7 @@ function intercept(side) { // a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule) if (plan && plan.net && (ball.z - ch.z) * vz < 0) { const tc = (ch.z - z) / vz; - if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; } + if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; } } const back = plan && !plan.net; // a baseliner lets it bounce // ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at @@ -1510,9 +1529,18 @@ function intercept(side) { // the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s) const xl = side === 'P' ? xLimit() : 99; let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces; + // ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down + // into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he + // cannot get under is run down after its bounce, as before, and his reply is capped. + // DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his + // backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed + if (side === 'P' && plan && plan.over) return plan.over; + const smashing = side === 'P' && plan && plan.lob && plan.over === undefined; 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 * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); } + if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl + && Math.hypot(x - ch.x, z - ch.z) / (spd * TUNE.smashRun) <= i * dt) return (plan.over = { x, z }); // 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. @@ -1542,6 +1570,7 @@ function intercept(side) { deepest = pt; // last resort: run at the deepest hittable point } } + if (smashing) plan.over = null; // no overhead in reach: he runs it down const to = rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better // a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z } : to; @@ -1566,7 +1595,7 @@ export function ringCue() { for (let t = 0; t < 1.6; t += dt) { const volley = b === 0; const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); - const legal = y <= CONTACT.maxY && (y >= CONTACT.minY || vy > 0) + const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0) && !(volley && y < COURT.netH && ball.rx && !ball.rx.net); const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0; const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k; @@ -1693,7 +1722,12 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P'; if (ball.lastHit === ch.side && !own) return; const dx = ball.x - ch.x, dz = ball.z - ch.z; - if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; + // THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes + const overhead = !isAI && volley && rallyFeel(); + if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return; + // ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read + // it): that one is met on the run, a defensive block like every run-down lob, however high + const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over); 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 // planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while // the bounce will still land inside this window. If it will not, this is the last chance and the @@ -1739,7 +1773,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { ? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1)) : [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } - : flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; + : flick ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash) // THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop // when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally // ball it always hit, carrying its spin. One roll, so the shares are exactly the table's. @@ -1748,6 +1782,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random(); const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5; if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; } + else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; } else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; } else aim.spin = st.spin; @@ -1767,8 +1802,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ); // ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply. // That is the lob's real reward — rarely the winner itself, nearly always the point. - if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ); - if (kill && q >= TUNE.punishQ) aim.kill = true; // a put-away only if no cap took it down (a low volley is a block) + if (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it + if (kill && q >= TUNE.punishQ) aim.kill = true; + if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // a put-away only if no cap took it down (a low volley is a block) if (!isAI && swingLog()) { G.swingSnaps = null; G.rallyNear = null; const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop') @@ -1779,7 +1815,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // ...and WHICH WAY the timing missed: contact before the window's centre means the ball was // already there — the swing came LATE; after it, the swing came EARLY const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`; - logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, + logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); } doHit(ch, q, aim); @@ -1840,7 +1876,8 @@ function racketAI(rz, dt) { if (G.match) G.aiReactT += dt; // ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing, // which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched. - const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead); + const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead + || ball.smash && G.aiReactT < TUNE.smashRead); 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); @@ -2301,7 +2338,11 @@ export function update(dt) { const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it // ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn) - let rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); + // HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while + // his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to + if (ball.lastHit === 'P') P.rushing = ball.approach === 'P' && !G.wasServe; + const ownRun = !chasing && rallyFeel() && ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P'); + let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run // started on the strike's frame with a perfect read and covered the whole court — no put-away ever // won a point, and the CPU still had no way to take one diff --git a/src/net.js b/src/net.js index b9ff89b..ca165c5 100644 --- a/src/net.js +++ b/src/net.js @@ -75,7 +75,7 @@ export function netSendShot() { // the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here export function netApplyShot(m) { const s = mirror(m); - ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null; + ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.heels = 0; ball.approach = null; ball.retreat = null; ball.smash = false; ball.x = s.x; ball.y = m.y; ball.z = s.z; ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz; ball.g = m.g; ball.pow = m.pow; ball.spin = m.spin || 1; // the soft ball's backspin: both phones bounce it alike diff --git a/src/state.js b/src/state.js index 4a7ebe4..7d8e2dc 100644 --- a/src/state.js +++ b/src/state.js @@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, - active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false, + active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, retreat: null, smash: false, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false, // NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the // striker's phone that is mid-roll, a whole court away from the call, and it read as the game // loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring @@ -206,7 +206,7 @@ export const other = s => s === 'P' ? 'O' : 'P'; export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export function setupPoint() { - G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null; + G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; ball.approach = null; ball.retreat = null; ball.smash = false; G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing // (the MATCH falls through to the tennis branch below: diff --git a/test.js b/test.js index 9efa822..eef3c90 100644 --- a/test.js +++ b/test.js @@ -1486,6 +1486,79 @@ test('LOBBED at the net, the player backpedals too, and only blocks it back', () } finally { TUNE.autoAssist = assist; } }); +// ---- THE PLAYER'S WAY TO THE NET AND BACK, AND THE SMASH (2026-10-14, Oliver: "Steuerung ans Netz gehen" +// ... "wenn man einen Lob vom Gegner als Volley annimmt, soll man derb schmettern") --------------------- +// One player shot with a given swipe from (x 0, z pz), then `secs` of flight with the opponent parked out +// of play. Returns the shot's flags and where the player's own run took him. +function ownShot(swipe, pz, secs = 1.2) { + freshSplatter(); startMatch(); G.interlude.introT = 0; + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + const rz = oppOf(); + Object.assign(rz, { x: 3, z: -0.6, swingCool: 999, swingT: -1 }); + G.phase = 'rally'; G.wasServe = false; + Object.assign(P, { x: 0, z: pz, swingT: TUNE.swingWindup + TUNE.swingWindow * winLevelNow() / 2 - DT }); + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 1, rx: { net: false, short: false }, + spin: 1, soft: false, x: 0.4, y: 1.0, z: pz - 0.4, vx: 0, vy: -0.5, vz: 3, g: TUNE.gravity }); + G.swipe = swipe; + update(DT); G.swipe = null; + assert.equal(ball.lastHit, 'P', 'the swing connects'); + const out = { approach: ball.approach, retreat: ball.retreat }; + for (let i = 0; i < secs * 60 && ball.active; i++) { rz.swingCool = 999; update(DT); } + out.fromNet = Math.abs(P.z - COURT.netZ); + return out; + } finally { TUNE.autoAssist = assist; } +} + +test('THE ATTACK: a long up-flick follows the ball to the net, a short one stays back', () => { + const up = mag => ({ ax: 0, az: -1, fx: 0, fy: -1, mag }); + const attack = ownShot(up(TUNE.attackMin + 30), 24), deep = ownShot(up(TUNE.attackMin - 30), 24); + assert.equal(attack.approach, 'P', 'the long up-flick is the attack'); + assert.ok(attack.fromNet < TUNE.netFrom, `...and he is at the net by the time it gets there: ${attack.fromNet.toFixed(1)} m`); + assert.equal(deep.approach, null, 'the short one is the deep ball'); + assert.ok(deep.fromNet > TUNE.netFrom + 3, `...and he stays back: ${deep.fromNet.toFixed(1)} m`); +}); + +test('THE WAY BACK: a long down-flick from the net is the lob, and he goes back behind it; a drop keeps the net', () => { + const down = mag => ({ ax: 0, az: 1, fx: 0, fy: 1, mag }); + const lob = ownShot(down(TUNE.flickFull + 20), COURT.netZ + TUNE.netStand, 2); + const drop = ownShot(down(TUNE.dropMax - 30), COURT.netZ + TUNE.netStand, 1); + assert.equal(lob.retreat, 'P', 'the lob is the way back'); + assert.ok(lob.fromNet > TUNE.netFrom, `...and he is back behind the net zone: ${lob.fromNet.toFixed(1)} m`); + assert.equal(drop.retreat, null, 'a drop volley is not a retreat'); + assert.ok(drop.fromNet < TUNE.netFrom, `...he holds the net: ${drop.fromNet.toFixed(1)} m`); +}); + +test('THE SMASH: a lob taken out of the air is struck hard to the open court; a deep lob still beats him', () => { + // the opponent's lob from its baseline, at the player standing at the net; a gold-ring tap, as a player would + const lobAt = (tz, speed) => { + freshSplatter(); startMatch(); G.interlude.introT = 0; + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + const zb = oppOf(); Object.assign(zb, { x: 2, z: 0.5, swingCool: 999 }); + G.phase = 'rally'; G.wasServe = false; + Object.assign(P, { x: 0, z: COURT.netZ + TUNE.netStand, swingT: -1 }); + const v = solveShot(0, 1, 2, 0, tz, speed); + Object.assign(ball, { active: true, scored: false, lastHit: 'O', bounces: 0, rx: null, spin: 1, soft: true, pow: 0.8, + heels: 0, x: 0, y: 1, z: 2, vx: v.vx, vy: v.vy, vz: v.vz, g: TUNE.gravity }); + let tapped = false; + for (let i = 0; i < 4 * 60 && ball.lastHit === 'O' && ball.active; i++) { + zb.swingCool = 999; + if (!tapped && ringCue()?.gold) { tapped = true; keyPressed('Space'); } + update(DT); + } + return { hit: ball.lastHit === 'P', smash: ball.smash, q: ball.pow, pace: Math.hypot(ball.vx, ball.vz), + land: ball.lastHit === 'P' ? predictLanding() : null }; + } finally { TUNE.autoAssist = assist; } + }; + const short = lobAt(COURT.L - 6, 9), deep = lobAt(COURT.L - 1.5, 9); + assert.ok(short.hit && short.smash, 'a short lob is smashed'); + assert.ok(short.q > 0.6 && short.pace > 30, `...hard: q ${short.q.toFixed(2)}, ${short.pace.toFixed(1)} m/s`); + assert.ok(Math.abs(short.land.x) > 2.9, `...into the open court: lands at x ${short.land.x.toFixed(1)}`); + assert.ok(!deep.smash, 'a deep lob is not smashed — he turns and runs it down'); + if (deep.hit) assert.ok(deep.q <= TUNE.lowVolleyQ + 1e-9, 'and only blocks it back'); +}); + test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and why the point went', () => { // Oliver, 2026-10-14: "bitte bau nochmal die Funktion ein die protokolliert warum ich einen Ball nicht // bekommen habe". It was gated on G.mode === 'tennis' and the match runs as 'splatter' — the Climb diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index 17ca762..6ef7875 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -5,6 +5,7 @@ // shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT". // Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1, // JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate. +// ATTACK=0.3 (the long up-flick share), NETX=1 (net/smash tally), KILL=1 (put-aways), T_= sets TUNE. // ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands. let _seed = (Number(process.env.SEED) || 20260811) >>> 0; Math.random = () => { // xorshift32 — small, fast, good enough for jitter @@ -91,8 +92,10 @@ const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[M // 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when // the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks. const HUMAN = (process.env.BOT || 'human') === 'human'; +const ATTACK = Number(process.env.ATTACK || 0); // share of baseline shots played as the attack (long up-flick) const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7; const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); +const netx = { attacks: 0, smash: 0, smashWon: 0, lobbed: 0, open: null, p0: 0 }; const probe = [], kills = []; let kOpen = null, kpow = 1; const bot = { armed: false, fire: -1, jit: null, swipe: null }; const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n; @@ -103,6 +106,7 @@ function humanAim() { const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop + if (ATTACK && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && r < ATTACK) return sw(away * 0.3, -1, 130); // the attack: follow it in if (r < 0.45) return sw(away, -0.2, 110); // to the open side if (r < 0.65) return sw(0, -1, 80); // deep through the middle return null; // a plain tap @@ -171,6 +175,10 @@ for (const rung of RUNGS) { const lh = ball.lastHit; if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces; update(DT); + if (lh !== 'P' && ball.lastHit === 'P' && ball.active) { if (ball.approach === 'P' && !G.wasServe) netx.attacks++; if (ball.smash) { netx.smash++; netx.open = 'smash'; } } + if (lh === 'P' && ball.lastHit === 'O' && ball.active && ball.soft && Math.abs(P.z - COURT.netZ) < TUNE.netFrom) netx.lobbed++; + if (netx.open && (G.phase !== 'rally' || !ball.active)) { const d0 = score.pts[0] - netx.p0; if (d0 > 0) netx.smashWon++; netx.open = null; } + if (netx.open === null) netx.p0 = score.pts[0]; if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; } if (ball.lastHit !== 'O') G.swipe = null; // KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went, @@ -232,6 +240,7 @@ for (const rung of RUNGS) { rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) }); } if (!process.env.SUMMARY) { console.table(rows); console.table(land); } +if (process.env.NETX) console.log(JSON.stringify(netx)); if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); } if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); } if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }