Pace was the missing dial, and the reachability test finally exists

"there are still balls which are basically not reachable with auto run..
please recheck" — and, asked twice: "did you write tests for the last issues
we encountered?" The straight answer: no. The unreachable-balls issue only
ever had config assertions, which is why it came back twice.

Pace is the ladder's fourth dial. Width alone never fixed reachability
because the returns left at ~27 m/s, crossing to the baseline in ~0.6s — less
than auto-run needs from the far corner. matchOppPace 0.78 puts opponent one
at ~20 m/s (measured live: twelve returns, 20.0-20.7), a quarter more flight
time, and matchOppPacePer hands it back per rung.

The vignette deepens: dmg x 0.8, cap 0.85, and the clean centre shrinks with
damage instead of only the rim darkening.

And the reachability regression the issue always deserved: a headless 90s
autopilot soak asserting at least 10 returns sampled and at most 15% that
never came within volley reach. Sabotage-verified. Its honest limit is
recorded in PROJECT.md: the autopilot attends perfectly and taps
frame-precisely, so it out-covers a human — it is a floor against
catastrophic regressions, not a proxy for the thumb. Oliver's playtest stays
the real gate.

46 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 23:18:33 +02:00
1 parent 8ccf0a8fad
commit fdbadce94d
6 files changed
+73 -4

No files matched your search

+3
View File
@@ -394,6 +394,9 @@ export const TUNE = {
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
// returns, which across a game is an opponent that beats itself
matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat
matchOppPace: 0.78, // scale on the opponent's return SPEED for opponent one (~21 m/s instead
// of ~27) — the flight time auto-run needs. THE reachability dial
matchOppPacePer: 0.05,// ...climbing back per rung, capped at full pace
matchWideBase: 2.1, // m either side of centre the FIRST opponent's returns may land — the width an
// auto-run player can actually cover. Its scatter painted lines from game one
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
+7
View File
@@ -146,6 +146,13 @@ function doHit(ch, quality, aim) {
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
}
tx = Math.max(-5, Math.min(5, tx));
if (G.match && ch.side !== 'P') {
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
// time than auto-run needs from the far corner. Opponent one gives you the flight time; the
// rungs take it back.
s *= Math.min(1, TUNE.matchOppPace + (G.match.opp - 1) * TUNE.matchOppPacePer);
}
if (G.match) {
// THE OPPONENT'S WIDTH IS THE LADDER'S THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
// quality), which paints lines an auto-run player cannot reach — "still a LOT of unreachable
+6 -3
View File
@@ -1971,9 +1971,12 @@ export function render() {
const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1);
const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0;
if (dmg > 0 || pulse > 0) {
const a = Math.min(0.72, dmg * 0.5 + pulse);
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * (0.52 - dmg * 0.14),
W / 2, H / 2, Math.max(W, H) * 0.74);
// dmg * 0.8: at half health the frame is unmistakably bloodied, at 1 hp it closes in hard
// ("the vignette can get more pronounced") — the inner radius tightens with damage too, so
// deep wounds shrink the clean centre instead of only darkening the rim
const a = Math.min(0.85, dmg * 0.8 + pulse);
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * (0.5 - dmg * 0.22),
W / 2, H / 2, Math.max(W, H) * 0.7);
g.addColorStop(0, 'rgba(120,8,12,0)');
g.addColorStop(1, `rgba(120,8,12,${a.toFixed(3)})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);