Bring the swing log to every rally mode, and time the gold ring

Oliver wants to hand over a log of why he missed balls. It existed behind
a scoreboard tap but was gated on G.mode === 'tennis', so the Climb, the
cup and training (which run as 'splatter') logged nothing. It now writes
in every rally mode: hits with shot, tap age and any quality cap, misses
with the new let-it-bounce reason, and a WON/LOST line naming why each
point went. Tapping the open log copies all 200 entries with a header.

Its first catch: the gold ring went gold at a distance, which is too
early for the slow balls steps 1-3 brought in. It is now timed off the
ball's real path to his reach (against where his run is taking him):
gold means touch now. ringTime 0 restores the old ring. v365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 11:41:05 +02:00
1 parent 180288ef8f
commit d110e12321
8 files changed
+225 -41

No files matched your search

+86 -21
View File
@@ -749,12 +749,9 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
// verdict is being applied, which is what stops the two phones echoing a point back and forth.
if (netJudging()) netSendPoint(side, reason);
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
}
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung)
G.passStreak++; // their ball died on your side untouched: two in a row and the ghost hand shows the tap (render.drawHitHint)
logPoint(side, reason);
const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
sendBoy();
@@ -1505,23 +1502,79 @@ function intercept(side) {
return rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
}
// THE GOLD RING, TIMED (2026-10-14). The approach ring used to go gold at a DISTANCE (matchRingSweet
// 2.7 m) — right for the ~20 m/s rally balls it was calibrated on, and too early for every slow ball
// steps 1-3 brought in: a pusher's moonball, a lob, a drop, a deep ball braked by its bounce. A
// gold-ring tap on a slow ball missed "30 ms after the window" (the swing log's first catch). Now the
// cue rolls the ball forward to the moment it enters the player's real contact reach (the same reach,
// height band and let-it-bounce rule trySwing applies) and is gold while a finger landing NOW centres
// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring.
export function ringCue() {
if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null;
const g = ball.g || TUNE.gravity, dt = 1 / 120;
let { x, y, z, vx, vy, vz } = ball, b = ball.bounces;
// ...against where the player WILL be: the auto-run is still carrying him to its target at the pace
// he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame
const sp = Math.hypot(P.vx || 0, P.vz || 0), tg = P.tgt;
const togo = tg ? Math.hypot(tg.x - P.x, tg.z - P.z) : 0;
for (let t = 0; t < 1.6; t += dt) {
const volley = b === 0;
const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
const legal = y <= CONTACT.maxY && (y >= CONTACT.minY || vy > 0)
&& !(volley && y < COURT.netH && ball.rx && !ball.rx.net);
const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0;
const px = P.x + (tg ? tg.x - P.x : 0) * k, pz = P.z + (tg ? tg.z - P.z : 0) * k;
if (legal && Math.hypot(x - px, (z - pz) * 0.8) <= reach) {
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel();
const sweet = TUNE.swingWindup + win / 2, band = win / 2 * TUNE.ringBand;
return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band };
}
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); b++; if (b > 1) return null; }
}
return null;
}
// ---------------------------------------------------------- miss diagnostics ----
// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the
// gate that rejected it and the ball's closest approach. Balls that pass without a
// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest
// miss reason is drawn on the HUD (TUNE.missHud).
// Every player swing in a rally gets logged: HIT with quality, shot and how long the tap was, or
// MISS with the gate that rejected it and the ball's closest approach — and every point ends with
// a WON/LOST line naming why. Tap the scoreboard to see it; tap the log to COPY all of it (main.js).
// Inspect live via T.G.missLog; the latest miss reason can ride the HUD (TUNE.missHud).
// RALLY MODES since 2026-10-14 (Oliver: "bitte bau nochmal die Funktion ein die protokolliert warum
// ich einen Ball nicht bekommen habe"): it was gated on G.mode === 'tennis', and the match runs as
// 'splatter' under its interlude — so the Climb and the cup, the modes he plays, logged nothing.
const r2 = x => Math.round(x * 100) / 100;
const swingLog = () => G.mode === 'tennis' || rallyFeel();
export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring)
entry.at = r2(G.idleT);
G.missLog.push(entry);
if (G.missLog.length > 50) G.missLog.shift();
if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT };
if (G.missLog.length > 200) G.missLog.shift();
if (entry.result !== 'HIT' && entry.result !== 'WON') G.lastMiss = { text: entry.reason, at: G.idleT };
}
// The line a POINT ends on, so a run of MISS/HIT lines reads as a story: who took it and why, and —
// when it was lost with no swing at all — how close the ball came. Called by pointTo (classic) and
// by resolveInterlude (the match), the two places every verdict passes.
export function logPoint(side, reason) {
if (!swingLog()) return;
const mine = ball.lastHit === 'P', n = G.rallyNear;
const why = reason === 'out' ? (mine ? 'your ball out' : 'their ball out')
: reason === 'double_bounce' ? (mine ? 'bounced twice on their side' : 'bounced twice on your side')
: reason === 'double_fault' ? 'double fault'
: reason === 'dead_roller' ? 'ball died'
: reason === 'passed_player' ? 'passed you'
: reason === 'unreachable_after_bounce' || reason === 'unreachable_in_flight' ? 'called early: out of his reach'
: reason === 'body_destroyed' ? 'body destroyed' : reason || 'unspecified';
const unswung = side === 'O' && !mine && n && !G.swung;
logSwing({ result: side === 'P' ? 'WON' : 'LOST',
reason: why + (unswung ? ` — never swung, it came within ${r2(n.d)}m at y=${r2(n.y)}m` : ''),
...(unswung ? { dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) } : {}) });
}
function logWhiff() {
const snaps = G.swingSnaps; G.swingSnaps = null;
if (G.mode !== 'tennis') return;
if (!swingLog()) return;
if (!snaps || !snaps.length) {
logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' });
return;
@@ -1545,11 +1598,12 @@ function logWhiff() {
reason = last.t < TUNE.swingWindup
? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened`
: `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`;
} else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`;
} else if (inWin.every(s => s.lb)) reason = 'waited for the bounce — still in the air below the tape (from the back you let it bounce)';
else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`;
else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`;
else reason = 'unclassified — in reach, in window, in height band'; // should not happen
}
logSwing({ result: 'MISS', reason, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t),
logSwing({ result: 'MISS', reason: reason + ` · tap ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t),
ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) });
}
@@ -1614,6 +1668,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
const q0 = q; // (the log names whichever cap below took it down)
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
@@ -1622,9 +1677,15 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
// That is the lob's real reward — rarely the winner itself, nearly always the point.
if (rallyFeel() && ball.rx && ball.rx.lob) q = Math.min(q, TUNE.lowVolleyQ);
if (!isAI && G.mode === 'tennis') {
if (!isAI && swingLog()) {
G.swingSnaps = null; G.rallyNear = null;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
: aim.az < 0 ? ' deep' : '';
const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : '';
const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels'
: ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop';
logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · tap ${P.tap || 0}ms${cap}`,
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
}
doHit(ch, q, aim);
ch.swingT = -1;
@@ -1942,6 +2003,7 @@ export function keyPressed(code, lead = 0) {
// clock starts that far along: the timing is judged from touch-down, the ball still has to be
// there for the contact.
P.swingT = rallyFeel() ? lead : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
P.tap = Math.round(lead * 1000); // ...and the log says how long the tap was (0 = a key or a flick)
}
}
@@ -2109,7 +2171,7 @@ export function update(dt) {
// walking between points" rule is untouched — nothing arms a burst except the player.
if (G.phase === 'rally' || G.mgT > 0) {
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
if (swingLog() && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
}
@@ -2155,6 +2217,7 @@ export function update(dt) {
const tgt = chasing ? intercept('P')
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
: rallyFeel() ? rallyHome(P) : HOME.P;
P.tgt = tgt; // the timed gold ring (ringCue) predicts the contact from where this run ends
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d);
@@ -2210,11 +2273,13 @@ export function update(dt) {
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
if (P.swingT >= 0) {
P.swingT += dt;
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame)
if (swingLog() && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame)
const volley = ball.bounces === 0;
(G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y,
bz: ball.z, bounces: ball.bounces,
r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() });
bz: ball.z, bounces: ball.bounces, lb: letBounce(),
// the reach trySwing really tests — the match's longer racket included, or a near miss reads wide
r: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1),
w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() });
}
trySwing(P, false); // a contact resets swingT to -1 inside trySwing
// volley-length window while a hostile shot is in the air too, or the swing clock expires at