From f9b0dd2efb1502aafd047da3b863ce75114e2379 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 14 Oct 2026 11:06:08 +0200 Subject: [PATCH] Time a tap from touch-down, and give the opponents' legs a knee Oliver after step 1: much harder, stuck at the Steward, "trotz gutem Timing nicht mehr bekommen". A tap only proves it is a tap when the finger lifts, so the swing started 100-150 ms after he meant it; the old volley window hid that, step 1's groundstrokes did not. A tap made on the gold ring fell from q 0.82 to 0.09 (and a third were missed at 150 ms), murky balls landed short and the opponent came in. The swing clock now starts at the touch's age (rally modes only); flicks keep their crossing. The opponents' legs had no ceiling: 7.3 m/s at the Runner, 15.7 at the Champion against the player's ~6.2. Past legKnee (6 m/s) only 15% of the growth survives, monotone, so the ladder order holds; champion ~7.5. v362. Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 24 ++++++++++++++++++++++++ index.html | 2 +- src/config.js | 3 +++ src/gameplay.js | 23 +++++++++++++++++++---- src/input.js | 2 +- test.js | 35 ++++++++++++++++++++++++++++++++++- 6 files changed, 82 insertions(+), 7 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 1a9aae5..74f4c8e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5262,6 +5262,30 @@ late opponent at the net covers every pass, so the bot loses ~95% of the points 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. +### Step 1's phone verdict, and the two fixes it surfaced (2026-10-14, v362) +Oliver: *"schritt 1 ist schon gut ... Das Spiel ist momentan wesentlich schwerer geworden (not a bad +thing) — ich komme als geübter Spieler über the Climb vielleicht höchstens bis zum Steward. das liegt vor +allem gefühlt an der Geschwindigkeit und manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu +bekommen."* Two real defects, measured, and neither of them was a balance value: +- **A TAP SWUNG WHEN THE FINGER LIFTED.** A touch only proves it is a tap at touchend (it could still + become a flick), so the swing started 100-150 ms after the player meant it. The volley window used to + absorb that (x1.8 window, x1.4 reach). Step 1 made the rally groundstrokes, and the lag became visible. + Ring-tapping bot at opponent 6, finger down exactly on gold: + 0 ms of tap = 100% returned at q 0.82, 100 ms = 95% at **q 0.09**, 150 ms = **40%** returned. + Murky returns land short, a short ball pulls the opponent in, and his legs then made him unpassable: a + cascade, felt as "speed". **Fix:** input.js hands the touch's age to `keyPressed('Space', lead)`, and + the rally swing clock starts at `lead` (rally modes only). Timing is judged from touch-down; the ball + still has to arrive inside the window. Flicks keep their crossing-fired swing (the left-flick saga). + After the fix: 100 ms taps 99-100% at q 0.96. Verified with real touch events in the pane (a 120 ms + tap starts the swing at 0.122 s). Test: a gold-ring tap with 120 ms on top returns >= 90% at q >= 0.6 + (it fails at q 0.08 without the fix). +- **THE LEGS GET A KNEE** (step 4, first cut). `gameplay.knee`: up to `legKnee` (6.0 m/s) nothing + changes; past it only `legSoft` (15%) of the growth survives. Runner 7.3 -> 6.2, Umpire 8.3 -> 6.3, + Coach 10.6 -> 6.7, Champion 15.7 -> ~7.5. The player is ~6.2 at level 1 and gets more with level and + peer gifts. The mapping is monotone, so the ladder order and the like-for-like speed tests hold. Only + the rig guard (`> 7 m/s`) moved, to `> legKnee*0.8`. Rungs 1-7 are unchanged: the Steward (6) already + ran 5.6. Ball PACE is untouched; it is at full pace times the league tier from rung 5. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 355a142..6a34c97 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 b9dbc94..015cbf4 100644 --- a/src/config.js +++ b/src/config.js @@ -578,6 +578,9 @@ export const TUNE = { // the peer gift; reaching parity earns all of it. Never a penalty. peerRun: 0.08, // parity adds 8% legs on top of the permanent level bonus peerWindow: 0.12, // ...and 12% contact window. Skill still wins; grinding opens the margin + legKnee: 6.0, // m/s past which an opponent's legs stop growing at full rate (gameplay.knee) — + // the player's auto-run is ~6.2. Was uncapped: the champion ran 15.7 m/s + legSoft: 0.15, // ...and the share of the growth past the knee that survives (champion ~7.5) matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against // the broken recovery (the player never walked back to centre, so every // wide return looked like opponent strength) — with real footwork restored diff --git a/src/gameplay.js b/src/gameplay.js index 19976a2..ff75075 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1198,7 +1198,15 @@ const matchStep = () => { const n = G.match.opp - 1; return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0); }; -const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace() +// THE LEGS HAVE A KNEE (2026-10-14). The ladder multiplied speed rung on rung with nothing on top: +// 7.3 m/s at the RUNNER, 10.6 at the HEAD COACH, 15.7 at the CHAMPION — a sprinter's top speed and +// 2.5x the player's ~6.2. Under step 1's geometry a late opponent at the net then covered every pass. +// Up to legKnee nothing changes; past it only legSoft of the growth survives, so the order of the +// ladder (and every like-for-like speed test) holds while the champion lands near ~7.5. Wrong-footing, +// the read, placement and consistency are what climb past it now. +const knee = v => v <= TUNE.legKnee ? v : TUNE.legKnee + (v - TUNE.legKnee) * TUNE.legSoft; +const rzChaseSpeed = rz => knee(rzRawSpeed(rz)); +const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace() * (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1 + TUNE.duelSpeedMax * duelSkill()) * (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1) @@ -1750,7 +1758,7 @@ function meleeCooldown() { TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); } -export function keyPressed(code) { +export function keyPressed(code, lead = 0) { // THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no // ball and no scoring, so every other meaning a key has here is meaningless inside it. if (G.bench) return benchKey(code); @@ -1840,8 +1848,15 @@ export function keyPressed(code) { return; } if (G.phase === 'rally' && P.swingT < 0) { - recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK() }); - P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; + recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK(), lead }); + // A TAP COUNTS FROM THE MOMENT THE FINGER LANDS (rally modes). A tap is only known to BE a tap + // when the finger lifts — it could still turn into a flick — so it used to swing ~100-150 ms + // after the player meant it. Inside step 1's groundstroke window that collapsed the contact + // quality of a tap made exactly on the gold ring from 0.82 to 0.09 (Oliver, 2026-10-14: "trotz + // gutem Timing nicht mehr bekommen"). input.js hands the touch's age in as `lead`, and the swing + // clock starts that far along: the timing is judged from touch-down, the ball still has to be + // there for the contact. + P.swingT = rallyFeel() ? lead : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; } } diff --git a/src/input.js b/src/input.js index 09316a8..eeb89f6 100644 --- a/src/input.js +++ b/src/input.js @@ -223,7 +223,7 @@ const endTouch = e => { // (a bare hold used to fire at a free auto-target; that was the whole exploit) if (aim && dist >= TUNE.pullMin) dragServe(aim); } - else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack + else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space', dt / 1000); } // tap — dead if it was a near-miss at the rack; timed from touch-down (keyPressed) else if (!stick.adopted && dist > TUNE.flickMin && (stick.swung || dt < FLICK_MS || (rallyFeel() && G.phase === 'rally'))) { // flick = aimed swing. The FLICK_MS deadline only still applies where the swing could NOT diff --git a/test.js b/test.js index 571bff7..ce60b70 100644 --- a/test.js +++ b/test.js @@ -920,7 +920,8 @@ test('league 5 has no speed dip below league 4, including serve and wrong-footed const fourth = cfg.leagueOf(4).band, fifth = cfg.leagueOf(5).band; let floor = 0; for (let n = fourth[0]; n <= fourth[1]; n++) floor = Math.max(floor, measure(n, serve, surprised)); - assert.ok(floor > 7, 'the rig measures a real full-speed chase'); + // since the legs have a knee (gameplay.knee) the late ladder runs at ~6-7.5 m/s, not 7-15 + assert.ok(floor > TUNE.legKnee * 0.8, 'the rig measures a real full-speed chase: ' + floor.toFixed(2)); for (let n = fifth[0]; n <= fifth[1]; n++) { const speed = measure(n, serve, surprised); assert.ok(speed >= floor, `${cfg.matchFoe(n).short}: ${speed} must clear ${floor} (serve ${serve}, surprised ${surprised})`); @@ -1136,6 +1137,38 @@ test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobo } finally { TUNE.matchFlub = flub; } }); +test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => { + // Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen". + // A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the + // groundstroke window that took a tap made exactly on the gold ring from q 0.82 to 0.09, and at + // 150 ms a third of the balls were simply missed. keyPressed's `lead` starts the swing clock at + // touch-down; this is the ring-tapping player, with a real finger's 120 ms of tap on top. + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; G.match.opp = 6; + for (let i = 0; i < 2.2 * 60; i++) update(DT); + const LAG = 7; // frames: ~120 ms between the finger landing and it lifting + let fire = -1, armed = false, incoming = 0, returned = 0, sumQ = 0; + for (let i = 0; i < 90 * 60; i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; + 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' && ball.vz > 0 && !armed) { + const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); + if (Math.abs(d - TUNE.matchRingSweet) < TUNE.matchRingBand) { armed = true; incoming++; fire = i + LAG; } + } + if (i === fire) keyPressed('Space', LAG / 60); // the lift: the touch's age rides along + const before = ball.lastHit; + update(DT); + if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; } + else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; + } + assert.ok(incoming >= 30, 'a real sample: ' + incoming); + assert.ok(returned / incoming >= 0.9, `a gold-ring tap returns the ball: ${returned}/${incoming}`); + assert.ok(sumQ / returned >= 0.6, 'and returns it cleanly: mean q ' + (sumQ / returned).toFixed(2)); + } finally { TUNE.autoAssist = assist; } +}); + 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;