From e727e9d95c7a22d4feea0e845b29802884a6b036 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sun, 13 Sep 2026 12:01:17 -0400 Subject: [PATCH 1/2] Read the hoops oscillator at release, and score against the real hole The shot's vertical velocity is vy = -2.9 + 0.7*cos(phi), sampled when the ball leaves the hand -- exactly 49 logic ticks after the input that triggers it (measured: 49 every time, sd 0, n=12). The helper predicted from the phase at PRESS and in fact passed cosPhi as null, so the oscillator term was absent entirely. Over 49 ticks the phase advances 35.035 degrees (G16[0] += 1.3 per 2 ticks, angle = G16[0]*1.1 deg), which is most of the reason the arc swung. Correcting it with source constants and nothing fitted took the arc error from sd 70.6px to 28.7px. The same correction applied at PRESS phase instead bought only 18% and 30% on the same shots, against 49% and 54% at release. That gap is the load-bearing comparison because it is invariant to the flight time: an early pass had the flight time wrong by 4x and release still beat press, 9% to -1%. Three runs agree. Separately, ghostMade tested the wrong thing. The game scores on a 25px-radius circle around (p95+39, p96+113); the helper tested whether the arc crossed the detected rim bar's y inside a horizontal window. The detected rim sits ~24px above the real scoring point (session A -24.23 sd 2.84, session B -23.63 sd 2.98, n=82 pooled after checking the sessions agree). Both target and criterion now match the game, with point-to-segment distance so sub-sampling does not bias the minimum high. What this does NOT do, stated plainly: overall ghostMade agreement is flat -- 64% before, 62% after, inside the noise at n=42. That is because most makes in this game are BANKED (20 of 26), and no ballistic arc can predict a ball that rattles in off the rim. On the makes a clean arc can predict, the change is real: missed swishes fell from 7 to 3. Residual arc error is still ~30px against a 25px hole, so this is necessary and not sufficient. The old uL/uR oscillator correction is removed rather than left disabled -- it was fit against the wrong phase and never moved outcomes. Its measured coefficients are kept in a comment so it is not re-derived by accident. calVer is unchanged: the learned shotA/shotL/shotR stay valid as the cos=0 case. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 241 +++++++++++++++++++++++++++++-------------- idleon-suite.user.js | 241 +++++++++++++++++++++++++++++-------------- 2 files changed, 328 insertions(+), 154 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 32cc328..f0b98d7 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.15 +// @version 1.17 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-hoops.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-hoops.user.js // @description Dotted-line shot preview + live ball arc for the Swishy Hoops minigame in Legends of IdleOn @@ -653,6 +653,14 @@ // cos(phi) = (A*cos(wt) - B*sin(wt)) / amp // falls straight out, continuous everywhere and with no sign to choose. const PLAT_W = 2 * Math.PI / 5.035; // rad/s, from the game's own clock + // Release lags the input that triggers it by exactly 49 logic ticks + // (measured: 49 every time, sd 0, n=12). Over that many ticks the same + // oscillator advances G16[0] += 1.3 every 2 ticks, angle = G16[0]*1.1deg, + // so the phase at release is 24.5*1.3*1.1 = 35.035deg ahead of the phase + // read this frame. shotCurve wants cos of the ROTATED phase; see its + // comment for why the rotation, not the raw phase, is what gets applied. + const REL_PHASE = 35.035 * Math.PI / 180; + const REL_COS = Math.cos(REL_PHASE), REL_SIN = Math.sin(REL_PHASE); let platHist = []; function platCos(H, t) { if (!plat) return null; @@ -696,8 +704,16 @@ ss += (q.y - pred) * (q.y - pred); } if (Math.sqrt(ss / n) > amp * 0.25) return null; + // Same fit, both quadrature components. cos is exactly what shipped before + // (verified against the engine's own phase: error mean 0.0001, sd 0.0055 + // over 38 shots); sin falls out of the identical c1/c2/amp with no new + // fitting, and is what lets a caller rotate this phase forward to a later + // tick -- see the release-phase correction in shotCurve(). const wt = PLAT_W * (t / 1000); - return Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(wt)) / amp)); + return { + cos: Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(wt)) / amp)), + sin: Math.max(-1, Math.min(1, (c1 * Math.sin(wt) + c2 * Math.cos(wt)) / amp)), + }; } let holdT = -1e9; // last time a ball was seen in your hands let flightPlat = null; // where the platform was when this shot left @@ -739,41 +755,54 @@ // at (px+17, py-97), and only the x part is needed here because the curve is // already anchored in y to the platform. const RELX = 17 / 960; - function shotCurve(px, py, dir, W, cosPhi) { + // A prior attempt corrected uL/uR (the parabola's crossings) from cosPhi + // measured at press time: 65 offline shots gave L vs cos at -0.0949 (r2 + // 0.73) and R vs cos at +0.0253 (r2 0.23), the opposite split from what the + // release-point geometry predicted. That correction is gone, not just + // disabled -- it was fit against the wrong phase (press, not release; see + // below) and never showed up in outcomes (swish/score rate unchanged with + // it on vs off). Left removed rather than re-derived, since the slope term + // below now carries the oscillator's effect. + function shotCurve(px, py, dir, W, cosRel) { const A = cfg.shotA / W; - let uL = cfg.shotL * W, uR = cfg.shotR * W; - if (cosPhi != null) { - // MEASURED, not derived. The physics is certain -- platform height and - // release velocity are one oscillator in quadrature -- but the geometry - // for how a change in vy redistributes between the two crossings was - // wrong, and confidently so. - // - // Re-cutting the parabola from a fixed release point predicted the effect - // landing almost entirely on R (-0.0654 per unit cos) and barely touching - // L (-0.0171). Driven against the offline rip of the game -- 65 shots, - // 14 committed per-flight fits, cos sampled across its whole range -- the - // truth is the other way round: - // - // A vs cos +0.0235 +-0.0435 r2 0.02 (predicted 0: confirmed) - // L vs cos -0.0949 +-0.0165 r2 0.73 (predicted -0.0171) - // R vs cos +0.0253 +-0.0132 r2 0.23 (predicted -0.0654) - // - // Curvature is untouched by the oscillator exactly as the physics says, - // which is the part of the model that holds. But the coupling shows up in - // L at 5.8 standard errors, while R has the wrong sign and does not clear - // two. Most likely because the tracked part of a flight pins the - // descending branch, leaving L to absorb the change -- R is measured, L - // is extrapolated. - // - // So the slopes are taken from the fits instead of from the geometry. - // R's is left in at its measured value despite being weak; dropping it - // would tilt the arc, and 0.0253 is small enough that being wrong about - // it costs little either way. - uL += -0.0949 * cosPhi * W; - uR += 0.0253 * cosPhi * W; - } - return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR); }, - A, uL, uR, px, py, dir }; + const uL = cfg.shotL * W, uR = cfg.shotR * W; + // The oscillator term, evaluated at RELEASE rather than at press. + // + // The engine sets vy = -2.9 + 0.7*cos(phi) at the instant the ball leaves + // the hand, and release is exactly 49 logic ticks after the input that + // triggers it (measured: 49 every time, sd 0, n=12) -- not at the phase + // the player was aiming with. Over those 49 ticks the same oscillator + // that drives the platform keeps advancing: G16[0] += 1.3 every 2 ticks, + // angle = G16[0]*1.1 degrees, so 49 ticks is 24.5*1.3*1.1 = 35.035 + // degrees of phase. cosRel is that rotation applied by the caller -- + // cos(phi)*cos(35.035deg) - sin(phi)*sin(35.035deg) -- before it gets + // here; this function just uses it. + // + // The calibrated parabola (shotA/shotL/shotR) was fit across many shots + // at random phases, which averages cos(phi) to zero, so it represents the + // cos=0 case and the FULL term applies here, not a difference from it. + // In game units vy is px/tick and vx is 3.9 px/tick, so the added slope + // in u (screen px along the flight direction) is 0.7*cos(phi_release)/3.9 + // -- dimensionless, so it scales with the canvas like everything else. + // + // Measured against the offline rip: applying this at RELEASE phase took + // arc error sd 60.0 -> 30.5px (n=19) and 70.6 -> 32.5px (n=32), a 49% and + // 54% reduction. The same correction evaluated at PRESS phase on the same + // shots reduced sd by only 18% and 30%. Release phase is what the engine + // actually uses, and the data agrees. + // + // That press-vs-release gap is the load-bearing comparison, because it is + // invariant to the flight time: both arms scale with it. An early pass had + // the flight time wrong by 4x (it read the ball's position before launch, + // which still held the PREVIOUS shot's resting place) and release still + // beat press, 9% to -1%. The conclusion survived the bug that hid it. + // + // Necessary, not sufficient: residual sd is still ~32px against a 25px + // scoring radius, and rimDy is separately biased about 25px high and is + // not touched by this change. + const dSlope = cosRel == null ? 0 : 0.7 * cosRel / 3.9; + return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR) + dSlope * u; }, + A, uL, uR, dSlope, px, py, dir }; } let lastRim = null, rimT = 0; let frame = 0; @@ -875,23 +904,77 @@ // past the net into the backboard post — it is wider than the hole and its // centre sits ~5% of its width right of it. Scoring off the raw bar at // +/-0.55 called anything within 68px a swish, which is wider than the hoop. - const HOLE_OFF = -0.05, HOLE_HALF = 0.30; + const HOLE_OFF = -0.05, HOLE_HALF = 0.30; // still used by drawRim's debug window, below + + // The game does NOT score by testing whether the arc's height crosses the + // rim bar's y inside a horizontal window (the HOLE_OFF/HOLE_HALF test this + // replaces). It scores when the ball's CENTRE comes within 25 game-px of a + // fixed scoring point offset from the hoop sprite's top-left corner, seen in + // the game's own code as (p95+39, p96+113). The helper's visually-detected + // rim sits measurably above that point, not on it. Measured across 82 shots + // in two independent sessions, which agree with each other and were + // therefore pooled: + // rim.y - scorePt.y: session A -24.23px sd 2.84, session B -23.63px sd 2.98 + // pooled: true scoring point = detected rim + (-4.4, +23.9) canvas px + // Expressed below as fractions of canvas size, never absolute px, because + // the game's physics -- and this helper's own calibration -- scales with + // the viewport: + // true scoring point = detected rim + (-0.00457*W, +0.04431*H) + // scoring radius = 0.04630*H (25 game-px on a 540-tall design canvas) + // + // The old crossing-through-rim.y test was wrong on both axes -- wrong + // target (rim.y instead of the scoring point ~24px below it) and wrong + // criterion (a line crossing instead of the game's own circle test) -- and + // it systematically UNDER-called makes: 18 called vs 27 actual across 43 + // shots, 56% agreement. The same 25px-radius circle test applied to the + // game's own ball trace (not this helper's prediction) agreed with the real + // outcome 40/43 -- that is the standard this replacement is judged against. + const SCORE_DX = -0.00457; // * W: detected rim.x -> scoring point x + const SCORE_DY = 0.04431; // * H: detected rim.y -> scoring point y + const SCORE_R = 0.04630; // * H: scoring radius (25px on a 540-tall canvas) + + // Closest point on segment P0->P1 to target T, clamped so it cannot fall + // outside the segment (standard clamped projection). Used instead of + // point-to-point distance because the arc below is only sampled every + // `step` px: checking sampled points alone biases the minimum distance + // HIGH between samples and would re-introduce the under-calling above. + function closestOnSeg(p0, p1, t) { + const dx = p1.x - p0.x, dy = p1.y - p0.y; + const len2 = dx * dx + dy * dy; + let u = len2 > 0 ? ((t.x - p0.x) * dx + (t.y - p0.y) * dy) / len2 : 0; + u = Math.max(0, Math.min(1, u)); + const x = p0.x + u * dx, y = p0.y + u * dy; + return { x, y, d: Math.hypot(t.x - x, t.y - y) }; + } + + // Set by drawCurve() each call, for the caller to publish on the probe -- + // drawCurve itself still returns only the made/missed boolean, matching + // every existing call site. + let lastMinDist = null, lastScorePt = null; + function drawCurve(yAt, xStart, dir, W, H, style) { const pts = []; - let made = false, hitX = 0; const rim = lastRim; - const holeX = rim ? rim.x + rim.w * HOLE_OFF : 0; + const scorePt = rim ? { x: rim.x + SCORE_DX * W, y: rim.y + SCORE_DY * H } : null; + const radius = SCORE_R * H; + let minDist = Infinity, closest = null; const step = Math.max(3, W / 240) * dir; for (let x = xStart, i = 0; i < 900; i++, x += step) { const y = yAt(x); + const p = { x, y }; const prev = pts[pts.length - 1]; - if (rim && prev && prev.y <= rim.y && y >= rim.y && - x > holeX - rim.w * HOLE_HALF && x < holeX + rim.w * HOLE_HALF) { made = true; hitX = x; } - pts.push({ x, y }); + if (scorePt && prev) { + const c = closestOnSeg(prev, p, scorePt); + if (c.d < minDist) { minDist = c.d; closest = c; } + } + pts.push(p); if (y > H + 80 || x < -80 || x > W + 80) break; } + lastMinDist = scorePt ? +minDist.toFixed(1) : null; + lastScorePt = scorePt ? { x: +scorePt.x.toFixed(1), y: +scorePt.y.toFixed(1) } : null; if (pts.length < 2) return false; + const made = !!scorePt && minDist < radius; const green = cfg.makes && made; octx.save(); octx.shadowColor = 'rgba(0,0,0,.7)'; @@ -910,7 +993,7 @@ octx.beginPath(); octx.arc(e.x, e.y, 4, 0, Math.PI * 2); octx.stroke(); if (made) { octx.lineWidth = 2.5; - octx.beginPath(); octx.arc(hitX, rim.y, 7, 0, Math.PI * 2); octx.stroke(); + octx.beginPath(); octx.arc(closest.x, closest.y, 7, 0, Math.PI * 2); octx.stroke(); } octx.restore(); return made; @@ -994,7 +1077,13 @@ const pl = findPlatform(img.d, img.sw, img.sh, k, W); if (pl) { plat = pl; platT = t; } else if (t - platT > 700) plat = null; - const cosPhi = platCos(H, t); + const ph = platCos(H, t); + const cosPhi = ph ? ph.cos : null; + // Rotate the phase read THIS frame forward to where it will be at + // release, 49 ticks (35.035deg) later -- cos(phi+d) = cos(phi)cos(d) - + // sin(phi)sin(d). This is what shotCurve's oscillator term needs; see + // its comment for the rest of the derivation. + const cosRel = ph ? ph.cos * REL_COS - ph.sin * REL_SIN : null; if (cfg.debug) { octx.lineWidth = 1; @@ -1138,50 +1227,45 @@ } // ---- shot preview, anchored to the platform ---- - let ghostMade = null, ghostRimY = null; + let ghostMade = null, ghostRimY = null, ghostMinDist = null, scorePt = null; // Drawn whenever a ball is in your hands — NOT gated on "no shot in flight". // After a miss both are true at once, and suppressing the preview then is // exactly when you need it to line up the next shot. if (cfg.ghost && plat && ready) { const dir = lastRim ? Math.sign(lastRim.x - plat.x) || 1 : 1; - // DISABLED AGAIN, on outcome data rather than on how the preview looks. - // - // Driven against the offline rip with the game's own score as ground - // truth, 49 shots paired from release to result: - // - // GREEN n=28 1/28 swish 19/28 scored (68%) - // red n=21 0/21 swish 14/21 scored (67%) - // - // Green means "this arc threads the hole". One of 28 did. And green - // scores no better than red, so the preview carries no information about - // whether the shot goes in -- which makes a second-order correction to it - // unmeasurable by construction. + // RE-ENABLED, on the release-phase slope in shotCurve() rather than the + // uL/uR correction this comment used to explain away. That one was + // fit and applied at PRESS phase, which is not the phase the engine + // actually launches on -- release is 49 ticks (35.035deg) later, see + // REL_PHASE above and the derivation in shotCurve(). Applying the + // correction at the right phase is what changed the outcome: // - // The reported feel matches: descending shots are worse (10/16 against - // 9/12 ascending, though that gap is only ~0.7 SE and proves nothing on - // its own), and ascending is off too when the rim is CLOSE -- small u, - // where the arc is dominated by uL, which is the term this correction - // moves hardest at up to 0.095 W. The most likely reading is that the - // measured L slope is too large to apply raw. + // arc error sd, n=19: 60.0px -> 30.5px at release phase + // (only 18% of that gain if applied at press) + // arc error sd, n=32: 70.6px -> 32.5px at release phase + // (only 30% of that gain if applied at press) // - // Re-enabling it on a correlation with the per-flight fits was too weak a - // standard. Fits are the helper's own reading of the arc; whether shots - // go in is the thing that matters, and by that measure this does not - // help. The estimator and the measurements stay -- platCos is sound and - // the coupling is real -- but nothing here ships until the arc's error at - // the rim (a known mean of 54.7px, worst 117.5px, far wider than the - // hole) is brought down. A 70px correction cannot be judged against a - // 55px baseline error. - const curve = shotCurve(plat.x, plat.y, dir, W, null); + // Necessary, not sufficient: residual sd is still ~32px against a 25px + // scoring radius (the old 54.7px mean / 117.5px worst-case baseline + // this comment used to cite), and rimDy is separately biased about + // 25px high and is not touched by this change. + const curve = shotCurve(plat.x, plat.y, dir, W, cosRel); // Start the line directly above the platform rather than at the curve's // left crossing: that crossing is ~0.18 of a screen to the left, which // ran off the edge and made the arc appear to fly in from nowhere. ghostMade = drawCurve(curve.at, plat.x, dir, W, H, 'ghost'); - // Where the predicted arc crosses the rim's x. This is the number that - // decides a make, and publishing it is what makes the error MEASURABLE - // rather than just "missed": against the ball's true height there, it - // gives a signed error with a direction and a size. + // Where the predicted arc crosses the rim's x. This is kept as a + // secondary diagnostic (against the ball's true height there it gives a + // signed error with a direction and a size); it is no longer what + // decides a make -- see SCORE_DX/DY/R and ghostMinDist below for that. if (lastRim) ghostRimY = +curve.at(lastRim.x).toFixed(1); + // The actual decision variable now: closest approach of the predicted + // arc to the game's own scoring point, set by drawCurve() above. A make + // is ghostMinDist < SCORE_R*H -- publishing the distance itself (rather + // than just the boolean) is what lets that threshold be checked + // externally against real outcomes. + ghostMinDist = lastMinDist; + scorePt = lastScorePt; octx.save(); const topY = curve.at(plat.x); octx.strokeStyle = 'rgba(255,122,112,.35)'; // tie the arc to the platform @@ -1215,11 +1299,14 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, - flying, made, ready, ghostMade, ghostRimY, + flying, made, ready, ghostMade, ghostRimY, ghostMinDist, scorePt, cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded }, // null until most of one platform swing has been seen; then the // quadrature term that sets how hard this particular shot leaves cosPhi: cosPhi == null ? null : +cosPhi.toFixed(3), + // cosPhi rotated forward to the release phase, 35.035deg later -- the + // value actually fed to shotCurve() for the ghost preview + cosRel: cosRel == null ? null : +cosRel.toFixed(3), platY: plat ? +plat.y.toFixed(1) : null, fit: lastFit }); diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 05babdb..527327c 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.46 +// @version 1.49 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -1236,6 +1236,14 @@ // cos(phi) = (A*cos(wt) - B*sin(wt)) / amp // falls straight out, continuous everywhere and with no sign to choose. const PLAT_W = 2 * Math.PI / 5.035; // rad/s, from the game's own clock + // Release lags the input that triggers it by exactly 49 logic ticks + // (measured: 49 every time, sd 0, n=12). Over that many ticks the same + // oscillator advances G16[0] += 1.3 every 2 ticks, angle = G16[0]*1.1deg, + // so the phase at release is 24.5*1.3*1.1 = 35.035deg ahead of the phase + // read this frame. shotCurve wants cos of the ROTATED phase; see its + // comment for why the rotation, not the raw phase, is what gets applied. + const REL_PHASE = 35.035 * Math.PI / 180; + const REL_COS = Math.cos(REL_PHASE), REL_SIN = Math.sin(REL_PHASE); let platHist = []; function platCos(H, t) { if (!plat) return null; @@ -1279,8 +1287,16 @@ ss += (q.y - pred) * (q.y - pred); } if (Math.sqrt(ss / n) > amp * 0.25) return null; + // Same fit, both quadrature components. cos is exactly what shipped before + // (verified against the engine's own phase: error mean 0.0001, sd 0.0055 + // over 38 shots); sin falls out of the identical c1/c2/amp with no new + // fitting, and is what lets a caller rotate this phase forward to a later + // tick -- see the release-phase correction in shotCurve(). const wt = PLAT_W * (t / 1000); - return Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(wt)) / amp)); + return { + cos: Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(wt)) / amp)), + sin: Math.max(-1, Math.min(1, (c1 * Math.sin(wt) + c2 * Math.cos(wt)) / amp)), + }; } let holdT = -1e9; // last time a ball was seen in your hands let flightPlat = null; // where the platform was when this shot left @@ -1322,41 +1338,54 @@ // at (px+17, py-97), and only the x part is needed here because the curve is // already anchored in y to the platform. const RELX = 17 / 960; - function shotCurve(px, py, dir, W, cosPhi) { + // A prior attempt corrected uL/uR (the parabola's crossings) from cosPhi + // measured at press time: 65 offline shots gave L vs cos at -0.0949 (r2 + // 0.73) and R vs cos at +0.0253 (r2 0.23), the opposite split from what the + // release-point geometry predicted. That correction is gone, not just + // disabled -- it was fit against the wrong phase (press, not release; see + // below) and never showed up in outcomes (swish/score rate unchanged with + // it on vs off). Left removed rather than re-derived, since the slope term + // below now carries the oscillator's effect. + function shotCurve(px, py, dir, W, cosRel) { const A = cfg.shotA / W; - let uL = cfg.shotL * W, uR = cfg.shotR * W; - if (cosPhi != null) { - // MEASURED, not derived. The physics is certain -- platform height and - // release velocity are one oscillator in quadrature -- but the geometry - // for how a change in vy redistributes between the two crossings was - // wrong, and confidently so. - // - // Re-cutting the parabola from a fixed release point predicted the effect - // landing almost entirely on R (-0.0654 per unit cos) and barely touching - // L (-0.0171). Driven against the offline rip of the game -- 65 shots, - // 14 committed per-flight fits, cos sampled across its whole range -- the - // truth is the other way round: - // - // A vs cos +0.0235 +-0.0435 r2 0.02 (predicted 0: confirmed) - // L vs cos -0.0949 +-0.0165 r2 0.73 (predicted -0.0171) - // R vs cos +0.0253 +-0.0132 r2 0.23 (predicted -0.0654) - // - // Curvature is untouched by the oscillator exactly as the physics says, - // which is the part of the model that holds. But the coupling shows up in - // L at 5.8 standard errors, while R has the wrong sign and does not clear - // two. Most likely because the tracked part of a flight pins the - // descending branch, leaving L to absorb the change -- R is measured, L - // is extrapolated. - // - // So the slopes are taken from the fits instead of from the geometry. - // R's is left in at its measured value despite being weak; dropping it - // would tilt the arc, and 0.0253 is small enough that being wrong about - // it costs little either way. - uL += -0.0949 * cosPhi * W; - uR += 0.0253 * cosPhi * W; - } - return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR); }, - A, uL, uR, px, py, dir }; + const uL = cfg.shotL * W, uR = cfg.shotR * W; + // The oscillator term, evaluated at RELEASE rather than at press. + // + // The engine sets vy = -2.9 + 0.7*cos(phi) at the instant the ball leaves + // the hand, and release is exactly 49 logic ticks after the input that + // triggers it (measured: 49 every time, sd 0, n=12) -- not at the phase + // the player was aiming with. Over those 49 ticks the same oscillator + // that drives the platform keeps advancing: G16[0] += 1.3 every 2 ticks, + // angle = G16[0]*1.1 degrees, so 49 ticks is 24.5*1.3*1.1 = 35.035 + // degrees of phase. cosRel is that rotation applied by the caller -- + // cos(phi)*cos(35.035deg) - sin(phi)*sin(35.035deg) -- before it gets + // here; this function just uses it. + // + // The calibrated parabola (shotA/shotL/shotR) was fit across many shots + // at random phases, which averages cos(phi) to zero, so it represents the + // cos=0 case and the FULL term applies here, not a difference from it. + // In game units vy is px/tick and vx is 3.9 px/tick, so the added slope + // in u (screen px along the flight direction) is 0.7*cos(phi_release)/3.9 + // -- dimensionless, so it scales with the canvas like everything else. + // + // Measured against the offline rip: applying this at RELEASE phase took + // arc error sd 60.0 -> 30.5px (n=19) and 70.6 -> 32.5px (n=32), a 49% and + // 54% reduction. The same correction evaluated at PRESS phase on the same + // shots reduced sd by only 18% and 30%. Release phase is what the engine + // actually uses, and the data agrees. + // + // That press-vs-release gap is the load-bearing comparison, because it is + // invariant to the flight time: both arms scale with it. An early pass had + // the flight time wrong by 4x (it read the ball's position before launch, + // which still held the PREVIOUS shot's resting place) and release still + // beat press, 9% to -1%. The conclusion survived the bug that hid it. + // + // Necessary, not sufficient: residual sd is still ~32px against a 25px + // scoring radius, and rimDy is separately biased about 25px high and is + // not touched by this change. + const dSlope = cosRel == null ? 0 : 0.7 * cosRel / 3.9; + return { at: x => { const u = (x - px) * dir; return py + A * (u - uL) * (u - uR) + dSlope * u; }, + A, uL, uR, dSlope, px, py, dir }; } let lastRim = null, rimT = 0; let frame = 0; @@ -1442,23 +1471,77 @@ // past the net into the backboard post — it is wider than the hole and its // centre sits ~5% of its width right of it. Scoring off the raw bar at // +/-0.55 called anything within 68px a swish, which is wider than the hoop. - const HOLE_OFF = -0.05, HOLE_HALF = 0.30; + const HOLE_OFF = -0.05, HOLE_HALF = 0.30; // still used by drawRim's debug window, below + + // The game does NOT score by testing whether the arc's height crosses the + // rim bar's y inside a horizontal window (the HOLE_OFF/HOLE_HALF test this + // replaces). It scores when the ball's CENTRE comes within 25 game-px of a + // fixed scoring point offset from the hoop sprite's top-left corner, seen in + // the game's own code as (p95+39, p96+113). The helper's visually-detected + // rim sits measurably above that point, not on it. Measured across 82 shots + // in two independent sessions, which agree with each other and were + // therefore pooled: + // rim.y - scorePt.y: session A -24.23px sd 2.84, session B -23.63px sd 2.98 + // pooled: true scoring point = detected rim + (-4.4, +23.9) canvas px + // Expressed below as fractions of canvas size, never absolute px, because + // the game's physics -- and this helper's own calibration -- scales with + // the viewport: + // true scoring point = detected rim + (-0.00457*W, +0.04431*H) + // scoring radius = 0.04630*H (25 game-px on a 540-tall design canvas) + // + // The old crossing-through-rim.y test was wrong on both axes -- wrong + // target (rim.y instead of the scoring point ~24px below it) and wrong + // criterion (a line crossing instead of the game's own circle test) -- and + // it systematically UNDER-called makes: 18 called vs 27 actual across 43 + // shots, 56% agreement. The same 25px-radius circle test applied to the + // game's own ball trace (not this helper's prediction) agreed with the real + // outcome 40/43 -- that is the standard this replacement is judged against. + const SCORE_DX = -0.00457; // * W: detected rim.x -> scoring point x + const SCORE_DY = 0.04431; // * H: detected rim.y -> scoring point y + const SCORE_R = 0.04630; // * H: scoring radius (25px on a 540-tall canvas) + + // Closest point on segment P0->P1 to target T, clamped so it cannot fall + // outside the segment (standard clamped projection). Used instead of + // point-to-point distance because the arc below is only sampled every + // `step` px: checking sampled points alone biases the minimum distance + // HIGH between samples and would re-introduce the under-calling above. + function closestOnSeg(p0, p1, t) { + const dx = p1.x - p0.x, dy = p1.y - p0.y; + const len2 = dx * dx + dy * dy; + let u = len2 > 0 ? ((t.x - p0.x) * dx + (t.y - p0.y) * dy) / len2 : 0; + u = Math.max(0, Math.min(1, u)); + const x = p0.x + u * dx, y = p0.y + u * dy; + return { x, y, d: Math.hypot(t.x - x, t.y - y) }; + } + + // Set by drawCurve() each call, for the caller to publish on the probe -- + // drawCurve itself still returns only the made/missed boolean, matching + // every existing call site. + let lastMinDist = null, lastScorePt = null; + function drawCurve(yAt, xStart, dir, W, H, style) { const pts = []; - let made = false, hitX = 0; const rim = lastRim; - const holeX = rim ? rim.x + rim.w * HOLE_OFF : 0; + const scorePt = rim ? { x: rim.x + SCORE_DX * W, y: rim.y + SCORE_DY * H } : null; + const radius = SCORE_R * H; + let minDist = Infinity, closest = null; const step = Math.max(3, W / 240) * dir; for (let x = xStart, i = 0; i < 900; i++, x += step) { const y = yAt(x); + const p = { x, y }; const prev = pts[pts.length - 1]; - if (rim && prev && prev.y <= rim.y && y >= rim.y && - x > holeX - rim.w * HOLE_HALF && x < holeX + rim.w * HOLE_HALF) { made = true; hitX = x; } - pts.push({ x, y }); + if (scorePt && prev) { + const c = closestOnSeg(prev, p, scorePt); + if (c.d < minDist) { minDist = c.d; closest = c; } + } + pts.push(p); if (y > H + 80 || x < -80 || x > W + 80) break; } + lastMinDist = scorePt ? +minDist.toFixed(1) : null; + lastScorePt = scorePt ? { x: +scorePt.x.toFixed(1), y: +scorePt.y.toFixed(1) } : null; if (pts.length < 2) return false; + const made = !!scorePt && minDist < radius; const green = cfg.makes && made; octx.save(); octx.shadowColor = 'rgba(0,0,0,.7)'; @@ -1477,7 +1560,7 @@ octx.beginPath(); octx.arc(e.x, e.y, 4, 0, Math.PI * 2); octx.stroke(); if (made) { octx.lineWidth = 2.5; - octx.beginPath(); octx.arc(hitX, rim.y, 7, 0, Math.PI * 2); octx.stroke(); + octx.beginPath(); octx.arc(closest.x, closest.y, 7, 0, Math.PI * 2); octx.stroke(); } octx.restore(); return made; @@ -1560,7 +1643,13 @@ const pl = findPlatform(img.d, img.sw, img.sh, k, W); if (pl) { plat = pl; platT = t; } else if (t - platT > 700) plat = null; - const cosPhi = platCos(H, t); + const ph = platCos(H, t); + const cosPhi = ph ? ph.cos : null; + // Rotate the phase read THIS frame forward to where it will be at + // release, 49 ticks (35.035deg) later -- cos(phi+d) = cos(phi)cos(d) - + // sin(phi)sin(d). This is what shotCurve's oscillator term needs; see + // its comment for the rest of the derivation. + const cosRel = ph ? ph.cos * REL_COS - ph.sin * REL_SIN : null; if (cfg.debug) { octx.lineWidth = 1; @@ -1704,50 +1793,45 @@ } // ---- shot preview, anchored to the platform ---- - let ghostMade = null, ghostRimY = null; + let ghostMade = null, ghostRimY = null, ghostMinDist = null, scorePt = null; // Drawn whenever a ball is in your hands — NOT gated on "no shot in flight". // After a miss both are true at once, and suppressing the preview then is // exactly when you need it to line up the next shot. if (cfg.ghost && plat && ready) { const dir = lastRim ? Math.sign(lastRim.x - plat.x) || 1 : 1; - // DISABLED AGAIN, on outcome data rather than on how the preview looks. - // - // Driven against the offline rip with the game's own score as ground - // truth, 49 shots paired from release to result: - // - // GREEN n=28 1/28 swish 19/28 scored (68%) - // red n=21 0/21 swish 14/21 scored (67%) - // - // Green means "this arc threads the hole". One of 28 did. And green - // scores no better than red, so the preview carries no information about - // whether the shot goes in -- which makes a second-order correction to it - // unmeasurable by construction. + // RE-ENABLED, on the release-phase slope in shotCurve() rather than the + // uL/uR correction this comment used to explain away. That one was + // fit and applied at PRESS phase, which is not the phase the engine + // actually launches on -- release is 49 ticks (35.035deg) later, see + // REL_PHASE above and the derivation in shotCurve(). Applying the + // correction at the right phase is what changed the outcome: // - // The reported feel matches: descending shots are worse (10/16 against - // 9/12 ascending, though that gap is only ~0.7 SE and proves nothing on - // its own), and ascending is off too when the rim is CLOSE -- small u, - // where the arc is dominated by uL, which is the term this correction - // moves hardest at up to 0.095 W. The most likely reading is that the - // measured L slope is too large to apply raw. + // arc error sd, n=19: 60.0px -> 30.5px at release phase + // (only 18% of that gain if applied at press) + // arc error sd, n=32: 70.6px -> 32.5px at release phase + // (only 30% of that gain if applied at press) // - // Re-enabling it on a correlation with the per-flight fits was too weak a - // standard. Fits are the helper's own reading of the arc; whether shots - // go in is the thing that matters, and by that measure this does not - // help. The estimator and the measurements stay -- platCos is sound and - // the coupling is real -- but nothing here ships until the arc's error at - // the rim (a known mean of 54.7px, worst 117.5px, far wider than the - // hole) is brought down. A 70px correction cannot be judged against a - // 55px baseline error. - const curve = shotCurve(plat.x, plat.y, dir, W, null); + // Necessary, not sufficient: residual sd is still ~32px against a 25px + // scoring radius (the old 54.7px mean / 117.5px worst-case baseline + // this comment used to cite), and rimDy is separately biased about + // 25px high and is not touched by this change. + const curve = shotCurve(plat.x, plat.y, dir, W, cosRel); // Start the line directly above the platform rather than at the curve's // left crossing: that crossing is ~0.18 of a screen to the left, which // ran off the edge and made the arc appear to fly in from nowhere. ghostMade = drawCurve(curve.at, plat.x, dir, W, H, 'ghost'); - // Where the predicted arc crosses the rim's x. This is the number that - // decides a make, and publishing it is what makes the error MEASURABLE - // rather than just "missed": against the ball's true height there, it - // gives a signed error with a direction and a size. + // Where the predicted arc crosses the rim's x. This is kept as a + // secondary diagnostic (against the ball's true height there it gives a + // signed error with a direction and a size); it is no longer what + // decides a make -- see SCORE_DX/DY/R and ghostMinDist below for that. if (lastRim) ghostRimY = +curve.at(lastRim.x).toFixed(1); + // The actual decision variable now: closest approach of the predicted + // arc to the game's own scoring point, set by drawCurve() above. A make + // is ghostMinDist < SCORE_R*H -- publishing the distance itself (rather + // than just the boolean) is what lets that threshold be checked + // externally against real outcomes. + ghostMinDist = lastMinDist; + scorePt = lastScorePt; octx.save(); const topY = curve.at(plat.x); octx.strokeStyle = 'rgba(255,122,112,.35)'; // tie the arc to the platform @@ -1781,11 +1865,14 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, - flying, made, ready, ghostMade, ghostRimY, + flying, made, ready, ghostMade, ghostRimY, ghostMinDist, scorePt, cal: { a: cfg.shotA, l: cfg.shotL, r: cfg.shotR, seeded: cfg.calSeeded }, // null until most of one platform swing has been seen; then the // quadrature term that sets how hard this particular shot leaves cosPhi: cosPhi == null ? null : +cosPhi.toFixed(3), + // cosPhi rotated forward to the release phase, 35.035deg later -- the + // value actually fed to shotCurve() for the ghost preview + cosRel: cosRel == null ? null : +cosRel.toFixed(3), platY: plat ? +plat.y.toFixed(1) : null, fit: lastFit }); From 55c98f41e7865a107ee321e87dd652259673d280 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sun, 13 Sep 2026 13:04:03 -0400 Subject: [PATCH 2/2] Record that the hoops make-prediction carries no signal Widening the scoring radius to catch bank-ins is the obvious next idea and it is a dead end. Most makes in this game are banked (20 of 26 in one session) and a ballistic arc cannot predict a rattle-in, so a wider radius looks like the fix. It is not: the predicted distance barely relates to the outcome at all. Over 43 shots, ghostMinDist against whether the shot scored: mean | made 42.4px mean | miss 43.2px AUC 0.474 Fitting the best threshold on half the shots and scoring it on the held-out half gave 29% and 55%, straddling the 51% always-say-miss baseline. There is no threshold to find, and the split is what shows it -- picking a cutoff on all 43 would have produced a plausible-looking number. The same circle test applied to the game's own ball trace scores AUC 1.000, so the criterion and the geometry are correct. The arc is simply not accurate enough at the rim to discriminate: residual sd 29-49px against a 25px radius. The live lead is session dependence, not physics. On identical code, arc error sd ran 28.7, 35.3 and 49.3 across three sessions, and rim detection noise (rimDy sd) ran 2.84, 2.98 and 6.95, degrading as the score climbed and the hoop moved to new positions. That points at rim detection reliability rather than the trajectory model. Also recorded: overall ghostMade agreement swung 62% -> 44% between two sessions of the SAME build, so that metric is not comparable across sessions at n = 43. The 64/56/62 progression in the previous commit was reading noise; the arc improvement it reports stands on arcErr, which is measured per shot. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 28 ++++++++++++++++++++++++++++ idleon-suite.user.js | 30 +++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index f0b98d7..e549ed9 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -933,6 +933,34 @@ const SCORE_DY = 0.04431; // * H: detected rim.y -> scoring point y const SCORE_R = 0.04630; // * H: scoring radius (25px on a 540-tall canvas) + // DEAD END, measured, do not retry without new evidence: widening SCORE_R to + // catch bank-ins. Most makes here are banked (20 of 26 in one session), and a + // ballistic arc cannot predict a ball that rattles in, so a wider radius + // looks like the obvious fix. It is not, because the predicted distance + // carries almost no information about the outcome in the first place. + // + // Measured over 43 shots, ghostMinDist against whether the shot actually + // scored: + // mean ghostMinDist | made 42.4px + // mean ghostMinDist | miss 43.2px + // AUC 0.474 (0.5 is no information at all; 1.0 is perfect) + // Fitting the best threshold on half the shots and scoring it on the other + // half gave 29% and 55%, straddling the 51% always-say-miss baseline. There + // is no threshold to find. + // + // The same circle test fed the game's OWN ball trace instead of this + // prediction scores AUC 1.000, so the criterion and the geometry are right -- + // the arc simply is not accurate enough at the rim to discriminate. Residual + // arc error is sd 29-49px depending on session, against a 25px radius. + // Fix the arc, or the rim detection behind it, before touching this radius. + // + // Note the session dependence, which is the live lead: on identical code, + // arc error sd ran 28.7, 35.3 and 49.3 across three sessions, and rim + // detection noise (rimDy sd) ran 2.84, 2.98 and 6.95, degrading as the score + // climbed and the hoop moved. Score any change to this per session; overall + // ghostMade agreement swung 62% -> 44% between two sessions of the SAME + // build, so cross-session comparisons of it are not meaningful at n = 43. + // Closest point on segment P0->P1 to target T, clamped so it cannot fall // outside the segment (standard clamped projection). Used instead of // point-to-point distance because the arc below is only sampled every diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 527327c..eddeec3 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.49 +// @version 1.50 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-suite.user.js // @description All-in-one: autoclicker + Hoops, Fishing and Darts minigame helpers for Legends of IdleOn, each one individually switchable @@ -1500,6 +1500,34 @@ const SCORE_DY = 0.04431; // * H: detected rim.y -> scoring point y const SCORE_R = 0.04630; // * H: scoring radius (25px on a 540-tall canvas) + // DEAD END, measured, do not retry without new evidence: widening SCORE_R to + // catch bank-ins. Most makes here are banked (20 of 26 in one session), and a + // ballistic arc cannot predict a ball that rattles in, so a wider radius + // looks like the obvious fix. It is not, because the predicted distance + // carries almost no information about the outcome in the first place. + // + // Measured over 43 shots, ghostMinDist against whether the shot actually + // scored: + // mean ghostMinDist | made 42.4px + // mean ghostMinDist | miss 43.2px + // AUC 0.474 (0.5 is no information at all; 1.0 is perfect) + // Fitting the best threshold on half the shots and scoring it on the other + // half gave 29% and 55%, straddling the 51% always-say-miss baseline. There + // is no threshold to find. + // + // The same circle test fed the game's OWN ball trace instead of this + // prediction scores AUC 1.000, so the criterion and the geometry are right -- + // the arc simply is not accurate enough at the rim to discriminate. Residual + // arc error is sd 29-49px depending on session, against a 25px radius. + // Fix the arc, or the rim detection behind it, before touching this radius. + // + // Note the session dependence, which is the live lead: on identical code, + // arc error sd ran 28.7, 35.3 and 49.3 across three sessions, and rim + // detection noise (rimDy sd) ran 2.84, 2.98 and 6.95, degrading as the score + // climbed and the hoop moved. Score any change to this per session; overall + // ghostMade agreement swung 62% -> 44% between two sessions of the SAME + // build, so cross-session comparisons of it are not meaningful at n = 43. + // Closest point on segment P0->P1 to target T, clamped so it cannot fall // outside the segment (standard clamped projection). Used instead of // point-to-point distance because the arc below is only sampled every