Last active
April 8, 2026 02:01
-
-
Save zachthedev/e0942b59da239232237736e4de500aa0 to your computer and use it in GitHub Desktop.
Discord 2026 April Fools - Lost Meadow Perfect Player
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * /////////////////////////////////////////////// | |
| * Last Meadow · Perfect Player | |
| * /////////////////////////////////////////////// | |
| * | |
| * @version 1.2.13 | |
| * | |
| * DOM-derived state machine: every tick reads selectors to determine state. | |
| * No flags from previous actions; the bot adapts instantly to UI changes. | |
| * | |
| * @remarks | |
| * Activity cooldowns (server-enforced): gathering 0s, crafting 2min, combat 3min. | |
| * Gathering bar: 100 target, decays 1/100ms, click value 15@0% to 3@100%. | |
| * Minigame targets: healer 3 matches, tank 30 blocks, DPS 10 clicks. | |
| * | |
| * Achievement coverage (27 total, all client-triggerable): | |
| * | |
| * Basic / Progress: | |
| * ONE "Job Hopper" played every class once (progress, req 3) | |
| * TWO "Undeclared Major" played every profession once (progress, req 3) | |
| * THREE "Gigabrain" healed allies with no mistakes (basic, healer) | |
| * FOUR "Master of Deflection" blocked all fireballs (basic, tank) | |
| * FIVE "Aim God" never missed a target (basic, DPS) | |
| * SIX "Progression Enjoyer" visited achievements page (basic) | |
| * SEVEN "Number Go Up" visited stats page (basic) | |
| * EIGHT "Curious Cat" visited stats page 10 times (progress) | |
| * NINE "Ow My Ears" adjusted audio levels (basic) | |
| * TEN "Exploring My Options" changed class (basic) | |
| * ELEVEN "Midlife Crisis" changed profession (basic) | |
| * TWELVE "I EAT CRAYONS" unlocked every achievement (progress, req 26) | |
| * THIRTEEN "Grass Toucher" adventured 5x without bar zeroing (progress) | |
| * | |
| * Combat per class (multi-title, [10,25,50,100,500]): | |
| * FOURTEEN DPS, FIFTEEN Tank, SIXTEEN Healer | |
| * | |
| * Craft per class (multi-title, [10,25,50,100,500]): | |
| * SEVENTEEN Magic, EIGHTEEN Armor, NINETEEN Weapon | |
| * | |
| * Single-title (tiered counts [10..5000]): | |
| * TWENTY "Wrong Game", TWENTY_ONE "Dungeon Diver", TWENTY_TWO "Battlemaster", | |
| * TWENTY_THREE "Tinkerer", TWENTY_FOUR "Hunter", TWENTY_FIVE "Lumberjack", | |
| * TWENTY_SIX "Geologist" | |
| * | |
| * Level thresholds (multi-title, [5,10,25,50,67,100]): | |
| * TWENTY_SEVEN: Sprout > Mossfoot > Verdant > Warden > Archdruid > Primordial | |
| * | |
| * Changelog: | |
| * 1.0.0 (2026-04-03) - Initial release | |
| * - Full automation: gathering, crafting, combat, all three minigames | |
| * - All 27 achievements covered; missing progress repaired on boot | |
| * - Class cycling with priority sorting | |
| * - Adaptive minigame timing and gathering bar optimization | |
| * - Status bar with live state, cooldowns, and progress | |
| * | |
| * 1.1.0 (2026-04-04) - Shield accuracy + Unlock Local button | |
| * - Shield: imminent lock, predictive handoff, mouse blocking | |
| * - Miss report dialog with copyable JSON | |
| * - One-click unlock for all client-only achievements | |
| * - Safe re-injection on account switch | |
| * | |
| * 1.2.0 (2026-04-04) - Near-perfect shield | |
| * - Shield reads game state directly via React fiber (exact speed) | |
| * - 60fps loop matches the game's collision check rate | |
| * - Dual-imminent coverage for paired close fireballs | |
| * | |
| * 1.2.1 (2026-04-04) - Polish | |
| * - Unlock button only appears when missing achievements are detected | |
| * - Fiber matching survives hook reordering | |
| * | |
| * 1.2.2 (2026-04-04) - Shield safety net | |
| * - Force-block catches fireballs that skip the collision zone | |
| * - Earlier shield commitment, fewer last-second misses | |
| * - Status line covers all four minigames and shield stats | |
| * | |
| * 1.2.3 (2026-04-04) - Class picker fix | |
| * - Picker now completes both pages of the wizard (was stuck on combat) | |
| * | |
| * 1.2.4 (2026-04-05) - Stuck craft countdown fix | |
| * - Game bug workaround: activity button's timer dies after first | |
| * cooldown. Watcher detects the frozen timer and clicks through. | |
| * - Activity buttons identified by their React props (no more confusing | |
| * gathering with craft/battle) | |
| * - Perf: cached state derivation, smaller shield rAF allocations | |
| * | |
| * 1.2.5 (2026-04-05) - Stuck timer fix + perf | |
| * - Frozen timer detected within 3s and clicked through | |
| * - Minigame state reset on every entry (arrows, grid, shield) | |
| * | |
| * 1.2.6 (2026-04-06) - 1h cooldown, store-based reads | |
| * - Class change cooldown reduced from 24h to 1h (server change) | |
| * - Cooldown is now read directly from the game's local store, no | |
| * polling. Class picker navigation runs in the tick loop so manual | |
| * picker opens get auto-completed too. | |
| * - Change Class button is opened directly from the main view | |
| * - API class change is now a loud, banner-flashing fallback only | |
| * | |
| * 1.2.7 (2026-04-06) - HUD redesign | |
| * - Full-width top banner with per-state emoji and urgency colors | |
| * (amber for minigames, blue for one-shots, red for errors) | |
| * - Toasts moved below the banner so they don't stomp it | |
| * - Bottom bar class segment rebuilt as a compact pill with an inline | |
| * cooldown mini-bar | |
| * - showUnlockBtn fixed (was silently broken by a shadowed reference) | |
| * | |
| * 1.2.8 (2026-04-07) - Faithful achievement unlock state | |
| * - Repaired and locally-unlocked achievements now correctly carry | |
| * unlocked=true at max progress. Symptom: I EAT CRAYONS showed | |
| * 24/26 instead of 26/26 even with everything else done. | |
| * - Job Hopper / Undeclared Major now count from selected-class set | |
| * size (the game increments on class pick, not on activity). | |
| * - Brand-new accounts no longer get the "changed class" basics | |
| * falsely stamped on first boot. | |
| * | |
| * 1.2.9 (2026-04-07) - No more 429s on stuck-timer click-through | |
| * - Stuck-timer detector was tripping mid-cooldown and self-rate- | |
| * limiting because the game's frozen timer text persists for the | |
| * entire cooldown, not just at expiry. The click-through is now | |
| * gated on the real remaining cooldown reading 0. | |
| * | |
| * 1.2.10 (2026-04-07) - Smarter class cycling | |
| * - The class loop is now a single "always pick the best combo" | |
| * decision rather than a fixed-order multi-pass cycle. Re-evaluates | |
| * on every cooldown expiry; switches without prompting when a | |
| * different combo becomes the better target. | |
| * - Combo log shows every combo with its count, percent maxed, and | |
| * MAX tag for combos that are fully done. | |
| * - Profession counts read from the local store, not the API. | |
| * | |
| * 1.2.11 (2026-04-07) - Wall-time efficiency, live HUD, wrap fix | |
| * - Combo picker now factors in activity cooldowns. With 1h between | |
| * class changes and ~20 battles + 30 crafts per hour available, | |
| * a combo whose classes can't absorb that volume wastes wall time. | |
| * The picker prefers combos that fully saturate the hour, falling | |
| * back to highest-efficiency in the endgame. Rates are derived | |
| * from the cooldown constants so they update automatically if the | |
| * server tweaks them. | |
| * - Combo log adds an `eff:XX%` column and a `(partial)` tag for | |
| * sub-100% combos. | |
| * - HUD class pill percentage updates live as crafts and battles | |
| * complete during the cooldown wait (used to be a stale snapshot). | |
| * - HUD wrap fix for narrow screens: the class pill now flows its | |
| * contents to a second row instead of overflowing the bar, and | |
| * wrapped status segments no longer touch. | |
| * - Cleanup: dropped a couple of dead code paths in the class loop | |
| * (early break that the server would never honor, leftover 30s | |
| * polling from the old API-based check). | |
| * | |
| * 1.2.13 (2026-04-07) - Token bucket rate limiter | |
| * - Replaces the old createBackoff (which insta-reset slowdowns on | |
| * the next 200, wiping any 429-learned state) with a real token | |
| * bucket data structure plus AIMD adaptation on the refill rate. | |
| * Two layers: the bucket handles bursts and steady-state | |
| * budgeting, AIMD handles convergence on the actual server limit. | |
| * This is the same architecture production rate limiters | |
| * (AWS SDK, Stripe client) use. | |
| * - Per-endpoint buckets: activity, user-data, counters each have | |
| * their own state. A 429 on activity/complete no longer slows | |
| * down the unrelated user-data and counters polls. | |
| * - Action-site gating: tryStartActivity consumes an activity token | |
| * before clicking, so burstTryStart's racing attempts now | |
| * respect the bucket instead of bypassing it via setTimeout. The | |
| * api() helper takesAsync on its endpoint's bucket, since fetch | |
| * doesn't go through the XHR observer hook. | |
| * - The tick loop schedules at the fastest non-paused bucket's | |
| * interval, so a slow bucket doesn't starve a fast one. Bot is | |
| * no longer fully halted on a single endpoint's pause; only the | |
| * affected actions wait. | |
| * - Header-driven proactive limiting was attempted first per | |
| * Discord's documented X-RateLimit-* headers but the gorilla | |
| * edge does not emit them (verified by reading raw response | |
| * headers from /gorilla/counters during the 2026-04-07 dump). | |
| * Token bucket + AIMD is the fallback. | |
| */ | |
| // stop previous instance if re-injected (e.g. after account switch) | |
| if (window._meadowBotLoaded) { | |
| if (typeof window._meadowBotStop === "function") window._meadowBotStop(); | |
| } | |
| window._meadowBotLoaded = true; | |
| const VERSION = "1.2.13"; | |
| /** /////// Selectors /////// */ | |
| const SEL = { | |
| gameRoot: ".container__73695", | |
| mainScreen: ".main__8e80e", | |
| topBar: ".actions__73695", | |
| settings: ".settings__41b81", | |
| sliderGroup: ".sliderGroup__41b81", | |
| gameArea: ".game__5c62c", | |
| gameActions: ".gameActions__8e80e", | |
| activityButton: ".activityButton__8af73", | |
| enabledClickable: ".clickable__5c90e:not(.disabled__65fca)", | |
| enabledButton: ".button__8128e:not(.disabled__8128e) .clickable__5c90e", | |
| clickable: ".clickable__5c90e", | |
| continueWrapper: ".continueButtonWrapper__24749", | |
| goBackModal: ".container__8a031", | |
| progressBorder: ".progressBorder__5c4d5", | |
| // minigames | |
| grid: ".grid__0dcd3", | |
| gridItem: ".gridItem__0dcd3", | |
| shaker: ".shaker_cce732", | |
| projectile: ".projectile_cce732", | |
| targetContainer: ".container_b6b008", | |
| target: ".target_b6b008", | |
| arrow: ".character__34527 img:not(.arrowSuccess__34527)", | |
| arrowSuccess: ".character__34527 .arrowSuccess__34527", | |
| dragonClickable: ".dragonClickable__8e80e", | |
| // nav buttons (aria-label) | |
| btnSettings: '[aria-label="Settings"]', | |
| btnStats: '[aria-label="Stats"]', | |
| btnBack: '[aria-label="Back"]', | |
| btnAchievements: '[aria-label="Achievements"]', | |
| // class change UI (population panel) | |
| changeClassBtn: '[aria-label="Change Class"]', | |
| changeClassWrapper: ".changeClassButton__41b81", | |
| disabledButton: ".disabled__8128e", | |
| // class picker (covers both COMBAT_CLASS_SELECTION and CRAFTING_CLASS_SELECTION) | |
| pickerSelectable: ".selectable__460d6", | |
| // achievement detection | |
| achievementItem: ".achievementItem_dc9a93", | |
| achievementName: ".achievementItemName_dc9a93", | |
| achievementProgress: ".achievementItemProgressText_dc9a93", | |
| achievementLocked: "achievementItemLocked_dc9a93", | |
| achievementLevel: ".level__07f98", | |
| countdown: ".countdown__8af73", | |
| countdownClass: "countdown__8af73", | |
| countdownText: ".countdownText__8af73", | |
| // compound selectors | |
| sliderClickable: ".sliderGroup__41b81 .clickable__5c90e", | |
| }; | |
| /** /////// State machine /////// */ | |
| /** | |
| * Rich state objects: each carries its own display metadata. | |
| * @param {string} type - Unique state identifier | |
| * @param {object} meta - Optional passive/active label overrides | |
| * @returns {Readonly<{type: string, passive: string, active: string|null}>} | |
| */ | |
| function state(type, meta = {}) { | |
| return Object.freeze({ | |
| type, | |
| passive: meta.passive || "gathering", | |
| active: meta.active || null, | |
| toString() { | |
| return type; | |
| }, | |
| }); | |
| } | |
| const State = Object.freeze({ | |
| BOOTING: state("booting", { passive: "booting" }), | |
| INTRO: state("intro", { passive: "intro" }), | |
| MAIN_IDLE: state("idle"), | |
| MINIGAME_GRID: state("minigame:grid", { active: "combat: memory grid" }), | |
| MINIGAME_SHIELD: state("minigame:shield", { active: "combat: shield block" }), | |
| MINIGAME_TARGETS: state("minigame:targets", { | |
| active: "combat: target click", | |
| }), | |
| MINIGAME_ARROWS: state("minigame:arrows", { active: "crafting: arrows" }), | |
| REWARDS: state("rewards", { active: "collecting rewards" }), | |
| SETTINGS_OPEN: state("settings", { active: "settings open" }), | |
| MODAL_OPEN: state("modal:exit", { active: "dismissing modal" }), | |
| ONE_SHOT_VOLUME: state("one-shot:volume", { active: "one-shot: volume" }), | |
| ONE_SHOT_PAGES: state("one-shot:pages", { active: "one-shot: pages" }), | |
| ONE_SHOT_CLASS: state("one-shot:class", { | |
| active: "one-shot: class cycling", | |
| }), | |
| CLASS_PICKER: state("class-picker", { active: "class picker" }), | |
| }); | |
| /** | |
| * Achievement entry carrying server ordinal and display name. | |
| * @param {number} id - Server-side ordinal | |
| * @param {string} name - Display name shown in the achievements UI | |
| * @param {number} [req] - Threshold at which `unlocked: true` should be | |
| * dispatched. For "progress" type, this is the single requirement. For | |
| * "single-title-levels" / "multi-title-levels", this is the last tier's | |
| * requirement (the max-tier threshold). Verified against game source | |
| * module 346640 (the `l8` achievement definition map). | |
| */ | |
| function ach(id, name, req) { | |
| return Object.freeze({ | |
| id, | |
| name, | |
| req, | |
| toString() { | |
| return String(id); | |
| }, | |
| valueOf() { | |
| return id; | |
| }, | |
| }); | |
| } | |
| /** | |
| * Achievement identifiers. Each entry carries id (ordinal), display name, | |
| * and the threshold at which `unlocked: true` is dispatched (see `ach()`). | |
| * Numeric coercion works: `+ACH.GIGABRAIN === 2`. | |
| * | |
| * Max-tier thresholds below are copied verbatim from game source module | |
| * 346640; keep in sync if the game adds new tiers. | |
| */ | |
| const ACH = Object.freeze({ | |
| JOB_HOPPER: ach(0, "Job Hopper", 3), | |
| UNDECLARED_MAJOR: ach(1, "Undeclared Major", 3), | |
| GIGABRAIN: ach(2, "Gigabrain"), | |
| MASTER_OF_DEFLECTION: ach(3, "Master of Deflection"), | |
| AIM_GOD: ach(4, "Aim God"), | |
| PROGRESSION_ENJOYER: ach(5, "Progression Enjoyer"), | |
| NUMBER_GO_UP: ach(6, "Number Go Up"), | |
| CURIOUS_CAT: ach(7, "Curious Cat", 10), | |
| OW_MY_EARS: ach(8, "Ow My Ears"), | |
| EXPLORING_MY_OPTIONS: ach(9, "Exploring My Options"), | |
| MIDLIFE_CRISIS: ach(10, "Midlife Crisis"), | |
| I_EAT_CRAYONS: ach(11, "I EAT CRAYONS"), | |
| GRASS_TOUCHER: ach(12, "Grass Toucher", 5), | |
| COMBAT_DPS: ach(13, "", 500), | |
| COMBAT_TANK: ach(14, "", 500), | |
| COMBAT_HEALER: ach(15, "", 500), | |
| CRAFT_MAGIC: ach(16, "", 500), | |
| CRAFT_ARMOR: ach(17, "", 500), | |
| CRAFT_WEAPON: ach(18, "", 500), | |
| WRONG_GAME: ach(19, "Wrong Game", 500), | |
| DUNGEON_DIVER: ach(20, "Dungeon Diver", 5000), | |
| BATTLEMASTER: ach(21, "Battlemaster", 5000), | |
| TINKERER: ach(22, "Tinkerer", 5000), | |
| HUNTER: ach(23, "Hunter", 2500), | |
| LUMBERJACK: ach(24, "Lumberjack", 2500), | |
| GEOLOGIST: ach(25, "Geologist", 2500), | |
| LEVEL: ach(26, "", 100), | |
| }); | |
| /** | |
| * Gathering materials. achId links each material to its gathering achievement. | |
| */ | |
| const MATERIALS = Object.freeze({ | |
| leather: { achId: ACH.HUNTER }, | |
| wood: { achId: ACH.LUMBERJACK }, | |
| metal: { achId: ACH.GEOLOGIST }, | |
| }); | |
| /** Ordered list of material keys, used for display and iteration. */ | |
| const MATERIAL_NAMES = Object.keys(MATERIALS); | |
| /** | |
| * Flat class definitions with role discriminant. | |
| * Each entry carries: role ("combat"|"crafting"), display name, achievement ID. | |
| * Combat entries also carry invalidPairing (the crafting class they can't pair with). | |
| */ | |
| const CLASS_DEFS = Object.freeze({ | |
| healer: { | |
| role: "combat", | |
| display: "Priest", | |
| achId: ACH.COMBAT_HEALER, | |
| invalidPairing: "magic_crafter", | |
| }, | |
| tank: { | |
| role: "combat", | |
| display: "Paladin", | |
| achId: ACH.COMBAT_TANK, | |
| invalidPairing: "armor_crafter", | |
| }, | |
| dps: { | |
| role: "combat", | |
| display: "Ranger", | |
| achId: ACH.COMBAT_DPS, | |
| invalidPairing: "weapon_crafter", | |
| }, | |
| magic_crafter: { | |
| role: "crafting", | |
| display: "Scholar", | |
| achId: ACH.CRAFT_MAGIC, | |
| }, | |
| armor_crafter: { | |
| role: "crafting", | |
| display: "Smithy", | |
| achId: ACH.CRAFT_ARMOR, | |
| }, | |
| weapon_crafter: { | |
| role: "crafting", | |
| display: "Fletcher", | |
| achId: ACH.CRAFT_WEAPON, | |
| }, | |
| }); | |
| // derived views for code that needs role-grouped access | |
| const CLASSES = { | |
| combat: Object.fromEntries( | |
| Object.entries(CLASS_DEFS).filter(([, v]) => v.role === "combat"), | |
| ), | |
| crafting: Object.fromEntries( | |
| Object.entries(CLASS_DEFS).filter(([, v]) => v.role === "crafting"), | |
| ), | |
| }; | |
| Object.freeze(CLASSES); | |
| Object.freeze(CLASSES.combat); | |
| Object.freeze(CLASSES.crafting); | |
| /** Server-enforced cooldown between class changes (1 hour). */ | |
| const CLASS_CHANGE_COOLDOWN_MS = 60 * 60 * 1000; | |
| /** | |
| * Server-enforced cooldown per activity. Matches `k` in game module 346640 | |
| * and drives the display/disabled state in module 311600. | |
| */ | |
| const ACTIVITY_COOLDOWN_MS = { | |
| gathering: 0, | |
| crafting: 2 * 60 * 1000, | |
| combat: 3 * 60 * 1000, | |
| }; | |
| /** All combat class keys. */ | |
| const ALL_COMBAT = Object.keys(CLASSES.combat); | |
| /** All crafting class keys. */ | |
| const ALL_CRAFTING = Object.keys(CLASSES.crafting); | |
| /** | |
| * Valid class combos, derived from invalidPairing declarations. | |
| */ | |
| const CLASS_COMBOS = ALL_COMBAT.flatMap((c) => | |
| ALL_CRAFTING.filter((cr) => CLASSES.combat[c].invalidPairing !== cr).map( | |
| (cr) => [c, cr], | |
| ), | |
| ); | |
| /** | |
| * When non-null, the main tick skips handler dispatch. Only used by | |
| * one-shots that actively manipulate the UI (volume, pages). | |
| */ | |
| let activeOneShot = null; | |
| let oneShotDetail = ""; | |
| /** | |
| * Label shown alongside the derived state for non-blocking background | |
| * tasks (class cycling). Does not suppress handler dispatch. | |
| */ | |
| let backgroundTask = null; | |
| /** | |
| * When true, the main tick and activity starter skip all actions. | |
| * Toggled by the pause button in the status bar. | |
| */ | |
| let paused = false; | |
| /** AbortController for teardown; signal checked by long-running loops. */ | |
| const _abort = new AbortController(); | |
| /** | |
| * Set to true after boot's async init (detection, repair, one-shots) | |
| * completes. Until then, the tick loop handles intro/minigames but | |
| * tryStartActivity and the activity observer are suppressed. | |
| */ | |
| let bootReady = false; | |
| /** | |
| * Label for the combo classChangeLoop is currently playing. Shown in the | |
| * HUD class pill while the background loop is running. | |
| */ | |
| const classProgress = { current: null }; | |
| /** | |
| * Class picker driving state. `_priorityCombos` is the ranked combo list | |
| * (lowest percentage first) recomputed by classChangeLoop on every | |
| * iteration. `_pickerTarget` is the combo to apply when the picker opens | |
| * (set by classChangeLoop, read by handleClassPicker). When null, the | |
| * handler falls back to the top of _priorityCombos so manual picker opens | |
| * still get auto-completed. | |
| * @type {Array<{combat: string, crafting: string, count: number, max: number, pct: number, maxed: boolean}>|null} | |
| */ | |
| let _priorityCombos = null; | |
| let _pickerTarget = null; | |
| /** | |
| * Activates a one-shot lock, suppressing handler dispatch. | |
| * @param {string} state - The one-shot State to activate | |
| * @param {string} detail - Optional detail string shown in the status bar | |
| */ | |
| function setOneShot(state, detail = "") { | |
| activeOneShot = state; | |
| oneShotDetail = detail; | |
| } | |
| /** | |
| * Clears the active one-shot lock, re-enabling handler dispatch. | |
| */ | |
| function clearOneShot() { | |
| activeOneShot = null; | |
| oneShotDetail = ""; | |
| } | |
| /** | |
| * Runs fn inside a one-shot lock; clears on completion or error. | |
| * @param {string} state - The one-shot State to hold during execution | |
| * @param {Function} fn - Async function to run while the lock is held | |
| * @returns {Promise<*>} The return value of fn | |
| */ | |
| async function withOneShot(state, fn) { | |
| setOneShot(state); | |
| try { | |
| return await fn(); | |
| } finally { | |
| clearOneShot(); | |
| } | |
| } | |
| /** /////// Shared constants /////// */ | |
| const THEME = { | |
| fg: "#7fff00", | |
| bg: "#2d5016", | |
| bgPanel: "rgba(20,40,10,0.75)", | |
| bgToast: "rgba(45,80,22,0.95)", | |
| bgDebug: "rgba(26,46,10,0.92)", | |
| border: "rgba(127,255,0,0.5)", | |
| borderDim: "rgba(127,255,0,0.25)", | |
| font: "monospace", | |
| }; | |
| const Z_HUD = 999999; | |
| const Z_DEBUG = Z_HUD + 1; | |
| /** Flux action type constants. */ | |
| const FLUX_UPDATE_USER = "GORILLA_UPDATE_USER_DATA_SUCCESS"; | |
| const FLUX_UPDATE_ACH = "GORILLA_UPDATE_ACHIEVEMENT"; | |
| /** Per-class achievement max tier (FOURTEEN through NINETEEN). */ | |
| const PER_CLASS_MAX_TIER = 500; | |
| /** | |
| * Theoretical max activities per class change cooldown, derived from the | |
| * server-enforced cooldowns. Each completed battle adds +1 to the active | |
| * combat class counter; each completed craft adds +1 to the active | |
| * crafting class counter. These caps drive the wall-time efficiency math | |
| * in rankCombos: a combo is "fully active" when both classes have enough | |
| * headroom to absorb the corresponding cap before reaching PER_CLASS_MAX_TIER. | |
| */ | |
| const MAX_BATTLES_PER_COOLDOWN = Math.floor( | |
| CLASS_CHANGE_COOLDOWN_MS / ACTIVITY_COOLDOWN_MS.combat, | |
| ); | |
| const MAX_CRAFTS_PER_COOLDOWN = Math.floor( | |
| CLASS_CHANGE_COOLDOWN_MS / ACTIVITY_COOLDOWN_MS.crafting, | |
| ); | |
| const MAX_COMBO_GAIN_PER_COOLDOWN = | |
| MAX_BATTLES_PER_COOLDOWN + MAX_CRAFTS_PER_COOLDOWN; | |
| /** Combined max count for one combo (both classes at PER_CLASS_MAX_TIER). */ | |
| const COMBO_MAX_COUNT = PER_CLASS_MAX_TIER * 2; | |
| /** Arrow sequence completions per crafting session. */ | |
| const ARROW_WINS_PER_ROUND = 3; | |
| /** Ms between polls while paused (shared by psleep and waitFor). */ | |
| const PAUSE_POLL_MS = 200; | |
| /** Staggered delays for racing activity button transitions. */ | |
| const ACTIVITY_RACE_DELAYS = [50, 150, 250]; | |
| /** Shield targeting tuning constants. */ | |
| const SHIELD = { | |
| COLLISION_TOLERANCE: 104, | |
| IMMINENT_ETA: 4, | |
| HANDOFF_ETA: 6, | |
| ALIGNED_THRESHOLD: 50, | |
| MAX_HANDOFF_SHIFT: 90, | |
| }; | |
| /** Builds a cssText string from a key-value object. */ | |
| function css(obj) { | |
| return Object.entries(obj) | |
| .map(([k, v]) => `${k}:${v}`) | |
| .join(";"); | |
| } | |
| /** | |
| * Dispatches a GORILLA_UPDATE_ACHIEVEMENT action via Flux. | |
| * Shared by unlockLocalAchievements and repairAchievements. | |
| * | |
| * The Flux reducer for GORILLA_UPDATE_ACHIEVEMENT blindly stores whatever | |
| * `achievementProgress` payload it receives; only the game's own | |
| * `progressAchievement` helper sets `unlocked: true` when total reaches | |
| * the requirement. Bypassing that helper (as we do here) leaves progress | |
| * and tiered achievements with `unlocked: false` until they're explicitly | |
| * fixed. Two consequences: | |
| * | |
| * 1. Progress achievements (Job Hopper, Undeclared Major, Curious Cat, | |
| * Grass Toucher) need `unlocked: true` at their requirement or the | |
| * I EAT CRAYONS meta counter refuses to count them — it checks | |
| * `t.unlocked === true` for non-tiered types. | |
| * 2. Tiered achievements need `unlocked: true` at their last-tier | |
| * threshold so the game's "achievement fully maxed" state matches | |
| * reality; the CRAYONS counter uses `tier > 0` for tiered types so | |
| * this doesn't affect CRAYONS, but any other gate that reads the | |
| * raw `unlocked` field (e.g. unlock-toast triggers) requires it. | |
| * | |
| * Both types resolve from the same `achEntry.req` field: for progress | |
| * it's the single requirement, for tiered it's the last tier's threshold. | |
| * Callers can still pass an explicit `unlocked` to override. | |
| */ | |
| function dispatchAchievement(dispatcher, achEntry, type, total, unlocked) { | |
| let resolved = unlocked; | |
| if (resolved == null) { | |
| if (type === "basic") { | |
| resolved = true; | |
| } else if (achEntry.req != null && total >= achEntry.req) { | |
| resolved = true; | |
| } else { | |
| resolved = false; | |
| } | |
| } | |
| dispatcher.dispatch({ | |
| type: FLUX_UPDATE_ACH, | |
| achievementProgress: { | |
| type, | |
| id: +achEntry, | |
| total, | |
| unlocked: resolved, | |
| }, | |
| }); | |
| } | |
| /** | |
| * Clicks Back and waits for the main screen. Retries once on failure. | |
| */ | |
| async function navigateBack(root, timeoutMs = 2000) { | |
| qs(SEL.btnBack)?.click(); | |
| if (await waitFor(() => mainScreenReady(root), timeoutMs)) return true; | |
| qs(SEL.btnBack)?.click(); | |
| return !!(await waitFor(() => mainScreenReady(root), timeoutMs)); | |
| } | |
| /** | |
| * Schedules tryStartActivity at staggered intervals to race transitions. | |
| */ | |
| function burstTryStart() { | |
| for (const ms of ACTIVITY_RACE_DELAYS) { | |
| setTimeout(tryStartActivity, ms); | |
| } | |
| } | |
| /** Converts a possibly-null date string to a Date, or undefined. */ | |
| function maybeDate(v) { | |
| return v ? new Date(v) : undefined; | |
| } | |
| /** /////// Logging /////// */ | |
| /** Console log prefix badge. */ | |
| const LOG_TAG = "%c\u{1f33f} Meadow Bot"; | |
| /** CSS applied to LOG_TAG for styled console output. */ | |
| const LOG_STYLE = css({ | |
| background: THEME.bg, | |
| color: THEME.fg, | |
| "font-weight": "bold", | |
| padding: "2px 6px", | |
| "border-radius": "3px", | |
| }); | |
| /** Logs to the console with the Meadow Bot badge prefix. */ | |
| const log = (...args) => console.log(LOG_TAG, LOG_STYLE, ...args); | |
| /** /////// HUD overlay /////// */ | |
| /** Urgency color themes for the full-width control banner. */ | |
| const URGENCY_THEMES = { | |
| warn: { | |
| bg: "linear-gradient(180deg,rgba(80,55,10,0.95),rgba(60,40,5,0.88))", | |
| color: "#ffc24d", | |
| border: "rgba(255,194,77,0.7)", | |
| }, | |
| nav: { | |
| bg: "linear-gradient(180deg,rgba(20,45,70,0.95),rgba(15,35,55,0.88))", | |
| color: "#7ad0ff", | |
| border: "rgba(122,208,255,0.7)", | |
| }, | |
| error: { | |
| bg: "linear-gradient(180deg,rgba(70,20,20,0.95),rgba(50,15,15,0.88))", | |
| color: "#ff6b6b", | |
| border: "rgba(255,107,107,0.7)", | |
| }, | |
| }; | |
| /** | |
| * Creates the HUD system (control banner + transient toast + bottom bar). | |
| * All DOM refs are encapsulated. | |
| */ | |
| function createHUD() { | |
| let toast = null; | |
| let toastTimer = null; | |
| let toastFadeTimer = null; | |
| // persistent full-width control banner | |
| let banner = null; | |
| let bannerText = null; | |
| let bannerDot = null; | |
| let bannerInfo = null; // { emoji, label, urgency, detail } or null | |
| let bar = null; | |
| let leftSpan = null; | |
| let centerSpan = null; | |
| let statsSeg = null; | |
| let matsSeg = null; | |
| let classSeg = null; | |
| let fallbackSeg = null; | |
| let classPill = null; // composed pill elements for W3 widget | |
| let loopLabel = null; | |
| let achBtn = null; | |
| let lastUpdate = 0; | |
| function formatLabel(st) { | |
| if (paused) return "PAUSED"; | |
| if (activeOneShot) { | |
| const label = activeOneShot.active || activeOneShot; | |
| return oneShotDetail ? `${label} (${oneShotDetail})` : label; | |
| } | |
| const { passive, active } = st; | |
| if (!active) return passive; | |
| return `${passive} + ${active}`; | |
| } | |
| function ensureToast() { | |
| if (toast) return; | |
| toast = document.createElement("div"); | |
| toast.style.cssText = css({ | |
| position: "fixed", | |
| top: "52px", // clear the control banner | |
| left: "50%", | |
| transform: "translateX(-50%)", | |
| "z-index": Z_HUD, | |
| background: THEME.bgPanel, | |
| color: THEME.fg, | |
| font: `bold 15px/1.4 ${THEME.font}`, | |
| padding: "10px 20px", | |
| "border-radius": "8px", | |
| border: `1px solid ${THEME.borderDim}`, | |
| "pointer-events": "none", | |
| transition: "opacity 0.3s", | |
| opacity: 0, | |
| "max-width": "480px", | |
| "white-space": "pre-line", | |
| "text-align": "center", | |
| "box-shadow": "0 4px 20px rgba(0,0,0,0.5)", | |
| "backdrop-filter": "blur(4px)", | |
| }); | |
| document.body.appendChild(toast); | |
| } | |
| function ensureBanner() { | |
| if (banner) return; | |
| // inject the blinking keyframe once | |
| if (!document.getElementById("meadow-bot-keyframes")) { | |
| const style = document.createElement("style"); | |
| style.id = "meadow-bot-keyframes"; | |
| style.textContent = | |
| "@keyframes meadow-blink{0%,100%{opacity:1}50%{opacity:.3}}"; | |
| document.head.appendChild(style); | |
| } | |
| banner = document.createElement("div"); | |
| banner.style.cssText = css({ | |
| position: "fixed", | |
| top: "0", | |
| left: "0", | |
| right: "0", | |
| "z-index": Z_HUD, | |
| padding: "9px 18px", | |
| "font-family": THEME.font, | |
| "font-weight": "700", | |
| "font-size": "13px", | |
| "letter-spacing": "0.06em", | |
| display: "none", | |
| "align-items": "center", | |
| "justify-content": "center", | |
| gap: "12px", | |
| "pointer-events": "none", | |
| "backdrop-filter": "blur(6px)", | |
| "border-bottom": "1px solid transparent", | |
| transition: "opacity 0.25s, transform 0.25s", | |
| opacity: "0", | |
| transform: "translateY(-100%)", | |
| "box-shadow": "0 4px 16px rgba(0,0,0,0.4)", | |
| }); | |
| bannerDot = document.createElement("span"); | |
| bannerDot.style.cssText = css({ | |
| width: "8px", | |
| height: "8px", | |
| "border-radius": "50%", | |
| "box-shadow": "0 0 8px currentColor", | |
| animation: "meadow-blink 1s ease-in-out infinite", | |
| "flex-shrink": "0", | |
| }); | |
| bannerText = document.createElement("span"); | |
| bannerText.style.cssText = "white-space:nowrap;text-transform:uppercase"; | |
| banner.appendChild(bannerDot); | |
| banner.appendChild(bannerText); | |
| document.body.appendChild(banner); | |
| } | |
| function applyBannerTheme(urgency) { | |
| const theme = URGENCY_THEMES[urgency] || URGENCY_THEMES.warn; | |
| banner.style.background = theme.bg; | |
| banner.style.color = theme.color; | |
| banner.style.borderBottomColor = theme.border; | |
| } | |
| function renderBanner(info) { | |
| if (!info) { | |
| if (!banner) return; | |
| banner.style.opacity = "0"; | |
| banner.style.transform = "translateY(-100%)"; | |
| setTimeout(() => { | |
| if (banner && !bannerInfo) banner.style.display = "none"; | |
| }, 250); | |
| return; | |
| } | |
| ensureBanner(); | |
| applyBannerTheme(info.urgency); | |
| const urgencyLabel = | |
| info.urgency === "error" | |
| ? "ERROR" | |
| : info.urgency === "nav" | |
| ? "BOT · PLEASE WAIT" | |
| : "BOT · HANDS OFF"; | |
| const detailSuffix = info.detail ? ` · ${info.detail}` : ""; | |
| bannerText.textContent = `${info.emoji} ${urgencyLabel} · ${info.label}${detailSuffix}`; | |
| banner.style.display = "flex"; | |
| requestAnimationFrame(() => { | |
| banner.style.opacity = "1"; | |
| banner.style.transform = "translateY(0)"; | |
| }); | |
| } | |
| /** Shallow compare of banner info objects. */ | |
| function sameInfo(a, b) { | |
| if (a === b) return true; | |
| if (!a || !b) return false; | |
| return ( | |
| a.emoji === b.emoji && | |
| a.label === b.label && | |
| a.urgency === b.urgency && | |
| a.detail === b.detail | |
| ); | |
| } | |
| return { | |
| show(text, duration = 3000) { | |
| ensureToast(); | |
| clearTimeout(toastTimer); | |
| clearTimeout(toastFadeTimer); | |
| toast.textContent = text; | |
| toast.style.opacity = "1"; | |
| if (duration > 0) { | |
| toastTimer = setTimeout(() => { | |
| toast.style.opacity = "0"; | |
| }, duration); | |
| } | |
| }, | |
| hide() { | |
| clearTimeout(toastTimer); | |
| clearTimeout(toastFadeTimer); | |
| if (toast) toast.style.opacity = "0"; | |
| }, | |
| /** | |
| * Sets (or clears with null) the persistent control banner at the | |
| * top of the screen. Idempotent: re-calling with the same info is | |
| * cheap. Info is `{emoji, label, urgency, detail?}` or null. | |
| */ | |
| setControlLabel(info) { | |
| if (sameInfo(info, bannerInfo)) return; | |
| bannerInfo = info; | |
| renderBanner(info); | |
| }, | |
| /** | |
| * Shows a one-shot error banner (red, auto-clears after duration). | |
| * Used for noisy API-fallback warnings. | |
| */ | |
| showError(label, duration = 5000) { | |
| ensureBanner(); | |
| const prev = bannerInfo; | |
| const errorInfo = { | |
| emoji: "\u26a0\ufe0f", | |
| label, | |
| urgency: "error", | |
| }; | |
| bannerInfo = errorInfo; | |
| renderBanner(errorInfo); | |
| setTimeout(() => { | |
| // restore prior banner if nothing else changed it | |
| if (bannerInfo === errorInfo) { | |
| bannerInfo = prev; | |
| renderBanner(prev); | |
| } | |
| }, duration); | |
| }, | |
| init() { | |
| const btnCSS = css({ | |
| cursor: "pointer", | |
| "pointer-events": "auto", | |
| padding: "2px 8px", | |
| border: `1px solid ${THEME.border}`, | |
| "border-radius": "4px", | |
| opacity: 0.8, | |
| "user-select": "none", | |
| }); | |
| bar = document.createElement("div"); | |
| bar.style.cssText = css({ | |
| position: "fixed", | |
| bottom: 0, | |
| left: 0, | |
| right: 0, | |
| "z-index": Z_HUD, | |
| background: THEME.bgPanel, | |
| color: THEME.fg, | |
| font: `bold 11px/1.4 ${THEME.font}`, | |
| padding: "4px 12px", | |
| display: "flex", | |
| "justify-content": "space-between", | |
| "align-items": "center", | |
| gap: "12px", | |
| "border-top": `1px solid ${THEME.borderDim}`, | |
| "pointer-events": "none", | |
| "backdrop-filter": "blur(4px)", | |
| }); | |
| const rightGroup = document.createElement("span"); | |
| rightGroup.style.cssText = | |
| "display:flex;align-items:center;gap:6px;flex-shrink:0"; | |
| loopLabel = document.createElement("span"); | |
| loopLabel.style.cssText = "opacity:0.5;font-size:10px"; | |
| loopLabel.textContent = "50ms"; | |
| const pauseBtn = document.createElement("span"); | |
| pauseBtn.style.cssText = btnCSS; | |
| pauseBtn.textContent = "\u23F8"; | |
| pauseBtn.onclick = () => { | |
| paused = !paused; | |
| pauseBtn.textContent = paused ? "\u25B6" : "\u23F8"; | |
| log(paused ? "Paused" : "Resumed"); | |
| }; | |
| achBtn = document.createElement("span"); | |
| achBtn.style.cssText = btnCSS; | |
| achBtn.textContent = "\uD83C\uDFC6 Unlock Local"; | |
| achBtn.style.display = "none"; | |
| achBtn.onclick = () => { | |
| unlockLocalAchievements(); | |
| achBtn.style.display = "none"; | |
| }; | |
| leftSpan = document.createElement("span"); | |
| leftSpan.style.cssText = "flex-shrink:0;white-space:nowrap"; | |
| centerSpan = document.createElement("span"); | |
| // gap is row x col: 4px between wrapped lines so they don't touch, | |
| // 14px between segments on the same line. min-width:0 lets the | |
| // wrap kick in instead of overflowing the bar on narrow viewports. | |
| centerSpan.style.cssText = | |
| "flex:1;min-width:0;display:flex;flex-wrap:wrap;" + | |
| "align-items:center;gap:4px 14px;opacity:0.9;font-size:10px"; | |
| // stats + materials + class pill + fallback (for non-class one-shots) | |
| statsSeg = document.createElement("span"); | |
| statsSeg.style.whiteSpace = "nowrap"; | |
| matsSeg = document.createElement("span"); | |
| matsSeg.style.whiteSpace = "nowrap"; | |
| // W3 compact class pill: rounded pill with labels + inline mini-bar. | |
| // max-width:100% caps the pill at its slot width; flex-wrap lets | |
| // children flow to a second row inside the pill on narrow viewports | |
| // instead of overflowing past the bar's right edge. white-space: | |
| // nowrap keeps each child's text intact (no breaking inside the | |
| // combo name); the wrap happens at child boundaries. gap is row x | |
| // col so wrapped rows get a small vertical gap. | |
| classSeg = document.createElement("span"); | |
| classSeg.style.cssText = css({ | |
| display: "none", | |
| "flex-wrap": "wrap", | |
| "align-items": "center", | |
| gap: "4px 7px", | |
| padding: "3px 10px", | |
| background: "rgba(127,255,0,0.06)", | |
| border: `1px solid ${THEME.borderDim}`, | |
| "border-radius": "999px", | |
| "white-space": "nowrap", | |
| "font-size": "10px", | |
| "max-width": "100%", | |
| }); | |
| // pre-build pill children so we only mutate text/width during tick | |
| classPill = {}; | |
| classPill.cycleLbl = document.createElement("span"); | |
| classPill.cycleLbl.style.cssText = | |
| "color:#a0c090;font-size:9px;letter-spacing:0.12em"; | |
| classPill.cycleLbl.textContent = "TARGET"; | |
| classPill.cur = document.createElement("span"); | |
| classPill.cur.style.color = THEME.fg; | |
| classPill.barWrap = document.createElement("span"); | |
| classPill.barWrap.style.cssText = css({ | |
| display: "inline-block", | |
| width: "56px", | |
| height: "4px", | |
| background: "rgba(127,255,0,0.12)", | |
| "border-radius": "2px", | |
| overflow: "hidden", | |
| }); | |
| classPill.barFill = document.createElement("span"); | |
| classPill.barFill.style.cssText = css({ | |
| display: "block", | |
| height: "100%", | |
| width: "0%", | |
| background: THEME.fg, | |
| transition: "width 0.3s linear", | |
| }); | |
| classPill.barWrap.appendChild(classPill.barFill); | |
| classPill.cd = document.createElement("span"); | |
| classPill.cd.style.color = THEME.fg; | |
| classPill.sep1 = document.createElement("span"); | |
| classPill.sep1.style.color = "rgba(127,255,0,0.3)"; | |
| classPill.sep1.textContent = "·"; | |
| classPill.pct = document.createElement("span"); | |
| classPill.pct.style.color = THEME.fg; | |
| classSeg.appendChild(classPill.cycleLbl); | |
| classSeg.appendChild(classPill.cur); | |
| classSeg.appendChild(classPill.barWrap); | |
| classSeg.appendChild(classPill.cd); | |
| classSeg.appendChild(classPill.sep1); | |
| classSeg.appendChild(classPill.pct); | |
| fallbackSeg = document.createElement("span"); | |
| fallbackSeg.style.whiteSpace = "nowrap"; | |
| fallbackSeg.style.display = "none"; | |
| centerSpan.appendChild(statsSeg); | |
| centerSpan.appendChild(matsSeg); | |
| centerSpan.appendChild(classSeg); | |
| centerSpan.appendChild(fallbackSeg); | |
| rightGroup.appendChild(loopLabel); | |
| rightGroup.appendChild(achBtn); | |
| rightGroup.appendChild(pauseBtn); | |
| bar.appendChild(leftSpan); | |
| bar.appendChild(centerSpan); | |
| bar.appendChild(rightGroup); | |
| document.body.appendChild(bar); | |
| }, | |
| update(state) { | |
| if (!bar) return; | |
| const now = Date.now(); | |
| if (now - lastUpdate < 250) return; | |
| lastUpdate = now; | |
| const s = stats; | |
| leftSpan.textContent = `\u{1f33f} ${formatLabel(state)}`; | |
| statsSeg.textContent = `gathered:${s.gathers} crafted:${s.crafts} battled:${s.battles}`; | |
| matsSeg.textContent = MATERIAL_NAMES.map((m) => `${m}:${s[m]}`).join( | |
| " ", | |
| ); | |
| // class pill: current combo + cooldown bar + live percentage to max. | |
| // pct is recomputed from GorillaStore on every tick so it ticks up | |
| // as crafts/battles complete during the cooldown wait. | |
| const cur = classProgress.current; | |
| if (cur) { | |
| const cdStatus = getClassChangeStatus(); | |
| const remainingMs = cdStatus?.remainingMs ?? 0; | |
| const totalMs = CLASS_CHANGE_COOLDOWN_MS; | |
| const fillPct = totalMs | |
| ? Math.max( | |
| 0, | |
| Math.min(100, ((totalMs - remainingMs) / totalMs) * 100), | |
| ) | |
| : 100; | |
| classPill.cur.textContent = cur; | |
| classPill.barFill.style.width = `${fillPct.toFixed(1)}%`; | |
| classPill.cd.textContent = | |
| cdStatus && !cdStatus.ready ? formatWait(remainingMs) : "ready"; | |
| const pc = getProfessionCompletion(); | |
| const slash = cur.indexOf("/"); | |
| if (pc && slash > 0) { | |
| const c = cur.slice(0, slash); | |
| const k = cur.slice(slash + 1); | |
| const a = Math.min(pc[c] ?? 0, PER_CLASS_MAX_TIER); | |
| const b = Math.min(pc[k] ?? 0, PER_CLASS_MAX_TIER); | |
| const livePct = ((a + b) / COMBO_MAX_COUNT) * 100; | |
| classPill.pct.textContent = `${livePct.toFixed(1)}%`; | |
| } else { | |
| classPill.pct.textContent = "-"; | |
| } | |
| classSeg.style.display = "inline-flex"; | |
| } else { | |
| classSeg.style.display = "none"; | |
| } | |
| // fallback segment for non-class background tasks (volume, pages) | |
| if (backgroundTask && !paused && !activeOneShot && !cur) { | |
| fallbackSeg.textContent = backgroundTask; | |
| fallbackSeg.style.display = ""; | |
| } else { | |
| fallbackSeg.style.display = "none"; | |
| } | |
| // Show the activity bucket since that's the dominant traffic. | |
| // Other buckets only matter when they get rate limited, which | |
| // surfaces in the log. | |
| loopLabel.textContent = `${buckets.activity.interval}ms`; | |
| }, | |
| showUnlockBtn() { | |
| if (achBtn) achBtn.style.display = ""; | |
| }, | |
| remove() { | |
| if (bar) bar.remove(); | |
| if (toast) toast.remove(); | |
| if (banner) banner.remove(); | |
| }, | |
| }; | |
| } | |
| const hud = createHUD(); | |
| /** | |
| * Persistent top-banner info per state. When the tick loop enters any of | |
| * these states, a full-width "bot controlling" header bar is shown. | |
| * `urgency` drives color theming: "warn" (amber) for hands-off critical | |
| * states, "nav" (blue) for quieter browse/scroll states. MAIN_IDLE, | |
| * SETTINGS_OPEN, and BOOTING intentionally omit a banner. | |
| */ | |
| const CONTROL_LABELS = { | |
| "minigame:grid": { | |
| emoji: "\u{1f9e0}", | |
| label: "playing memory grid", | |
| urgency: "warn", | |
| }, | |
| "minigame:shield": { | |
| emoji: "\u{1f6e1}\u{fe0f}", | |
| label: "blocking shield", | |
| urgency: "warn", | |
| }, | |
| "minigame:targets": { | |
| emoji: "\u{1f3af}", | |
| label: "clicking targets", | |
| urgency: "warn", | |
| }, | |
| "minigame:arrows": { | |
| emoji: "\u{1f3f9}", | |
| label: "crafting arrows", | |
| urgency: "warn", | |
| }, | |
| "class-picker": { | |
| emoji: "\u{1f500}", | |
| label: "picking new class", | |
| urgency: "warn", | |
| }, | |
| intro: { | |
| emoji: "\u{2699}\u{fe0f}", | |
| label: "navigating start menu", | |
| urgency: "nav", | |
| }, | |
| rewards: { | |
| emoji: "\u{1f4b0}", | |
| label: "collecting rewards", | |
| urgency: "nav", | |
| }, | |
| "modal:exit": { | |
| emoji: "\u{274c}", | |
| label: "dismissing modal", | |
| urgency: "nav", | |
| }, | |
| }; | |
| /** Friendly info for one-shots (override state label when active). */ | |
| const ONE_SHOT_LABELS = { | |
| "one-shot:volume": { | |
| emoji: "\u{1f50a}", | |
| label: "adjusting volume", | |
| urgency: "nav", | |
| }, | |
| "one-shot:pages": { | |
| emoji: "\u{1f4d6}", | |
| label: "browsing pages", | |
| urgency: "nav", | |
| }, | |
| "one-shot:class": { | |
| emoji: "\u{1f500}", | |
| label: "cycling class", | |
| urgency: "warn", | |
| }, | |
| }; | |
| /** | |
| * Resolves the persistent control-banner info for the current tick. | |
| * One-shots take priority over the derived state. | |
| * @param {object} state - Derived State enum value | |
| * @returns {{emoji:string,label:string,urgency:string,detail?:string}|null} | |
| */ | |
| function getControlLabel(state) { | |
| if (paused) return null; | |
| if (activeOneShot) { | |
| const info = ONE_SHOT_LABELS[activeOneShot.type]; | |
| if (!info) return null; | |
| return oneShotDetail ? { ...info, detail: oneShotDetail } : info; | |
| } | |
| return CONTROL_LABELS[state?.type] ?? null; | |
| } | |
| /** /////// Utilities /////// */ | |
| /** @returns {{ ok: true, value: T }} */ | |
| const Ok = (value) => ({ ok: true, value }); | |
| /** @returns {{ ok: false, error: E }} */ | |
| const Err = (error) => ({ ok: false, error }); | |
| /** Raw sleep; prefer psleep for pause-aware waits. */ | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| /** | |
| * Pause-aware sleep: blocks while paused, then sleeps the requested time. | |
| * All async one-shots and background loops should use this instead of sleep. | |
| * @param {number} ms - Duration to sleep in milliseconds | |
| * @returns {Promise<void>} | |
| */ | |
| async function psleep(ms) { | |
| _abort.signal.throwIfAborted(); | |
| while (paused && !_abort.signal.aborted) await sleep(PAUSE_POLL_MS); | |
| _abort.signal.throwIfAborted(); | |
| await sleep(ms); | |
| } | |
| /** /////// Adaptive delay (AIMD) /////// */ | |
| /** | |
| * Learns the fastest safe delay for repeated actions. | |
| * Success: shrink by ~8%. Failure: grow by 50%. | |
| * Converges to the minimum safe value within ~20 iterations. | |
| * @param {string} name - Label for logging | |
| * @param {number} initial - Starting delay in ms | |
| * @param {number} min - Lower bound for the delay in ms | |
| * @param {number} max - Upper bound for the delay in ms | |
| * @returns {{ ms: number, ok: Function, fail: Function, toString: Function }} AIMD delay tracker | |
| */ | |
| function adaptiveDelay(name, initial, min, max) { | |
| let ms = initial; | |
| let okCount = 0; | |
| let failCount = 0; | |
| return { | |
| get ms() { | |
| return ms; | |
| }, | |
| ok() { | |
| okCount++; | |
| ms = Math.max(min, Math.ceil(ms * 0.92)); | |
| }, | |
| fail() { | |
| failCount++; | |
| ms = Math.min(max, Math.ceil(ms * 1.5)); | |
| }, | |
| toString() { | |
| return `${name}:${ms}ms(${okCount}ok/${failCount}f)`; | |
| }, | |
| [Symbol.toPrimitive](hint) { | |
| return hint === "number" ? ms : this.toString(); | |
| }, | |
| }; | |
| } | |
| /** /////// Polling wait /////// */ | |
| /** | |
| * Pause-aware polling that replaces fixed psleep calls in one-shots. | |
| * Returns the truthy value from conditionFn, or null on timeout. | |
| * Callers can use the return directly instead of re-querying. | |
| * @param {Function} conditionFn - Predicate to poll; truthy value returned on success | |
| * @param {number} timeoutMs - Maximum wait time in ms | |
| * @param {number} pollMs - Interval between polls in ms | |
| * @returns {Promise<*>} The truthy value, or null on timeout | |
| */ | |
| async function waitFor(conditionFn, timeoutMs = 3000, pollMs = 50) { | |
| const deadline = Date.now() + timeoutMs; | |
| while (Date.now() < deadline) { | |
| _abort.signal.throwIfAborted(); | |
| while (paused && !_abort.signal.aborted) await sleep(PAUSE_POLL_MS); | |
| _abort.signal.throwIfAborted(); | |
| try { | |
| const v = conditionFn(); | |
| if (v) return v; | |
| } catch (e) { | |
| console.debug("[waitFor] condition threw:", e); | |
| } | |
| await sleep(pollMs); | |
| } | |
| return null; | |
| } | |
| /** Shorthand querySelector. */ | |
| const qs = (sel, parent = document) => parent.querySelector(sel); | |
| /** Shorthand querySelectorAll (returns array). */ | |
| const qsa = (sel, parent = document) => [...parent.querySelectorAll(sel)]; | |
| /** | |
| * Common condition: main screen visible under game root. | |
| * @param {Element} root - The game root container | |
| * @returns {Element|null} The main screen element, or null if not found | |
| */ | |
| function mainScreenReady(root) { | |
| return qs(SEL.mainScreen, root); | |
| } | |
| /** | |
| * Click an element by selector, then poll for a condition. | |
| * @param {string} sel - CSS selector for the element to click | |
| * @param {Element} root - Parent element to scope the selector query | |
| * @param {Function} conditionFn - Predicate to poll after clicking | |
| * @param {number} timeoutMs - Maximum wait time in ms | |
| * @returns {Promise<boolean>} True if condition passed, false on timeout | |
| */ | |
| async function clickAndWait(sel, root, conditionFn, timeoutMs = 3000) { | |
| qs(sel, root)?.click(); | |
| return waitFor(conditionFn, timeoutMs); | |
| } | |
| /** Caches the __reactFiber key per element to avoid repeated Object.keys scans. */ | |
| const _fiberKeyCache = new WeakMap(); | |
| /** | |
| * Walks up the React fiber tree from an element looking for matching props. | |
| * @param {Element} el - DOM element whose fiber to traverse | |
| * @param {Function} predicate - Test against each fiber's memoizedProps | |
| * @param {number} maxDepth - Maximum number of fiber parents to traverse | |
| * @returns {Object|null} The matching memoizedProps, or null | |
| */ | |
| function walkFiber(el, predicate, maxDepth = 20) { | |
| let key = _fiberKeyCache.get(el); | |
| if (!key) { | |
| key = Object.keys(el).find((k) => k.startsWith("__reactFiber")); | |
| if (!key) return null; | |
| _fiberKeyCache.set(el, key); | |
| } | |
| let fiber = el[key]; | |
| for (let i = 0; i < maxDepth; i++) { | |
| if (!fiber) break; | |
| if (predicate(fiber.memoizedProps)) return fiber.memoizedProps; | |
| fiber = fiber.return; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Dispatches an Escape keydown event on the document. | |
| */ | |
| function pressEscape() { | |
| document.dispatchEvent( | |
| new KeyboardEvent("keydown", { | |
| key: "Escape", | |
| code: "Escape", | |
| keyCode: 27, | |
| bubbles: true, | |
| }), | |
| ); | |
| } | |
| /** | |
| * Formats a millisecond duration as a human-readable "Xh Ym" or "Ym" string. | |
| * @param {number} ms - Duration in milliseconds | |
| * @returns {string} Formatted time string | |
| */ | |
| function formatWait(ms) { | |
| const hrs = Math.floor(ms / 3600000); | |
| const mins = Math.ceil((ms % 3600000) / 60000); | |
| return hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m`; | |
| } | |
| /** | |
| * Sleeps until deadline while updating backgroundTask every tick. | |
| * @param {number} deadline - Unix timestamp (ms) to sleep until | |
| * @param {Function} labelFn - Called with remaining ms; returns the backgroundTask label | |
| * @param {number} tickMs - Interval between label updates in ms | |
| * @returns {Promise<void>} | |
| */ | |
| async function sleepWithCountdown(deadline, labelFn, tickMs = 5000) { | |
| while (true) { | |
| const remaining = deadline - Date.now(); | |
| if (remaining <= 0) break; | |
| backgroundTask = labelFn(remaining); | |
| await psleep(Math.min(tickMs, remaining)); | |
| } | |
| } | |
| /** /////// Stats + rate limiter /////// */ | |
| /** Session counters displayed in the status bar. */ | |
| const stats = { | |
| gathers: 0, | |
| crafts: 0, | |
| battles: 0, | |
| leather: 0, | |
| wood: 0, | |
| metal: 0, | |
| classChanges: 0, | |
| }; | |
| /** | |
| * Creates a token bucket with AIMD-adaptive refill rate. | |
| * | |
| * The data structure is a classic token bucket: a budget of tokens that | |
| * refills at a fixed rate up to a capacity. Each request consumes one | |
| * token. If the bucket is empty, the caller waits until enough tokens | |
| * have been refilled. The capacity allows occasional bursts (multiple | |
| * quick requests) without exceeding the long-run rate. | |
| * | |
| * On top of that, the refill rate ADAPTS via AIMD (TCP congestion | |
| * control's algorithm, the same one that lets TCP find the maximum | |
| * sustainable bandwidth on a shared link): | |
| * | |
| * - On 429: rate *= DECREASE_FACTOR (multiplicative decrease) | |
| * - On N 200s: rate += RATE_STEP (additive increase) | |
| * | |
| * The two layers are orthogonal: the bucket structure handles bursts | |
| * and steady-state budgeting; AIMD handles convergence on the actual | |
| * server limit. Real production rate limiters (AWS SDK, Stripe, etc.) | |
| * combine both for the same reason. | |
| * | |
| * Server retry-after is honored as a hard pause AND triggers the | |
| * multiplicative decrease. The slowdown PERSISTS after the pause | |
| * expires (no insta-reset on the next 200), which is the whole reason | |
| * the previous createBackoff failed to learn. | |
| * | |
| * Why not header-driven (the proactive option): the gorilla edge does | |
| * not emit X-RateLimit-* response headers (verified empirically). The | |
| * body's retry_after on 429s is the only proactive signal we get. | |
| * | |
| * @param {string} name - Bucket name for logs and HUD | |
| * @returns {object} Token bucket controller | |
| */ | |
| function createTokenBucket(name) { | |
| // Tunables. These are guesses since we can't observe the server's | |
| // actual limits without running the bot live; pick conservative | |
| // starting values and let AIMD do the rest. | |
| const INITIAL_RATE = 20; // tokens/sec (matches old 50ms tick) | |
| const CAPACITY = 5; // burst budget | |
| const MIN_RATE = 0.5; // floor: 1 req every 2s, even under heavy 429s | |
| const MAX_RATE = 30; // ceiling: don't speed beyond initial+50% | |
| const SUCCESSES_PER_STEP = 30; | |
| const RATE_STEP = 1; // additive increase: +1 token/sec per step | |
| const DECREASE_FACTOR = 0.5; // multiplicative decrease: halve rate on 429 | |
| let refillRate = INITIAL_RATE; | |
| let tokens = CAPACITY; | |
| let lastRefill = Date.now(); | |
| let pauseUntil = 0; | |
| let consecSuccesses = 0; | |
| let totalRateLimits = 0; | |
| function refill() { | |
| const now = Date.now(); | |
| const elapsed = (now - lastRefill) / 1000; | |
| tokens = Math.min(CAPACITY, tokens + elapsed * refillRate); | |
| lastRefill = now; | |
| } | |
| return { | |
| name, | |
| /** | |
| * Try to consume one token. Returns 0 if a token was taken; | |
| * otherwise returns positive ms-to-wait (either pause remaining | |
| * or time until the next refill produces a token). | |
| */ | |
| take() { | |
| const now = Date.now(); | |
| if (now < pauseUntil) return pauseUntil - now; | |
| refill(); | |
| if (tokens >= 1) { | |
| tokens -= 1; | |
| return 0; | |
| } | |
| return Math.ceil(((1 - tokens) * 1000) / refillRate); | |
| }, | |
| /** | |
| * Async take: blocks until a token is available, then consumes it. | |
| * Honors the abort signal so a stop()ed bot doesn't leak waiters. | |
| */ | |
| async takeAsync(abortSignal) { | |
| while (true) { | |
| if (abortSignal?.aborted) return false; | |
| const wait = this.take(); | |
| if (wait === 0) return true; | |
| await new Promise((r) => setTimeout(r, wait)); | |
| } | |
| }, | |
| /** | |
| * Credit a successful response. Every SUCCESSES_PER_STEP clean ones | |
| * in a row, the refill rate climbs by RATE_STEP. Successes during | |
| * an active retry-after don't count: the server is still angry, the | |
| * pause just hasn't expired. | |
| */ | |
| onSuccess() { | |
| if (Date.now() < pauseUntil) return; | |
| consecSuccesses++; | |
| if (consecSuccesses >= SUCCESSES_PER_STEP && refillRate < MAX_RATE) { | |
| refillRate = Math.min(MAX_RATE, refillRate + RATE_STEP); | |
| consecSuccesses = 0; | |
| } | |
| }, | |
| /** | |
| * Penalize on a 429. Multiplicative slowdown of the refill rate, | |
| * the bucket is drained so we don't burst the moment the pause | |
| * expires, and the server's retry-after becomes a hard pause. | |
| * The rate slowdown is NOT undone when the pause ends. | |
| */ | |
| onRateLimit(retryAfterMs = 0) { | |
| consecSuccesses = 0; | |
| totalRateLimits++; | |
| refillRate = Math.max(MIN_RATE, refillRate * DECREASE_FACTOR); | |
| tokens = 0; | |
| if (retryAfterMs > 0) { | |
| pauseUntil = Math.max(pauseUntil, Date.now() + retryAfterMs); | |
| } | |
| }, | |
| isPaused() { | |
| return Date.now() < pauseUntil; | |
| }, | |
| pauseRemainingMs() { | |
| return Math.max(0, pauseUntil - Date.now()); | |
| }, | |
| /** Refill rate in tokens/sec, rounded for display. */ | |
| get rate() { | |
| return Math.round(refillRate * 10) / 10; | |
| }, | |
| /** Current token count, rounded for display. */ | |
| get tokens() { | |
| refill(); | |
| return Math.round(tokens * 10) / 10; | |
| }, | |
| get capacity() { | |
| return CAPACITY; | |
| }, | |
| /** Natural inter-request interval at the current refill rate. */ | |
| get interval() { | |
| return Math.ceil(1000 / refillRate); | |
| }, | |
| get rateLimitCount() { | |
| return totalRateLimits; | |
| }, | |
| }; | |
| } | |
| /** | |
| * Per-endpoint buckets. Separating activity from user-data and counters | |
| * means a 429 on activity completes won't slow down the unrelated | |
| * status fetches that the bot also makes. | |
| */ | |
| const buckets = { | |
| activity: createTokenBucket("activity"), | |
| userData: createTokenBucket("user-data"), | |
| counters: createTokenBucket("counters"), | |
| }; | |
| /** | |
| * Routes a /gorilla/ URL to its bucket. | |
| * @param {string} url | |
| * @returns {object|null} The bucket, or null for non-gorilla URLs | |
| */ | |
| function bucketForUrl(url) { | |
| if (!url) return null; | |
| if (url.includes("/gorilla/activity/")) return buckets.activity; | |
| if (url.includes("/gorilla/user-data")) return buckets.userData; | |
| if (url.includes("/gorilla/counters")) return buckets.counters; | |
| return null; | |
| } | |
| /** | |
| * Computes the next tick interval as the fastest non-paused bucket's | |
| * natural interval. While all buckets are paused, polls at 500ms (or | |
| * the shortest remaining pause) so the HUD stays responsive. | |
| */ | |
| function nextTickInterval() { | |
| let fastest = Infinity; | |
| let shortestPause = Infinity; | |
| for (const b of Object.values(buckets)) { | |
| if (b.isPaused()) { | |
| shortestPause = Math.min(shortestPause, b.pauseRemainingMs()); | |
| } else { | |
| fastest = Math.min(fastest, b.interval); | |
| } | |
| } | |
| if (fastest === Infinity) { | |
| return Math.min(500, shortestPause); | |
| } | |
| return fastest; | |
| } | |
| /** | |
| * Logs a summary of bot stats and timing to the console. | |
| */ | |
| function printStatus() { | |
| const matStr = MATERIAL_NAMES.map((m) => `${m}:${stats[m]}`).join(" "); | |
| const ss = shieldState; | |
| const shieldStr = `shield:${ss.blocks}blk/${ss.forceBlocks}forced/${shieldMissReport.missCount}miss(${ss.rounds}r)`; | |
| const targetStr = `target:${_targetClicks}clicks`; | |
| log( | |
| `gathers:${stats.gathers} craft:${stats.crafts} battle:${stats.battles}` + | |
| ` | ${matStr}` + | |
| ` | class:${stats.classChanges} | tick:${buckets.activity.interval}ms` + | |
| ` | ${arrowState.timing} ${gridState.timing}` + | |
| ` ${shieldStr} ${targetStr}`, | |
| ); | |
| } | |
| /** /////// DOM helpers /////// */ | |
| /** | |
| * Finds the first enabled clickable element inside a parent. | |
| * @param {Element|null} parent - Container to search within | |
| * @returns {Element|null} The enabled clickable element, or null | |
| */ | |
| function getEnabledButton(parent) { | |
| return parent ? qs(SEL.enabledClickable, parent) : null; | |
| } | |
| /** | |
| * Clicks the gathering area button to advance the gathering bar. | |
| * @param {Element} root - The game root container | |
| * @returns {boolean} True if a click was dispatched | |
| */ | |
| function clickAdventure(root) { | |
| const btn = getEnabledButton(qs(SEL.gameArea, root)); | |
| if (btn) btn.click(); | |
| return !!btn; | |
| } | |
| /** Total dragon clicks this session (for throttle at max tier). */ | |
| let _dragonClicks = 0; | |
| /** Timestamp of last dragon click (for 2s throttle). */ | |
| let _dragonLastClick = 0; | |
| /** | |
| * Click main screen area for TWENTY achievement. | |
| * Rate-limited: clicks every 2s once past 500 (max tier reached). | |
| * @param {Element} root - The game root container | |
| * @returns {boolean} True if a click was dispatched | |
| */ | |
| function clickMainArea(root) { | |
| const dragon = qs(SEL.dragonClickable, root); | |
| if (dragon) { | |
| // throttle after max tier to avoid wasted traffic | |
| if (_dragonClicks >= PER_CLASS_MAX_TIER) { | |
| const now = Date.now(); | |
| if (now - _dragonLastClick < 2000) return false; | |
| _dragonLastClick = now; | |
| } | |
| dragon.click(); | |
| _dragonClicks++; | |
| return true; | |
| } | |
| const main = qs(SEL.mainScreen, root); | |
| if (!main) return false; | |
| const gameArea = qs(SEL.gameArea, main); | |
| const actions = qs(SEL.gameActions, main); | |
| const topBar = qs(SEL.topBar, root); | |
| for (const el of qsa(SEL.clickable, main)) { | |
| if (gameArea?.contains(el) || actions?.contains(el) || topBar?.contains(el)) | |
| continue; | |
| el.click(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Resolves the enabled clickable element for a given DOM node. | |
| * Checks child, self, then parent (the game nests these inconsistently). | |
| * @param {Element} el - The element to resolve from | |
| * @returns {Element|null} The clickable element, or null if disabled/absent | |
| */ | |
| function resolveClickable(el) { | |
| return ( | |
| qs(SEL.enabledClickable, el) || | |
| (el.matches?.(SEL.enabledClickable) ? el : null) || | |
| el.closest?.(SEL.enabledClickable) | |
| ); | |
| } | |
| /** /////// State derivation /////// */ | |
| /** Cached by deriveState each tick so handlers can skip the re-query. */ | |
| let _gameRoot = null; | |
| /** Cached deriveState result; reused if called multiple times within 16ms. */ | |
| let _derivedState = null; | |
| let _derivedAt = 0; | |
| /** | |
| * Reads the DOM to determine the current game state. | |
| * Caches the result for 16ms so overlapping callers (tick, observer, | |
| * burst, poll) don't repeat the full querySelector cascade. | |
| * @returns {string} A State enum value representing the current UI state | |
| */ | |
| function deriveState() { | |
| const now = Date.now(); | |
| if (_derivedState !== null && now - _derivedAt < 16) return _derivedState; | |
| _derivedAt = now; | |
| const root = qs(SEL.gameRoot); | |
| _gameRoot = root; | |
| if (!root) return (_derivedState = State.BOOTING); | |
| // overlays take priority (settings checked first because goBackModal's | |
| // .container__8a031 is shared between settings and exit modals) | |
| if (qs(SEL.settings)) return (_derivedState = State.SETTINGS_OPEN); | |
| if (qs(SEL.goBackModal)) return (_derivedState = State.MODAL_OPEN); | |
| // reward screen | |
| if (qs(SEL.continueWrapper)) return (_derivedState = State.REWARDS); | |
| // active minigames | |
| if (qs(SEL.grid)) return (_derivedState = State.MINIGAME_GRID); | |
| if (qs(SEL.shaker)) return (_derivedState = State.MINIGAME_SHIELD); | |
| if (qs(SEL.targetContainer)) return (_derivedState = State.MINIGAME_TARGETS); | |
| if (qs(SEL.arrow)) return (_derivedState = State.MINIGAME_ARROWS); | |
| // class picker (combat or crafting page); takes precedence over intro | |
| // because the picker reuses the same screen area as the intro screens | |
| if (qs(SEL.pickerSelectable)) return (_derivedState = State.CLASS_PICKER); | |
| // intro screens (no game area or actions rendered yet) | |
| if (!qs(SEL.gameArea, root) && !qs(SEL.gameActions, root)) | |
| return (_derivedState = State.INTRO); | |
| return (_derivedState = State.MAIN_IDLE); | |
| } | |
| /** /////// Handlers /////// */ | |
| // Each handler is called when deriveState() returns the matching state. | |
| // They act on the current DOM and return nothing. | |
| /** | |
| * Handles intro screens by clicking Continue/Next buttons and class cards. | |
| */ | |
| function handleIntro() { | |
| const root = _gameRoot; | |
| if (!root) return; | |
| const topBar = qs(SEL.topBar, root); | |
| for (const sel of [SEL.enabledButton, SEL.enabledClickable]) { | |
| for (const btn of qsa(sel, root)) { | |
| if (topBar?.contains(btn)) continue; | |
| btn.click(); | |
| return; | |
| } | |
| } | |
| } | |
| /** | |
| * Handles the main idle state by clicking the gathering bar and main area. | |
| */ | |
| function handleMainIdle() { | |
| const root = _gameRoot; | |
| if (!root) return; | |
| clickAdventure(root); | |
| clickMainArea(root); | |
| } | |
| /** | |
| * Clicks the continue button on the rewards screen and races the next activity. | |
| */ | |
| function handleRewards() { | |
| const btn = getEnabledButton(qs(SEL.continueWrapper)); | |
| if (btn) { | |
| btn.click(); | |
| burstTryStart(); | |
| } | |
| } | |
| /** Priority-ordered text matches for modal dismissal. */ | |
| const MODAL_ACTIONS = ["Cancel", "Go Back"]; | |
| /** | |
| * Dismisses modal dialogs by clicking the highest-priority matching button. | |
| */ | |
| function handleModal() { | |
| const modal = qs(SEL.goBackModal); | |
| if (!modal) return; | |
| const buttons = qsa(SEL.clickable, modal); | |
| for (const text of MODAL_ACTIONS) { | |
| const btn = buttons.find((el) => el.textContent?.includes(text)); | |
| if (btn) { | |
| btn.click(); | |
| return; | |
| } | |
| } | |
| } | |
| /** | |
| * Handles the class picker (combat or crafting page) by selecting the | |
| * intended option and clicking Next/Start. Tick-driven and re-entrant: | |
| * each tick re-evaluates which page is showing and picks the next action. | |
| * | |
| * The intended combo is `_pickerTarget` (set by classChangeLoop). If null | |
| * (manual picker open), falls back to the top of the priority ranking so | |
| * the user can open the picker themselves and have the bot finish it with | |
| * the currently-lowest combo. | |
| */ | |
| function handleClassPicker() { | |
| const root = _gameRoot; | |
| if (!root) return; | |
| let target = _pickerTarget; | |
| if (!target && _priorityCombos && _priorityCombos.length > 0) { | |
| const top = _priorityCombos[0]; | |
| target = [top.combat, top.crafting]; | |
| } | |
| if (!target) return; | |
| const [combat, crafting] = target; | |
| // detect page by which action button is rendered | |
| const next = findPickerAction(root, "Next"); | |
| const start = findPickerAction(root, "Start"); | |
| if (next) { | |
| // page 1: combat selection | |
| const opt = findClassOption(root, CLASSES.combat[combat]?.display); | |
| if (!opt) return; | |
| opt.click(); // re-clicking a selected option is a no-op | |
| next.click(); // game enables Next as soon as a selection lands | |
| } else if (start) { | |
| // page 2: crafting selection | |
| const opt = findClassOption(root, CLASSES.crafting[crafting]?.display); | |
| if (!opt) return; | |
| opt.click(); | |
| start.click(); | |
| } | |
| } | |
| /** /////// Minigame completion cooldown /////// */ | |
| // After a minigame's final round, the game briefly flashes a fresh | |
| // reset view (wins back to 0) before removing it. These helpers let | |
| // each handler self-suppress during that flash. Call mgIdle(state) | |
| // when no work is found, mgSuppressed(state) before acting, and | |
| // mgAct(state) when taking action. | |
| /** Timestamp when each minigame handler last transitioned to idle. */ | |
| const _mgIdleAt = {}; | |
| /** Whether each minigame handler was active on the previous tick. */ | |
| const _mgWasActive = {}; | |
| /** Post-completion suppression window to ignore the brief reset flash. */ | |
| const MG_COOLDOWN_MS = 300; | |
| /** | |
| * Records that a minigame handler found no work this tick. | |
| * @param {string} state - The minigame State enum value | |
| */ | |
| function mgIdle(state) { | |
| if (_mgWasActive[state]) { | |
| _mgIdleAt[state] = Date.now(); | |
| _mgWasActive[state] = false; | |
| } | |
| } | |
| /** | |
| * Checks if a minigame is in its post-completion cooldown. | |
| * @param {string} state - The minigame State enum value | |
| * @returns {boolean} True if the handler should skip acting this tick | |
| */ | |
| function mgSuppressed(state) { | |
| const t = _mgIdleAt[state]; | |
| return t > 0 && Date.now() - t < MG_COOLDOWN_MS; | |
| } | |
| /** | |
| * Records that a minigame handler took action this tick. | |
| * @param {string} state - The minigame State enum value | |
| */ | |
| function mgAct(state) { | |
| _mgWasActive[state] = true; | |
| _mgIdleAt[state] = 0; | |
| } | |
| /** /////// Minigame: memory grid (healer combat) /////// */ | |
| /** Encapsulated grid minigame state. */ | |
| const gridState = { | |
| timing: adaptiveDelay("grid", 200, 30, 500), | |
| busy: false, | |
| timers: [], | |
| }; | |
| /** Cached glyph values per grid item element. Glyph never changes per mount. */ | |
| const _gridGlyphCache = new WeakMap(); | |
| /** CSS class applied by the game when a grid item has been matched. */ | |
| const GRID_MATCHED_CLASS = "matched__0dcd3"; | |
| /** | |
| * Handles the memory grid minigame by finding and clicking matched triples. | |
| */ | |
| function handleGrid() { | |
| if (gridState.busy) return; | |
| const grid = qs(SEL.grid); | |
| if (!grid) return; | |
| const items = qsa(SEL.gridItem, grid); | |
| if (items.length === 0) return; | |
| // build unflipped list; cache glyph per element, read flipped from | |
| // matched CSS class to avoid fiber walks on already-solved items. | |
| const unflipped = []; | |
| for (const item of items) { | |
| if (item.classList.contains(GRID_MATCHED_CLASS)) continue; | |
| let glyph = _gridGlyphCache.get(item); | |
| if (glyph === undefined) { | |
| const props = walkFiber(item, (p) => p?.gridItem, 10); | |
| glyph = props?.gridItem?.glyph ?? null; | |
| _gridGlyphCache.set(item, glyph); | |
| } | |
| if (glyph == null) continue; | |
| // check flipped via fiber only for non-matched items (still animating) | |
| const props = walkFiber(item, (p) => p?.gridItem, 10); | |
| if (props?.gridItem?.flipped) continue; | |
| unflipped.push({ el: item, glyph }); | |
| } | |
| if (unflipped.length === 0) { | |
| mgIdle(State.MINIGAME_GRID); | |
| return; | |
| } | |
| if (mgSuppressed(State.MINIGAME_GRID)) return; | |
| // if exactly 3 remain, they must all match (game is solvable); click them | |
| // even if walkFiber failed on one and groupBy can't form a group of 3. | |
| let toClick; | |
| if (unflipped.length === 3) { | |
| toClick = unflipped; | |
| } else { | |
| const groups = Object.groupBy(unflipped, (c) => c.glyph); | |
| toClick = Object.values(groups).find((g) => g.length === 3); | |
| } | |
| if (toClick) { | |
| mgAct(State.MINIGAME_GRID); | |
| // stagger so React completes a render cycle between clicks; the | |
| // game's onClick captures grid state from the render closure. | |
| gridState.busy = true; | |
| gridState.timers.length = 0; | |
| const s = +gridState.timing; | |
| const els = toClick.map((c) => c.el); | |
| els[0].click(); | |
| gridState.timers.push(setTimeout(() => els[1].click(), s)); | |
| gridState.timers.push(setTimeout(() => els[2].click(), s * 2)); | |
| gridState.timers.push( | |
| setTimeout(() => { | |
| if (_abort.signal.aborted) return; | |
| // verify all 3 cells were processed by React | |
| const allOk = els.every( | |
| (el) => | |
| !document.contains(el) || el.classList.contains(GRID_MATCHED_CLASS), | |
| ); | |
| if (allOk) gridState.timing.ok(); | |
| else gridState.timing.fail(); | |
| // chain: immediately try the next group instead of waiting for tick | |
| gridState.busy = false; | |
| handleGrid(); | |
| }, s * 3), | |
| ); | |
| } | |
| } | |
| /** /////// Minigame: shield (tank combat) /////// */ | |
| // Reads game state directly from the React fiber (module 535445 hooks). | |
| // Writes shield position by injecting into M.current ref, bypassing mousemove. | |
| // Runs on requestAnimationFrame (~16.7ms) to match the game's 60fps tick. | |
| // | |
| // Targeting strategy: | |
| // 1. ETA-sorted: target the projectile closest to the collision zone | |
| // 2. Dual-imminent midpoint: when two projectiles are both close, position | |
| // at their midpoint if they're within 208px (2 * collision tolerance) | |
| // 3. Predictive handoff: if aligned with primary and it has lead time, | |
| // pre-shift toward the next projectile | |
| /** Game-side constants for fiber-based collision zone math. */ | |
| const SHIELD_GAME = { | |
| PROJECTILE_HEIGHT: 163, | |
| SHIELD_WIDTH: 138, | |
| PROJECTILE_WIDTH: 70, | |
| SHIELD_Y_OFFSET: 96, | |
| COLLISION_ZONE_HEIGHT: 20, | |
| GAME_TICK_MS: 1000 / 60, | |
| }; | |
| /** | |
| * Reads the shield game's internal state directly from the React fiber tree. | |
| * Bypasses DOM reads entirely; returns the live projectile array, shield | |
| * position, and blocked count from the game's own refs. | |
| * | |
| * Hook layout (module 535445, component at fiber depth 2 from .shaker_cce732): | |
| * #0 useRef(true) = perfect run flag | |
| * #1 useRef(null) = containerRef | |
| * #2 useRef(Date.now()) = last tick timestamp | |
| * #3 useRef([]) = d.current (live projectile array) | |
| * #6 useRef(0) = D.current (blocked count) | |
| * #8 useRef({x,y}) = M.current (shield position) | |
| */ | |
| function readShieldFiber() { | |
| const shaker = qs(SEL.shaker); | |
| if (!shaker) return null; | |
| let fiberKey = _fiberKeyCache.get(shaker); | |
| if (!fiberKey) { | |
| fiberKey = Object.keys(shaker).find((k) => k.startsWith("__reactFiber")); | |
| if (!fiberKey) return null; | |
| _fiberKeyCache.set(shaker, fiberKey); | |
| } | |
| // depth 2: div.shaker -> Shaker class -> v function component | |
| const fiber = shaker[fiberKey]?.return?.return; | |
| if (!fiber?.memoizedState) return null; | |
| // the component combines hooks from useActivity + useShield + its own, | |
| // so indices vary. Search by value shape instead of hardcoded position. | |
| let hook = fiber.memoizedState; | |
| let projectiles = null; | |
| let shieldRef = null; | |
| let containerEl = null; | |
| let lastTick = null; | |
| let blockedCount = null; | |
| while (hook) { | |
| const ms = hook.memoizedState; | |
| if (ms && typeof ms === "object" && "current" in ms) { | |
| const val = ms.current; | |
| // projectile array: [{x, y, speed, hitAt, blockedAt}, ...] | |
| if ( | |
| Array.isArray(val) && | |
| (val.length === 0 || (val[0] && "speed" in val[0] && "hitAt" in val[0])) | |
| ) { | |
| projectiles = val; | |
| } | |
| // shield position: {x, y} (not a projectile, no speed key) | |
| else if ( | |
| val && | |
| typeof val === "object" && | |
| "x" in val && | |
| "y" in val && | |
| !("speed" in val) | |
| ) { | |
| shieldRef = ms; | |
| } | |
| // container DOM element | |
| else if (val instanceof Element) { | |
| containerEl = val; | |
| } | |
| // blocked count: small number ref, found after projectiles | |
| else if ( | |
| typeof val === "number" && | |
| val < 1000 && | |
| blockedCount === null && | |
| projectiles !== null | |
| ) { | |
| blockedCount = ms; // keep the ref so we can write .current | |
| } | |
| // lastTick: timestamp ref (large number, found before projectiles) | |
| else if (typeof val === "number" && val > 1e12 && lastTick === null) { | |
| lastTick = val; | |
| } | |
| } | |
| hook = hook.next; | |
| } | |
| if (!projectiles) return null; | |
| const shieldPos = shieldRef?.current; | |
| return { | |
| projectiles, | |
| lastTick, | |
| blockedCountRef: blockedCount, // ref object with .current (number) | |
| shieldPos, | |
| shieldRef, | |
| containerEl, | |
| }; | |
| } | |
| /** Encapsulated shield minigame state. */ | |
| const shieldState = { | |
| lastCX: null, | |
| lockedCX: null, | |
| lockETA: 0, | |
| mouseBlocked: false, | |
| blocks: 0, | |
| forceBlocks: 0, | |
| rounds: 0, | |
| shieldY: 0, // cached from container rect; stable during a round | |
| }; | |
| // block real mouse input during the shield minigame so the user's cursor | |
| // can't overwrite the bot's direct M.current writes via the game's listener. | |
| function shieldBlockMouse() { | |
| if (shieldState.mouseBlocked) return; | |
| shieldState.mouseBlocked = true; | |
| window.addEventListener("mousemove", shieldMouseFilter, true); | |
| } | |
| function shieldUnblockMouse() { | |
| if (!shieldState.mouseBlocked) return; | |
| shieldState.mouseBlocked = false; | |
| window.removeEventListener("mousemove", shieldMouseFilter, true); | |
| } | |
| function shieldMouseFilter(e) { | |
| e.stopImmediatePropagation(); | |
| } | |
| /** Encapsulated shield miss reporting system for user bug reports. */ | |
| const shieldMissReport = (function createShieldMissReport() { | |
| const RING_MAX = 60; | |
| const ring = new Array(RING_MAX).fill(null); | |
| let ringIdx = 0; | |
| let ringLen = 0; | |
| /** Returns the last N entries from the circular ring buffer. */ | |
| function ringSlice(n) { | |
| const count = Math.min(n, ringLen); | |
| const result = []; | |
| for (let i = count; i > 0; i--) { | |
| result.push(ring[(ringIdx - i + RING_MAX) % RING_MAX]); | |
| } | |
| return result; | |
| } | |
| const known = new WeakMap(); | |
| let nextId = 1; | |
| let missLog = []; | |
| const missedPids = new Set(); | |
| let roundActive = false; | |
| const allRounds = []; | |
| let toast = null; | |
| const btnStyle = css({ | |
| background: THEME.bgPanel, | |
| color: THEME.fg, | |
| border: `1px solid ${THEME.borderDim}`, | |
| "border-radius": "4px", | |
| padding: "3px 10px", | |
| font: `bold 12px ${THEME.font}`, | |
| cursor: "pointer", | |
| "white-space": "nowrap", | |
| }); | |
| function updateToast(totalMisses, totalRounds) { | |
| if (!toast) { | |
| toast = document.createElement("div"); | |
| toast.style.cssText = css({ | |
| position: "fixed", | |
| bottom: "32px", | |
| right: "16px", | |
| "z-index": Z_DEBUG, | |
| background: THEME.bgPanel, | |
| color: THEME.fg, | |
| font: `bold 13px/1.4 ${THEME.font}`, | |
| padding: "10px 14px", | |
| "border-radius": "8px", | |
| border: `1px solid ${THEME.borderDim}`, | |
| "box-shadow": "0 4px 16px rgba(0,0,0,0.4)", | |
| display: "flex", | |
| "align-items": "center", | |
| gap: "10px", | |
| "pointer-events": "auto", | |
| "backdrop-filter": "blur(4px)", | |
| transition: "opacity 0.3s,transform 0.3s", | |
| opacity: 0, | |
| transform: "translateY(8px)", | |
| }); | |
| document.body.appendChild(toast); | |
| requestAnimationFrame(() => { | |
| toast.style.opacity = "1"; | |
| toast.style.transform = "translateY(0)"; | |
| }); | |
| } | |
| toast.textContent = ""; | |
| const p = (n, w) => | |
| `${n} ${w}${n !== 1 ? (w.endsWith("s") ? "es" : "s") : ""}`; | |
| const label = document.createElement("span"); | |
| label.textContent = `Shield: ${p(totalMisses, "miss")} in ${p(totalRounds, "round")}`; | |
| const copyBtn = document.createElement("button"); | |
| copyBtn.style.cssText = btnStyle; | |
| copyBtn.textContent = "Copy to report bug"; | |
| copyBtn.onclick = () => { | |
| const report = JSON.stringify({ v: VERSION, rounds: allRounds }); | |
| navigator.clipboard | |
| .writeText(report) | |
| .then(() => { | |
| copyBtn.textContent = "Copied!"; | |
| setTimeout(() => (copyBtn.textContent = "Copy to report bug"), 2000); | |
| }) | |
| .catch(() => (copyBtn.textContent = "Failed")); | |
| }; | |
| const dismissBtn = document.createElement("button"); | |
| dismissBtn.style.cssText = btnStyle; | |
| dismissBtn.textContent = "\u2715"; | |
| dismissBtn.title = "Dismiss"; | |
| dismissBtn.onclick = () => { | |
| if (!toast) return; | |
| toast.style.opacity = "0"; | |
| toast.style.transform = "translateY(8px)"; | |
| setTimeout(() => { | |
| toast?.remove(); | |
| toast = null; | |
| }, 300); | |
| }; | |
| toast.appendChild(label); | |
| toast.appendChild(copyBtn); | |
| toast.appendChild(dismissBtn); | |
| } | |
| return { | |
| get roundActive() { | |
| return roundActive; | |
| }, | |
| set roundActive(v) { | |
| roundActive = v; | |
| }, | |
| get missCount() { | |
| return allRounds.reduce((n, r) => n + r.misses.length, 0); | |
| }, | |
| id(el) { | |
| let id = known.get(el); | |
| if (id == null) { | |
| id = nextId++; | |
| known.set(el, id); | |
| } | |
| return id; | |
| }, | |
| snap(data) { | |
| ring[ringIdx] = { t: Date.now(), ...data }; | |
| ringIdx = (ringIdx + 1) % RING_MAX; | |
| if (ringLen < RING_MAX) ringLen++; | |
| }, | |
| miss(reason, detail) { | |
| const pid = detail.pid; | |
| if (pid != null && missedPids.has(pid)) return; | |
| if (pid != null) missedPids.add(pid); | |
| missLog.push({ | |
| t: Date.now(), | |
| reason, | |
| ...detail, | |
| recentTicks: ringSlice(10), | |
| }); | |
| }, | |
| flush(reason) { | |
| let wH; | |
| for (let i = 0; i < ringLen; i++) { | |
| const s = ring[(ringIdx - 1 - i + RING_MAX) % RING_MAX]; | |
| if (s?.wH) { | |
| wH = s.wH; | |
| break; | |
| } | |
| } | |
| ringIdx = 0; | |
| ringLen = 0; | |
| const roundMisses = missLog.slice(); | |
| missLog.length = 0; | |
| missedPids.clear(); | |
| if (roundMisses.length === 0) return; | |
| allRounds.push({ | |
| t: Date.now(), | |
| roundEnd: reason, | |
| wH, | |
| misses: roundMisses, | |
| }); | |
| const totalMisses = allRounds.reduce((n, r) => n + r.misses.length, 0); | |
| log( | |
| `[SHIELD] ${roundMisses.length} miss(es) this round` + | |
| ` (${totalMisses} total across ${allRounds.length} round(s))`, | |
| ); | |
| updateToast(totalMisses, allRounds.length); | |
| }, | |
| removeToast() { | |
| toast?.remove(); | |
| toast = null; | |
| }, | |
| }; | |
| })(); | |
| /** | |
| * Classifies projectiles using the game's internal fiber state (zero-lag). | |
| * Uses exact speed values for ETA and computes distance to the actual | |
| * collision zone (not screen bottom). | |
| */ | |
| /** Reusable arrays for classifyProjectilesFiber to avoid per-frame allocation. */ | |
| const _alivePool = []; | |
| function classifyProjectilesFiber(fiberData, wH) { | |
| _alivePool.length = 0; | |
| const { PROJECTILE_HEIGHT, SHIELD_Y_OFFSET, GAME_TICK_MS } = SHIELD_GAME; | |
| const collisionZoneTop = wH - SHIELD_Y_OFFSET; | |
| for (let i = 0; i < fiberData.projectiles.length; i++) { | |
| const p = fiberData.projectiles[i]; | |
| const bottom = p.y + PROJECTILE_HEIGHT; | |
| const cx = p.x + SHIELD_GAME.PROJECTILE_WIDTH / 2; | |
| if (p.blockedAt != null || p.hitAt != null) continue; | |
| if (bottom >= wH) { | |
| shieldMissReport.miss("reached-bottom-still-moving", { | |
| pid: i, | |
| cx, | |
| bottom, | |
| velocity: p.speed, | |
| wH, | |
| shieldCX: shieldState.lastCX, | |
| drift: | |
| shieldState.lastCX !== null | |
| ? Math.abs(cx - shieldState.lastCX) | |
| : null, | |
| hadPrev: true, | |
| source: "fiber", | |
| }); | |
| continue; | |
| } | |
| const distToZone = collisionZoneTop - bottom; | |
| const etaTicks = | |
| distToZone > 0 ? distToZone / ((p.speed * GAME_TICK_MS) / 1000) : 0; | |
| const eta = etaTicks * (GAME_TICK_MS / 50); | |
| _alivePool.push({ bottom, cx, eta, speed: p.speed }); | |
| } | |
| return _alivePool; | |
| } | |
| /** | |
| * Safety net: catches projectiles that tunneled through the game's 20px | |
| * collision zone due to setInterval timing jitter. If a projectile is alive, | |
| * its bottom is at or past the collision zone top, and its X overlaps the | |
| * shield, we directly mutate the fiber state (set blockedAt, increment the | |
| * blocked count ref) so the game treats it as blocked on its next tick. | |
| * | |
| * The game checks hit-ground (y+163 >= wH) AFTER checking blocked, so if | |
| * we set blockedAt before the next game tick, the game will see it and skip | |
| * the hit-ground path. | |
| */ | |
| function forceBlockTunneling(fiberData, wH) { | |
| const { shieldRef, blockedCountRef } = fiberData; | |
| if (!shieldRef?.current || !blockedCountRef) return 0; | |
| const shieldX = shieldRef.current.x; | |
| const shieldY = shieldRef.current.y; | |
| const zoneBottom = shieldY + SHIELD_GAME.COLLISION_ZONE_HEIGHT; | |
| // expanded zone: catch projectiles from zone top down to screen bottom. | |
| // if the projectile bottom is anywhere in [shieldY, wH) and X overlaps, | |
| // the game should have blocked it but missed due to tunneling. | |
| let forced = 0; | |
| for (const p of fiberData.projectiles) { | |
| if (p.blockedAt != null || p.hitAt != null) continue; | |
| const bottom = p.y + SHIELD_GAME.PROJECTILE_HEIGHT; | |
| // must be at or past the collision zone top, but not yet at screen bottom | |
| // (once at screen bottom, the game has already set hitAt) | |
| if (bottom < shieldY || bottom >= wH) continue; | |
| // X overlap: same check as the game | |
| const xOverlap = | |
| p.x + SHIELD_GAME.PROJECTILE_WIDTH > shieldX && | |
| p.x < shieldX + SHIELD_GAME.SHIELD_WIDTH; | |
| if (!xOverlap) continue; | |
| // projectile is past the zone bottom; game should have caught it but didn't | |
| if (bottom > zoneBottom) { | |
| p.blockedAt = Date.now(); | |
| blockedCountRef.current++; | |
| shieldState.forceBlocks++; | |
| forced++; | |
| log( | |
| `[SHIELD] Force-blocked tunneling projectile ` + | |
| `(bottom=${bottom.toFixed(1)}, zone=${shieldY.toFixed(0)}–${zoneBottom.toFixed(0)})`, | |
| ); | |
| } | |
| } | |
| return forced; | |
| } | |
| /** | |
| * Given the alive projectile list, computes where the shield should move. | |
| * Manages imminent lock and predictive handoff. | |
| */ | |
| function computeTargetCX(alive) { | |
| if (alive.length > 1) alive.sort((a, b) => a.eta - b.eta); | |
| const primary = alive[0]; | |
| const secondary = alive.length > 1 ? alive[1] : null; | |
| // dual-imminent: if both primary and secondary are imminent, position at | |
| // their midpoint so the 138px shield covers both (works if gap <= 208px). | |
| if ( | |
| secondary && | |
| primary.eta <= SHIELD.IMMINENT_ETA && | |
| secondary.eta <= SHIELD.IMMINENT_ETA | |
| ) { | |
| const gap = Math.abs(primary.cx - secondary.cx); | |
| if (gap <= 2 * SHIELD.COLLISION_TOLERANCE) { | |
| const midCX = (primary.cx + secondary.cx) / 2; | |
| shieldState.lockedCX = midCX; | |
| shieldState.lockETA = primary.eta; | |
| return { targetCX: midCX, primary, secondary }; | |
| } | |
| } | |
| // imminent lock: commit to target within IMMINENT_ETA ticks | |
| if (primary.eta <= SHIELD.IMMINENT_ETA) { | |
| shieldState.lockedCX = primary.cx; | |
| shieldState.lockETA = primary.eta; | |
| } else if (shieldState.lockedCX !== null) { | |
| const stillRelevant = alive.some( | |
| (a) => | |
| Math.abs(a.cx - shieldState.lockedCX) < SHIELD.COLLISION_TOLERANCE && | |
| a.eta <= SHIELD.HANDOFF_ETA, | |
| ); | |
| if (!stillRelevant) shieldState.lockedCX = null; | |
| } | |
| if (shieldState.lockedCX !== null) { | |
| return { targetCX: shieldState.lockedCX, primary, secondary }; | |
| } | |
| let targetCX = primary.cx; | |
| // predictive handoff: if aligned with primary and enough lead time, | |
| // pre-shift toward secondary within collision tolerance. | |
| if ( | |
| primary.eta > SHIELD.HANDOFF_ETA && | |
| shieldState.lastCX !== null && | |
| secondary | |
| ) { | |
| if (Math.abs(primary.cx - shieldState.lastCX) < SHIELD.ALIGNED_THRESHOLD) { | |
| const dx = secondary.cx - primary.cx; | |
| targetCX = | |
| primary.cx + | |
| Math.max( | |
| -SHIELD.MAX_HANDOFF_SHIFT, | |
| Math.min(SHIELD.MAX_HANDOFF_SHIFT, dx), | |
| ); | |
| } | |
| } | |
| return { targetCX, primary, secondary }; | |
| } | |
| /** | |
| * Handles the shield minigame by reading game state from the React fiber | |
| * and injecting shield position directly into M.current. | |
| */ | |
| function handleShield() { | |
| const fiberData = readShieldFiber(); | |
| if (!fiberData) { | |
| if (qsa(SEL.projectile).length === 0) { | |
| mgIdle(State.MINIGAME_SHIELD); | |
| } | |
| return; | |
| } | |
| const hasProjectiles = fiberData.projectiles.some( | |
| (p) => p.hitAt == null && p.blockedAt == null, | |
| ); | |
| if (!hasProjectiles) { | |
| shieldUnblockMouse(); | |
| if (shieldMissReport.roundActive) { | |
| shieldState.rounds++; | |
| // count blocks from the game's own ref | |
| if (fiberData.blockedCountRef) { | |
| shieldState.blocks = fiberData.blockedCountRef.current; | |
| } | |
| shieldMissReport.flush("no projectiles (round end)"); | |
| shieldMissReport.roundActive = false; | |
| } | |
| shieldState.lockedCX = null; | |
| shieldState.shieldY = 0; | |
| mgIdle(State.MINIGAME_SHIELD); | |
| return; | |
| } | |
| shieldMissReport.roundActive = true; | |
| shieldBlockMouse(); | |
| const wH = window.innerHeight; | |
| const alive = classifyProjectilesFiber(fiberData, wH); | |
| if (alive.length === 0) { | |
| shieldState.lockedCX = null; | |
| return; | |
| } | |
| if (mgSuppressed(State.MINIGAME_SHIELD)) return; | |
| mgAct(State.MINIGAME_SHIELD); | |
| const { targetCX, primary, secondary } = computeTargetCX(alive); | |
| // when locked, re-write every tick even if targetCX hasn't changed; | |
| // ensures the ref stays current across all game ticks between bot frames. | |
| const isLocked = shieldState.lockedCX !== null; | |
| if (!isLocked && targetCX === shieldState.lastCX) { | |
| forceBlockTunneling(fiberData, wH); | |
| return; | |
| } | |
| shieldState.lastCX = targetCX; | |
| // direct ref injection: write to M.current (the same ref object the game's | |
| // collision check reads). Bypasses mousemove dispatch entirely; no events | |
| // to drop, no listener to miss. The ref is hook #8's memoizedState. | |
| const { shieldRef, containerEl } = fiberData; | |
| if (shieldRef && containerEl) { | |
| // cache Y once per round; container doesn't move during the minigame | |
| if (shieldState.shieldY === 0) { | |
| shieldState.shieldY = | |
| containerEl.getBoundingClientRect().bottom - | |
| SHIELD_GAME.SHIELD_Y_OFFSET; | |
| } | |
| shieldRef.current = { | |
| x: targetCX - SHIELD_GAME.SHIELD_WIDTH / 2, | |
| y: shieldState.shieldY, | |
| }; | |
| } | |
| // safety net: catch projectiles that tunneled past the 20px zone | |
| forceBlockTunneling(fiberData, wH); | |
| } | |
| /** rAF-synced shield loop: runs at 60fps to match the game's tick rate. */ | |
| let _shieldRAF = null; | |
| function shieldRAFLoop() { | |
| if (_abort.signal.aborted) { | |
| _shieldRAF = null; | |
| return; | |
| } | |
| handleShield(); | |
| _shieldRAF = requestAnimationFrame(shieldRAFLoop); | |
| } | |
| function startShieldRAF() { | |
| if (_shieldRAF != null) return; | |
| _shieldRAF = requestAnimationFrame(shieldRAFLoop); | |
| } | |
| function stopShieldRAF() { | |
| if (_shieldRAF != null) { | |
| cancelAnimationFrame(_shieldRAF); | |
| _shieldRAF = null; | |
| } | |
| } | |
| // invalidate cached shield Y on resize so it's re-measured next frame | |
| window.addEventListener("resize", () => { | |
| shieldState.shieldY = 0; | |
| }); | |
| /** /////// Minigame: target click (DPS combat) /////// */ | |
| /** De-dupes target clicks to avoid re-clicking lingering DOM elements. */ | |
| const _clickedTargets = new WeakSet(); | |
| let _targetClicks = 0; | |
| /** | |
| * Handles the target-click minigame. Tracks clicked targets via WeakSet | |
| * to avoid re-clicking elements still lingering in DOM (Aim God risk). | |
| */ | |
| function handleTargets() { | |
| const container = qs(SEL.targetContainer); | |
| if (!container) { | |
| mgIdle(State.MINIGAME_TARGETS); | |
| return; | |
| } | |
| const targets = qsa(SEL.target, container); | |
| if (targets.length === 0) { | |
| mgIdle(State.MINIGAME_TARGETS); | |
| return; | |
| } | |
| if (mgSuppressed(State.MINIGAME_TARGETS)) return; | |
| mgAct(State.MINIGAME_TARGETS); | |
| for (const t of targets) { | |
| if (_clickedTargets.has(t)) continue; | |
| _clickedTargets.add(t); | |
| _targetClicks++; | |
| const clickable = qs(SEL.clickable, t) || t.closest(SEL.clickable); | |
| (clickable || t).click(); | |
| } | |
| } | |
| /** /////// Minigame: arrows (crafting) /////// */ | |
| /** Valid arrow key names for the crafting minigame. */ | |
| const ARROW_KEYS = new Set(["ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"]); | |
| /** Encapsulated arrow minigame state with reset capability. */ | |
| const arrowState = { | |
| timing: adaptiveDelay("arrow", 80, 15, 200), | |
| lastEl: null, | |
| wins: 0, | |
| prevSuccess: 0, | |
| lastCalled: 0, | |
| waited: false, | |
| reset() { | |
| this.lastEl = null; | |
| this.wins = 0; | |
| this.prevSuccess = 0; | |
| this.waited = false; | |
| }, | |
| }; | |
| /** | |
| * Handles the arrow-key crafting minigame by dispatching key events. | |
| */ | |
| function handleArrows() { | |
| const s = arrowState; | |
| // session gap: >2s since last call means a new game; reset tracking | |
| const now = Date.now(); | |
| if (s.lastCalled > 0 && now - s.lastCalled > 2000) s.reset(); | |
| s.lastCalled = now; | |
| if (s.wins >= ARROW_WINS_PER_ROUND) return; | |
| const arrow = qs(SEL.arrow); | |
| if (!arrow) { | |
| mgIdle(State.MINIGAME_ARROWS); | |
| s.lastEl = null; | |
| return; | |
| } | |
| // track sequence completions via success arrow count drops | |
| const successCount = qsa(SEL.arrowSuccess).length; | |
| if (successCount < s.prevSuccess && s.prevSuccess > 0) { | |
| s.wins++; | |
| if (s.wins >= ARROW_WINS_PER_ROUND) return; | |
| } | |
| s.prevSuccess = successCount; | |
| // don't re-press the same DOM element; wait for React to swap it out | |
| if (arrow === s.lastEl) { | |
| s.waited = true; | |
| return; | |
| } | |
| if (mgSuppressed(State.MINIGAME_ARROWS)) return; | |
| const key = arrow.alt; | |
| if (!ARROW_KEYS.has(key)) return; | |
| // learn from previous press: extra wait means delay was too short | |
| if (s.lastEl !== null) { | |
| if (s.waited) s.timing.fail(); | |
| else s.timing.ok(); | |
| } | |
| s.waited = false; | |
| mgAct(State.MINIGAME_ARROWS); | |
| s.lastEl = arrow; | |
| const opts = { key, code: key, bubbles: true, cancelable: true }; | |
| document.dispatchEvent(new KeyboardEvent("keydown", opts)); | |
| document.dispatchEvent(new KeyboardEvent("keyup", opts)); | |
| } | |
| /** /////// Handler dispatch table /////// */ | |
| /** Maps each State to its handler. Missing entries mean "no action." */ | |
| const handlers = new Map([ | |
| [State.INTRO, handleIntro], | |
| [State.MAIN_IDLE, handleMainIdle], | |
| [State.MINIGAME_GRID, handleGrid], | |
| [State.MINIGAME_SHIELD, startShieldRAF], | |
| [State.MINIGAME_TARGETS, handleTargets], | |
| [State.MINIGAME_ARROWS, handleArrows], | |
| [State.REWARDS, handleRewards], | |
| [State.MODAL_OPEN, handleModal], | |
| [State.CLASS_PICKER, handleClassPicker], | |
| ]); | |
| /** /////// One-shot: volume (achievement NINE) /////// */ | |
| /** | |
| * Simulates a click on a slider at the given percentage position. | |
| * @param {Element} slider - The slider's clickable element | |
| * @param {number} targetPct - Target position as a percentage (0-100) | |
| */ | |
| function nudgeSlider(slider, targetPct) { | |
| const rect = slider.getBoundingClientRect(); | |
| const cy = rect.top + rect.height / 2; | |
| const clientX = rect.left + rect.width * (targetPct / 100); | |
| const opts = { clientX, clientY: cy, button: 0, bubbles: true }; | |
| slider.dispatchEvent(new MouseEvent("mousedown", opts)); | |
| slider.dispatchEvent(new MouseEvent("mouseup", opts)); | |
| } | |
| /** | |
| * Opens settings, nudges each volume slider by ~10%, restores original values, | |
| * then closes settings. The GORILLA_SET_VOLUME dispatch triggers the "Ow My | |
| * Ears" achievement (NINE). | |
| * @returns {Promise<void>} | |
| */ | |
| async function triggerVolumeAchievement() { | |
| const root = qs(SEL.gameRoot); | |
| if (!root) { | |
| log("Game not visible for volume"); | |
| return; | |
| } | |
| await withOneShot(State.ONE_SHOT_VOLUME, async () => { | |
| hud.show("Volume: opening settings..."); | |
| qs(SEL.btnSettings, root)?.click(); | |
| if (!(await waitFor(() => qs(SEL.settings), 3000))) { | |
| log("Settings did not open"); | |
| hud.hide(); | |
| return; | |
| } | |
| const sliders = qsa(SEL.sliderClickable); | |
| for (let si = 0; si < sliders.length; si++) { | |
| const slider = sliders[si]; | |
| const bar = qs(SEL.progressBorder, slider); | |
| const currentPct = parseFloat(bar?.style?.width) || 0; | |
| const nudgePct = Math.min( | |
| 95, | |
| Math.max(5, currentPct < 50 ? currentPct + 10 : currentPct - 10), | |
| ); | |
| setOneShot(State.ONE_SHOT_VOLUME, `slider ${si + 1}/${sliders.length}`); | |
| hud.show( | |
| `Volume ${si + 1}/${sliders.length}: ${currentPct}% -> ${nudgePct}%`, | |
| 0, | |
| ); | |
| nudgeSlider(slider, nudgePct); | |
| await psleep(150); // min delay for Flux dispatch (no DOM signal) | |
| hud.show( | |
| `Volume ${si + 1}/${sliders.length}: restoring to ${currentPct}%`, | |
| 0, | |
| ); | |
| nudgeSlider(slider, currentPct); | |
| await psleep(150); | |
| } | |
| pressEscape(); | |
| if (!(await waitFor(() => !qs(SEL.settings), 1500))) { | |
| pressEscape(); | |
| await waitFor(() => !qs(SEL.settings), 1500); | |
| } | |
| log("Volume done, settings closed (NINE)"); | |
| hud.show("Volume done (NINE)", 3000); | |
| }); | |
| } | |
| /** /////// One-shot: page visits (SIX, SEVEN, EIGHT) /////// */ | |
| /** | |
| * Visits the stats page 10 times and the achievements page once. | |
| * SIX (basic): fires when achievements component mounts. | |
| * SEVEN (basic): fires on first stats page visit. | |
| * EIGHT "Curious Cat" (progress, req 10): increments each stats visit. | |
| * @returns {Promise<void>} | |
| */ | |
| async function pageVisitAchievements() { | |
| const root = qs(SEL.gameRoot); | |
| if (!root) { | |
| log("Game not visible for page visits"); | |
| return; | |
| } | |
| await withOneShot(State.ONE_SHOT_PAGES, async () => { | |
| const STATS_VISITS = 10; | |
| for (let i = 0; i < STATS_VISITS; i++) { | |
| setOneShot(State.ONE_SHOT_PAGES, `stats ${i + 1}/${STATS_VISITS}`); | |
| hud.show(`Stats ${i + 1}/${STATS_VISITS}`); | |
| qs(SEL.btnStats, root)?.click(); | |
| await waitFor(() => !qs(SEL.mainScreen, root), 2000); | |
| await navigateBack(root); | |
| } | |
| setOneShot(State.ONE_SHOT_PAGES, "achievements"); | |
| hud.show("Achievements 1/1"); | |
| qs(SEL.btnAchievements, root)?.click(); | |
| await waitFor(() => !qs(SEL.mainScreen, root), 3000); | |
| await navigateBack(root); | |
| log("Page visits done (SIX, SEVEN, EIGHT)"); | |
| hud.show("Page visits done", 3000); | |
| }); | |
| } | |
| /** /////// API /////// */ | |
| /** API path for the current user's gorilla game data. */ | |
| const API_USER_DATA = "/gorilla/user-data/@me"; | |
| /** | |
| * Sends a request to the Discord gorilla API. Goes through fetch (not | |
| * XHR), so the XHR observer hook doesn't see it; we have to consult | |
| * the right bucket directly here. | |
| * @param {string} method - HTTP method (GET, POST, etc.) | |
| * @param {string} path - API path appended to the base URL | |
| * @param {Object} [body] - Request body to JSON-serialize | |
| * @returns {Promise<Response>} Fetch response | |
| */ | |
| async function api(method, path, body) { | |
| const bucket = bucketForUrl(path); | |
| if (bucket) { | |
| const ok = await bucket.takeAsync(_abort.signal); | |
| if (!ok) throw new DOMException("Aborted", "AbortError"); | |
| } | |
| const opts = { method, headers: { ...window._h } }; | |
| if (body) { | |
| opts.headers["Content-Type"] = "application/json"; | |
| opts.body = JSON.stringify(body); | |
| } | |
| const res = await fetch("https://discord.com/api/v9" + path, opts); | |
| // Feed the bucket from the fetch response too, mirroring the XHR | |
| // hook. The two sources funnel into the same per-endpoint state. | |
| if (bucket) { | |
| if (res.status === 429) { | |
| let retryMs = 0; | |
| const retryHeader = res.headers.get("Retry-After"); | |
| if (retryHeader) retryMs = parseFloat(retryHeader) * 1000; | |
| try { | |
| const cloned = res.clone(); | |
| const body = await cloned.json(); | |
| if (body.retry_after) | |
| retryMs = Math.max(retryMs, body.retry_after * 1000); | |
| } catch (_) {} | |
| bucket.onRateLimit(retryMs); | |
| log( | |
| `Rate limited [${bucket.name}] (#${bucket.rateLimitCount}). Rate -> ${bucket.rate}/s` + | |
| (retryMs ? `, retry-after ${Math.ceil(retryMs / 1000)}s` : ""), | |
| ); | |
| } else if (res.ok) { | |
| bucket.onSuccess(); | |
| } | |
| } | |
| return res; | |
| } | |
| /** | |
| * Fetches the current user's gorilla game data from the API. | |
| * @returns {Promise<Object>} Parsed JSON user data | |
| */ | |
| function fetchUserData() { | |
| return api("GET", API_USER_DATA).then((r) => r.json()); | |
| } | |
| /** /////// Flux access via React fiber /////// */ | |
| /** Cached Flux references (GorillaStore + Dispatcher). */ | |
| let _flux = null; | |
| /** | |
| * Locates GorillaStore and Flux Dispatcher by walking the React fiber tree. | |
| * The GorillaStore lives in a useStateFromStores ref at fiber depth ~4. | |
| * The Dispatcher is on store._dispatcher. No webpack or localStorage needed. | |
| * @returns {{ GorillaStore: Object, Dispatcher: Object }|null} Flux refs, or null if not found | |
| */ | |
| function getFlux() { | |
| if (_flux) return _flux; | |
| const root = qs(SEL.gameRoot); | |
| if (!root) return null; | |
| const fiberKey = Object.keys(root).find((k) => k.startsWith("__reactFiber")); | |
| if (!fiberKey) return null; | |
| let fiber = root[fiberKey]; | |
| for (let depth = 0; depth < 100 && fiber; depth++) { | |
| let hook = fiber.memoizedState; | |
| for (let hi = 0; hi < 30 && hook; hi++) { | |
| const ms = hook.memoizedState; | |
| if (ms && typeof ms === "object" && "current" in ms) { | |
| const stores = ms.current?.stores; | |
| if (Array.isArray(stores)) { | |
| for (const s of stores) { | |
| if (s?.getSelectedCombatClasses && s?._dispatcher) { | |
| _flux = { | |
| GorillaStore: s, | |
| Dispatcher: s._dispatcher, | |
| }; | |
| log("Flux found via fiber:", { | |
| store: s.constructor?.displayName, | |
| dispatcher: typeof s._dispatcher.dispatch, | |
| }); | |
| return _flux; | |
| } | |
| } | |
| } | |
| } | |
| hook = hook.next; | |
| } | |
| fiber = fiber.return; | |
| } | |
| log("Flux not found in fiber tree"); | |
| return null; | |
| } | |
| /** | |
| * Reads the class-change cooldown directly from GorillaStore. Returns | |
| * `{ selectedAtMs, remainingMs, ready }` or null if the store isn't | |
| * available yet. No network call: the store value updates locally on | |
| * every successful class change Flux dispatch, so polling this is free. | |
| * @returns {{ selectedAtMs: number, remainingMs: number, ready: boolean }|null} | |
| */ | |
| function getClassChangeStatus() { | |
| const flux = getFlux(); | |
| if (!flux?.GorillaStore?.getClassSelectedAt) return null; | |
| const selectedAt = flux.GorillaStore.getClassSelectedAt(); | |
| const selectedAtMs = selectedAt?.getTime?.() ?? 0; | |
| const remainingMs = Math.max( | |
| 0, | |
| selectedAtMs + CLASS_CHANGE_COOLDOWN_MS - Date.now(), | |
| ); | |
| return { selectedAtMs, remainingMs, ready: remainingMs === 0 }; | |
| } | |
| /** | |
| * Reads profession completion counts from GorillaStore. The store is | |
| * updated locally on every GORILLA_COMPLETE_ACTIVITY_SUCCESS dispatch | |
| * (game module 21947), so polling is free and always current. | |
| * @returns {Object<string, number>|null} Class key -> count, or null if | |
| * the store hasn't loaded user data | |
| */ | |
| function getProfessionCompletion() { | |
| const flux = getFlux(); | |
| return flux?.GorillaStore?.getStats?.()?.professionCompletion ?? null; | |
| } | |
| /** | |
| * Reads the currently selected (combat, crafting) pair from GorillaStore. | |
| * @returns {[string, string]|null} Pair, or null if either is missing | |
| */ | |
| function getCurrentCombo() { | |
| const flux = getFlux(); | |
| const c = flux?.GorillaStore?.getCombatClass?.(); | |
| const k = flux?.GorillaStore?.getCraftingClass?.(); | |
| return c && k ? [c, k] : null; | |
| } | |
| /** | |
| * Ranks all valid combos by total profession completion (lowest first) | |
| * and computes their wall-time efficiency for the next class change | |
| * cooldown. Each class contributes up to PER_CLASS_MAX_TIER toward the | |
| * combo total; COMBO_MAX_COUNT is the fully-maxed total. | |
| * | |
| * Wall-time efficiency: how much of the next cooldown will actually be | |
| * earning new counts. For each non-maxed combo, the bot can earn at most | |
| * MAX_BATTLES_PER_COOLDOWN combat counts and MAX_CRAFTS_PER_COOLDOWN | |
| * craft counts in the hour. If either class doesn't have enough headroom | |
| * to absorb its cap, the remainder of the hour is spent on a maxed | |
| * class and contributes nothing (wasted wall time). | |
| * | |
| * earnedCombat = min(headroomCombat, MAX_BATTLES_PER_COOLDOWN) | |
| * earnedCraft = min(headroomCraft, MAX_CRAFTS_PER_COOLDOWN) | |
| * eff = (earnedCombat + earnedCraft) / MAX_COMBO_GAIN_PER_COOLDOWN | |
| * fullyActive = eff >= 1.0 | |
| * | |
| * The base sort is lowest count first, with maxed combos pushed to the | |
| * end. pickTarget reorders this list when the lowest combo is not | |
| * fully active. | |
| * | |
| * @param {Object<string, number>} pc - Profession completion map | |
| * @returns {Array<{combat: string, crafting: string, count: number, max: number, pct: number, maxed: boolean, eff: number, fullyActive: boolean}>} | |
| */ | |
| function rankCombos(pc) { | |
| return CLASS_COMBOS.map(([c, k]) => { | |
| const a = Math.min(pc[c] ?? 0, PER_CLASS_MAX_TIER); | |
| const b = Math.min(pc[k] ?? 0, PER_CLASS_MAX_TIER); | |
| const count = a + b; | |
| const headroomCombat = PER_CLASS_MAX_TIER - a; | |
| const headroomCraft = PER_CLASS_MAX_TIER - b; | |
| const earnedCombat = Math.min(headroomCombat, MAX_BATTLES_PER_COOLDOWN); | |
| const earnedCraft = Math.min(headroomCraft, MAX_CRAFTS_PER_COOLDOWN); | |
| const eff = (earnedCombat + earnedCraft) / MAX_COMBO_GAIN_PER_COOLDOWN; | |
| return { | |
| combat: c, | |
| crafting: k, | |
| count, | |
| max: COMBO_MAX_COUNT, | |
| pct: count / COMBO_MAX_COUNT, | |
| maxed: count >= COMBO_MAX_COUNT, | |
| eff, | |
| fullyActive: eff >= 1, | |
| }; | |
| }).sort((x, y) => { | |
| if (x.maxed !== y.maxed) return x.maxed ? 1 : -1; | |
| return x.count - y.count; | |
| }); | |
| } | |
| /** | |
| * Picks the next combo to play on. Prefers combos that can fully | |
| * saturate the next cooldown without wasting wall time on a maxed | |
| * class. When multiple combos are fully active, the lowest-count one | |
| * wins. In the endgame (no combo can be fully active), the highest | |
| * efficiency wins, with lowest count as tiebreaker. | |
| * | |
| * @param {ReturnType<typeof rankCombos>} ranked - Ranked combo list | |
| * @returns {ReturnType<typeof rankCombos>[number]|null} The chosen combo, | |
| * or null if every | |
| * combo is maxed | |
| */ | |
| function pickTarget(ranked) { | |
| const open = ranked.filter((r) => !r.maxed); | |
| if (open.length === 0) return null; | |
| const fullyActive = open.filter((r) => r.fullyActive); | |
| if (fullyActive.length > 0) { | |
| // already sorted by count ascending; first one wins | |
| return fullyActive[0]; | |
| } | |
| // endgame: pick highest efficiency, tiebreak on lowest count | |
| return open.slice().sort((x, y) => { | |
| if (x.eff !== y.eff) return y.eff - x.eff; | |
| return x.count - y.count; | |
| })[0]; | |
| } | |
| /** | |
| * Reads the remaining cooldown for an activity directly from GorillaStore. | |
| * Mirrors the game's own calculation in module 311600: | |
| * remaining = ACTIVITY_COOLDOWN_MS[type] - (now - endedAt.getTime()) | |
| * Used by the stuck-timer handler to tell a genuinely-expired cooldown | |
| * (safe to click through) from a frozen timer mid-cooldown (would 429). | |
| * @param {"crafting"|"combat"} type | |
| * @returns {number|null} Remaining ms, or null if the store isn't ready | |
| */ | |
| function getActivityCooldownRemaining(type) { | |
| const flux = getFlux(); | |
| if (!flux?.GorillaStore) return null; | |
| const getter = | |
| type === "crafting" ? "getCraftingEndedAt" : "getCombatEndedAt"; | |
| const endedAt = flux.GorillaStore[getter]?.(); | |
| const endedMs = endedAt?.getTime?.(); | |
| if (!endedMs) return null; | |
| return Math.max(0, endedMs + ACTIVITY_COOLDOWN_MS[type] - Date.now()); | |
| } | |
| /** | |
| * Syncs a class change to the in-memory GorillaStore via Flux dispatch. | |
| * @param {Object} rawApiBody - Raw API response body from user-data endpoint | |
| */ | |
| function syncClassChange(rawApiBody) { | |
| const flux = getFlux(); | |
| if (!flux) { | |
| log("Cannot sync: Flux not available"); | |
| return; | |
| } | |
| const userData = mapUserData(rawApiBody); | |
| flux.Dispatcher.dispatch({ type: FLUX_UPDATE_USER, userData }); | |
| log(`Synced class change: ${userData.combatClass}/${userData.craftingClass}`); | |
| } | |
| /** | |
| * Transforms raw API user data into the Flux store's camelCase format. | |
| * @param {Object} raw - Raw API response with snake_case keys | |
| * @returns {Object} Mapped user data with camelCase keys and Date objects | |
| */ | |
| function mapUserData(raw) { | |
| return { | |
| userId: raw.user_id, | |
| craftingClass: raw.crafting_class, | |
| combatClass: raw.combat_class, | |
| hasStartedCombat: raw.has_started_combat, | |
| hasStartedCrafting: raw.has_started_crafting, | |
| hasStartedGathering: raw.has_started_gathering, | |
| gatheringEndedAt: maybeDate(raw.gathering_ended_at), | |
| craftingEndedAt: maybeDate(raw.crafting_ended_at), | |
| combatEndedAt: maybeDate(raw.combat_ended_at), | |
| stats: { | |
| resourceContribution: raw.stats.resource_contribution, | |
| resourceConsumption: raw.stats.resource_consumption, | |
| activityCompletion: raw.stats.activity_completion, | |
| professionCompletion: raw.stats.profession_completion, | |
| }, | |
| level: raw.level, | |
| xp: raw.xp, | |
| classSelectedAt: maybeDate(raw.class_selected_at), | |
| }; | |
| } | |
| /** /////// UI-based class change /////// */ | |
| /** | |
| * Flow from source analysis: | |
| * 1. Change Class button (in settings) → refreshes user data, closes | |
| * settings, navigates game to COMBAT_CLASS_SELECTION screen | |
| * 2. Pick combat class from .selectable__460d6 grid (img alt = name) | |
| * 3. Screen transitions to CRAFTING_CLASS_SELECTION | |
| * (invalid pairings disabled via .disabled__460d6) | |
| * 4. Pick crafting class | |
| * 5. Transition to MAIN calls nb({combatClass, craftingClass}) which | |
| * fires the API POST + all Flux events (achievement tracking works) | |
| */ | |
| /** CSS selectors for the class picker UI. */ | |
| const SEL_CLASS_PICKER = { | |
| selectable: ".selectable__460d6", | |
| classAsset: ".classAsset__460d6", | |
| container: ".container__460d6", | |
| disabled: ".disabled__460d6", | |
| actions: ".actions__8041c", | |
| actionBtn: ".button__65fca .visibleText__65fca", | |
| }; | |
| /** | |
| * Finds a class option by matching the img alt text inside .selectable__460d6. | |
| * @param {Element} root - The game root container | |
| * @param {string} displayName - The class display name to search for | |
| * @returns {Element|null} The matching selectable element, or null | |
| */ | |
| function findClassOption(root, displayName) { | |
| for (const sel of qsa(SEL_CLASS_PICKER.selectable, root)) { | |
| const img = qs(SEL_CLASS_PICKER.classAsset, sel); | |
| if (img?.alt === displayName) return sel; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Finds a picker action button ("Next", "Start", "Back") by visible text. | |
| * @param {Element} root - The game root container | |
| * @param {string} label - Button text to match | |
| * @returns {Element|null} The closest .clickable__5c90e ancestor, or null | |
| */ | |
| function findPickerAction(root, label) { | |
| for (const el of qsa(SEL_CLASS_PICKER.actionBtn, root)) { | |
| if (el.textContent.trim() === label) return el.closest(SEL.clickable); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Opens the change-class wizard. The actual picker navigation (selecting | |
| * combat → Next → crafting → Start) is driven by handleClassPicker in the | |
| * tick loop, which makes the flow re-entrant and lets manual picker opens | |
| * work the same way. This function only opens the button and waits for | |
| * the picker to close. | |
| * @param {string} combatClass - Combat class API key (e.g. "healer") | |
| * @param {string} craftingClass - Crafting class API key (e.g. "magic_crafter") | |
| * @returns {Promise<boolean>} True if the picker opened and closed normally | |
| */ | |
| async function changeClassViaUI(combatClass, craftingClass) { | |
| const root = qs(SEL.gameRoot); | |
| if (!root) return false; | |
| // the Change Class button lives on the main game view at | |
| // .container__8e80e > .resources__8e80e > .action_f000ba > | |
| // .changeClassButton__41b81 > [aria-label="Change Class"] | |
| const btn = await waitFor( | |
| () => | |
| qs(`${SEL.changeClassWrapper} ${SEL.changeClassBtn}`, root) || | |
| qs(SEL.changeClassBtn, root), | |
| 3000, | |
| ); | |
| if (!btn) { | |
| log("Change Class button not found on main view"); | |
| return false; | |
| } | |
| // The React component recomputes the disabled state from an internal | |
| // setInterval(..., 1000) that calls setState(Date.now()), so its view of | |
| // the cooldown can lag behind our getClassChangeStatus() by up to ~1s | |
| // (plus setInterval jitter). If the bot's store check said "ready" but | |
| // the button is still visually disabled, wait a couple of ticks for | |
| // React to catch up before falling back to the API. | |
| const isBtnDisabled = () => | |
| btn.matches(SEL.disabledButton) || !!btn.closest(SEL.disabledButton); | |
| if (isBtnDisabled() && !(await waitFor(() => !isBtnDisabled(), 3000))) { | |
| log("Change Class button is disabled (cooldown still active)"); | |
| return false; | |
| } | |
| // ensure handleClassPicker has the right target before the picker opens | |
| _pickerTarget = [combatClass, craftingClass]; | |
| const clickTarget = btn.matches(SEL.clickable) | |
| ? btn | |
| : qs(SEL.clickable, btn) || btn; | |
| clickTarget.click(); | |
| // wait for the picker to actually appear | |
| if (!(await waitFor(() => qs(SEL.pickerSelectable), 3000))) { | |
| log("Class picker did not appear"); | |
| return false; | |
| } | |
| // handleClassPicker (tick loop) drives the picker through both pages. | |
| // Wait for the picker to disappear and the main screen to come back. | |
| const closed = await waitFor( | |
| () => !qs(SEL.pickerSelectable) && mainScreenReady(root), | |
| 15000, | |
| ); | |
| if (!closed) { | |
| log("Class picker did not close in time"); | |
| return false; | |
| } | |
| log(`Class changed via UI: ${combatClass}/${craftingClass}`); | |
| return true; | |
| } | |
| /** | |
| * Changes class via the in-game UI. If that fails, falls back to the API | |
| * POST endpoint as a last resort (noisily — this should almost never | |
| * happen in the new flow because we verify button state and the picker | |
| * handler drives navigation tick-by-tick). | |
| * @param {string} combat - Combat class API key | |
| * @param {string} crafting - Crafting class API key | |
| * @returns {Promise<{ok: true} | {ok: false, error: {status: number}}>} Result | |
| */ | |
| async function changeClass(combat, crafting) { | |
| const uiOk = await changeClassViaUI(combat, crafting); | |
| if (uiOk) return Ok(true); | |
| // last-resort fallback. Log loudly so we notice when it fires in the | |
| // wild; normally the UI path should always succeed. | |
| log( | |
| `WARNING: UI class change failed for ${combat}/${crafting}, falling back to API POST`, | |
| ); | |
| hud.showError( | |
| `UI class change failed for ${combat}/${crafting}, using API fallback`, | |
| 6000, | |
| ); | |
| const r = await api("POST", API_USER_DATA, { | |
| combat_class: combat, | |
| crafting_class: crafting, | |
| }); | |
| if (r.status === 200) { | |
| const body = await r.json(); | |
| syncClassChange(body); | |
| log(`API fallback succeeded for ${combat}/${crafting}`); | |
| return Ok(true); | |
| } | |
| log(`API fallback failed: HTTP ${r.status}`); | |
| return Err({ status: r.status }); | |
| } | |
| /** /////// Unlock client-only achievements /////// */ | |
| /** | |
| * Unlocks all achievements that are tracked purely in the client | |
| * (GorillaAchievementStore / IndexedDB). These have no server-side | |
| * source of truth, so the server cannot contradict them. | |
| * | |
| * Client-only achievements (verified against game source module 21947 | |
| * and the minigame component hooks): | |
| * | |
| * basic (unlocked: true): | |
| * THREE "Gigabrain" grid minigame perfect (healer) | |
| * FOUR "Master of Deflection" shield minigame perfect (tank) | |
| * FIVE "Aim God" target minigame perfect (DPS) | |
| * SIX "Progression Enjoyer" visited achievements page | |
| * SEVEN "Number Go Up" visited stats page | |
| * NINE "Ow My Ears" adjusted volume | |
| * TEN "Exploring My Options" changed combat class | |
| * ELEVEN "Midlife Crisis" changed crafting class | |
| * | |
| * progress (total >= requirement): | |
| * EIGHT "Curious Cat" visited stats 10 times (req 10) | |
| * THIRTEEN "Grass Toucher" completed gathering x5 (req 5) | |
| * | |
| * single-title-levels (total triggers tier unlocks): | |
| * TWENTY "Wrong Game" dragon clicks (tiers: 10,25,50,100,500) | |
| * TWENTY_ONE "Dungeon Diver" adventure clicks (tiers: 10,25,50,100,500,1000,2500,5000) | |
| * | |
| * NOT included (server-derivable, handled by repairAchievements): | |
| * ONE, TWO (class sets), FOURTEEN-NINETEEN (per-class profession), | |
| * TWENTY_TWO, TWENTY_THREE (total battles/crafts), | |
| * TWENTY_FOUR-TWENTY_SIX (materials), TWENTY_SEVEN (level) | |
| * | |
| * NOT included (meta, auto-computed by GorillaManager.handleUpdateAchievement): | |
| * TWELVE "I EAT CRAYONS" (counts all other unlocked achievements) | |
| */ | |
| function unlockLocalAchievements() { | |
| const flux = getFlux(); | |
| if (!flux) { | |
| log("Cannot unlock: Flux not found"); | |
| hud.show("Flux not found", 3000); | |
| return; | |
| } | |
| const { Dispatcher } = flux; | |
| const d = (ach, type, total, unlocked) => | |
| dispatchAchievement(Dispatcher, ach, type, total, unlocked); | |
| let count = 0; | |
| // basic achievements (unlocked: true) | |
| for (const a of [ | |
| ACH.GIGABRAIN, | |
| ACH.MASTER_OF_DEFLECTION, | |
| ACH.AIM_GOD, | |
| ACH.PROGRESSION_ENJOYER, | |
| ACH.NUMBER_GO_UP, | |
| ACH.OW_MY_EARS, | |
| ACH.EXPLORING_MY_OPTIONS, | |
| ACH.MIDLIFE_CRISIS, | |
| ]) { | |
| d(a, "basic", 1, true); | |
| count++; | |
| } | |
| // progress achievements (total >= requirement) | |
| d(ACH.CURIOUS_CAT, "progress", 10); | |
| d(ACH.GRASS_TOUCHER, "progress", 5); | |
| count += 2; | |
| // single-title-levels (set to max tier) | |
| d(ACH.WRONG_GAME, "single-title-levels", PER_CLASS_MAX_TIER); | |
| d(ACH.DUNGEON_DIVER, "single-title-levels", 5000); | |
| count += 2; | |
| log(`Unlocked ${count} local-only achievements`); | |
| hud.show(`Unlocked ${count} local achievements`, 5000); | |
| } | |
| /** /////// One-time repair: achievement state from server /////// */ | |
| /** | |
| * Reads authoritative server stats and syncs all fixable achievements | |
| * to their correct values via Flux dispatches. Client-side achievement | |
| * state (GorillaAchievementStore / localStorage) can drift from the | |
| * server after API-only class changes or mid-activity page reloads. | |
| * | |
| * Fixable from userData.stats: | |
| * professionCompletion: FOURTEEN-NINETEEN (per-class), TWENTY_TWO | |
| * (total battles), TWENTY_THREE (total crafts) | |
| * resourceContribution: TWENTY_FOUR-TWENTY_SIX (leather/wood/metal) | |
| * level: TWENTY_SEVEN | |
| * Fixable from class tracking: ONE, TWO (class sets), TEN, ELEVEN | |
| * | |
| * NOT fixable (client-only): THREE-FIVE (minigame perfects), | |
| * THIRTEEN (grass toucher), TWENTY (dragon clicks), | |
| * TWENTY_ONE (adventure clicks) | |
| * @returns {Promise<void>} | |
| */ | |
| async function repairAchievements() { | |
| const flux = getFlux(); | |
| if (!flux) { | |
| log("Cannot repair achievements: Flux not found in fiber"); | |
| return; | |
| } | |
| // fetch authoritative server data | |
| let raw; | |
| try { | |
| raw = await fetchUserData(); | |
| } catch (_) { | |
| log("Cannot repair: failed to fetch user data"); | |
| return; | |
| } | |
| const ud = mapUserData(raw); | |
| const pc = ud.stats.professionCompletion; | |
| const rc = ud.stats.resourceContribution; | |
| const { Dispatcher, GorillaStore } = flux; | |
| let fixes = 0; | |
| // Populate GorillaStore.selectedCombatClasses / selectedCraftingClasses | |
| // from server stats. The store's set is the authoritative source for | |
| // Job Hopper and Undeclared Major (the game increments them on class | |
| // PICK, not on activity completion, see module 345228 function `_`). | |
| // | |
| // On a returning user's original machine the set is already populated | |
| // from the game's natural flow; on a fresh client (new browser, cleared | |
| // IndexedDB) it starts empty and we rebuild it here. We can only see | |
| // classes the server remembers — any class the user picked but never | |
| // completed an activity with is invisible to `pc` and unrecoverable on | |
| // a fresh client, so set size is best-effort there. | |
| // | |
| // Dispatching FLUX_UPDATE_USER with `{ ...ud, [udKey]: cls }` both adds | |
| // `cls` to the relevant selected set (store reducer) and fires TEN or | |
| // ELEVEN via GorillaManager.handleUpdateUserDataSuccess. The final | |
| // restore dispatch at the end puts the real current class back. | |
| const classRoles = [ | |
| { | |
| keys: ALL_COMBAT, | |
| getter: "getSelectedCombatClasses", | |
| udKey: "combatClass", | |
| }, | |
| { | |
| keys: ALL_CRAFTING, | |
| getter: "getSelectedCraftingClasses", | |
| udKey: "craftingClass", | |
| }, | |
| ]; | |
| for (const { keys, getter, udKey } of classRoles) { | |
| for (const cls of keys) { | |
| if ((pc[cls] ?? 0) > 0 && !GorillaStore[getter]().has(cls)) { | |
| Dispatcher.dispatch({ | |
| type: FLUX_UPDATE_USER, | |
| userData: { ...ud, [udKey]: cls }, | |
| }); | |
| fixes++; | |
| } | |
| } | |
| } | |
| // also ensure the current class is in the set (it might have zero | |
| // profession_completion if the user just switched and hasn't played yet) | |
| for (const { getter, udKey } of classRoles) { | |
| const current = ud[udKey]; | |
| if (current && !GorillaStore[getter]().has(current)) { | |
| Dispatcher.dispatch({ type: FLUX_UPDATE_USER, userData: ud }); | |
| fixes++; | |
| break; | |
| } | |
| } | |
| // restore real userData after any dispatches | |
| if (fixes > 0) { | |
| Dispatcher.dispatch({ type: FLUX_UPDATE_USER, userData: ud }); | |
| } | |
| const setAch = (achEntry, type, total) => { | |
| dispatchAchievement(Dispatcher, achEntry, type, total); | |
| fixes++; | |
| }; | |
| // Read authoritative class counts from the store after reconstruction. | |
| // Using set.size (not pc > 0) correctly handles the "picked but never | |
| // played" case the game itself supports: a user who picks tank without | |
| // playing it still has Job Hopper at +1 because the game increments on | |
| // pick. Using pc > 0 alone would decrease Job Hopper on repair. | |
| // | |
| // EXPLORING_MY_OPTIONS / MIDLIFE_CRISIS are the "changed class" basic | |
| // achievements. Gate on size >= 2 so the bot never grants them on a | |
| // brand-new account that has only ever picked one class. The game sets | |
| // them naturally via GorillaManager.handleUpdateUserDataSuccess whenever | |
| // a class is present in userData, so users with any history get them | |
| // via the FLUX_UPDATE_USER dispatches above regardless of this gate. | |
| const combatClassCount = GorillaStore.getSelectedCombatClasses().size; | |
| const craftingClassCount = GorillaStore.getSelectedCraftingClasses().size; | |
| if (combatClassCount > 0) | |
| setAch(ACH.JOB_HOPPER, "progress", combatClassCount); | |
| if (craftingClassCount > 0) | |
| setAch(ACH.UNDECLARED_MAJOR, "progress", craftingClassCount); | |
| if (combatClassCount >= 2) setAch(ACH.EXPLORING_MY_OPTIONS, "basic", 1); | |
| if (craftingClassCount >= 2) setAch(ACH.MIDLIFE_CRISIS, "basic", 1); | |
| // per-class combat + crafting achievements | |
| const perClassGroups = [ | |
| { classes: CLASSES.combat, totalAch: ACH.BATTLEMASTER }, | |
| { classes: CLASSES.crafting, totalAch: ACH.TINKERER }, | |
| ]; | |
| const groupTotals = []; | |
| for (const { classes, totalAch } of perClassGroups) { | |
| let total = 0; | |
| for (const [cls, { achId }] of Object.entries(classes)) { | |
| const count = pc[cls] ?? 0; | |
| if (count > 0) setAch(achId, "multi-title-levels", count); | |
| total += count; | |
| } | |
| if (total > 0) setAch(totalAch, "single-title-levels", total); | |
| groupTotals.push(total); | |
| } | |
| // gathering materials | |
| for (const [mat, { achId }] of Object.entries(MATERIALS)) { | |
| const count = rc[mat] ?? 0; | |
| if (count > 0) setAch(achId, "single-title-levels", count); | |
| } | |
| if (ud.level > 0) setAch(ACH.LEVEL, "multi-title-levels", ud.level); | |
| const matSummary = MATERIAL_NAMES.map((m) => `${m}=${rc[m] ?? 0}`).join(", "); | |
| log( | |
| `Achievement repair done (${fixes} dispatches): ` + | |
| `combat=${GorillaStore.getSelectedCombatClasses().size}/${ALL_COMBAT.length}, ` + | |
| `crafting=${GorillaStore.getSelectedCraftingClasses().size}/${ALL_CRAFTING.length}, ` + | |
| `battles=${groupTotals[0]}, crafts=${groupTotals[1]}, ` + | |
| `${matSummary}, level=${ud.level}`, | |
| ); | |
| hud.show("Achievements synced from server", 5000); | |
| } | |
| /** /////// Class cycling (ONE, TWO, TEN-NINETEEN) /////// */ | |
| /** | |
| * Background loop: keep the bot on whichever combo pickTarget says is | |
| * the best place to spend the next class change cooldown. Re-evaluates | |
| * every iteration so a new pick is taken naturally on cooldown expiry. | |
| * Stops only when all combos have both classes at PER_CLASS_MAX_TIER. | |
| * | |
| * Decision per iteration: | |
| * 1. Read profession completion from GorillaStore (no API call). | |
| * 2. rankCombos computes count, percent to max, and wall-time | |
| * efficiency for the next cooldown for every combo. | |
| * 3. If every combo is maxed, exit. | |
| * 4. pickTarget selects the lowest-count fully-active combo when | |
| * one exists, or the highest-efficiency combo otherwise. | |
| * 5. If we're already on the target, just wait the cooldown out. | |
| * 6. Else if the cooldown is ready, switch via the UI and wait. | |
| * 7. Else (cooldown still active on a suboptimal combo), play out | |
| * the rest on whatever we're physically on; the next iteration | |
| * will pick again the moment the cooldown unlocks. | |
| * | |
| * Invalid pairings (CLASS_DEFS.invalidPairing): healer+magic, tank+armor, | |
| * dps+weapon. CLASS_COMBOS already filters these out. | |
| * | |
| * The only network call this loop makes is the class change POST itself | |
| * (and only as a fallback when the UI flow fails). All ranking, cooldown, | |
| * and current-combo reads come from GorillaStore. | |
| * @returns {Promise<void>} | |
| */ | |
| async function classChangeLoop() { | |
| while (true) { | |
| _abort.signal.throwIfAborted(); | |
| // GorillaStore is populated at boot via GORILLA_FETCH_USER_DATA_SUCCESS; | |
| // if we somehow raced ahead of the fetch, force one and retry. | |
| let pc = getProfessionCompletion(); | |
| if (!pc) { | |
| try { | |
| await fetchUserData(); | |
| } catch (e) { | |
| if (e?.name === "AbortError") throw e; | |
| } | |
| pc = getProfessionCompletion() ?? {}; | |
| } | |
| const ranked = rankCombos(pc); | |
| _priorityCombos = ranked; | |
| // log every combo so the user sees the full picture: count vs max, | |
| // percent maxed, wall-time efficiency for the next cooldown, and | |
| // whether the combo is already fully done | |
| log( | |
| "Combo progress:\n" + | |
| ranked | |
| .map((r) => { | |
| const pct = (r.pct * 100).toFixed(1); | |
| const eff = (r.eff * 100).toFixed(0); | |
| const tag = r.maxed ? " MAX" : r.fullyActive ? "" : " (partial)"; | |
| return ` ${r.combat}/${r.crafting} ${r.count}/${r.max} (${pct}%) eff:${eff}%${tag}`; | |
| }) | |
| .join("\n"), | |
| ); | |
| const target = pickTarget(ranked); | |
| if (!target) { | |
| _priorityCombos = null; | |
| _pickerTarget = null; | |
| classProgress.current = null; | |
| backgroundTask = null; | |
| hud.show("All per-class achievements maxed, cycling done", 5000); | |
| log("All combos at max tier. Stopping class cycling."); | |
| return; | |
| } | |
| const targetLabel = `${target.combat}/${target.crafting}`; | |
| const targetPct = (target.pct * 100).toFixed(1); | |
| const targetEff = (target.eff * 100).toFixed(0); | |
| const current = getCurrentCombo(); | |
| const onTarget = | |
| current && current[0] === target.combat && current[1] === target.crafting; | |
| const status = getClassChangeStatus(); | |
| if (onTarget) { | |
| // Already on the chosen combo. Wait the cooldown out and re-pick. | |
| classProgress.current = targetLabel; | |
| _pickerTarget = null; | |
| hud.show( | |
| `Target: ${targetLabel} ${targetPct}% · eff ${targetEff}%\nAlready on combo, waiting...`, | |
| 5000, | |
| ); | |
| await waitForCooldown(target.combat, target.crafting); | |
| } else if (status?.ready) { | |
| // Cooldown ready and we're on a different combo. Switch via the UI. | |
| // changeClass() drives changeClassViaUI() first; the API POST is | |
| // a loud, banner-flashing fallback only. | |
| classProgress.current = targetLabel; | |
| _pickerTarget = [target.combat, target.crafting]; | |
| hud.show( | |
| `Switching to ${targetLabel} (${targetPct}%, eff ${targetEff}%)`, | |
| 5000, | |
| ); | |
| if (!(await switchToTarget(target.combat, target.crafting))) { | |
| await psleep(30000); | |
| continue; | |
| } | |
| await waitForCooldown(target.combat, target.crafting); | |
| } else { | |
| // Suboptimal combo with cooldown still active. Play out the rest | |
| // on whatever we're physically on; the next iteration will pick | |
| // again as soon as the cooldown unlocks. | |
| const curCombat = current?.[0] ?? target.combat; | |
| const curCrafting = current?.[1] ?? target.crafting; | |
| const curLabel = `${curCombat}/${curCrafting}`; | |
| classProgress.current = curLabel; | |
| _pickerTarget = [target.combat, target.crafting]; | |
| hud.show( | |
| `Cooldown active on ${curLabel}\nWill switch to ${targetLabel} when ready`, | |
| 5000, | |
| ); | |
| await waitForCooldown(curCombat, curCrafting); | |
| } | |
| classProgress.current = null; | |
| _pickerTarget = null; | |
| } | |
| } | |
| /** | |
| * Opens the change-class wizard for the given combo. The picker pages | |
| * are driven by handleClassPicker on the tick loop; this just calls | |
| * changeClass() (which drives the UI flow and falls back to a loud API | |
| * POST if the UI path fails). Returns true on success, false on rate | |
| * limit or other failure (caller backs off). | |
| */ | |
| async function switchToTarget(combat, crafting) { | |
| try { | |
| const result = await changeClass(combat, crafting); | |
| if (result.ok) { | |
| stats.classChanges++; | |
| return true; | |
| } | |
| if (result.error?.status === 429) { | |
| // Unexpected: store said ready but the server disagreed. Back off | |
| // generously and let the outer loop re-rank on the next iteration. | |
| log("Unexpected 429 after store said ready; waiting 5m"); | |
| hud.show("429 after ready, waiting 5m", 10000); | |
| await psleep(5 * 60 * 1000); | |
| return false; | |
| } | |
| log( | |
| `Class change failed: HTTP ${result.error?.status ?? "?"}, retry in 30s`, | |
| ); | |
| return false; | |
| } catch (e) { | |
| if (e?.name === "AbortError") throw e; | |
| log("Class change error, retrying in 30s:", e); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Sleeps until the class change cooldown reads `ready` from GorillaStore. | |
| * No early exits: even if both classes hit PER_CLASS_MAX_TIER mid-wait, | |
| * the server still enforces the full 1h cooldown before a switch is | |
| * possible, so breaking early would just spin the outer loop. The HUD | |
| * pill reads professionCompletion live on every tick, so the displayed | |
| * percentage updates as crafts and battles complete during the wait. | |
| */ | |
| async function waitForCooldown(combat, crafting) { | |
| const comboLabel = `${combat}/${crafting}`; | |
| backgroundTask = comboLabel; | |
| hud.show(`${comboLabel}\nPlaying until cooldown expires...`, 5000); | |
| const preCrafts = stats.crafts; | |
| const preBattles = stats.battles; | |
| while (true) { | |
| _abort.signal.throwIfAborted(); | |
| const status = getClassChangeStatus(); | |
| if (!status) { | |
| // store hasn't loaded user data yet (rare); short retry | |
| await psleep(5000); | |
| continue; | |
| } | |
| if (status.ready) break; | |
| // sleep all the way to the cooldown deadline; sleepWithCountdown | |
| // updates the backgroundTask label in 5s chunks for the status bar | |
| const switchAt = Date.now() + status.remainingMs; | |
| await sleepWithCountdown( | |
| switchAt, | |
| () => `${comboLabel}, switch in ${formatWait(switchAt - Date.now())}`, | |
| ); | |
| } | |
| const dc = stats.crafts - preCrafts; | |
| const db = stats.battles - preBattles; | |
| log(`Activities in ${comboLabel}: +${dc} craft, +${db} battle`); | |
| } | |
| /** /////// XHR hooks /////// */ | |
| /** | |
| * Patches XMLHttpRequest to capture auth headers, monitor activity | |
| * completions, and feed the AIMD rate limiter on 200/429 responses. | |
| * @returns {{ unpatch: Function }} Call unpatch() to restore original prototypes | |
| */ | |
| function installXHRHooks() { | |
| const origOpen = XMLHttpRequest.prototype.open; | |
| const origSetHeader = XMLHttpRequest.prototype.setRequestHeader; | |
| const origSend = XMLHttpRequest.prototype.send; | |
| XMLHttpRequest.prototype.open = function (method, url) { | |
| this._meadowUrl = url; | |
| return origOpen.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.setRequestHeader = function (key, value) { | |
| if (!this._headers) this._headers = {}; | |
| this._headers[key] = value; | |
| return origSetHeader.apply(this, arguments); | |
| }; | |
| XMLHttpRequest.prototype.send = function (body) { | |
| const url = this._meadowUrl; | |
| // always update headers from the latest gorilla request (token rotation) | |
| if (url?.includes("gorilla") && this._headers) { | |
| window._h = this._headers; | |
| if (!window._ready) { | |
| window._ready = true; | |
| log("Headers captured."); | |
| boot(); | |
| } | |
| } | |
| // gorilla response monitoring | |
| if (url?.includes("/gorilla/")) { | |
| const bucket = bucketForUrl(url); | |
| this.addEventListener("load", function () { | |
| if (this.status === 429 && bucket) { | |
| // respect the longer of header and body retry-after | |
| const retryHeader = this.getResponseHeader("Retry-After"); | |
| let retryMs = retryHeader ? parseFloat(retryHeader) * 1000 : 0; | |
| try { | |
| const body = JSON.parse(this.responseText); | |
| if (body.retry_after) | |
| retryMs = Math.max(retryMs, body.retry_after * 1000); | |
| } catch (_) {} | |
| bucket.onRateLimit(retryMs); | |
| log( | |
| `Rate limited [${bucket.name}] (#${bucket.rateLimitCount}). Rate -> ${bucket.rate}/s` + | |
| (retryMs ? `, retry-after ${Math.ceil(retryMs / 1000)}s` : ""), | |
| ); | |
| } else if (this.status === 200 && bucket) { | |
| bucket.onSuccess(); | |
| } | |
| if (this.status !== 200 || !url.includes("/gorilla/activity/")) return; | |
| try { | |
| const data = JSON.parse(this.responseText); | |
| if (url.includes("gathering/complete") && data.changes) { | |
| stats.gathers++; | |
| for (const mat of MATERIAL_NAMES) { | |
| if (data.changes[mat]) stats[mat] += data.changes[mat]; | |
| } | |
| } | |
| if (url.includes("crafting/complete")) { | |
| stats.crafts++; | |
| watchCountdown("crafting"); | |
| } | |
| if (url.includes("combat/complete")) { | |
| stats.battles++; | |
| watchCountdown("combat"); | |
| } | |
| burstTryStart(); | |
| } catch (_) {} | |
| }); | |
| } | |
| return origSend.call(this, body); | |
| }; | |
| return { | |
| unpatch() { | |
| XMLHttpRequest.prototype.open = origOpen; | |
| XMLHttpRequest.prototype.setRequestHeader = origSetHeader; | |
| XMLHttpRequest.prototype.send = origSend; | |
| }, | |
| }; | |
| } | |
| /** /////// Main loop /////// */ | |
| /** | |
| * Single tick of the main loop. Derives state from DOM, dispatches to | |
| * the matching handler, then reschedules with AIMD interval. | |
| */ | |
| let _prevTickState = null; | |
| function tick() { | |
| if (_abort.signal.aborted) return; | |
| const state = deriveState(); | |
| // persistent "bot controlling" banner. getControlLabel returns null for | |
| // states where the user can safely interact (idle gathering, settings), | |
| // so the banner naturally hides itself when the bot steps back. | |
| hud.setControlLabel(getControlLabel(state)); | |
| // We don't gate the entire tick on bucket pause anymore: a 429 on | |
| // counters shouldn't stop gameplay handlers from running. Action | |
| // sites that produce HTTP traffic (tryStartActivity, the api() | |
| // helper) gate themselves on their specific bucket's take(). | |
| if (paused) { | |
| hud.update(state); | |
| } else if (activeOneShot) { | |
| hud.update(activeOneShot); | |
| } else { | |
| // stop the shield rAF loop when leaving the shield state | |
| if (state !== State.MINIGAME_SHIELD) stopShieldRAF(); | |
| // reset minigame state on entry so stale data from a previous round | |
| // can't block the handler (time-gap detection fails on fast cycles) | |
| if (state !== _prevTickState) { | |
| if (state === State.MINIGAME_ARROWS) arrowState.reset(); | |
| if (state === State.MINIGAME_GRID) { | |
| for (const id of gridState.timers) clearTimeout(id); | |
| gridState.timers.length = 0; | |
| gridState.busy = false; | |
| } | |
| if (state === State.MINIGAME_SHIELD) { | |
| shieldState.lastCX = null; | |
| shieldState.lockedCX = null; | |
| shieldState.shieldY = 0; | |
| } | |
| } | |
| const handler = handlers.get(state); | |
| if (handler) handler(); | |
| hud.update(state); | |
| } | |
| _prevTickState = state; | |
| // Schedule next tick at the fastest non-paused bucket's interval. | |
| // nextTickInterval handles the all-paused case (polls at 500ms). | |
| setTimeout(tick, nextTickInterval()); | |
| } | |
| /** Re-entrancy guard for tryStartActivity. */ | |
| let _activityBusy = false; | |
| /** | |
| * Countdown watcher. Activated when an activity XHR completes. Samples | |
| * the countdown text immediately (no cooldown wait); if frozen for 3s, | |
| * the timer's setInterval is dead and we click through via React fiber. | |
| */ | |
| const _countdownWatch = { | |
| crafting: { active: false, lastText: "", lastSeen: 0 }, | |
| combat: { active: false, lastText: "", lastSeen: 0 }, | |
| }; | |
| /** How long text must be frozen before we declare the timer stuck. */ | |
| const STUCK_THRESHOLD_MS = 3000; | |
| /** | |
| * Called from XHR hook when an activity completes. Starts watching | |
| * that activity's countdown for stuck-timer detection. | |
| * @param {"crafting"|"combat"} type | |
| */ | |
| function watchCountdown(type) { | |
| const w = _countdownWatch[type]; | |
| w.active = true; | |
| w.lastText = ""; | |
| w.lastSeen = 0; | |
| } | |
| /** | |
| * For a disabled activity button, checks if its countdown text is frozen. | |
| * A working countdown ticks every 1s; if the text is unchanged for 3s | |
| * the game's setInterval is dead. Before firing onClick, verifies the | |
| * real remaining cooldown via GorillaStore: the interval may have died | |
| * while a fresh 2m/3m cooldown is still counting down server-side, and | |
| * clicking through early just spams 429s until the DOM repaints. | |
| * @param {Element} el - The .activityButton__8af73 element | |
| * @param {"crafting"|"combat"} type - Activity type | |
| * @returns {boolean} True if it triggered a click-through | |
| */ | |
| function tryClickStuck(el, type) { | |
| const w = _countdownWatch[type]; | |
| if (!w.active) return false; | |
| const textEl = qs(SEL.countdownText, el); | |
| if (!textEl) { | |
| // no countdown visible; timer already expired or never started | |
| w.active = false; | |
| return false; | |
| } | |
| const now = Date.now(); | |
| const text = textEl.textContent; | |
| // text changed (or first sample): timer is alive, reset | |
| if (text !== w.lastText) { | |
| w.lastText = text; | |
| w.lastSeen = now; | |
| return false; | |
| } | |
| // text unchanged but haven't waited long enough yet | |
| if (now - w.lastSeen < STUCK_THRESHOLD_MS) return false; | |
| // text frozen for 3s. The game bug in module 311600 kills the 1s | |
| // setInterval after the first cooldown ends and never re-installs it, | |
| // so subsequent cooldowns freeze at "02:00"/"03:00" for their full | |
| // duration. Read GorillaStore to tell a real cooldown from a truly- | |
| // expired one before deciding to click through. | |
| const remainingMs = getActivityCooldownRemaining(type); | |
| if (remainingMs === null) { | |
| log(`[stuck] ${type}: frozen at "${text}" but GorillaStore unavailable`); | |
| return false; | |
| } | |
| if (remainingMs > 0) { | |
| // real cooldown still running server-side. _activityPoll (500ms) | |
| // will re-check; no need to schedule anything here. | |
| return false; | |
| } | |
| // cooldown actually elapsed but UI is frozen: fire onClick from fiber | |
| const props = walkFiber(el, (p) => p?.onClick && p?.activity); | |
| if (props?.onClick) { | |
| log( | |
| `Stuck ${type} timer (frozen at "${text}", cooldown elapsed), clicking through`, | |
| ); | |
| props.onClick(); | |
| w.active = false; | |
| return true; | |
| } | |
| log(`[stuck] ${type}: frozen at "${text}" but no onClick in fiber`); | |
| return false; | |
| } | |
| /** Activity types that correspond to minigames (from game source enum). */ | |
| const ACTIVITY_TYPES = new Set(["crafting", "combat"]); | |
| /** Caches the fiber-derived activity type per DOM element. */ | |
| const _activityTypeCache = new WeakMap(); | |
| /** | |
| * Reads the React fiber `activity` prop from an activityButton element. | |
| * Cached per element since the prop never changes for a given mount. | |
| * @param {Element} el - An .activityButton__8af73 element | |
| * @returns {string|null} | |
| */ | |
| function getActivityType(el) { | |
| let type = _activityTypeCache.get(el); | |
| if (type !== undefined) return type; | |
| type = walkFiber(el, (p) => p?.activity)?.activity ?? null; | |
| _activityTypeCache.set(el, type); | |
| return type; | |
| } | |
| /** | |
| * Clicks the first available craft or combat button. Identifies buttons | |
| * by their React `activity` prop, ignoring gathering and unknown elements. | |
| * For disabled buttons being watched, checks for stuck countdown timers | |
| * and clicks through via the fiber onClick if frozen. | |
| */ | |
| function tryStartActivity() { | |
| if (_activityBusy) return; | |
| if (!bootReady || paused || activeOneShot) return; | |
| // Consume an activity token before clicking. If the bucket is empty | |
| // or paused, skip this attempt; the observer + tick will retry. This | |
| // is what makes burstTryStart respect the rate limit even though it | |
| // bypasses the tick loop. | |
| if (buckets.activity.take() > 0) return; | |
| if (deriveState() !== State.MAIN_IDLE) return; | |
| _activityBusy = true; | |
| setTimeout(() => { | |
| _activityBusy = false; | |
| }, 100); | |
| const root = _gameRoot; | |
| if (!root) return; | |
| const actions = qs(SEL.gameActions, root); | |
| if (!actions) return; | |
| for (const el of qsa(SEL.activityButton, actions)) { | |
| const btn = resolveClickable(el); | |
| if (btn) { | |
| if (!ACTIVITY_TYPES.has(getActivityType(el))) continue; | |
| btn.click(); | |
| return; | |
| } | |
| // button is disabled; check if its countdown is stuck | |
| const type = getActivityType(el); | |
| if (type && tryClickStuck(el, type)) return; | |
| } | |
| } | |
| /** Cached MutationObserver instance. Stable across React re-renders. */ | |
| let _activityObserver = null; | |
| /** | |
| * Installs a MutationObserver on gameActions to detect button state changes | |
| * instantly. Observes only the actions container to avoid spurious firings. | |
| */ | |
| function installActivityObserver() { | |
| if (_activityObserver) return; | |
| const root = qs(SEL.gameRoot); | |
| if (!root) return; | |
| _activityObserver = new MutationObserver((mutations) => { | |
| tryStartActivity(); | |
| // when a countdown element is removed (timer hits 0), the button | |
| // may briefly stay disabled. rapid-fire to catch the transition. | |
| for (const m of mutations) { | |
| for (const node of m.removedNodes) { | |
| if ( | |
| node.classList?.contains(SEL.countdownClass) || | |
| node.querySelector?.(SEL.countdown) | |
| ) { | |
| burstTryStart(); | |
| return; | |
| } | |
| } | |
| } | |
| }); | |
| // falls back to root if actions container not found | |
| const target = qs(SEL.gameActions, root) || root; | |
| _activityObserver.observe(target, { | |
| attributes: true, | |
| attributeFilter: ["class"], | |
| subtree: true, | |
| childList: true, | |
| }); | |
| log( | |
| `Activity observer installed on ${target === root ? "game root" : "gameActions"}`, | |
| ); | |
| } | |
| /** /////// Runtime detection /////// */ | |
| /** | |
| * Opens the achievements page, reads the DOM to find which achievements | |
| * are already unlocked, then navigates back. Also earns SIX "Progression | |
| * Enjoyer" as a side effect (fires on mount). | |
| * @returns {Promise<Object|null>} Achievement status map, or null on failure | |
| */ | |
| async function detectAchievementState() { | |
| const root = qs(SEL.gameRoot); | |
| if (!root) return null; | |
| qs(SEL.btnAchievements, root)?.click(); | |
| await waitFor(() => qsa(SEL.achievementItem).length > 0, 3000); | |
| const items = qsa(SEL.achievementItem); | |
| if (items.length === 0) { | |
| await navigateBack(root, 1500); | |
| return null; | |
| } | |
| // parse all visible achievements from the DOM | |
| const achieved = new Map(); | |
| for (const item of items) { | |
| const name = qs(SEL.achievementName, item)?.textContent || ""; | |
| const locked = item.classList.contains(SEL.achievementLocked); | |
| const progress = qs(SEL.achievementProgress, item)?.textContent || ""; | |
| const levels = qsa(SEL.achievementLevel, item).length; | |
| let complete = false; | |
| if (!locked) { | |
| if (progress === "Max") complete = true; | |
| else if (!progress && levels === 0) | |
| complete = true; // basic, no tiers | |
| else { | |
| const match = progress.match(/^(\d[\d,]*)\s*\/\s*(\d[\d,]*)$/); | |
| if (match) { | |
| const cur = parseInt(match[1].replace(/,/g, ""), 10); | |
| const req = parseInt(match[2].replace(/,/g, ""), 10); | |
| complete = cur >= req; | |
| } | |
| } | |
| } | |
| achieved.set(name, { locked, progress, levels, complete }); | |
| } | |
| await navigateBack(root); | |
| const has = (name) => { | |
| const a = achieved.get(name); | |
| return a != null && !a.locked; | |
| }; | |
| const done = (name) => achieved.get(name)?.complete || false; | |
| const result = { | |
| volume: has(ACH.OW_MY_EARS.name), | |
| statsPage: has(ACH.NUMBER_GO_UP.name), | |
| achievementsPage: has(ACH.PROGRESSION_ENJOYER.name), | |
| curiousCat: done(ACH.CURIOUS_CAT.name), | |
| jobHopper: done(ACH.JOB_HOPPER.name), | |
| undeclaredMajor: done(ACH.UNDECLARED_MAJOR.name), | |
| totalVisible: items.length, | |
| }; | |
| log("Achievement detection:", result); | |
| return result; | |
| } | |
| /** /////// Boot /////// */ | |
| /** | |
| * Called once when XHR headers are captured. Reads achievement state | |
| * from the DOM, runs only the one-shots that are still needed, repairs | |
| * any drifted achievements, then starts the class cycling loop. | |
| */ | |
| let _statusPoll = null; | |
| function boot() { | |
| _statusPoll = setInterval(printStatus, 60000); | |
| installActivityObserver(); | |
| // wait for game to reach main screen, then detect and decide | |
| (async () => { | |
| try { | |
| await waitFor(() => { | |
| const r = qs(SEL.gameRoot); | |
| return r && qs(SEL.mainScreen, r); | |
| }, 15000); | |
| setOneShot(State.ONE_SHOT_PAGES, "scanning achievements"); | |
| const detected = await detectAchievementState(); | |
| clearOneShot(); | |
| await repairAchievements(); | |
| if (!detected) { | |
| log("Detection failed, running all one-shots"); | |
| hud.showUnlockBtn(); | |
| await triggerVolumeAchievement(); | |
| await pageVisitAchievements(); | |
| } else { | |
| // show unlock button only if any local-only achievements are missing | |
| const allLocalDone = | |
| detected.volume && | |
| detected.statsPage && | |
| detected.curiousCat && | |
| detected.achievementsPage; | |
| if (!allLocalDone) hud.showUnlockBtn(); | |
| const oneShots = [ | |
| { | |
| skip: detected.volume, | |
| fn: triggerVolumeAchievement, | |
| label: "volume", | |
| }, | |
| { | |
| skip: detected.statsPage && detected.curiousCat, | |
| fn: pageVisitAchievements, | |
| label: "page visits", | |
| }, | |
| ]; | |
| for (const { skip, fn, label } of oneShots) { | |
| if (skip) log(`Skipping ${label} (already done)`); | |
| else await fn(); | |
| } | |
| } | |
| } catch (e) { | |
| if (e?.name === "AbortError" || _abort.signal.aborted) return; | |
| log("Boot error, unlocking gameplay anyway:", e); | |
| } | |
| if (_abort.signal.aborted) return; | |
| bootReady = true; | |
| log("Boot init complete, gameplay unlocked"); | |
| classChangeLoop().catch((e) => { | |
| if (e?.name !== "AbortError") log("Class loop error:", e); | |
| }); | |
| })(); | |
| const scriptSrc = qs('script[src*="web."]')?.src; | |
| if (scriptSrc) { | |
| fetch(scriptSrc) | |
| .then((r) => r.text()) | |
| .then((src) => { | |
| window._src = src; | |
| log("Source cached"); | |
| }) | |
| .catch(() => log("Source cache failed")); | |
| } | |
| log(`Perfect Player v${VERSION}`); | |
| } | |
| /** /////// Kickoff /////// */ | |
| // Main loop and activity starter run immediately. Pre-boot, they | |
| // handle intro navigation and adventure clicks to trigger the first | |
| // gorilla XHR (which captures headers and calls boot()). | |
| const _xhrHooks = installXHRHooks(); | |
| hud.init(); | |
| tick(); | |
| const _activityPoll = setInterval(tryStartActivity, 500); | |
| /** | |
| * Stops the bot and restores patched prototypes. | |
| * Call `window._meadowBotStop()` from the console to teardown. | |
| */ | |
| window._meadowBotStop = () => { | |
| _abort.abort(); | |
| paused = true; | |
| clearInterval(_activityPoll); | |
| clearInterval(_statusPoll); | |
| for (const id of gridState.timers) clearTimeout(id); | |
| gridState.timers.length = 0; | |
| _activityObserver?.disconnect(); | |
| stopShieldRAF(); | |
| shieldUnblockMouse(); | |
| shieldMissReport.removeToast(); | |
| _xhrHooks.unpatch(); | |
| hud.remove(); | |
| window._meadowBotLoaded = false; | |
| delete window._h; | |
| delete window._ready; | |
| delete window._src; | |
| log("Bot stopped. Reload page or re-inject to restart."); | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment