Last active
July 12, 2026 10:28
-
-
Save thevtm/dee220828c1c0e19394f61da76a38a84 to your computer and use it in GitHub Desktop.
A JavaScript bot for the Gear Factory idle game
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Gear Factory Bot | |
| * ================ | |
| * | |
| * A JavaScript bot that automates the idle game "Gear Factory". | |
| * | |
| * What it does: | |
| * - Buy Gear: repeatedly clicks the "Buy Gear" button on the Build tab. | |
| * - Sort: repeatedly clicks the "Sort" button. | |
| * - Merge: merge gears very quickly. | |
| * | |
| * How to use: | |
| * 1. Open the game and make sure the game's iframe is selected as the | |
| * console's execution context (the iframe dropdown at the top of the | |
| * DevTools Console). The bot reads the game's React state, so it must | |
| * run inside the game frame — not the top-level page. | |
| * 2. Paste this entire script into the console and press Enter. | |
| * 3. Run `bot.start()` to start every bot, or start them individually: | |
| * bot.startBuyGear(); | |
| * bot.startSort(); | |
| * bot.startMerge(); | |
| * 4. Run `bot.stop()` to stop everything (or bot.stopBuyGear(), | |
| * bot.stopSort(), bot.stopMerge() to stop them individually). | |
| */ | |
| if (window.bot != null) { | |
| console.info("[bot] bot already defined, stopping previous instance"); | |
| window.bot.stop(); | |
| window.bot = null; | |
| } | |
| window.bot = { | |
| start() { | |
| this.preflightChecks(); | |
| this.startBuyGear(); | |
| this.startBuyBuildUpgrades(); | |
| this.startSort(); | |
| this.startMerge(); | |
| }, | |
| stop() { | |
| this.stopBuyGear(); | |
| this.stopBuyBuildUpgrades(); | |
| this.stopSort(); | |
| this.stopMerge(); | |
| }, | |
| preflightChecks() { | |
| this.ensureGameStateHookHasBeenFound(); | |
| }, | |
| assert(condition, message) { | |
| if (!condition) { | |
| throw new Error(message); | |
| } | |
| return condition; | |
| }, | |
| // State | |
| stateFiber: null, | |
| recorderState: "idle", | |
| recorderLog: [], | |
| ensureGameStateHookHasBeenFound() { | |
| if (this.stateFiber != null) { | |
| return; | |
| } | |
| this.stateFiber = this.findStateFiber(this.findRootFiber()); | |
| this.assert(this.stateFiber != null, "[bot] [state] could not find game state fiber"); | |
| }, | |
| getGameState() { | |
| this.ensureGameStateHookHasBeenFound(); | |
| const stateHook = this.findStateHook(this.stateFiber, 0); | |
| this.assert(stateHook != null, "[bot] [state] state hook is not set"); | |
| return stateHook.memoizedState; | |
| }, | |
| dispatchActionToGameState(action) { | |
| this.ensureGameStateHookHasBeenFound(); | |
| const stateHook = this.findStateHook(this.stateFiber, 0); | |
| this.assert(stateHook != null, "[bot] [state] state hook is not set"); | |
| this.assert(stateHook.queue != null, "[bot] [state] state hook does not have a queue"); | |
| this.assert( | |
| typeof stateHook.queue.dispatch === "function", | |
| "[bot] [state] state hook does not have a dispatch function", | |
| ); | |
| return stateHook.queue.dispatch(action); | |
| }, | |
| findRootFiber() { | |
| const rootEl = document.querySelector("#root") || document.body.firstElementChild; | |
| const rootKey = Object.keys(rootEl).find((k) => k.startsWith("__reactContainer$")); | |
| const rootFiber = rootEl[rootKey]; | |
| this.assert(rootFiber, "[bot] React root fiber not found on #root"); | |
| return rootFiber; | |
| }, | |
| findStateFiber(rootFiber, name = "Re") { | |
| let target = null; | |
| const walk = (fiber) => { | |
| while (fiber && !target) { | |
| const t = fiber.type; | |
| if (typeof t === "function" || (typeof t === "object" && t !== null)) { | |
| const n = t.displayName || t.name || t.render?.name || t.type?.name || ""; | |
| if (n === name) { | |
| target = fiber; | |
| return; | |
| } | |
| } | |
| if (fiber.child) walk(fiber.child); | |
| fiber = fiber.sibling; | |
| } | |
| }; | |
| walk(rootFiber.current ?? rootFiber); | |
| this.assert(target, `[bot] No component named "${name}" found in the fiber tree`); | |
| return target; | |
| }, | |
| findStateHook(state_fiber, hookIndex = 0) { | |
| let hook = state_fiber.memoizedState; | |
| for (let i = 0; i < hookIndex && hook; i++) hook = hook.next; | |
| this.assert(hook != null, `[bot] [state] hook at index ${hookIndex} not found`); | |
| return hook; | |
| }, | |
| startRecordingGameStateActions() { | |
| this.ensureGameStateHookHasBeenFound(); | |
| if (this.recorderState === "recording") { | |
| console.warn("[bot] [state] [recorder] already recording"); | |
| return; | |
| } | |
| queue = this.findStateHook(this.stateFiber).queue; | |
| this.assert(queue != null, "[bot] [state] [recorder] state hook does not have a queue"); | |
| this.recorderLog = []; | |
| this.recorderState = "recording"; | |
| const real = queue.dispatch; | |
| queue.__real = real; | |
| queue.dispatch = (...args) => { | |
| this.recorderLog.push(args.length === 1 ? args[0] : args); | |
| console.log("%c[action]", "color:#f60", ...args); | |
| return real(...args); | |
| }; | |
| queue.__spied = true; | |
| console.log("[bot] [state] [recorder] Recording. Interact with the app, then read `.log`."); | |
| return this; | |
| }, | |
| stopRecordingGameStateActions() { | |
| if (this.recorderState !== "recording") { | |
| console.warn("[bot] [state] [recorder] not recording"); | |
| return; | |
| } | |
| const queue = this.findStateHook(this.stateFiber).queue; | |
| this.assert(queue != null, "[bot] [state] [recorder] state hook does not have a queue"); | |
| this.assert(queue.__spied != null, "[bot] [state] [recorder] queue.__spied is not set"); | |
| this.assert(queue.__real != null, "[bot] [state] [recorder] queue.__real is not set"); | |
| this.recorderState = "idle"; | |
| queue.dispatch = queue.__real; | |
| delete queue.__real; | |
| delete queue.__spied; | |
| }, | |
| // Buy Gear Bot | |
| intervalIdBuyGear: null, | |
| startBuyGear(delayMs = 1000) { | |
| if (this.intervalIdBuyGear) { | |
| console.warn("[bot] [buy gear] bot already running, call bot.stop() first"); | |
| return; | |
| } | |
| this.intervalIdBuyGear = setInterval(() => this.runBuyGear(), delayMs); | |
| console.log("[bot] [buy gear] bot started"); | |
| }, | |
| stopBuyGear() { | |
| if (this.intervalIdBuyGear) { | |
| clearInterval(this.intervalIdBuyGear); | |
| this.intervalIdBuyGear = null; | |
| console.log("[bot] [buy gear] bot stopped"); | |
| } else { | |
| console.log("[bot] [buy gear] bot is not running"); | |
| } | |
| }, | |
| runBuyGear() { | |
| this.dispatchActionToGameState({ | |
| type: "BUY_MULT_GEAR", | |
| }); | |
| this.dispatchActionToGameState({ | |
| type: "BUY_MAX_GEAR", | |
| }); | |
| }, | |
| // Buy Build Upgrades Bot | |
| intervalIdBuyBuildUpgrades: null, | |
| startBuyBuildUpgrades(delayMs = 1000) { | |
| if (this.intervalIdBuyBuildUpgrades) { | |
| console.warn("[bot] [buy build upgrades] bot already running, call bot.stop() first"); | |
| return; | |
| } | |
| this.intervalIdBuyBuildUpgrades = setInterval(() => this.runBuyBuildUpgrades(), delayMs); | |
| console.log("[bot] [buy build upgrades] bot started"); | |
| }, | |
| stopBuyBuildUpgrades() { | |
| if (this.intervalIdBuyBuildUpgrades) { | |
| clearInterval(this.intervalIdBuyBuildUpgrades); | |
| this.intervalIdBuyBuildUpgrades = null; | |
| console.log("[bot] [buy build upgrades] bot stopped"); | |
| } else { | |
| console.log("[bot] [buy build upgrades] bot is not running"); | |
| } | |
| }, | |
| runBuyBuildUpgrades() { | |
| this.dispatchActionToGameState({ | |
| type: "BUY_MAX_UPG", | |
| id: "speed", | |
| max: 100, | |
| baseCost: 50, | |
| scale: 1.8, | |
| }); | |
| this.dispatchActionToGameState({ | |
| type: "BUY_MAX_UPG", | |
| id: "multi", | |
| max: 100, | |
| baseCost: 100, | |
| scale: 2, | |
| }); | |
| }, | |
| // Sort Bot | |
| intervalIdSort: null, | |
| startSort(delayMs = 500) { | |
| if (this.intervalIdSort) { | |
| console.warn("[bot] [sort] bot already running, call bot.stop() first"); | |
| return; | |
| } | |
| this.intervalIdSort = setInterval(() => this.runSort(), delayMs); | |
| console.log("[bot] [sort] bot started"); | |
| }, | |
| stopSort() { | |
| if (this.intervalIdSort) { | |
| clearInterval(this.intervalIdSort); | |
| this.intervalIdSort = null; | |
| console.log("[bot] [sort] bot stopped"); | |
| } else { | |
| console.log("[bot] [sort] bot is not running"); | |
| } | |
| }, | |
| runSort() { | |
| this.dispatchActionToGameState({ | |
| type: "SORT_GRID", | |
| }); | |
| }, | |
| // Merge Bot | |
| intervalIdMerge: null, | |
| getGearNodes() { | |
| return document.querySelectorAll(`[data-cell]`); | |
| }, | |
| startMerge(delayMs = 100) { | |
| if (!this.preflightChecksMerge()) { | |
| console.error("[bot] [merge] preflight checks failed, aborting"); | |
| return; | |
| } | |
| if (this.intervalIdMerge) { | |
| console.warn("[bot] [merge] bot already running, call bot.stop() first"); | |
| return; | |
| } | |
| this.intervalIdMerge = setInterval(() => this.runMerge(), delayMs); | |
| console.log("[bot] [merge] merge bot started"); | |
| }, | |
| stopMerge() { | |
| if (this.intervalIdMerge) { | |
| clearInterval(this.intervalIdMerge); | |
| this.intervalIdMerge = null; | |
| console.log("[bot] [merge] merge bot stopped"); | |
| } else { | |
| console.log("[bot] [merge] merge bot is not running"); | |
| } | |
| }, | |
| preflightChecksMerge() { | |
| if (this.getGearNodes().length === 0) { | |
| console.error("[bot] [merge] no gear nodes found"); | |
| return false; | |
| } | |
| return true; | |
| }, | |
| getGearValue(gear_state_cell) { | |
| if (gear_state_cell == null) { | |
| return "EMPTY"; | |
| } | |
| if (gear_state_cell === "CENTER") { | |
| return "CENTER"; | |
| } | |
| if (gear_state_cell?.type === "SQUARE") { | |
| return "SQUARE"; | |
| } | |
| // Multiplier | |
| const is_multiplier = gear_state_cell?.type === "MULT"; | |
| const is_permanent_multiplier = is_multiplier && gear_state_cell?.perm; | |
| if (is_permanent_multiplier) { | |
| return "PERMANENT_MULTIPLIER"; // skip permanent multipliers | |
| } | |
| if (is_multiplier) { | |
| return "MULTIPLIER"; | |
| } | |
| // Normal gear | |
| if (gear_state_cell?.tier == null) { | |
| console.warn(`[bot] [merge] unknown gear cell at index ${i}, skipping`, gear_state_cell); | |
| return "UNKNOWN"; | |
| } | |
| return `NORMAL_GEAR:${gear_state_cell.tier}`; | |
| }, | |
| canMergeMultGears() { | |
| return this.getGameState().pUpgrades.moveMultGear && this.getGameState().pUpgrades.mergeMultGear; | |
| }, | |
| runMerge() { | |
| // const gear_nodes = [...this.getGearNodes()]; | |
| const gear_prev_by_value = new Map(); | |
| const gear_grid = this.getGameState().grid; | |
| for (const [gear_index, gear_cell] of gear_grid.entries()) { | |
| // const gear = gear_nodes[i]; | |
| const gear_value = this.getGearValue(gear_cell); | |
| if ( | |
| gear_value === "EMPTY" || | |
| gear_value === "CENTER" || | |
| gear_value === "PERMANENT_MULTIPLIER" || | |
| gear_value === "SQUARE" | |
| ) { | |
| continue; | |
| } | |
| if (this.canMergeMultGears() === false && gear_value === "MULTIPLIER") { | |
| continue; | |
| } | |
| const prev_gear_index = gear_prev_by_value.get(gear_value); | |
| if (prev_gear_index != null) { | |
| // this.simulateReactDrag(prev_gear_index, gear); | |
| this.dispatchActionToGameState({ | |
| type: "MERGE", | |
| from: prev_gear_index, | |
| to: gear_index, | |
| }); | |
| gear_prev_by_value.delete(gear_value); | |
| // return; // only merge one pair per run | |
| } else { | |
| gear_prev_by_value.set(gear_value, gear_index); | |
| } | |
| } | |
| }, | |
| sleep(ms = 0) { | |
| return new Promise((r) => setTimeout(r, ms)); | |
| }, | |
| async simulateReactDrag(sourceContainer, targetContainer) { | |
| // dig out the draggable child from each container | |
| const sourceEl = sourceContainer.querySelector("[draggable]") || sourceContainer; | |
| const targetEl = targetContainer.querySelector("[draggable]") || targetContainer; | |
| const dt = new DataTransfer(); // only feeds setDragImage | |
| const at = (el) => { | |
| const r = el.getBoundingClientRect(); | |
| return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; | |
| }; | |
| const fire = (type, el, p) => | |
| el.dispatchEvent( | |
| new DragEvent(type, { | |
| bubbles: true, | |
| cancelable: true, | |
| composed: true, | |
| clientX: p.x, | |
| clientY: p.y, | |
| dataTransfer: dt, | |
| }), | |
| ); | |
| const s = at(sourceEl), | |
| t = at(targetEl); | |
| fire("dragstart", sourceEl, s); // source's draggable → sets dragged item in state | |
| await this.sleep(); // ← load-bearing: let React commit that state | |
| fire("dragenter", targetEl, t); | |
| fire("dragover", targetEl, t); | |
| await this.sleep(); | |
| fire("drop", targetEl, t); // target's draggable → reads state, drops in | |
| fire("dragend", sourceEl, s); | |
| }, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment