Diagnose why every rally ends in a net duel
Oliver: every match converges on serve, both at the net, reflex volleys, won by a wrong-foot. Measured headless: rally balls land on the service line by definition, the match CPU stands on the bounce point and volleys 90-100% of everything from opponent 6 up, and the auto-run always picks the earliest contact. Diagnosis, research and a four-step plan go into PROJECT.md (awaiting his go); tools/rally-sim.mjs is the gauge. Fallback tag rally-baseline-v360 marks the shipped feel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
806a739c6f
commit
f33d46366a
2 files changed
+247
No files matched your search
+91
@@ -5127,10 +5127,101 @@ 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)
|
||||
|
||||
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
|
||||
Täuschung des Gegners schließlich ein Punkt erzielt werden kann ... so läuft kein echtes Tennismatch
|
||||
ab."* He suspected the late opponents' speed. Both modes, classic and zombie tennis. **Fallback tag
|
||||
before any change: `rally-baseline-v360`** (806a739, pushed).
|
||||
|
||||
**Measured** with `node tools/rally-sim.mjs` (real modules, test.js's autopilot, 240 s per opponent;
|
||||
the service line is 6.4 m from the net, the baseline 11.9 m):
|
||||
|
||||
| zombie tennis | P meets the ball (m from net) | P volleys | O meets the ball | O volleys | time per shot |
|
||||
|---|---|---|---|---|---|
|
||||
| opponent 1 | 4.9 | 87% | 9.3 | 56% | 0.53-0.67 s |
|
||||
| opponent 6 | 7.7 | 52% | 7.6 | 90% | 0.63-0.72 s |
|
||||
| opponent 12 | 7.8 | 50% | 7.7 | 99% | 0.72 s |
|
||||
| opponent 18 | 8.5 | 7% | 6.6 | 100% | 0.72-0.75 s |
|
||||
|
||||
Every rally ball lands at a median **5.4 m from the baseline: on the service line**. Classic tennis
|
||||
is milder but has the same shape (contact 8.5-10.8 m from the net, 0.75-1.0 s per shot, the same
|
||||
5.4 m landings).
|
||||
|
||||
**Four structural causes, none of them a tuning value:**
|
||||
1. **The rally ball lands on the service line by definition.** `SHOT_DEPTH.mid` is 5.4, i.e. the
|
||||
service line itself. In real tennis that is a SHORT ball, an invitation to attack. `deep` (3.0) is
|
||||
the only ball in the back third, and the CPU asks for it 25% of the time.
|
||||
2. **The match opponent runs to the BOUNCE and takes the ball there.** `racketAI` aims at `pred.z`
|
||||
(the first-bounce point, since v352's "approach as far as 1.2 m behind the net"), and is clamped at
|
||||
`>= 1.5`, so it can never stand behind its own baseline. Standing on the bounce point, the ball
|
||||
enters its 1.6 m reach before it lands, so from opponent 6 up it volleys 90-100% of everything
|
||||
from the service line. The speed dials (`matchSpeedPer`, foe `speed`, `leaguePace`) only make it
|
||||
arrive there sooner. That is what "they get very fast" feels like.
|
||||
3. **The player's auto-run picks the EARLIEST reachable waist-high point.** `intercept('P')` walks
|
||||
the flight forward in time and returns the first point that qualifies, which is always the one
|
||||
nearest the net. At opponent 1, 72% of the player's contacts are inside the service line. The
|
||||
classic CPU goes through the same `intercept`, which is why classic has the shape too.
|
||||
4. **Every shot has the same shape.** `solveShot` at pace `s` to one of three depths. No height, no
|
||||
spin family, no lob or drop for the CPU. The lane is the only variable left, so wrong-footing
|
||||
(THE READ) is the only way a point can end. That is exactly his description.
|
||||
|
||||
**Prototype (scratch copy, not committed):** both sides take the ball AFTER the bounce, below 1.5 m
|
||||
and falling, and may stand up to 2.5 m behind their baseline. Homes move behind the baseline (P 24.4,
|
||||
O -0.6). The match CPU waits behind the bounce instead of on it. Rally depth is deep by default, and
|
||||
mistiming lands the ball SHORTER, never longer, so it adds no outs. Result: contact 10-12 m from the
|
||||
net on both sides, volleys 0-2%, 0.8-0.97 s per shot. Rallies at the high rungs then never end for
|
||||
the bot. That is expected: once the geometry is fixed, what is missing is the tools that END a point.
|
||||
|
||||
**What the research says** (real tennis and other tennis games, sources in the session's brief):
|
||||
- Real points are mostly SHORT. 0-4 shots make up ~70% of men's and ~66% of women's points, 5-8 ~20%, 9+ ~10%
|
||||
(AO 2015, Wimbledon 2021). Variety is lots of short points plus a long-rally tail, not long rallies
|
||||
everywhere.
|
||||
- Pros win 66-69% at the net BECAUSE they come in selectively. Controlled for the situation,
|
||||
serve-and-volley is worse than staying back: 66.8% vs 72.2% (Tennis Abstract). They approach "only when
|
||||
the point has developed in an inviting way", i.e. off a short ball. The net is made expensive by the
|
||||
passing shot dipped at the feet, the lob, and needing a good approach first. Our game has none of those
|
||||
three costs.
|
||||
- Even the most minimal arcade tennis games (Super Tennis, Virtua Tennis DC, Mario Tennis) give the lob
|
||||
its own input. Wii Sports rejected reading the swing's direction because "the ball doesn't fly in the
|
||||
direction you expect", which supports our one-gesture rule. Shape-coded swipes (VT Tennis mobile) fight
|
||||
with direction.
|
||||
- AI that is "just fast" is the genre's known failure. Matchpoint's career mode reportedly needs constant
|
||||
net rushing. What works is style dials (TopSpin's baseline/serve-volley/defense/all-round spreads) and
|
||||
believable over/under-hits. Free Tennis's AI "learns whether it is best to take the net or stay back".
|
||||
|
||||
**The plan put to him (smallest root first; every number a TUNE knob, the tag is the fallback):**
|
||||
1. **THE COURT GETS ITS LENGTH BACK.** No new input, both modes. Rally balls land deep, and mistiming lands
|
||||
them SHORT (never long, no new outs). Both sides take the ball after the bounce from behind the
|
||||
baseline. Coming forward happens only onto a short ball: the auto-run runs you in, and you are then
|
||||
visibly at the net, where you can be passed or lobbed. A volley below net height can only be blocked up
|
||||
soft. The suite gets a rally-shape test (contact depth, volley share, shots per point).
|
||||
2. **OPPONENTS WITH A GAME.** The CPU gains shot shapes (heavy topspin that kicks high and pushes you
|
||||
back, low skidding slice, moonball, lob, drop shot) and per-row STYLE dials on `OPPONENTS`: home depth,
|
||||
approach threshold, lob/drop rate, spin. The roster lines already read as styles: pushers at the bottom,
|
||||
ROLLER/BRUTE flat hitters, BALL BOY/RUNNER retrievers, CLUB PRO/DIRECTOR serve-and-volley,
|
||||
MASCOT/PHYSIO touch players, CHAMPION all-court, and he learns whether the net is working. The ladder
|
||||
then climbs through style and consistency rather than legs, and the speed dials are re-tuned
|
||||
afterwards. Context-chosen shots are fine for the CPU: the rule binds the player's gesture, and a lob's
|
||||
arc announces itself.
|
||||
3. **THE PLAYER'S SOFT BALL (Oliver's call).** The down-flick today means "short", which is a fast short
|
||||
ball nobody needs. Proposal: down = SOFT, and its length = how far, so short = drop shot, long = lob.
|
||||
One meaning in every situation, no new gesture. This is NOT the context-armed lob that was cut on
|
||||
2026-08-22. Rejected alternative: coding lob/drive by flick SPEED (AO Tennis Smash, Tennis Clash). A
|
||||
right thumb's left swipe is naturally ~2x slower (measured 2026-08-20), so every left flick would turn
|
||||
into a lob.
|
||||
4. **Retune the escalation** against the new shape, with `tools/rally-sim.mjs` and the step-1 test as the
|
||||
gauge.
|
||||
|
||||
# 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`.
|
||||
|
||||
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.
|
||||
**If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Rally geometry probe: `node tools/rally-sim.mjs` (RUNGS=1,6,12,18 SECS=240, CLASSIC=1 RUNGS=0,1,2 for
|
||||
// the three classic difficulties). Plays the real modules headless with test.js's autopilot (auto-run,
|
||||
// a fixed tap window, a random lane/deep flick) and prints WHERE each side meets the ball, how often
|
||||
// it volleys, how long each shot gives the other side, and where rally balls land. It measures the
|
||||
// shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT".
|
||||
// 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
|
||||
_seed ^= _seed << 13; _seed >>>= 0;
|
||||
_seed ^= _seed >> 17;
|
||||
_seed ^= _seed << 5; _seed >>>= 0;
|
||||
return _seed / 4294967296;
|
||||
};
|
||||
|
||||
// ---- platform stubs, installed before the game modules load ----------------
|
||||
const absorb = new Proxy(function () {}, {
|
||||
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
|
||||
set: () => true,
|
||||
apply: () => absorb,
|
||||
construct: () => absorb,
|
||||
});
|
||||
const fakeCtx = () => new Proxy({}, {
|
||||
get: (t, p) => {
|
||||
if (p === 'canvas') return fakeCanvas();
|
||||
if (p === 'measureText') return () => ({ width: 0 });
|
||||
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
|
||||
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
|
||||
return () => undefined;
|
||||
},
|
||||
set: () => true,
|
||||
});
|
||||
function fakeCanvas() {
|
||||
return {
|
||||
width: 270, height: 480, style: {},
|
||||
getContext: () => fakeCtx(),
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
|
||||
toDataURL: () => '',
|
||||
};
|
||||
}
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: k => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: k => store.delete(k),
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById: () => fakeCanvas(),
|
||||
createElement: () => fakeCanvas(),
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
body: { style: {}, appendChild() {} },
|
||||
};
|
||||
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
|
||||
globalThis.AudioContext = function () { return absorb; };
|
||||
globalThis.webkitAudioContext = globalThis.AudioContext;
|
||||
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
|
||||
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
|
||||
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
|
||||
globalThis.hapticLog = [];
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
|
||||
globalThis.addEventListener = globalThis.addEventListener || (() => {});
|
||||
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
|
||||
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
|
||||
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
|
||||
globalThis.requestAnimationFrame = () => 0;
|
||||
globalThis.devicePixelRatio = 2;
|
||||
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
|
||||
const R = new URL('../src/', import.meta.url).href;
|
||||
const state = await import(R + 'state.js');
|
||||
const { G, P, ball, score } = state;
|
||||
const gp = await import(R + 'gameplay.js');
|
||||
const { update, keyPressed, restartRun } = gp;
|
||||
const beats = await import(R + 'beats.js');
|
||||
const { zombies } = await import(R + 'zombies.js');
|
||||
const { TUNE, COURT } = await import(R + 'config.js');
|
||||
const prof = await import(R + 'profile.js');
|
||||
const DT = 1 / 60;
|
||||
const RUNGS = (process.env.RUNGS || '1,6,12,18').split(',').map(Number);
|
||||
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 = [];
|
||||
for (const rung of RUNGS) {
|
||||
restartRun();
|
||||
prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo();
|
||||
G.mode = 'splatter'; G.everTransitioned = true; state.setupPoint();
|
||||
const cfgm = await import(R + 'config.js');
|
||||
if (process.env.CLASSIC) { G.mode = 'tennis'; G.everTransitioned = false; G.classic = true; G.diff = cfgm.DIFFS[rung]; state.setupPoint(); }
|
||||
else beats.startMatch(false, null, rung);
|
||||
G.modal = null; G.modalQ.length = 0;
|
||||
for (let i = 0; i < 2.2 * 60; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
|
||||
const lands = [];
|
||||
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;
|
||||
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; }
|
||||
t += DT;
|
||||
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 && P.swingT < 0) {
|
||||
const lane = [-1, 0, 1][Math.floor(Math.random() * 3)], mag = 40 + Math.random() * 80;
|
||||
const fy = Math.random() < 0.4 ? -0.6 : 0, fx = lane * 0.8;
|
||||
const n = Math.hypot(fx, fy) || 1;
|
||||
swipe = lane || fy ? { ax: lane, az: fy < 0 ? -1 : 0, fx: fx / n, fy: fy / n, mag } : null;
|
||||
G.swipe = swipe;
|
||||
keyPressed('Space');
|
||||
}
|
||||
}
|
||||
update(DT);
|
||||
if (ball.lastHit !== 'O') G.swipe = null;
|
||||
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
|
||||
if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) {
|
||||
const ch = ball.lastHit === 'P' ? P : rz;
|
||||
if (ch && !G.wasServe) {
|
||||
const fromNet = Math.abs(ch.z - COURT.netZ);
|
||||
speeds.push({ side: ball.lastHit, v: Math.hypot(ball.vx, ball.vz) });
|
||||
hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y });
|
||||
}
|
||||
lastHitT = t; rally++;
|
||||
}
|
||||
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;
|
||||
lands.push({ side: ball.lastHit, fromBase });
|
||||
}
|
||||
prevLast = ball.active ? ball.lastHit : null; prevB = ball.bounces;
|
||||
}
|
||||
for (const side of ['P', 'O']) {
|
||||
const h = hits.filter(x => x.side === side);
|
||||
rows.push({ rung, side, shots: h.length,
|
||||
'medDistNet': +med(h.map(x => x.fromNet)).toFixed(1),
|
||||
'<6.4m(svcline)%': pct(h, x => x.fromNet < 6.4),
|
||||
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
|
||||
'volley%': pct(h, x => !x.bounced),
|
||||
'medTimeToHit_s': +med(h.filter(x => x.dt).map(x => x.dt)).toFixed(2),
|
||||
});
|
||||
}
|
||||
for (const side of ['P', 'O']) {
|
||||
const l = lands.filter(x => x.side === side).map(x => x.fromBase);
|
||||
const v = speeds.filter(x => x.side === side).map(x => x.v);
|
||||
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) });
|
||||
}
|
||||
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);
|
||||
Reference in new issue
Block a user