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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:06:08 +02:00
1 parent 4e7c816a25
commit f9b0dd2efb
6 files changed
+82 -7

No files matched your search

+24
View File
@@ -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.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=361"></script>
<script type="module" src="src/main.js?v=362"></script>
</body>
</html>
+3
View File
@@ -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
+19 -4
View File
@@ -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;
}
}
+1 -1
View File
@@ -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
+34 -1
View File
@@ -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;