Files
wuwiandClaude Opus 5.5 ba8d13a867 Make the double tap a stance that holds the net
Oliver on v376: "wenn man doppeltippt, geht er minimal vor". His own
ball's flight covered 4-9 m of the 10 to the net. His next ball was met
from mid-court, outside the net zone, and the recovery after that volley
walked him straight back to the baseline.

The double tap now sets a stance (P.post) that lasts until the next
double tap, a lob he has to run down, or the end of the point. Every
recovery heads for the net at full pace, and every incoming ball is
planned as a net man's. The 'back' stance clears once he is behind the
net zone, so short balls still pull him in.

Measured: contacts while committed are ~35% at the net and ~40% around
the service line, and he no longer falls back. The base ladder is
unchanged. New test; v377.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:56 +02:00

272 lines
19 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.
// JITTER=1 (auto-run shake: reversals, target jumps), =2 prints big jumps, =3 prints fast reversals.
// 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 followed in: a deep flick, then a DOUBLE TAP
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 jit = { shown: 0, rev: 0, fastRev: 0, lastRev: -99, last: 0, jumps: [], frames: 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 && P.post !== 'net' && Math.abs(P.z - COURT.netZ) >= TUNE.netFrom && Math.random() < ATTACK) { bot.dbl = true; return sw(away * 0.3, -1, 130); } // the attack
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); }
if (bot.dbl && ball.lastHit === 'P' && ball.active) { bot.dbl = false; bot.taps = [i + 4, i + 12]; } // follow it in: the double tap
if (bot.taps && bot.taps.includes(i)) keyPressed('Space', 0.1);
}
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;
const px0 = P.x, pz0 = P.z, tg0 = P.tgt;
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 && ball.smash) { netx.smash++; netx.open = 'smash'; }
if (P.post === 'net' && !netx.wasIn) netx.attacks++; // a run in (the double tap, or a short ball)
netx.wasIn = P.post === 'net';
// every contact of his made while committed to the net: how far from the net (the "minimal vor" check)
if (lh === 'O' && ball.lastHit === 'P' && (P.post === 'net' || netx.postWas === 'net')) (netx.after ||= []).push(+Math.abs(P.z - COURT.netZ).toFixed(1));
if (netx.postWas === 'net' && P.post !== 'net') netx.dropped = (netx.dropped || 0) + 1, netx.why = [...(netx.why || []), (G.phase !== 'rally' ? 'pointEnd' : ball.rx && ball.rx.lob ? 'lob' : 'other')];
netx.postWas = P.post;
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
// JITTER=1: the auto-run's shake — a direction reversal of the x step inside a chase, and how far the
// target jumps between frames (the two things a "zittert hin und her" can be made of)
if (process.env.JITTER && G.phase === 'rally' && ball.active && ball.lastHit === 'O' && lh === 'O') {
const dx = P.x - px0;
if (Math.abs(dx) > 1e-4) { if (jit.last && Math.sign(dx) !== Math.sign(jit.last)) { jit.rev++; if (i - jit.lastRev < 12) { jit.fastRev++; if (process.env.JITTER === '3' && jit.shown++ < 12) console.log('rev', i, 'dx', dx.toFixed(3), 'prev', jit.last.toFixed(3), 'P', P.x.toFixed(2), P.z.toFixed(2), 'tgt', P.tgt && P.tgt.x.toFixed(2), P.tgt && P.tgt.z.toFixed(2), 'swingT', P.swingT.toFixed(2), 'heels', ball.heels.toFixed(2)); } jit.lastRev = i; } jit.last = dx; }
if (tg0 && P.tgt) { const j = Math.hypot(P.tgt.x - tg0.x, P.tgt.z - tg0.z); jit.jumps.push(j); }
if (process.env.JITTER === '2' && tg0 && P.tgt && Math.hypot(P.tgt.x - tg0.x, P.tgt.z - tg0.z) > 0.5 && jit.shown++ < 25) console.log('jump', i, 'from', tg0.x.toFixed(2), tg0.z.toFixed(2), 'to', P.tgt.x.toFixed(2), P.tgt.z.toFixed(2), 'P', P.x.toFixed(2), P.z.toFixed(2), 'ball', ball.y.toFixed(2), ball.z.toFixed(2), 'b', ball.bounces, JSON.stringify(ball.rx));
jit.frames++;
} else jit.last = 0;
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.JITTER) { const j = jit.jumps.sort((a, b) => a - b), q = f => +(j[Math.floor(j.length * f)] || 0).toFixed(3);
console.log(JSON.stringify({ chaseFrames: jit.frames, reversals: jit.rev, fastReversals_lt200ms: jit.fastRev, tgtJump_p50: q(0.5), p90: q(0.9), p99: q(0.99), max: q(0.9999), over20cm: j.filter(x => x > 0.2).length })); }
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"));