Skip to content

Instantly share code, notes, and snippets.

@zachthedev
Last active April 8, 2026 02:01
Show Gist options
  • Select an option

  • Save zachthedev/e0942b59da239232237736e4de500aa0 to your computer and use it in GitHub Desktop.

Select an option

Save zachthedev/e0942b59da239232237736e4de500aa0 to your computer and use it in GitHub Desktop.
Discord 2026 April Fools - Lost Meadow Perfect Player
/**
* ///////////////////////////////////////////////
* 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