// 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 = [], pts = []; 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, pts0 = [...score.pts], net = { P: false, O: false }; const won = { P: 0, O: 0, Pnet: 0, PnetWon: 0, Onet: 0, OnetWon: 0 }; 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) }); if (fromNet < 6.4) net[ball.lastHit] = true; hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y }); } lastHitT = t; rally++; } const d0 = score.pts[0] - pts0[0], d1 = score.pts[1] - pts0[1]; if (d0 > 0 || d1 > 0) { const w = d0 > 0 ? 'P' : 'O'; won[w]++; for (const sd of ['P', 'O']) if (net[sd]) { won[sd + 'net']++; if (w === sd) won[sd + 'netWon']++; } } if (d0 || d1) net = { P: false, O: false }; pts0 = [...score.pts]; 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), 'medContactY': +med(h.map(x => x.y)).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) }); } pts.push({ rung, Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' }); 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); console.table(pts);