Oliver: "Steuerung ans Netz gehen", option (1), and "einen Lob vom Gegner als Volley annehmen soll derb schmettern". - A long up-flick (90 px or more) is the attack: the deep ball, and he follows it in. A long down-flick is the lob, and he goes back behind it. Short flicks keep the deep ball / the drop and leave him where he is. Length only, never context. - The net costs him what it costs the CPU: he split-steps (netReact) before reading the pass, and a CPU facing a net man passes at the line. Without both, the attacker won 78-95% of his net points. - The smash: a ball taken out of the air at 1.9-2.5 m is struck harder and to the open court, read late by the opponent, and pops "SMASH!". It wins ~80% of its points. - Lobbed at the net, he decides once whether he can get under it. If he can, he smashes. If not, he runs it down and only blocks it back, even if he meets it in the air. Re-deciding every frame smashed every lob. Measured at level 26: the base ladder is unchanged. Attacking pays against pushers and touch players and costs against big hitters, slicers and rushers. Three new tests; v374. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
16 KiB
JavaScript
250 lines
16 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".
|
|
// Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1,
|
|
// JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate.
|
|
// ATTACK=0.3 (the long up-flick share), NETX=1 (net/smash tally), KILL=1 (put-aways), T_<knob>=<v> sets TUNE.
|
|
// 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;
|
|
for (const [k, v] of Object.entries(process.env)) if (k.startsWith('T_')) TUNE[k.slice(2)] = Number(v); // T_punishRead=0.4 etc.
|
|
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; };
|
|
// BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a
|
|
// 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when
|
|
// the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks.
|
|
const HUMAN = (process.env.BOT || 'human') === 'human';
|
|
const ATTACK = Number(process.env.ATTACK || 0); // share of baseline shots played as the attack (long up-flick)
|
|
const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7;
|
|
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
|
|
const netx = { attacks: 0, smash: 0, smashWon: 0, lobbed: 0, open: null, p0: 0 };
|
|
const probe = [], kills = []; let kOpen = null, kpow = 1;
|
|
const bot = { armed: false, fire: -1, jit: null, swipe: null };
|
|
const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n;
|
|
return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; };
|
|
function humanAim() {
|
|
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
|
|
const oz = rz ? Math.abs(rz.z - COURT.netZ) : 12, r = Math.random();
|
|
const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on
|
|
if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him
|
|
if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop
|
|
if (ATTACK && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && r < ATTACK) return sw(away * 0.3, -1, 130); // the attack: follow it in
|
|
if (r < 0.45) return sw(away, -0.2, 110); // to the open side
|
|
if (r < 0.65) return sw(0, -1, 80); // deep through the middle
|
|
return null; // a plain tap
|
|
}
|
|
const rows = [], land = [], pts = [], whys = [];
|
|
for (const rung of RUNGS) {
|
|
restartRun(); Object.assign(bot, { armed: false, fire: -1, jit: null, swipe: null });
|
|
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(); }
|
|
// CLIMB=1: the arcade run (its own step boost at the bottom), pinned to this rung like the suite does
|
|
else if (process.env.CLIMB) { beats.startMatch(false, null, rung); G.match.train = false; }
|
|
else beats.startMatch(false, null, rung);
|
|
const why = {};
|
|
let prevE = null, lastHitE = null; // LOST is filed under how it came about: the player's last act before it
|
|
G.missLog.push = function (e) {
|
|
if (e.result === 'LOST') {
|
|
const k = /never swung/.test(e.reason) ? 'never swung' : prevE && prevE.result === 'MISS' ? 'MISS ' + prevE.reason.split(' — ')[0].replace(/[\d.]+m?s?/g, '#').slice(0, 30)
|
|
: lastHitE && /q=0\.[0-4]/.test(lastHitE.reason) ? 'after weak hit q<.5' : 'after good hit';
|
|
why['L: ' + k] = (why['L: ' + k] || 0) + 1;
|
|
} else if (e.result === 'WON') why.WON = (why.WON || 0) + 1;
|
|
if (e.result === 'HIT') lastHitE = e;
|
|
if (e.result === 'WON' || e.result === 'LOST') lastHitE = null;
|
|
if (e.result !== 'THEM') prevE = e;
|
|
return Array.prototype.push.call(this, e);
|
|
};
|
|
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 (process.env.CLIMB && G.match) { G.match.opp = rung; G.match.train = false; G.hp = G.hpMax; }
|
|
if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; }
|
|
t += DT;
|
|
if (HUMAN && (ball.lastHit !== 'O' || !ball.active || G.phase !== 'rally')) { bot.jit = null; if (i !== bot.fire) { bot.armed = false; bot.fire = -1; } }
|
|
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 (HUMAN) {
|
|
// the finger lands when the ring says "now" give or take a human's timing (JIT ms, gaussian),
|
|
// and a tap lifts LAG frames later carrying its age; a flick fires on the land (its crossing)
|
|
if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !bot.armed) {
|
|
const c = gp.ringCue();
|
|
if (c) {
|
|
if (bot.jit === null) bot.jit = Math.max(-2, Math.min(2, gauss())) * JIT + Number(process.env.BIAS || 0) / 1000;
|
|
if (c.eta - c.sweet + bot.jit <= 0 || c.eta <= 0) { bot.land = { i, eta: c.eta, sweet: c.sweet, b: ball.bounces }; bot.armed = true; bot.jit = null; bot.swipe = humanAim(); bot.fire = i + (bot.swipe ? 0 : LAG); }
|
|
}
|
|
}
|
|
if (i === bot.fire) { G.swipe = bot.swipe; keyPressed('Space', bot.swipe ? 0 : LAG / 60, !!bot.swipe); }
|
|
}
|
|
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');
|
|
}
|
|
}
|
|
const lh = ball.lastHit;
|
|
if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces;
|
|
update(DT);
|
|
if (lh !== 'P' && ball.lastHit === 'P' && ball.active) { if (ball.approach === 'P' && !G.wasServe) netx.attacks++; if (ball.smash) { netx.smash++; netx.open = 'smash'; } }
|
|
if (lh === 'P' && ball.lastHit === 'O' && ball.active && ball.soft && Math.abs(P.z - COURT.netZ) < TUNE.netFrom) netx.lobbed++;
|
|
if (netx.open && (G.phase !== 'rally' || !ball.active)) { const d0 = score.pts[0] - netx.p0; if (d0 > 0) netx.smashWon++; netx.open = null; }
|
|
if (netx.open === null) netx.p0 = score.pts[0];
|
|
if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; }
|
|
if (ball.lastHit !== 'O') G.swipe = null;
|
|
// KILL=1: every put-away (a q >= .95 CPU ball straight after a player ball under .5) — where it went,
|
|
// how far the player had to go, how long he had, and whether he got it back
|
|
if (process.env.KILL) {
|
|
if (lh === 'P' && ball.lastHit === 'O' && kpow < 0.5 && ball.pow >= 0.95) {
|
|
const L = gp.predictLanding(); kills.push({ px: +P.x.toFixed(1), pz: +P.z.toFixed(1), lx: L && +L.x.toFixed(1), lz: L && +L.z.toFixed(1), tl: L && +L.t.toFixed(2), v: +Math.hypot(ball.vx, ball.vz).toFixed(1), got: null }); kOpen = kills[kills.length - 1];
|
|
}
|
|
if (kOpen && ball.lastHit === 'P' && lh === 'O') { kOpen.got = +ball.pow.toFixed(2); kOpen = null; }
|
|
if (kOpen && (!ball.active || G.phase !== 'rally')) { kOpen.got = 'LOST'; kOpen.gap = +(G.rallyNear ? G.rallyNear.d : -1).toFixed(2); kOpen = null; }
|
|
if (ball.lastHit === 'P') kpow = ball.pow;
|
|
}
|
|
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) });
|
|
}
|
|
const oh = hits.filter(x => x.side === 'O');
|
|
pts.push({ rung, foe: (zombies.find(z => z.variant === 'r')?.foe?.short) || '', 'P win%': Math.round(100 * won.P / Math.max(1, won.P + won.O)),
|
|
'shots/pt': +(rallies.reduce((a, b) => a + b, 0) / Math.max(1, rallies.length)).toFixed(1), 'O at net%': pct(oh, x => x.fromNet < 6.4), 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) : '' });
|
|
whys.push({ rung, ...why });
|
|
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) });
|
|
}
|
|
if (!process.env.SUMMARY) { console.table(rows); console.table(land); }
|
|
if (process.env.NETX) console.log(JSON.stringify(netx));
|
|
if (process.env.KILL) { console.table(kills.slice(0, 30)); const l = kills.filter(k => k.got === 'LOST').length; console.log('put-aways', kills.length, 'won by CPU', l); }
|
|
if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); }
|
|
if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }
|
|
console.table(pts);
|
|
if (process.env.WHY) console.table(whys);
|
|
if (process.env.LOG) console.log(G.missLog.slice(process.env.LOG === "all" ? 0 : -25).map(e => `${e.at} ${e.result} ${e.reason}`).join("\n"));
|