diff --git a/PROJECT.md b/PROJECT.md index 8e12d7d..8536788 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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. diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs new file mode 100644 index 0000000..f4cf8ae --- /dev/null +++ b/tools/rally-sim.mjs @@ -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);