From 1a18ba3bf1fccd875308c46eb5a888995d9da9ba Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:32:31 -0400 Subject: [PATCH 1/7] See the 18+ mph wind arrow, and tell an arrow from HUD text by density readWind matched two of the game's three arrow sprites. DartWind2 -- the one shown at 18 mph and up -- is red-orange, matched neither hue window, and so the strongest winds in the game read as 'none' and were modelled as still air. The same shape of bug as the magenta gate fixed in 2e4a0ab, and the last of them. Red is harder than the other two because it shares the HUD's own palette. The brown panel behind the arrow is hue 0-32 at saturation .30-.75; the amber text and trim beside it run hue 33-44. The arrow's own hue is quantised -- 73.5% of it under 36.3 and the rest exactly at 36.3, sitting inside the amber -- so hue cannot separate them at any cutoff. Brightness looked like the answer and was not. The arrow is v=1.00 throughout and the brown never passes .72, but the amber reaches .96. A first cut at .85 invented a wind on 70 frames of a recording that has none; .97 still let 22 through. That was fitting a constant to whichever frame had last been looked at, which is the same mistake this file already carries a warning about in the aim reach floor. What actually separates an arrow from HUD text is that an arrow is a solid blob. The sprites fill 4-8% of the scanned window (480, 518 and 258 px of a window 0.12W x 0.10H); the amber scatter being picked up was 22-32 px, under half a percent. The old "at least 8 pixels" test dates from when only cyan and magenta were matched, neither of which appears in the HUD at all. It is now 2% of the window area, which sits in a 15x gap and is resolution independent. Replayed against three recordings, all of which report no wind: 1032, 2369 and 3043 frames, zero false reds, and aim counts unchanged at 1032, 2369 and 2983. Those recordings can only prove red is not seen where it should not be. None of them contains an 18+ mph wind, so that red IS seen when it should be remains unverified until one turns up in play. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-darts.user.js | 67 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/idleon-darts.user.js b/idleon-darts.user.js index 1f563f6..65cf7dd 100644 --- a/idleon-darts.user.js +++ b/idleon-darts.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Darts Helper // @namespace nativerobot -// @version 1.14 +// @version 1.15 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-darts.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-darts.user.js // @description Draws the predicted dart path and where it lands on the board, wind included, for the Throwy Darts minigame @@ -84,10 +84,11 @@ // belongs to a later aim. That method cannot measure this and should not be // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height - // v6: magenta is NO LONGER gated. The colour was never a kind of wind, it is - // a strength tier — the game picks the arrow sprite as + // v6: magenta is NO LONGER gated, and v7 added red. The colour was never a + // kind of wind, it is a strength tier — the game picks the arrow sprite as // mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2 - // so cyan is simply every wind under 10 mph and magenta is 10-17. Every + // so cyan is every wind under 10 mph, magenta 10-17, red 18 and up. Red was + // not matched at all until v7 and read as 'none'; see windPx. Every // cyan logged here came in at 4/6/8/9 mph and every magenta at 10/11/13, // which is that boundary exactly. Gating magenta therefore threw away the // STRONGEST winds, modelling a 13 mph crosswind as still air. @@ -416,15 +417,62 @@ // Do not "calibrate" windK against this until the offset is anchored. // S is the native-resolution crop from grabWind, so the whole image IS the // window -- no sub-window arithmetic here any more. + // The three arrow sprites, and the one that used to be invisible. + // + // DartWind0 cyan hue 185-209 v .91-1.00 under 10 mph + // DartWind1 magenta hue 275-293 v 1.00 10-17 mph + // DartWind2 red hue 3- 36 v 1.00 18 mph and up + // + // Only the first two were ever matched, so an 18+ mph wind read as 'none' and + // was modelled as still air -- the strongest winds in the game, treated as no + // wind at all. Exactly the same shape of bug as the magenta gate. + // + // Red needs care the other two do not. It shares the HUD's own colours: the + // brown panel behind it is hue 0-32 saturation .30-.75, and the amber text and + // trim beside it run hue 33-44 -- so the arrow overlaps its background in BOTH + // hue and saturation. Hue cannot separate them at all: the arrow's hue is + // quantised, 73.5% of it below 36.3 and the remainder exactly at 36.3, right + // inside the amber. + // + // Brightness helps -- the arrow is v=1.00 throughout and the brown never gets + // past .72 -- but it is not enough on its own, because the amber reaches .96. + // What actually separates an arrow from HUD text is that an arrow is a solid + // blob; see the density gate in readWind. + // + // One asymmetry to know about: every darts recording reports 'none', which + // makes them a free test that red is not seen where it should not be. None of + // them contains an 18+ mph wind, so that red IS seen when it should be stays + // unverified until one turns up. + const windPx = (h, s, v) => + s > 0.35 && v > 0.6 && ( + (h > 165 && h < 215) || // cyan + (h > 270 && h < 335) || // magenta + (h < 45 && v > 0.85) // red, 18 mph and up + ); + function readWind(S) { if (!S) return { key: 'none', deg: 0 }; let pts = []; for (let y = 0; y < S.h; y++) for (let x = 0; x < S.w; x++) { const [h, s, v] = px(S, x, y); - if (s > 0.35 && v > 0.6 && ((h > 165 && h < 215) || (h > 270 && h < 335))) pts.push({ x, y, h }); + if (windPx(h, s, v)) pts.push({ x, y, h }); } - if (pts.length < 8) return { key: 'none', deg: 0 }; + // An arrow is a BLOB, not a scattering. Requiring merely 8 pixels was + // enough while only cyan and magenta were matched -- neither colour appears + // in the HUD -- but red shares the HUD's own palette, and a handful of + // amber text pixels would otherwise be read as a wind. + // + // Density is what separates them, and it does not care about colour at all: + // the arrow sprites fill 4-8% of this window (480, 518 and 258 px of a + // window that is 0.12W x 0.10H), while the amber scatter that was being + // picked up ran 22-32 px, under half a percent. 2% sits in the gap with + // room on both sides. + // + // This replaces a v threshold that was being tuned against whichever frame + // was last looked at -- .85 let 70 false frames through, .97 still let 22 + // through -- which is fitting a constant to noise rather than measuring. + if (pts.length < 0.02 * S.w * S.h) return { key: 'none', deg: 0 }; // The window catches a few matching pixels hard against its left edge that // are not part of the arrow at all -- seen as a stray column many pixels // clear of the glyph in a captured mask. They are far enough out to drag @@ -449,7 +497,12 @@ let ux = Math.cos(th), uy = Math.sin(th); if (ux < 0) { ux = -ux; uy = -uy; } const hue = pts.reduce((p, c) => p + c.h, 0) / n; - return { key: hue < 240 ? 'cyan' : 'magenta', deg: Math.atan2(-uy, ux) * 180 / Math.PI }; + // Staged, not a single split: red sits at ~20, which a `hue < 240` test + // would have called cyan. predict() no longer cares which name it gets -- + // every detected wind is trusted since v6 -- but the status line says it + // and the probe records it, so it should be the truth. + const key = hue < 45 ? 'red' : hue < 240 ? 'cyan' : 'magenta'; + return { key, deg: Math.atan2(-uy, ux) * 180 / Math.PI }; } From 9aa1bdaac800026fc5603b2441e1f9391d40dc9a Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:32:36 -0400 Subject: [PATCH 2/7] Pack docked panels into lanes instead of shelving them into rows Expanding the clicker pushed a collapsed Darts panel most of a screen down, past the empty space under the Suite panel where it plainly belonged. That is what shelving does: every wrapped panel starts below the TALLEST one before it, so one tall panel sets the depth for everything that follows and leaves a hole beside the short ones. Panels now run along the dock's edge until the viewport is used up, which fixes a set of lanes -- columns for a top dock, rows for a left one -- and everything after goes into whichever lane is currently shallowest. A short panel fills the gap beside a short neighbour rather than clearing the tall one. Lanes are disjoint along the edge, so nothing can overlap however the depths fall, and a panel is placed in the shallowest lane it actually FITS in where one exists, since the panels differ in width by about 30px and one dropped into a narrower lane would hang over its neighbour. The rebuilt suite also carries the darts wind change from 1a18ba3. Note for anyone editing the suite parts: `node --check` on 00-head.js or any other 0*-*.js will always fail. They are fragments -- 00-head opens the IIFE and 05-hub closes it -- so a syntax error there means nothing. Check the built idleon-suite.user.js instead. Reading that failure as real cost a revert of a good edit today. Not seen running; the packing postdates the last injection. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-suite.user.js | 129 ++++++++++++++++++++++++++++++----------- tools/suite/00-head.js | 60 +++++++++++-------- 2 files changed, 131 insertions(+), 58 deletions(-) diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 1834945..890a3d9 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.37 +// @version 1.41 // @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 @@ -156,37 +156,47 @@ // ?? not ||: the hub is dockOrder 0, which || would treat as missing and // sort to the bottom of its own dock. const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); - // A run wraps rather than running off the edge. Five panels do not fit - // across a half-width window, and with several expanded they do not fit - // down a short one either — and a panel past the edge is the exact trap the - // clamping in place() exists to avoid: unreachable, and unreachable means - // undraggable, so there is no way back to it. + // Packed into lanes, not shelved into rows. Shelving — starting every + // wrapped panel below the TALLEST one before it — leaves a hole: expanding + // the clicker pushed a collapsed Darts panel most of a screen down, past + // the empty space under the Suite panel where it plainly belonged. // - // `run` is the thickness of the current row (or column): the tallest panel - // in a row, the widest in a column, which is what the next one has to clear. - // The first panel of a run never wraps — if one panel is bigger than the - // whole viewport there is nowhere better for it, and wrapping on it would - // spin. - let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; + // So panels run along the dock's edge until the viewport is used up, and + // that fixes a set of lanes: columns for a top dock, rows for a left one. + // Everything after goes into whichever lane is currently SHALLOWEST, so a + // short panel fills the gap beside a short neighbour instead of clearing + // the tall one. Lanes are disjoint along the edge, so nothing can overlap + // however the depths fall. + const lim = vert ? window.innerHeight - DOCK_EDGE : window.innerWidth - DOCK_EDGE; + const lanes = []; // { pos, size, edge } along / across / depth used + let cursor = DOCK_EDGE; for (const { ui } of list) { - if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot const p = ui.panel; p.style.right = 'auto'; - // Measured before placing: the width is pinned by style.width so the - // height does not depend on where it ends up, and the wrap has to be - // decided before the position is written. + // Measured before placing: style.width pins the width, so the height does + // not depend on where it lands, and the lane has to be chosen first. const r = p.getBoundingClientRect(); - if (vert) { - if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { - x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; - } - } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { - y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; - } - p.style.left = x + 'px'; - p.style.top = y + 'px'; - if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } - else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } + const along = vert ? r.height : r.width; // extent along the dock edge + const deep = vert ? r.width : r.height; // extent away from it + let lane; + if (cursor + along <= lim || !lanes.length) { + // Room for another lane — or this is the first panel, which opens one + // even if it is bigger than the viewport, because there is nowhere else. + lane = { pos: cursor, size: along, edge: DOCK_EDGE }; + lanes.push(lane); + cursor += along + DOCK_GAP; + } else { + // Prefer the shallowest lane this actually FITS in; panels differ by up + // to ~30px and one placed in a narrower lane would hang over its + // neighbour. Fall back to the shallowest overall if none is wide enough. + const fits = lanes.filter(l => l.size >= along); + const pool = fits.length ? fits : lanes; + lane = pool.reduce((m, l) => (l.edge < m.edge ? l : m), pool[0]); + } + p.style.left = (vert ? lane.edge : lane.pos) + 'px'; + p.style.top = (vert ? lane.pos : lane.edge) + 'px'; + lane.edge += deep + DOCK_GAP; } } @@ -2841,10 +2851,11 @@ // belongs to a later aim. That method cannot measure this and should not be // used to re-tune landN. Compare against the tracked flight instead. landN: 0, // landing correction / height - // v6: magenta is NO LONGER gated. The colour was never a kind of wind, it is - // a strength tier — the game picks the arrow sprite as + // v6: magenta is NO LONGER gated, and v7 added red. The colour was never a + // kind of wind, it is a strength tier — the game picks the arrow sprite as // mag < 10 ? DartWind0 : mag < 18 ? DartWind1 : DartWind2 - // so cyan is simply every wind under 10 mph and magenta is 10-17. Every + // so cyan is every wind under 10 mph, magenta 10-17, red 18 and up. Red was + // not matched at all until v7 and read as 'none'; see windPx. Every // cyan logged here came in at 4/6/8/9 mph and every magenta at 10/11/13, // which is that boundary exactly. Gating magenta therefore threw away the // STRONGEST winds, modelling a 13 mph crosswind as still air. @@ -3099,15 +3110,62 @@ // Do not "calibrate" windK against this until the offset is anchored. // S is the native-resolution crop from grabWind, so the whole image IS the // window -- no sub-window arithmetic here any more. + // The three arrow sprites, and the one that used to be invisible. + // + // DartWind0 cyan hue 185-209 v .91-1.00 under 10 mph + // DartWind1 magenta hue 275-293 v 1.00 10-17 mph + // DartWind2 red hue 3- 36 v 1.00 18 mph and up + // + // Only the first two were ever matched, so an 18+ mph wind read as 'none' and + // was modelled as still air -- the strongest winds in the game, treated as no + // wind at all. Exactly the same shape of bug as the magenta gate. + // + // Red needs care the other two do not. It shares the HUD's own colours: the + // brown panel behind it is hue 0-32 saturation .30-.75, and the amber text and + // trim beside it run hue 33-44 -- so the arrow overlaps its background in BOTH + // hue and saturation. Hue cannot separate them at all: the arrow's hue is + // quantised, 73.5% of it below 36.3 and the remainder exactly at 36.3, right + // inside the amber. + // + // Brightness helps -- the arrow is v=1.00 throughout and the brown never gets + // past .72 -- but it is not enough on its own, because the amber reaches .96. + // What actually separates an arrow from HUD text is that an arrow is a solid + // blob; see the density gate in readWind. + // + // One asymmetry to know about: every darts recording reports 'none', which + // makes them a free test that red is not seen where it should not be. None of + // them contains an 18+ mph wind, so that red IS seen when it should be stays + // unverified until one turns up. + const windPx = (h, s, v) => + s > 0.35 && v > 0.6 && ( + (h > 165 && h < 215) || // cyan + (h > 270 && h < 335) || // magenta + (h < 45 && v > 0.85) // red, 18 mph and up + ); + function readWind(S) { if (!S) return { key: 'none', deg: 0 }; let pts = []; for (let y = 0; y < S.h; y++) for (let x = 0; x < S.w; x++) { const [h, s, v] = px(S, x, y); - if (s > 0.35 && v > 0.6 && ((h > 165 && h < 215) || (h > 270 && h < 335))) pts.push({ x, y, h }); + if (windPx(h, s, v)) pts.push({ x, y, h }); } - if (pts.length < 8) return { key: 'none', deg: 0 }; + // An arrow is a BLOB, not a scattering. Requiring merely 8 pixels was + // enough while only cyan and magenta were matched -- neither colour appears + // in the HUD -- but red shares the HUD's own palette, and a handful of + // amber text pixels would otherwise be read as a wind. + // + // Density is what separates them, and it does not care about colour at all: + // the arrow sprites fill 4-8% of this window (480, 518 and 258 px of a + // window that is 0.12W x 0.10H), while the amber scatter that was being + // picked up ran 22-32 px, under half a percent. 2% sits in the gap with + // room on both sides. + // + // This replaces a v threshold that was being tuned against whichever frame + // was last looked at -- .85 let 70 false frames through, .97 still let 22 + // through -- which is fitting a constant to noise rather than measuring. + if (pts.length < 0.02 * S.w * S.h) return { key: 'none', deg: 0 }; // The window catches a few matching pixels hard against its left edge that // are not part of the arrow at all -- seen as a stray column many pixels // clear of the glyph in a captured mask. They are far enough out to drag @@ -3132,7 +3190,12 @@ let ux = Math.cos(th), uy = Math.sin(th); if (ux < 0) { ux = -ux; uy = -uy; } const hue = pts.reduce((p, c) => p + c.h, 0) / n; - return { key: hue < 240 ? 'cyan' : 'magenta', deg: Math.atan2(-uy, ux) * 180 / Math.PI }; + // Staged, not a single split: red sits at ~20, which a `hue < 240` test + // would have called cyan. predict() no longer cares which name it gets -- + // every detected wind is trusted since v6 -- but the status line says it + // and the probe records it, so it should be the truth. + const key = hue < 45 ? 'red' : hue < 240 ? 'cyan' : 'magenta'; + return { key, deg: Math.atan2(-uy, ux) * 180 / Math.PI }; } // ---------- reading the wind speed ---------- diff --git a/tools/suite/00-head.js b/tools/suite/00-head.js index dc956b2..94ab5eb 100644 --- a/tools/suite/00-head.js +++ b/tools/suite/00-head.js @@ -156,37 +156,47 @@ // ?? not ||: the hub is dockOrder 0, which || would treat as missing and // sort to the bottom of its own dock. const list = docks.slice().sort((a, b) => (a.def.dockOrder ?? 99) - (b.def.dockOrder ?? 99)); - // A run wraps rather than running off the edge. Five panels do not fit - // across a half-width window, and with several expanded they do not fit - // down a short one either — and a panel past the edge is the exact trap the - // clamping in place() exists to avoid: unreachable, and unreachable means - // undraggable, so there is no way back to it. + // Packed into lanes, not shelved into rows. Shelving — starting every + // wrapped panel below the TALLEST one before it — leaves a hole: expanding + // the clicker pushed a collapsed Darts panel most of a screen down, past + // the empty space under the Suite panel where it plainly belonged. // - // `run` is the thickness of the current row (or column): the tallest panel - // in a row, the widest in a column, which is what the next one has to clear. - // The first panel of a run never wraps — if one panel is bigger than the - // whole viewport there is nowhere better for it, and wrapping on it would - // spin. - let x = DOCK_EDGE, y = DOCK_EDGE, run = 0; + // So panels run along the dock's edge until the viewport is used up, and + // that fixes a set of lanes: columns for a top dock, rows for a left one. + // Everything after goes into whichever lane is currently SHALLOWEST, so a + // short panel fills the gap beside a short neighbour instead of clearing + // the tall one. Lanes are disjoint along the edge, so nothing can overlap + // however the depths fall. + const lim = vert ? window.innerHeight - DOCK_EDGE : window.innerWidth - DOCK_EDGE; + const lanes = []; // { pos, size, edge } along / across / depth used + let cursor = DOCK_EDGE; for (const { ui } of list) { - if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot + if (ui.cfg.hidden) continue; // hidden panels are a nub, not a slot const p = ui.panel; p.style.right = 'auto'; - // Measured before placing: the width is pinned by style.width so the - // height does not depend on where it ends up, and the wrap has to be - // decided before the position is written. + // Measured before placing: style.width pins the width, so the height does + // not depend on where it lands, and the lane has to be chosen first. const r = p.getBoundingClientRect(); - if (vert) { - if (y > DOCK_EDGE && y + r.height > window.innerHeight - DOCK_EDGE) { - x += run + DOCK_GAP; y = DOCK_EDGE; run = 0; - } - } else if (x > DOCK_EDGE && x + r.width > window.innerWidth - DOCK_EDGE) { - y += run + DOCK_GAP; x = DOCK_EDGE; run = 0; + const along = vert ? r.height : r.width; // extent along the dock edge + const deep = vert ? r.width : r.height; // extent away from it + let lane; + if (cursor + along <= lim || !lanes.length) { + // Room for another lane — or this is the first panel, which opens one + // even if it is bigger than the viewport, because there is nowhere else. + lane = { pos: cursor, size: along, edge: DOCK_EDGE }; + lanes.push(lane); + cursor += along + DOCK_GAP; + } else { + // Prefer the shallowest lane this actually FITS in; panels differ by up + // to ~30px and one placed in a narrower lane would hang over its + // neighbour. Fall back to the shallowest overall if none is wide enough. + const fits = lanes.filter(l => l.size >= along); + const pool = fits.length ? fits : lanes; + lane = pool.reduce((m, l) => (l.edge < m.edge ? l : m), pool[0]); } - p.style.left = x + 'px'; - p.style.top = y + 'px'; - if (vert) { y += r.height + DOCK_GAP; run = Math.max(run, r.width); } - else { x += r.width + DOCK_GAP; run = Math.max(run, r.height); } + p.style.left = (vert ? lane.edge : lane.pos) + 'px'; + p.style.top = (vert ? lane.pos : lane.edge) + 'px'; + lane.edge += deep + DOCK_GAP; } } From 0afbea0071c895182aefd42da493df2b0be2a983 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:57:33 -0400 Subject: [PATCH 3/7] Draw the catch window the fishing game actually uses, not just the spot The helper marked where each fish is and what power lands on it, but said nothing about how close is close enough. That is knowable exactly. The game's test is |fishX - bobberX| < 6 + SIZE[type] with SIZE = [6,6,9,10,12,13,17,17] in lane units, the 6 being the bobber's own half-width. Points identify the type -- 1pt is type 2, 2pt type 3, 3pt type 4, 5pt type 6 -- so the tolerances are 15, 16, 18 and 23 lane units, and the pufferfish (type 5, size 13) is 19. Those are stored as fractions of the lane rather than pixels, because the lane is about 299.5 lane units across. Two independent routes agree on that number: inverting the measured aim curve puts the lane's ends at game x 11 and 311, and the game seeds fish between 40 and 295 with the bobber landing between 24 and 285, all inside that span. A fraction survives any window size; a pixel count would not. Two things are drawn from it. On the lane, a bar as wide as the real tolerance, so a near miss looks near instead of mysterious -- a whale is half again as forgiving as a fish, which the sprites do not suggest. On the gauge, the tick becomes a BAND: the ends of the catch window mapped back through the aim curve are the range of gauge fills that still land the cast. That is the release slack, which is the thing actually being aimed at; a tick says where perfect is and nothing about the room around it. The curve is not linear, so the band sits asymmetrically around the tick and tightens for fish further out. The pufferfish gets the same bar, since its window is how far away to stay, and at 19 units it is wider than every catch except the whale. Replayed over a fishing recording: 1289 frames, 1267 with a lane, no exceptions -- which matters because the standalone has no error containment around its loop, so a throw in the drawing would stop the helper dead. The bars and bands have NOT been seen rendered; that needs a live fishing spot. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-fishing.user.js | 70 ++++++++++++++++++++++++++++++++++++++---- idleon-suite.user.js | 70 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/idleon-fishing.user.js b/idleon-fishing.user.js index 3c37c03..09e079b 100644 --- a/idleon-fishing.user.js +++ b/idleon-fishing.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Fishing Helper // @namespace nativerobot -// @version 2.4 +// @version 2.5 // @downloadURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-fishing.user.js // @updateURL https://raw.githubusercontent.com/averagenative/idleon-userscripts/main/idleon-fishing.user.js // @description Draws where your cast will land, plus fish and hazard markers, for the IdleOn fishing minigame @@ -314,12 +314,30 @@ const isEel = (h, s, v) => h > 30 && h < 55 && s > 0.35 && v > 0.55; const isSquid = (h, s, v) => h > 255 && h <= 315 && s > 0.12 && v > 0.35; const isWhale = (h, s, v) => h > 228 && h < 258 && s > 0.22 && s < 0.6 && v > 0.3; + // How close the bobber has to land, per species, as a fraction of the lane. + // The game's catch test is + // |fishX - bobberX| < 6 + SIZE[type] + // with SIZE = [6,6,9,10,12,13,17,17] in lane units and the 6 being the + // bobber's own half-width. Points identify the type: 1pt is type 2, 2pt is + // type 3, 3pt is type 4 and 5pt is type 6, so the tolerances come out at + // 15, 16, 18 and 23 lane units. The pufferfish is type 5, size 13, so 19. + // + // The lane is about 299.5 of those units across, and two independent routes + // agree on it: inverting the measured aim curve puts the lane ends at game x + // 11 and 311, and the game seeds fish between 40 and 295 with the bobber + // landing between 24 and 285 — all inside that span. Dividing by it turns a + // tolerance into a fraction of whatever the lane measures on screen, so this + // survives any window size, which raw pixels would not. + const LANE_UNITS = 299.5; + const tol = u => u / LANE_UNITS; + const SPECIES = [ - { name: 'FISH', pts: 1, color: '#4ade80', test: isFish }, - { name: 'EEL', pts: 2, color: '#facc15', test: isEel }, - { name: 'SQUID', pts: 3, color: '#e879f9', test: isSquid }, - { name: 'WHALE', pts: 5, color: '#60a5fa', test: isWhale }, + { name: 'FISH', pts: 1, color: '#4ade80', test: isFish, catchN: tol(15) }, + { name: 'EEL', pts: 2, color: '#facc15', test: isEel, catchN: tol(16) }, + { name: 'SQUID', pts: 3, color: '#e879f9', test: isSquid, catchN: tol(18) }, + { name: 'WHALE', pts: 5, color: '#60a5fa', test: isWhale, catchN: tol(23) }, ]; + const HAZARD_N = tol(19); // pufferfish, type 5, size 13 // ---------- the lane ---------- // The fishing lane is a long flat blue bar. Its longest horizontal run is both @@ -916,6 +934,20 @@ // Left of each catch, the power that would land the cast on it — the // number to release the gauge at. Recomputed every frame, so once the // fish start moving (later in a run) the label tracks them. + // The catch WINDOW, not just the spot: a bar as wide as the tolerance the + // game actually allows, so a near miss is visibly near rather than a + // mystery. A whale is half again as forgiving as a fish, which is not + // something the sprite sizes make obvious. + octx.save(); + octx.lineWidth = 3; octx.globalAlpha = 0.45; + octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 2; + for (const f of fish) { + const r = (f.catchN || 0) * laneW; + if (r <= 0) continue; + octx.strokeStyle = f.color; + octx.beginPath(); octx.moveTo(f.x - r, f.y); octx.lineTo(f.x + r, f.y); octx.stroke(); + } + octx.restore(); for (const f of fish) { const p = invAim((f.x - laneX0) / laneW); drawLaneMark(f.x, f.y, f.color, `${f.name} +${f.pts}`, p !== null ? ((p * 100) | 0) + '%' : null); @@ -927,8 +959,17 @@ // other over a spot you actually want to hit. Hazards only cost you when // you land on a bare one, or miss everything; same W*0.02 as the marker. for (const z of haz) - if (!fish.some(f => Math.abs(f.x - z.x) < W * 0.02)) + if (!fish.some(f => Math.abs(f.x - z.x) < W * 0.02)) { + // Same treatment for the pufferfish: its window is how far away you + // have to stay, and at 19 lane units it is wider than every catch + // except the whale. + const r = HAZARD_N * laneW; + octx.save(); + octx.strokeStyle = '#f87171'; octx.lineWidth = 3; octx.globalAlpha = 0.45; + octx.beginPath(); octx.moveTo(z.x - r, z.y); octx.lineTo(z.x + r, z.y); octx.stroke(); + octx.restore(); drawLaneMark(z.x, z.y, '#f87171', 'AVOID'); + } } // ---- power meter ---- @@ -951,7 +992,24 @@ const p = invAim((f.x - laneX0) / laneW); if (p === null) continue; const tx = m.x * kx, ty = (m.bot - p * (m.bot - m.top)) * ky; + // A BAND, not a tick: the ends of the catch window mapped back through + // the aim curve give the range of gauge fills that still land on this + // fish. That is the release slack, and it is what you are actually + // aiming at — a tick says where perfect is and nothing about how much + // room there is around it. The curve is not linear, so the band is not + // symmetric about the tick, and it tightens the further out the fish is. + const r = (f.catchN || 0) * laneW; + const pLo = invAim((f.x - r - laneX0) / laneW); + const pHi = invAim((f.x + r - laneX0) / laneW); octx.strokeStyle = f.color; + if (pLo !== null && pHi !== null) { + const yLo = (m.bot - pLo * (m.bot - m.top)) * ky; + const yHi = (m.bot - pHi * (m.bot - m.top)) * ky; + octx.save(); + octx.globalAlpha = 0.35; octx.lineWidth = 6; + octx.beginPath(); octx.moveTo(tx - 2, yLo); octx.lineTo(tx - 2, yHi); octx.stroke(); + octx.restore(); + } octx.beginPath(); octx.moveTo(tx - 12, ty); octx.lineTo(tx + 8, ty); octx.stroke(); } octx.restore(); diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 890a3d9..c9f77ca 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.41 +// @version 1.42 // @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 @@ -1966,12 +1966,30 @@ const isEel = (h, s, v) => h > 30 && h < 55 && s > 0.35 && v > 0.55; const isSquid = (h, s, v) => h > 255 && h <= 315 && s > 0.12 && v > 0.35; const isWhale = (h, s, v) => h > 228 && h < 258 && s > 0.22 && s < 0.6 && v > 0.3; + // How close the bobber has to land, per species, as a fraction of the lane. + // The game's catch test is + // |fishX - bobberX| < 6 + SIZE[type] + // with SIZE = [6,6,9,10,12,13,17,17] in lane units and the 6 being the + // bobber's own half-width. Points identify the type: 1pt is type 2, 2pt is + // type 3, 3pt is type 4 and 5pt is type 6, so the tolerances come out at + // 15, 16, 18 and 23 lane units. The pufferfish is type 5, size 13, so 19. + // + // The lane is about 299.5 of those units across, and two independent routes + // agree on it: inverting the measured aim curve puts the lane ends at game x + // 11 and 311, and the game seeds fish between 40 and 295 with the bobber + // landing between 24 and 285 — all inside that span. Dividing by it turns a + // tolerance into a fraction of whatever the lane measures on screen, so this + // survives any window size, which raw pixels would not. + const LANE_UNITS = 299.5; + const tol = u => u / LANE_UNITS; + const SPECIES = [ - { name: 'FISH', pts: 1, color: '#4ade80', test: isFish }, - { name: 'EEL', pts: 2, color: '#facc15', test: isEel }, - { name: 'SQUID', pts: 3, color: '#e879f9', test: isSquid }, - { name: 'WHALE', pts: 5, color: '#60a5fa', test: isWhale }, + { name: 'FISH', pts: 1, color: '#4ade80', test: isFish, catchN: tol(15) }, + { name: 'EEL', pts: 2, color: '#facc15', test: isEel, catchN: tol(16) }, + { name: 'SQUID', pts: 3, color: '#e879f9', test: isSquid, catchN: tol(18) }, + { name: 'WHALE', pts: 5, color: '#60a5fa', test: isWhale, catchN: tol(23) }, ]; + const HAZARD_N = tol(19); // pufferfish, type 5, size 13 // ---------- the lane ---------- // The fishing lane is a long flat blue bar. Its longest horizontal run is both @@ -2567,6 +2585,20 @@ // Left of each catch, the power that would land the cast on it — the // number to release the gauge at. Recomputed every frame, so once the // fish start moving (later in a run) the label tracks them. + // The catch WINDOW, not just the spot: a bar as wide as the tolerance the + // game actually allows, so a near miss is visibly near rather than a + // mystery. A whale is half again as forgiving as a fish, which is not + // something the sprite sizes make obvious. + octx.save(); + octx.lineWidth = 3; octx.globalAlpha = 0.45; + octx.shadowColor = 'rgba(0,0,0,.6)'; octx.shadowBlur = 2; + for (const f of fish) { + const r = (f.catchN || 0) * laneW; + if (r <= 0) continue; + octx.strokeStyle = f.color; + octx.beginPath(); octx.moveTo(f.x - r, f.y); octx.lineTo(f.x + r, f.y); octx.stroke(); + } + octx.restore(); for (const f of fish) { const p = invAim((f.x - laneX0) / laneW); drawLaneMark(f.x, f.y, f.color, `${f.name} +${f.pts}`, p !== null ? ((p * 100) | 0) + '%' : null); @@ -2578,8 +2610,17 @@ // other over a spot you actually want to hit. Hazards only cost you when // you land on a bare one, or miss everything; same W*0.02 as the marker. for (const z of haz) - if (!fish.some(f => Math.abs(f.x - z.x) < W * 0.02)) + if (!fish.some(f => Math.abs(f.x - z.x) < W * 0.02)) { + // Same treatment for the pufferfish: its window is how far away you + // have to stay, and at 19 lane units it is wider than every catch + // except the whale. + const r = HAZARD_N * laneW; + octx.save(); + octx.strokeStyle = '#f87171'; octx.lineWidth = 3; octx.globalAlpha = 0.45; + octx.beginPath(); octx.moveTo(z.x - r, z.y); octx.lineTo(z.x + r, z.y); octx.stroke(); + octx.restore(); drawLaneMark(z.x, z.y, '#f87171', 'AVOID'); + } } // ---- power meter ---- @@ -2602,7 +2643,24 @@ const p = invAim((f.x - laneX0) / laneW); if (p === null) continue; const tx = m.x * kx, ty = (m.bot - p * (m.bot - m.top)) * ky; + // A BAND, not a tick: the ends of the catch window mapped back through + // the aim curve give the range of gauge fills that still land on this + // fish. That is the release slack, and it is what you are actually + // aiming at — a tick says where perfect is and nothing about how much + // room there is around it. The curve is not linear, so the band is not + // symmetric about the tick, and it tightens the further out the fish is. + const r = (f.catchN || 0) * laneW; + const pLo = invAim((f.x - r - laneX0) / laneW); + const pHi = invAim((f.x + r - laneX0) / laneW); octx.strokeStyle = f.color; + if (pLo !== null && pHi !== null) { + const yLo = (m.bot - pLo * (m.bot - m.top)) * ky; + const yHi = (m.bot - pHi * (m.bot - m.top)) * ky; + octx.save(); + octx.globalAlpha = 0.35; octx.lineWidth = 6; + octx.beginPath(); octx.moveTo(tx - 2, yLo); octx.lineTo(tx - 2, yHi); octx.stroke(); + octx.restore(); + } octx.beginPath(); octx.moveTo(tx - 12, ty); octx.lineTo(tx + 8, ty); octx.stroke(); } octx.restore(); From 79111a7bac1e2dc17ce1b9b3d82f343fa2518c88 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 18:59:31 -0400 Subject: [PATCH 4/7] Estimate the hoops platform phase as a phase, so cos stops jumping The oscillator correction was disabled in 57faab9 because recovering cos from |sin| plus a direction-of-travel sign is discontinuous exactly where the platform spends most of its visible time. On a real run it flipped sign 34 times and jumped by more than 0.5 in cos 17 times, worst case +0.946 to -0.955 across a single frame, so the preview leapt between the strongest and weakest shot. Nothing needs guessing, because the period is known exactly: G16[0] gains 1.3 every 20ms and phi = 1.1*G16[0] degrees, which is 71.5 deg/s and a 5.035s period. With w fixed, platY(t) = y0 + A*sin(wt) + B*cos(wt) is ordinary linear least squares in three unknowns over a rolling window, and cos(phi) = (A*cos(wt) - B*sin(wt)) / hypot(A,B) comes straight out -- continuous everywhere, with no sign to choose and nothing to flip at a turning point. The window is just over half a period. Shorter and sin and cos are too alike across it to separate, which lets A and B trade places freely. The fit is rejected unless its amplitude lands near the real swing (110 of 540 on the design canvas) and its residual is small against that amplitude, because a fit that has latched onto drift instead of the oscillation gives a confident wrong phase, which is worse than no correction. Replayed against the recording of the run that regressed: before after sign flips 34 7 (genuine zero crossings; cos passes jumps > 0.5 17 0 through zero twice a cycle) largest jump 1.90 0.119 cos range -0.99 .. 1.00 STILL DISABLED at the call site. Fixing the estimator is not the same as showing the correction helps, and the test for that -- whether each shot's fitted R tracks the cos it was thrown on -- cannot be run here: neither hoops recording commits a single per-flight fit, since commitCal wants 6+ tracked frames per flight and the replay does not produce them. cosPhi and the per-flight fit are both still published on the probe, so a live run would settle it. Re-enabling is one argument at the shotCurve call. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 80 +++++++++++++++++++++++++++++++++----------- idleon-suite.user.js | 80 +++++++++++++++++++++++++++++++++----------- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 0d49c8a..2b31435 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.12 +// @version 1.13 // @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 @@ -637,26 +637,68 @@ // one ~5s cycle that looks locally linear and correlates strongly, then fails // out of sample -- exactly the 43%-better-on-shotL, 3%-better-at-the-rim // split that was measured. - let platLo = Infinity, platHi = -Infinity, platHist = []; + // The phase is estimated AS A PHASE. The first attempt recovered cos from + // |sin| plus a direction-of-travel sign, which is discontinuous exactly where + // the platform spends most of its visible time: on a real run it flipped sign + // 34 times and jumped over 0.5 in cos 17 times, the worst going +0.946 to + // -0.955 across one frame as the platform reversed. The preview leapt between + // the strongest and weakest shot, which is worse than no correction. See + // 57faab9. + // + // The period is known exactly, so nothing has to be guessed: G16[0] gains 1.3 + // every 20ms and phi = 1.1*G16[0] degrees, giving 71.5 deg/s and a 5.035s + // period. With w fixed, + // platY(t) = y0 + A*sin(wt) + B*cos(wt) + // is linear least squares in (y0, A, B) over a window of observations, and + // amp = hypot(A, B) + // 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 + let platHist = []; function platCos(H, t) { if (!plat) return null; - platHist.push({ t, y: plat.y }); - while (platHist.length > 1 && t - platHist[0].t > 400) platHist.shift(); - if (plat.y < platLo) platLo = plat.y; - if (plat.y > platHi) platHi = plat.y; - // The full swing is 220 of 540 on the design canvas. Until most of one has - // been seen the midpoint is a guess, and a wrong midpoint is worse than no - // correction at all. - if (platHi - platLo < (200 / 540) * H) return null; - const y0 = (platLo + platHi) / 2, amp = (platHi - platLo) / 2; - const sn = Math.max(-1, Math.min(1, (plat.y - y0) / amp)); - if (platHist.length < 3) return null; - const dy = plat.y - platHist[0].y; - // Near the turning points the direction cannot be read -- but that is also - // where cos is near zero, so falling back to no correction there costs - // almost nothing. The failure is self-limiting. - if (Math.abs(dy) < 0.5) return null; - return Math.sign(dy) * Math.sqrt(Math.max(0, 1 - sn * sn)); + platHist.push({ t: t / 1000, y: plat.y }); + // Just over half a period. Less than that and sin and cos are too alike + // across the window to be told apart, which makes A and B swap freely. + while (platHist.length > 1 && t / 1000 - platHist[0].t > 3.0) platHist.shift(); + const n = platHist.length; + if (n < 20 || t / 1000 - platHist[0].t < 2.0) return null; + // normal equations for y = c0 + c1*sin(wt) + c2*cos(wt) + let Ss = 0, Sc = 0, Sss = 0, Scc = 0, Ssc = 0, Sy = 0, Sys = 0, Syc = 0; + for (const q of platHist) { + const sn = Math.sin(PLAT_W * q.t), cs = Math.cos(PLAT_W * q.t); + Ss += sn; Sc += cs; Sss += sn * sn; Scc += cs * cs; Ssc += sn * cs; + Sy += q.y; Sys += q.y * sn; Syc += q.y * cs; + } + const M = [[n, Ss, Sc], [Ss, Sss, Ssc], [Sc, Ssc, Scc]], V = [Sy, Sys, Syc]; + for (let i = 0; i < 3; i++) { + let piv = M[i][i]; + if (Math.abs(piv) < 1e-9) return null; + for (let k = i + 1; k < 3; k++) { + const f = M[k][i] / piv; + for (let j = i; j < 3; j++) M[k][j] -= f * M[i][j]; + V[k] -= f * V[i]; + } + } + if (Math.abs(M[2][2]) < 1e-9) return null; + const c2 = V[2] / M[2][2]; + const c1 = (V[1] - M[1][2] * c2) / M[1][1]; + const c0 = (V[0] - M[0][1] * c1 - M[0][2] * c2) / M[0][0]; + const amp = Math.hypot(c1, c2); + // The real swing is 110 of 540 on the design canvas. An amplitude far off + // that means the fit has latched onto drift or noise rather than the + // oscillation, and a wrong phase is worse than no correction at all. + const want = (110 / 540) * H; + if (amp < want * 0.5 || amp > want * 1.8) return null; + // and it has to actually describe the samples + let ss = 0; + for (const q of platHist) { + const pred = c0 + c1 * Math.sin(PLAT_W * q.t) + c2 * Math.cos(PLAT_W * q.t); + ss += (q.y - pred) * (q.y - pred); + } + if (Math.sqrt(ss / n) > amp * 0.25) return null; + const wt = PLAT_W * (t / 1000); + return Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(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 diff --git a/idleon-suite.user.js b/idleon-suite.user.js index c9f77ca..66c9a1c 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.42 +// @version 1.43 // @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 @@ -1220,26 +1220,68 @@ // one ~5s cycle that looks locally linear and correlates strongly, then fails // out of sample -- exactly the 43%-better-on-shotL, 3%-better-at-the-rim // split that was measured. - let platLo = Infinity, platHi = -Infinity, platHist = []; + // The phase is estimated AS A PHASE. The first attempt recovered cos from + // |sin| plus a direction-of-travel sign, which is discontinuous exactly where + // the platform spends most of its visible time: on a real run it flipped sign + // 34 times and jumped over 0.5 in cos 17 times, the worst going +0.946 to + // -0.955 across one frame as the platform reversed. The preview leapt between + // the strongest and weakest shot, which is worse than no correction. See + // 57faab9. + // + // The period is known exactly, so nothing has to be guessed: G16[0] gains 1.3 + // every 20ms and phi = 1.1*G16[0] degrees, giving 71.5 deg/s and a 5.035s + // period. With w fixed, + // platY(t) = y0 + A*sin(wt) + B*cos(wt) + // is linear least squares in (y0, A, B) over a window of observations, and + // amp = hypot(A, B) + // 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 + let platHist = []; function platCos(H, t) { if (!plat) return null; - platHist.push({ t, y: plat.y }); - while (platHist.length > 1 && t - platHist[0].t > 400) platHist.shift(); - if (plat.y < platLo) platLo = plat.y; - if (plat.y > platHi) platHi = plat.y; - // The full swing is 220 of 540 on the design canvas. Until most of one has - // been seen the midpoint is a guess, and a wrong midpoint is worse than no - // correction at all. - if (platHi - platLo < (200 / 540) * H) return null; - const y0 = (platLo + platHi) / 2, amp = (platHi - platLo) / 2; - const sn = Math.max(-1, Math.min(1, (plat.y - y0) / amp)); - if (platHist.length < 3) return null; - const dy = plat.y - platHist[0].y; - // Near the turning points the direction cannot be read -- but that is also - // where cos is near zero, so falling back to no correction there costs - // almost nothing. The failure is self-limiting. - if (Math.abs(dy) < 0.5) return null; - return Math.sign(dy) * Math.sqrt(Math.max(0, 1 - sn * sn)); + platHist.push({ t: t / 1000, y: plat.y }); + // Just over half a period. Less than that and sin and cos are too alike + // across the window to be told apart, which makes A and B swap freely. + while (platHist.length > 1 && t / 1000 - platHist[0].t > 3.0) platHist.shift(); + const n = platHist.length; + if (n < 20 || t / 1000 - platHist[0].t < 2.0) return null; + // normal equations for y = c0 + c1*sin(wt) + c2*cos(wt) + let Ss = 0, Sc = 0, Sss = 0, Scc = 0, Ssc = 0, Sy = 0, Sys = 0, Syc = 0; + for (const q of platHist) { + const sn = Math.sin(PLAT_W * q.t), cs = Math.cos(PLAT_W * q.t); + Ss += sn; Sc += cs; Sss += sn * sn; Scc += cs * cs; Ssc += sn * cs; + Sy += q.y; Sys += q.y * sn; Syc += q.y * cs; + } + const M = [[n, Ss, Sc], [Ss, Sss, Ssc], [Sc, Ssc, Scc]], V = [Sy, Sys, Syc]; + for (let i = 0; i < 3; i++) { + let piv = M[i][i]; + if (Math.abs(piv) < 1e-9) return null; + for (let k = i + 1; k < 3; k++) { + const f = M[k][i] / piv; + for (let j = i; j < 3; j++) M[k][j] -= f * M[i][j]; + V[k] -= f * V[i]; + } + } + if (Math.abs(M[2][2]) < 1e-9) return null; + const c2 = V[2] / M[2][2]; + const c1 = (V[1] - M[1][2] * c2) / M[1][1]; + const c0 = (V[0] - M[0][1] * c1 - M[0][2] * c2) / M[0][0]; + const amp = Math.hypot(c1, c2); + // The real swing is 110 of 540 on the design canvas. An amplitude far off + // that means the fit has latched onto drift or noise rather than the + // oscillation, and a wrong phase is worse than no correction at all. + const want = (110 / 540) * H; + if (amp < want * 0.5 || amp > want * 1.8) return null; + // and it has to actually describe the samples + let ss = 0; + for (const q of platHist) { + const pred = c0 + c1 * Math.sin(PLAT_W * q.t) + c2 * Math.cos(PLAT_W * q.t); + ss += (q.y - pred) * (q.y - pred); + } + if (Math.sqrt(ss / n) > amp * 0.25) return null; + const wt = PLAT_W * (t / 1000); + return Math.max(-1, Math.min(1, (c1 * Math.cos(wt) - c2 * Math.sin(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 From 10998a23b532ca4cc5051b6955f89239f6f62541 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 20:18:24 -0400 Subject: [PATCH 5/7] Measure the hoops oscillator coupling instead of deriving it, and re-enable The physics was never in doubt: platform height and release velocity are one oscillator in quadrature, so whether the platform is rising or falling changes where the shot lands from the same height. What was wrong was the geometry for how that redistributes across the arc, and it was wrong confidently. Re-cutting the parabola from a fixed release point predicted the effect landing almost entirely on R, the far crossing, and barely touching L. Driven against the offline rip of the game -- 65 shots, 14 committed per-flight fits, cos sampled across its full range -- it is the other way round: A vs cos +0.0235 +-0.0435 r2 0.02 predicted 0 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 ignores the oscillator exactly as the physics says, which is the part of the model that holds -- g/2vx^2 sees neither. But the coupling lands 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 now come from the fits. R's stays in at its measured value despite being weak, since dropping it would tilt the arc and 0.0253 is small enough that being wrong about it costs little. Re-enabled at the call site. It was switched off in 57faab9 because the old estimator made the preview jump at every turning point; measured again now with the phase fit and the correction live, over 12s of play: cos spans the full -1..1, six sign changes where a 5.035s period predicts about five real zero crossings, no jump over 0.5, largest 0.150. The old estimator managed 17 jumps over 0.5 and a worst of 1.90. None of this could be measured before. Neither hoops recording commits a single per-flight fit -- commitCal wants 6+ tracked frames per flight and the replays do not produce them -- so the offline rip is what made the test possible at all, and it gives better data than live play could: unlimited shots, no cooldown, and cos swept across its whole range by shooting slightly off the period. Still not outcome-validated. That the predicted arc now matches the measured coupling does not prove it sinks more shots, and the honest test for that is whether ghostMade tracks made over a long run. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 66 ++++++++++++++++++++++++-------------------- idleon-suite.user.js | 66 ++++++++++++++++++++++++-------------------- 2 files changed, 72 insertions(+), 60 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 2b31435..4028b5b 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.13 +// @version 1.14 // @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 @@ -744,21 +744,34 @@ const A = cfg.shotA / W; let uL = cfg.shotL * W, uR = cfg.shotR * W; if (cosPhi != null) { - // Re-cut the parabola for the vy this particular throw will actually get. - // Curvature is g/2vx^2 and cannot move -- neither g nor vx depends on the - // oscillator -- so the only thing that changes is the launch slope, by - // d(vy/vx) = 0.7*cos/3.9. The release point is left exactly where the - // shipped constants put it, which means cosPhi 0 reproduces the old curve - // to the pixel and this can only add the variation that was missing. - const ur = RELX * W; - const yr = A * (ur - uL) * (ur - uR); - const m = A * (2 * ur - uL - uR) + (0.7 * cosPhi) / 3.9; - const disc = m * m - 4 * A * yr; - if (disc > 0) { - const r = Math.sqrt(disc); - uL = ur + (-m - r) / (2 * A); - uR = ur + (-m + r) / (2 * A); - } + // 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 }; @@ -1132,20 +1145,13 @@ // 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 pending a correct phase estimate -- see platCos(). Deriving - // cos from |sin| plus a direction-of-travel sign makes the arc JUMP at - // every turning point: measured on a real run, 34 sign flips and 17 - // jumps of over 0.5 in cos, the worst going +0.946 -> -0.955 between two - // frames. That is the preview leaping between the strongest and weakest - // shot, which is worse than no correction at all. - // - // The reasoning that said this was safe -- "near the turning points cos - // is near zero, so a wrong sign costs little" -- was wrong. It holds only - // if y0 and amp are right, and they are not: taken from observed min/max - // they are outlier-sensitive, and the detector picks different rows of - // the platform as it slides. At the observed reversal sin was 0.288, so - // |cos| was 0.957 and the flip cost everything. - const curve = shotCurve(plat.x, plat.y, dir, W, null); + // Re-enabled. It was switched off in 57faab9 because deriving cos from + // |sin| plus a direction sign made the preview jump at every turning + // point; the phase fit in platCos() removed that (34 sign flips and 17 + // jumps over 0.5 became 7 smooth zero crossings and none), and the + // coupling itself is now measured off 65 offline shots rather than + // derived from geometry that had it backwards. + const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); // 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. diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 66c9a1c..361ab8b 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.43 +// @version 1.44 // @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 @@ -1327,21 +1327,34 @@ const A = cfg.shotA / W; let uL = cfg.shotL * W, uR = cfg.shotR * W; if (cosPhi != null) { - // Re-cut the parabola for the vy this particular throw will actually get. - // Curvature is g/2vx^2 and cannot move -- neither g nor vx depends on the - // oscillator -- so the only thing that changes is the launch slope, by - // d(vy/vx) = 0.7*cos/3.9. The release point is left exactly where the - // shipped constants put it, which means cosPhi 0 reproduces the old curve - // to the pixel and this can only add the variation that was missing. - const ur = RELX * W; - const yr = A * (ur - uL) * (ur - uR); - const m = A * (2 * ur - uL - uR) + (0.7 * cosPhi) / 3.9; - const disc = m * m - 4 * A * yr; - if (disc > 0) { - const r = Math.sqrt(disc); - uL = ur + (-m - r) / (2 * A); - uR = ur + (-m + r) / (2 * A); - } + // 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 }; @@ -1698,20 +1711,13 @@ // 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 pending a correct phase estimate -- see platCos(). Deriving - // cos from |sin| plus a direction-of-travel sign makes the arc JUMP at - // every turning point: measured on a real run, 34 sign flips and 17 - // jumps of over 0.5 in cos, the worst going +0.946 -> -0.955 between two - // frames. That is the preview leaping between the strongest and weakest - // shot, which is worse than no correction at all. - // - // The reasoning that said this was safe -- "near the turning points cos - // is near zero, so a wrong sign costs little" -- was wrong. It holds only - // if y0 and amp are right, and they are not: taken from observed min/max - // they are outlier-sensitive, and the detector picks different rows of - // the platform as it slides. At the observed reversal sin was 0.288, so - // |cos| was 0.957 and the flip cost everything. - const curve = shotCurve(plat.x, plat.y, dir, W, null); + // Re-enabled. It was switched off in 57faab9 because deriving cos from + // |sin| plus a direction sign made the preview jump at every turning + // point; the phase fit in platCos() removed that (34 sign flips and 17 + // jumps over 0.5 became 7 smooth zero crossings and none), and the + // coupling itself is now measured off 65 offline shots rather than + // derived from geometry that had it backwards. + const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); // 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. From 5f0193a94dcd6c5f4e814679f6b19dda6c1f7052 Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 20:26:30 -0400 Subject: [PATCH 6/7] Disable the hoops oscillator correction on outcome data, and restore shotA Re-enabling this in 10998a2 rested on the per-flight fits correlating with cos. That is the helper's own reading of its own arc, and it is a much weaker test than whether shots go in. Measured properly against the offline rip, using the game's own score as ground truth and pairing each release to its 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. A second-order correction cannot be judged against a prediction that is not discriminating at all. Play matches. Descending shots come out worse than ascending (10/16 against 9/12, which is ~0.7 SE and proves nothing alone, but is the reported direction) and ascending is off too when the rim is CLOSE -- small u, where the arc is dominated by uL, which is exactly the term this correction moves hardest, up to 0.095 of canvas width. The measured L slope looks too large to apply raw. Nothing measured here is thrown away: platCos and its phase fit stay, and so do the coupling numbers. The physics is still right. But this cannot ship while the arc's height at the rim is off by a mean of 54.7px, worst 117.5px, against a hole far narrower than that -- a 70px correction is not measurable on top of a 55px baseline error, which is also why 1 in 28 is the swish rate. shotA goes back to 2.233 as well. v7 replaced that fitted value with 2.177, derived as g/2vx^2 on the 960-wide design canvas, arguing the 2.6% gap was a tracking bias rather than noise. The offline rip's own per-flight fits put curvature at 2.2205, with the original fit and not with the derivation. Two independent measurements agreeing against one derivation means the derivation was wrong -- the same way the L/R geometry was. The next thing worth measuring is the SIGNED error at the rim: the ball's actual height as it crosses, against the predicted arc's height there, over a few hundred automated shots. That gives direction and magnitude instead of "missed", which is what fixing the base arc needs. The offline rip makes it free. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 65 +++++++++++++++++++++++++++++--------------- idleon-suite.user.js | 65 +++++++++++++++++++++++++++++--------------- 2 files changed, 86 insertions(+), 44 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index 4028b5b..e65b205 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Hoops Helper // @namespace nativerobot -// @version 1.14 +// @version 1.15 // @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 @@ -54,7 +54,7 @@ debug: false, // outline every detected blob // Calibration is stored as fractions of canvas size so it survives resizing // the window — the game scales its physics with the viewport. - calVer: 7, // bump to throw away calibration learned by an older build + calVer: 8, // bump to throw away calibration learned by an older build // The shot is a fixed parabola anchored to the PLATFORM, not to the ball in // your hands. Written as y = platY + A*(u - uL)*(u - R) where u is distance // right of the platform centre: A is curvature, uL and R are where the path @@ -188,16 +188,15 @@ // platform-relative, the arc meets platform height further out when the // platform sits lower, which is the observed sign. Settling it needs the // release instant, which nothing currently measures. - // v7: the seed is now derived rather than fitted. Curvature is g/2vx^2 with - // g = 0.069 and vx = 3.9 per 10ms step, which on the 960-wide design canvas - // is 0.069/(2*3.9^2)*960 = 2.177. The old 2.233 came off 13 tracked flights - // (sd 0.034, range 2.195..2.288) and sits just outside that, i.e. it is a - // systematic 2.6% rather than noise -- the same direction and size as the - // tracking bias found in the darts helper, where following a blob centroid - // through a rotating sprite inflated fitted accelerations. Self-calibration - // still runs and will pull toward whatever the tracker sees; this only - // changes where a fresh install starts. - shotA: 2.177, // curvature x canvas width + // Back to 2.233, the value fitted from 13 tracked flights (sd 0.034, range + // 2.195..2.288). v7 replaced it with 2.177, derived as g/2vx^2 on the + // 960-wide design canvas, on the argument that the 2.6% gap was a + // systematic tracking bias rather than noise. Measuring the offline rip of + // the game settles it the other way: its own per-flight fits put curvature + // at 2.2205, which sits with the original fit and not with the derivation. + // Two independent measurements agreeing against one derivation means the + // derivation is what is wrong. + shotA: 2.233, // curvature x canvas width shotL: -0.119, // upward crossing, fraction of width left of the platform shotR: 0.547, // landing range, fraction of width right of the platform calSeeded: true, @@ -211,9 +210,9 @@ // live flights the committed curvature ranged 1.865-2.941 around a true // 2.23 — a live config caught mid-session held 2.486. That is not stale, it // is contaminated, and averaging more shots into it does not wash it out. - if (cfg.calVer !== 7) { - cfg.calVer = 7; cfg.calSeeded = true; - cfg.shotA = 2.177; cfg.shotL = -0.119; cfg.shotR = 0.547; + if (cfg.calVer !== 8) { + cfg.calVer = 8; cfg.calSeeded = true; + cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; } delete cfg.grav; delete cfg.launch; delete cfg.launchN; delete cfg.gravN; const save = () => localStorage.setItem(KEY, JSON.stringify(cfg)); @@ -1145,13 +1144,35 @@ // 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; - // Re-enabled. It was switched off in 57faab9 because deriving cos from - // |sin| plus a direction sign made the preview jump at every turning - // point; the phase fit in platCos() removed that (34 sign flips and 17 - // jumps over 0.5 became 7 smooth zero crossings and none), and the - // coupling itself is now measured off 65 offline shots rather than - // derived from geometry that had it backwards. - const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); + // 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. + // + // 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. + // + // 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); // 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. diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 361ab8b..1a095e9 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.44 +// @version 1.45 // @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 @@ -725,7 +725,7 @@ debug: false, // outline every detected blob // Calibration is stored as fractions of canvas size so it survives resizing // the window — the game scales its physics with the viewport. - calVer: 7, // bump to throw away calibration learned by an older build + calVer: 8, // bump to throw away calibration learned by an older build // The shot is a fixed parabola anchored to the PLATFORM, not to the ball in // your hands. Written as y = platY + A*(u - uL)*(u - R) where u is distance // right of the platform centre: A is curvature, uL and R are where the path @@ -859,16 +859,15 @@ // platform-relative, the arc meets platform height further out when the // platform sits lower, which is the observed sign. Settling it needs the // release instant, which nothing currently measures. - // v7: the seed is now derived rather than fitted. Curvature is g/2vx^2 with - // g = 0.069 and vx = 3.9 per 10ms step, which on the 960-wide design canvas - // is 0.069/(2*3.9^2)*960 = 2.177. The old 2.233 came off 13 tracked flights - // (sd 0.034, range 2.195..2.288) and sits just outside that, i.e. it is a - // systematic 2.6% rather than noise -- the same direction and size as the - // tracking bias found in the darts helper, where following a blob centroid - // through a rotating sprite inflated fitted accelerations. Self-calibration - // still runs and will pull toward whatever the tracker sees; this only - // changes where a fresh install starts. - shotA: 2.177, // curvature x canvas width + // Back to 2.233, the value fitted from 13 tracked flights (sd 0.034, range + // 2.195..2.288). v7 replaced it with 2.177, derived as g/2vx^2 on the + // 960-wide design canvas, on the argument that the 2.6% gap was a + // systematic tracking bias rather than noise. Measuring the offline rip of + // the game settles it the other way: its own per-flight fits put curvature + // at 2.2205, which sits with the original fit and not with the derivation. + // Two independent measurements agreeing against one derivation means the + // derivation is what is wrong. + shotA: 2.233, // curvature x canvas width shotL: -0.119, // upward crossing, fraction of width left of the platform shotR: 0.547, // landing range, fraction of width right of the platform calSeeded: true, @@ -879,9 +878,9 @@ // live flights the committed curvature ranged 1.865-2.941 around a true // 2.23 — a live config caught mid-session held 2.486. That is not stale, it // is contaminated, and averaging more shots into it does not wash it out. - if (cfg.calVer !== 7) { - cfg.calVer = 7; cfg.calSeeded = true; - cfg.shotA = 2.177; cfg.shotL = -0.119; cfg.shotR = 0.547; + if (cfg.calVer !== 8) { + cfg.calVer = 8; cfg.calSeeded = true; + cfg.shotA = 2.233; cfg.shotL = -0.119; cfg.shotR = 0.547; } delete cfg.grav; delete cfg.launch; delete cfg.launchN; delete cfg.gravN; }); @@ -1711,13 +1710,35 @@ // 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; - // Re-enabled. It was switched off in 57faab9 because deriving cos from - // |sin| plus a direction sign made the preview jump at every turning - // point; the phase fit in platCos() removed that (34 sign flips and 17 - // jumps over 0.5 became 7 smooth zero crossings and none), and the - // coupling itself is now measured off 65 offline shots rather than - // derived from geometry that had it backwards. - const curve = shotCurve(plat.x, plat.y, dir, W, cosPhi); + // 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. + // + // 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. + // + // 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); // 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. From 479bd6ab4801ce3fa7d133f7521539a33a49097d Mon Sep 17 00:00:00 2001 From: averagenative Date: Sat, 12 Sep 2026 20:31:16 -0400 Subject: [PATCH 7/7] Publish where the predicted arc crosses the rim, and measure the error signed "Missed" is not a measurement. The number that decides a hoops shot is the height of the arc where it passes the rim, so the helper now publishes exactly that -- ghostRimY -- which turns the error into something with a direction and a size instead of a verdict. Measured against the offline rip, using the game's own state for both sides: its ball position [91][92] and hoop position [95][96], sampled in-page each frame, against the helper's own predicted arc at the same x. No pixel inference anywhere, on either side. SIGNED rim error, n=15 (negative = ball passes ABOVE the prediction) mean -76.3px median -91.5px sd 66.2 range -161.9 .. +49.6 ascending -93.1px (n=12) descending -9.2px (n=3) The ball flies well above the predicted arc, and nearly all of it is on ascending shots; descending ones sit close to the prediction. That is what the game's constants predict if the oscillator is ignored: ascending is cos<0, and vy = -2.9 + 0.7*cos makes that the STRONGER shot at -3.6 against -2.2, so a prediction built on an average-strength shot is undershot vertically by exactly the sort of margin seen here. So the coupling disabled in 5f0193a is real and is needed; what was wrong was its form. Applying it through uL moved the arc in a way that did not correct the height at the rim -- which is consistent with the earlier finding that ascending and descending had the SAME error at the rim while the correction was active, and with the report that close-rim shots were off. A first pass at this measurement gave mean +32 against a median of -22, which is a distribution dragged by one +346px outlier: the ball resets to the player's hands between shots, crossing the hoop's x travelling LEFT. Requiring a rightward crossing during flight fixed it and the two statistics now agree in sign. Worth remembering that a mean and median of opposite signs means the summary is being set by outliers, not by the effect. n=15 with only 3 descending is thin. The rig is cheap to re-run now, and the next step is enough shots to fit the correction against the rim error directly rather than against the per-flight fits. Co-Authored-By: Claude Opus 5 (1M context) --- idleon-hoops.user.js | 9 +++++++-- idleon-suite.user.js | 11 ++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/idleon-hoops.user.js b/idleon-hoops.user.js index e65b205..32cc328 100644 --- a/idleon-hoops.user.js +++ b/idleon-hoops.user.js @@ -1138,7 +1138,7 @@ } // ---- shot preview, anchored to the platform ---- - let ghostMade = null; + let ghostMade = null, ghostRimY = 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. @@ -1177,6 +1177,11 @@ // 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. + if (lastRim) ghostRimY = +curve.at(lastRim.x).toFixed(1); octx.save(); const topY = curve.at(plat.x); octx.strokeStyle = 'rgba(255,122,112,.35)'; // tie the arc to the platform @@ -1210,7 +1215,7 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, - flying, made, ready, ghostMade, + flying, made, ready, ghostMade, ghostRimY, 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 diff --git a/idleon-suite.user.js b/idleon-suite.user.js index 1a095e9..05babdb 100644 --- a/idleon-suite.user.js +++ b/idleon-suite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name IdleOn Helper Suite // @namespace nativerobot -// @version 1.45 +// @version 1.46 // @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 @@ -1704,7 +1704,7 @@ } // ---- shot preview, anchored to the platform ---- - let ghostMade = null; + let ghostMade = null, ghostRimY = 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. @@ -1743,6 +1743,11 @@ // 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. + if (lastRim) ghostRimY = +curve.at(lastRim.x).toFixed(1); octx.save(); const topY = curve.at(plat.x); octx.strokeStyle = 'rgba(255,122,112,.35)'; // tie the arc to the platform @@ -1776,7 +1781,7 @@ probe({ frame, plat, rim: lastRim, rimWhy, blobs: cands.length, tracks: tracks.length, - flying, made, ready, ghostMade, + flying, made, ready, ghostMade, ghostRimY, 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