A shot's extra gravity is its dive in flight, but it was carried through the bounce: heavy topspin skidded away low and Oliver met nearly every ball 0-25 cm off the grass. After its bounce a ball now falls under plain gravity (tennis modes only), and every planner that rolls a bounce forward switches at the same moment. The higher bounces pushed the auto-run into volleys, so it now takes a kicking ball at the top of its bounce, up to chest height. Median contact rises from 0.42 to 1.13 m at the bottom of the ladder. v369. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
8.8 KiB
JavaScript
168 lines
8.8 KiB
JavaScript
// 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);
|