From 7625a730e66134d250878380fec8c27d95da9c31 Mon Sep 17 00:00:00 2001 From: nasko Date: Fri, 24 Jul 2026 10:26:45 +0200 Subject: [PATCH] feat: auto experimentation table Hands-off Experimentation Table automation: navigate to the table, play the addons then Superpairs, claim rewards, renew with bits+XP up to a daily cap, and resume farming. - Tick-driven solvers for Superpairs (reveal/remember/pair), Chronomatron and Ultrasequencer (record the played sequence, replay it), gated on a master toggle and driven off the board state rather than fragile phase-name matching. - Session orchestrator (ExperimentationManager) under a new EXPERIMENTING macro state: precise centred walk to a saved table spot, stakes-tier selection that skips enchanting-locked and unaffordable tiers, practice-mode support, XP-cost check with optional Titanic bottle buy+splash, Experiment Over reward claiming, and renewal bounded by the server's daily counter. - Reward target and stop point read from the tier lore. - Config + ServiceLoader UI page (Table Spot with highlight, practice mode, delays, renewal cap), MacroState/tick-handler/chat wiring, and a design spec. --- .../2026-07-22-auto-experimentation-design.md | 83 ++ .../AetherAutomationTickHandler.java | 12 + .../aether/bootstrap/AetherChatEvents.java | 1 + .../java/dev/aether/config/AetherConfig.java | 37 + .../java/dev/aether/macro/MacroState.java | 3 +- .../experimentation/ChronomatronSolver.java | 214 ++++ .../experimentation/ExperimentUtils.java | 165 +++ .../ExperimentationManager.java | 977 ++++++++++++++++++ .../experimentation/SuperpairsSolver.java | 274 +++++ .../experimentation/UltrasequencerSolver.java | 167 +++ .../aether/renderer/PositionHighlighter.java | 16 + .../ExperimentationRegistryProvider.java | 150 +++ .../dev.aether.ui.MainGUIRegistryProvider | 1 + 13 files changed, 2099 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-22-auto-experimentation-design.md create mode 100644 src/main/java/dev/aether/modules/experimentation/ChronomatronSolver.java create mode 100644 src/main/java/dev/aether/modules/experimentation/ExperimentUtils.java create mode 100644 src/main/java/dev/aether/modules/experimentation/ExperimentationManager.java create mode 100644 src/main/java/dev/aether/modules/experimentation/SuperpairsSolver.java create mode 100644 src/main/java/dev/aether/modules/experimentation/UltrasequencerSolver.java create mode 100644 src/main/java/dev/aether/ui/providers/modules/ExperimentationRegistryProvider.java diff --git a/docs/superpowers/specs/2026-07-22-auto-experimentation-design.md b/docs/superpowers/specs/2026-07-22-auto-experimentation-design.md new file mode 100644 index 0000000..ca7f21b --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-auto-experimentation-design.md @@ -0,0 +1,83 @@ +# Auto Experimentation Table — Design + +Date: 2026-07-22. Approved by nasko in-session. + +## Goal + +One button/toggle runs the whole Experimentation Table routine hands-off: pause farming, +walk to the table, play Superpairs plus both addon games, claim, renew charges with +bits + XP levels up to a configured cap (0-3/day), repeat until charges are gone, +resume farming. + +## Architecture + +Two layers, mirroring existing mod patterns: + +### 1. Tick-driven solvers (`dev.aether.modules.experimentation`) + +Registered in `AetherAutomationTickHandler.handleContainerMenus`, gated on the +`AUTO_EXPERIMENTS` master toggle. They act on whichever experiment GUI is open, so they +also work if the table is opened manually, and they self-recover when Hypixel closes and +reopens screens mid-game. + +Common mechanics (verified against Odin AutoExperiments, Skyblock-Client and Skyblocker +solvers, adapted to modern mappings): + +- Phase signal: slot 49's hover name — `Remember the pattern!` (show phase) vs + `Timer: …` (input phase). +- Clicks: `ClientUtils.performSlotClick(screen, idx, 0, PICKUP)` with humanized delays + from `EXPERIMENTS_CLICK_DELAY_MIN/MAX`. + +**SuperpairsSolver** (`Superpairs (…)` title): reveal-remember-pair. Hidden cards are +cyan stained glass. Click unknown cards, remember every revealed `ItemStack` per slot +(full-stack `ItemStack.matches` comparison — two different pairs can share a display +name). Priority: complete a known pair > click the partner of a just-revealed card +> reveal an unknown. Plays until the board completes or locks (stall guard). Neither +Odin nor Skyblocker auto-clicks Superpairs; this is novel. + +**ChronomatronSolver** (`Chronomatron (…)`): during show phase, track the glinted +(`hasFoil`) terracotta on slots 17–34 element by element (Skyblocker's replay-count +method — same colour can repeat). Store the chain as *items* (colours), not slot ids, +so board shuffles can't break replay. During input phase, click the chain in order. +Stop threshold: 15 chains (max XP toggle on) or `11 - serumCount`. + +**UltrasequencerSolver** (`Ultrasequencer (…)`): during show phase, map slots 9–44 whose +hover name is a pure number. During input phase, click remembered slots ascending. +Stop threshold: 20 (max XP toggle) or `9 - serumCount`. + +### 2. Worker orchestrator (`ExperimentationManager`) + +Linear worker-thread flow (Bazaar/Composter style) under new +`MacroState.State.EXPERIMENTING`: + +1. Pause farming if it was running (disable macro, remember to resume). +2. Navigate: walk-pathfind to the saved stand position, rotate to the saved table + block, use-click, wait for the table GUI title. +3. Menu loop: click each experiment whose lore offers play; wait while a game GUI is + open (the tick solvers play it); reopen the table as needed. +4. Renewals: find the renew button, log its bits/XP cost from lore at runtime (no + hard-coded prices), click through any confirm screen, bounded by + `EXPERIMENTS_RENEWALS_PER_DAY` (0-3). +5. Finish: close, chat summary, restore farming state. + +Abort checkpoints (`MacroWorkerThread.shouldAbortTask`) at every step, per-game and +per-navigation timeouts, unexpected screens end the run cleanly with a chat message. + +Table position is captured by a "Set table spot" action: stand at the table looking at +it; saves player position plus crosshair block. + +## Config (AetherConfig) + UI + +New `ExperimentationRegistryProvider` (Modules section, ServiceLoader-registered): +master toggle `AUTO_EXPERIMENTS`, Run Now action, Set Table Spot action, +`EXPERIMENTS_MAX_CLICKS` (play to 15/20/full board), `EXPERIMENTS_SERUM_COUNT` (0-3), +`EXPERIMENTS_RENEWALS_PER_DAY` (0-3), `EXPERIMENTS_CLICK_DELAY_MIN/MAX`, and the saved +table coordinates. + +Out of scope: highlight-only rendering (Skyblocker covers that), scheduling/cron runs, +Metaphysical Serum consumption automation. + +## Testing + +No test suite exists; verification is `./gradlew build` plus in-game runs with +Show Debug on (solvers and orchestrator log each phase decision). diff --git a/src/main/java/dev/aether/bootstrap/AetherAutomationTickHandler.java b/src/main/java/dev/aether/bootstrap/AetherAutomationTickHandler.java index f357e06..1a6af05 100644 --- a/src/main/java/dev/aether/bootstrap/AetherAutomationTickHandler.java +++ b/src/main/java/dev/aether/bootstrap/AetherAutomationTickHandler.java @@ -35,6 +35,9 @@ import dev.aether.modules.session.RecoveryManager; import dev.aether.modules.session.RestartManager; import dev.aether.modules.SupercraftManager; +import dev.aether.modules.experimentation.ChronomatronSolver; +import dev.aether.modules.experimentation.SuperpairsSolver; +import dev.aether.modules.experimentation.UltrasequencerSolver; import dev.aether.ui.theme.Theme; import dev.aether.util.AetherResources; import dev.aether.util.BpsTracker; @@ -117,6 +120,15 @@ private static void handleContainerMenus(Minecraft client) { if (client.screen == currentScreen) { SupercraftManager.handleRecipeGui(currentScreen); } + if (client.screen == currentScreen) { + SuperpairsSolver.handleMenu(client, currentScreen); + } + if (client.screen == currentScreen) { + ChronomatronSolver.handleMenu(client, currentScreen); + } + if (client.screen == currentScreen) { + UltrasequencerSolver.handleMenu(client, currentScreen); + } } private static void tickManagers(Minecraft client) { diff --git a/src/main/java/dev/aether/bootstrap/AetherChatEvents.java b/src/main/java/dev/aether/bootstrap/AetherChatEvents.java index cad7e8b..f8809c9 100644 --- a/src/main/java/dev/aether/bootstrap/AetherChatEvents.java +++ b/src/main/java/dev/aether/bootstrap/AetherChatEvents.java @@ -94,6 +94,7 @@ public static void register() { handlePestChatTrigger(lowerText, plainText); handleStashState(lowerText); AutoSprayonatorManager.onChatMessage(plainText); + dev.aether.modules.experimentation.ExperimentationManager.onChatMessage(plainText); CropFeverManager.handleChatMessage(Minecraft.getInstance(), plainText); } finally { isHandlingMessage = false; diff --git a/src/main/java/dev/aether/config/AetherConfig.java b/src/main/java/dev/aether/config/AetherConfig.java index 09f8696..3cf62b7 100644 --- a/src/main/java/dev/aether/config/AetherConfig.java +++ b/src/main/java/dev/aether/config/AetherConfig.java @@ -1022,6 +1022,43 @@ private static boolean readBoolean(JsonObject root, String key, boolean fallback public static final IntEntry AUTO_COMPOSTER_FUEL_AMOUNT = Config.integer("autoComposterFuelAmount", 1) .range(1, 2000000); + // -- EXPERIMENTATION TABLE ------------------------------------------------- + public static final BooleanEntry AUTO_EXPERIMENTS = Config.bool("autoExperiments", false); + // Play addons to the max-XP thresholds (Chronomatron 15, Ultrasequencer 20) + // instead of exiting at the reward cap reduced by consumed serums. + public static final BooleanEntry EXPERIMENTS_MAX_CLICKS = Config.bool("experimentsMaxClicks", true); + public static final IntEntry EXPERIMENTS_SERUM_COUNT = Config.integer("experimentsSerumCount", 0) + .range(0, 3); + public static final IntEntry EXPERIMENTS_RENEWALS_PER_DAY = Config.integer("experimentsRenewalsPerDay", 0) + .range(0, 3); + // Buy + splash a Titanic Experience Bottle from Bazaar when the table + // refuses a game because the player's XP level is too low. + public static final BooleanEntry EXPERIMENTS_AUTO_BUY_XP = Config.bool("experimentsAutoBuyXp", true); + // Right-click tiers to play them in practice mode (free, no charges). + public static final BooleanEntry EXPERIMENTS_PRACTICE_MODE = Config.bool("experimentsPracticeMode", false); + // Stop at the highest reward threshold stated in the tier's lore + // ("Series of 7: +2 Clicks") instead of playing on for no extra reward. + public static final BooleanEntry EXPERIMENTS_STOP_AT_MAX_REWARD = Config.bool("experimentsStopAtMaxReward", true); + public static final IntEntry EXPERIMENTS_CLICK_DELAY_MIN = Config.integer("experimentsClickDelayMin", 160) + .range(50, 1000); + public static final IntEntry EXPERIMENTS_CLICK_DELAY_MAX = Config.integer("experimentsClickDelayMax", 320) + .range(50, 2000); + // Gap between individual note clicks in the timed games. Too high and a + // long chain won't fit inside the round's countdown. + public static final IntEntry EXPERIMENTS_NOTE_DELAY_MIN = Config.integer("experimentsNoteDelayMin", 250) + .range(50, 1000); + public static final IntEntry EXPERIMENTS_NOTE_DELAY_MAX = Config.integer("experimentsNoteDelayMax", 420) + .range(50, 2000); + // Stand position (like the composter spot) plus the captured look angles; + // navigation walks here, restores the view, and right-clicks the table. + public static final IntEntry EXPERIMENTS_TABLE_X = Config.integer("experimentsTableX", 0); + public static final IntEntry EXPERIMENTS_TABLE_Y = Config.integer("experimentsTableY", 0); + public static final IntEntry EXPERIMENTS_TABLE_Z = Config.integer("experimentsTableZ", 0); + public static final DoubleEntry EXPERIMENTS_TABLE_YAW = Config.doubleVal("experimentsTableYaw", 0.0); + public static final DoubleEntry EXPERIMENTS_TABLE_PITCH = Config.doubleVal("experimentsTablePitch", 0.0); + public static final BooleanEntry EXPERIMENTS_TABLE_HIGHLIGHT = Config.bool("experimentsTableHighlight", true); + public static final BooleanEntry EXPERIMENTS_TABLE_SET = Config.bool("experimentsTableSet", false); + // -- SUPERCRAFT ------------------------------------------------------------ public static final BooleanEntry AUTO_SUPERCRAFT = Config.bool("autoSupercraft", false); public static final IntEntry AUTO_SUPERCRAFT_INTERVAL_MINUTES = Config.integer("autoSupercraftIntervalMinutes", 120) diff --git a/src/main/java/dev/aether/macro/MacroState.java b/src/main/java/dev/aether/macro/MacroState.java index 0268111..7937b10 100644 --- a/src/main/java/dev/aether/macro/MacroState.java +++ b/src/main/java/dev/aether/macro/MacroState.java @@ -15,7 +15,8 @@ public enum State { EQUIPMENT, GEORGE, DROPPING_JUNK, - REWARPING + REWARPING, + EXPERIMENTING } public enum Location { diff --git a/src/main/java/dev/aether/modules/experimentation/ChronomatronSolver.java b/src/main/java/dev/aether/modules/experimentation/ChronomatronSolver.java new file mode 100644 index 0000000..6f4ad54 --- /dev/null +++ b/src/main/java/dev/aether/modules/experimentation/ChronomatronSolver.java @@ -0,0 +1,214 @@ +package dev.aether.modules.experimentation; + +import java.util.ArrayList; +import java.util.List; + +import dev.aether.config.AetherConfig; +import dev.aether.util.ClientUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.item.ItemStack; + +/** + * Auto-plays Chronomatron. + *

+ * The game plays a sequence of notes, one longer each round, and the player + * plays it back. So: record every note as it lights up, and once the expected + * number for this round has been played, click them back in order. There is no + * rule about repeated colours or note ordering, and none is assumed - a note is + * simply whatever lights up next. + *

+ * Recording is deliberately NOT gated on the slot 49 marker: the last note of a + * round is played as the marker flips to the countdown, so a phase-gated + * recorder silently drops it. + *

+ * Notes are identified by colour, since the board swaps terracotta for glass + * between phases and repeats the same colour row several times - any slot of + * the right colour is a valid click. + */ +public final class ChronomatronSolver { + + // Odin's scan window: covers every board layout across all tiers. + private static final int SCAN_START = 10; + private static final int SCAN_END = 43; + private static final int MAX_XP_CHAIN = 15; + private static final int REWARD_CAP_CHAIN = 11; + /** Hypixel reopens this screen between phases; state must survive that. */ + private static final long NEW_GAME_GAP_MS = 5000L; + private static final long HEARTBEAT_MS = 500L; + + private static final List notes = new ArrayList<>(); + private static List litPrev = new ArrayList<>(); + /** How many notes the game should play this round (the round number). */ + private static int expected = 1; + private static boolean clicking = false; + private static int clicks = 0; + private static long lastClickAt = 0L; + private static long clickDelay = 0L; + /** Our own clicked note stays lit into the next round's playback. */ + private static boolean awaitingDark = false; + private static boolean active = false; + private static boolean missLogged = false; + private static long lastSeenAt = 0L; + private static long lastHeartbeatAt = 0L; + + private ChronomatronSolver() { + } + + public static void reset() { + notes.clear(); + litPrev = new ArrayList<>(); + expected = 1; + clicking = false; + clicks = 0; + lastClickAt = 0L; + awaitingDark = false; + active = false; + missLogged = false; + lastSeenAt = 0L; + lastHeartbeatAt = 0L; + } + + public static boolean isGameScreen(AbstractContainerScreen screen) { + // Tier suffix required: bare "Chronomatron" is the stakes menu. + return screen.getTitle().getString().replaceAll("(?i)§.", "").trim() + .matches("(?i)chronomatron ?\\(.*"); + } + + public static void handleMenu(Minecraft client, AbstractContainerScreen screen) { + if (!AetherConfig.AUTO_EXPERIMENTS.get() || client.player == null) { + return; + } + if (!isGameScreen(screen)) { + // Never reset here: Hypixel swaps this screen out between phases. + return; + } + + long now = System.currentTimeMillis(); + if (!active || now - lastSeenAt > NEW_GAME_GAP_MS) { + boolean restart = active; + reset(); + active = true; + ClientUtils.sendDebugMessage("[CM] " + (restart ? "new game" : "started") + ": " + + screen.getTitle().getString().replaceAll("(?i)§.", "").trim()); + } + lastSeenAt = now; + + List lit = litColours(screen); + if (now - lastHeartbeatAt > HEARTBEAT_MS) { + lastHeartbeatAt = now; + ClientUtils.sendDebugMessage("[CM] hb lit=" + lit + " notes=" + notes.size() + + "/" + expected + " clicking=" + clicking + " clicks=" + clicks + + " dark?" + awaitingDark); + } + + if (clicking) { + playBack(screen, lit, now); + return; + } + + // Ignore glints left over from our own clicks at the start of a round. + if (awaitingDark) { + if (lit.isEmpty()) { + awaitingDark = false; + } + litPrev = lit; + return; + } + + // A note begins when the board lights up after being dark. + if (!lit.isEmpty() && litPrev.isEmpty()) { + if (lit.size() > 1) { + ClientUtils.sendDebugMessage("[CM] note " + (notes.size() + 1) + + " ambiguous, lit=" + lit + " (taking " + lit.get(0) + ")"); + } + notes.add(lit.get(0)); + ClientUtils.sendDebugMessage("[CM] note " + notes.size() + "/" + expected + + ": " + lit.get(0)); + } + + // Sequence played and the board has gone dark again: play it back. + if (notes.size() >= expected && lit.isEmpty() && !litPrev.isEmpty()) { + clicking = true; + clicks = 0; + missLogged = false; + ClientUtils.sendDebugMessage("[CM] playing back " + notes); + } + litPrev = lit; + } + + private static void playBack(AbstractContainerScreen screen, List lit, long now) { + if (clicks >= notes.size()) { + // Round done; the next playback is one note longer. + expected = notes.size() + 1; + notes.clear(); + litPrev = new ArrayList<>(); + clicking = false; + awaitingDark = true; + int maxChain = maxChain(); + if (expected > maxChain) { + ClientUtils.sendDebugMessage("[CM] reached target chain " + maxChain + + ", closing for rewards."); + reset(); + ExperimentUtils.closeScreen(); + } + return; + } + if (now - lastClickAt < clickDelay) { + return; + } + + String colour = notes.get(clicks); + int target = slotForColour(screen, colour); + if (target < 0) { + if (!missLogged) { + missLogged = true; + ClientUtils.sendDebugMessage("[CM] no slot for '" + colour + "' (step " + + (clicks + 1) + "/" + notes.size() + "), lit=" + lit); + } + return; + } + ExperimentUtils.clickSlotMiddle(screen, target); + ClientUtils.sendDebugMessage("[CM] click " + (clicks + 1) + "/" + notes.size() + + " " + colour + " @ " + target); + lastClickAt = now; + clickDelay = ExperimentUtils.noteClickDelay(); + clicks++; + } + + /** Distinct lit (glinting) colours, in board order. */ + private static List litColours(AbstractContainerScreen screen) { + List colours = new ArrayList<>(); + for (int i = SCAN_START; i <= SCAN_END; i++) { + ItemStack stack = ExperimentUtils.stackAt(screen, i); + if (stack.isEmpty() || !stack.hasFoil()) { + continue; + } + String colour = ExperimentUtils.colourOf(stack); + if (colour != null && !colours.contains(colour)) { + colours.add(colour); + } + } + return colours; + } + + private static int slotForColour(AbstractContainerScreen screen, String colour) { + for (int i = SCAN_START; i <= SCAN_END; i++) { + if (colour.equals(ExperimentUtils.colourOf(ExperimentUtils.stackAt(screen, i)))) { + return i; + } + } + return -1; + } + + private static int maxChain() { + int loreTarget = ExperimentUtils.getRewardTarget(); + if (AetherConfig.EXPERIMENTS_STOP_AT_MAX_REWARD.get() && loreTarget > 0) { + return loreTarget; + } + if (AetherConfig.EXPERIMENTS_MAX_CLICKS.get()) { + return MAX_XP_CHAIN; + } + return REWARD_CAP_CHAIN - AetherConfig.EXPERIMENTS_SERUM_COUNT.get(); + } +} diff --git a/src/main/java/dev/aether/modules/experimentation/ExperimentUtils.java b/src/main/java/dev/aether/modules/experimentation/ExperimentUtils.java new file mode 100644 index 0000000..c4118a3 --- /dev/null +++ b/src/main/java/dev/aether/modules/experimentation/ExperimentUtils.java @@ -0,0 +1,165 @@ +package dev.aether.modules.experimentation; + +import dev.aether.config.AetherConfig; +import dev.aether.config.ConfigHelpers; +import dev.aether.util.ClientUtils; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +/** Shared helpers for the Experimentation Table solvers. */ +final class ExperimentUtils { + + // Slot 49 holds the phase indicator item in all experiment GUIs. + static final int PHASE_SLOT = 49; + static final String PHASE_SHOW = "Remember the pattern!"; + static final String PHASE_INPUT_PREFIX = "Timer: "; + + private ExperimentUtils() { + } + + static String stackName(ItemStack stack) { + return stack == null || stack.isEmpty() + ? "" + : stack.getHoverName().getString().replaceAll("(?i)§.", "").trim(); + } + + static ItemStack stackAt(AbstractContainerScreen screen, int index) { + if (screen.getMenu() == null || index < 0 || index >= screen.getMenu().slots.size()) { + return ItemStack.EMPTY; + } + Slot slot = screen.getMenu().slots.get(index); + return slot.hasItem() ? slot.getItem() : ItemStack.EMPTY; + } + + static String phaseName(AbstractContainerScreen screen) { + return stackName(stackAt(screen, PHASE_SLOT)); + } + + static boolean isInputPhase(AbstractContainerScreen screen) { + return phaseName(screen).startsWith(PHASE_INPUT_PREFIX); + } + + static boolean isShowPhase(AbstractContainerScreen screen) { + return phaseName(screen).equals(PHASE_SHOW); + } + + /** Item-id substring check, the codebase convention for Hypixel GUI items. */ + static boolean itemIdContains(ItemStack stack, String... needles) { + if (stack == null || stack.isEmpty()) { + return false; + } + String id = stack.getItem().toString().toLowerCase(); + for (String needle : needles) { + if (!id.contains(needle)) { + return false; + } + } + return true; + } + + // Ordered so compound names match before their suffixes (light_blue before blue). + private static final String[] COLOURS = { + "light_blue", "light_gray", "magenta", "purple", "orange", "yellow", + "lime", "green", "cyan", "blue", "pink", "brown", "black", "white", "gray", "red" + }; + + /** + * Colour keyword from the item id. The Chronomatron board shows dyed + * terracotta during the memory phase but can swap to stained glass for the + * input phase, so matching must be by colour, not item identity. + */ + static String colourOf(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return null; + } + String id = stack.getItem().toString().toLowerCase(); + for (String colour : COLOURS) { + if (id.contains(colour)) { + return colour; + } + } + return null; + } + + /** + * Highest "Series of N" / "Chain of N" reward threshold from the tier lore + * of the run in progress, or 0 when unknown. Playing past it earns nothing + * extra, so the solvers stop there and let the rewards screen open. + */ + private static volatile int rewardTarget = 0; + + static void setRewardTarget(int target) { + rewardTarget = target; + } + + static int getRewardTarget() { + return rewardTarget; + } + + /** Parses the biggest reward threshold out of a tier's lore. */ + static int parseRewardTarget(String lore) { + if (lore == null) { + return 0; + } + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("(?i)(?:series|chain) of (\\d+)") + .matcher(lore); + int best = 0; + while (matcher.find()) { + best = Math.max(best, Integer.parseInt(matcher.group(1))); + } + return best; + } + + static long nextClickDelay() { + return ConfigHelpers.getRandomizedDelay( + AetherConfig.EXPERIMENTS_CLICK_DELAY_MIN.get(), + AetherConfig.EXPERIMENTS_CLICK_DELAY_MAX.get()); + } + + /** Delay between note clicks while replaying a sequence. */ + static long noteClickDelay() { + return ConfigHelpers.getRandomizedDelay( + AetherConfig.EXPERIMENTS_NOTE_DELAY_MIN.get(), + AetherConfig.EXPERIMENTS_NOTE_DELAY_MAX.get()); + } + + static void clickSlot(AbstractContainerScreen screen, int index) { + ClientUtils.performSlotClick(screen, index, 0, ContainerInput.PICKUP); + } + + /** Right-click, which the stakes menu uses to start a practice run. */ + static void clickSlotRight(AbstractContainerScreen screen, int index) { + ClientUtils.performSlotClick(screen, index, 1, ContainerInput.PICKUP); + } + + /** + * Middle-click for the minigame boards (the Odin/SBC convention): CLONE + * never lifts the fake item onto the cursor, so no client/server desync + * shuffling the player's own inventory. + */ + static void clickSlotMiddle(AbstractContainerScreen screen, int index) { + ClientUtils.performSlotClick(screen, index, 2, ContainerInput.CLONE); + } + + /** + * Number of slots belonging to the open container itself. Chest menus + * always append the player's 36 inventory slots after the container's, and + * scanning into those picks up the player's own items. + */ + static int containerSlotCount(AbstractContainerScreen screen) { + if (screen.getMenu() == null) { + return 0; + } + return Math.max(0, screen.getMenu().slots.size() - 36); + } + + static void closeScreen() { + var client = net.minecraft.client.Minecraft.getInstance(); + if (client.player != null) { + client.player.closeContainer(); + } + } +} diff --git a/src/main/java/dev/aether/modules/experimentation/ExperimentationManager.java b/src/main/java/dev/aether/modules/experimentation/ExperimentationManager.java new file mode 100644 index 0000000..bdab9b0 --- /dev/null +++ b/src/main/java/dev/aether/modules/experimentation/ExperimentationManager.java @@ -0,0 +1,977 @@ +package dev.aether.modules.experimentation; + +import java.util.HashSet; +import java.util.Set; + +import dev.aether.config.AetherConfig; +import dev.aether.macro.FarmingMacroManager; +import dev.aether.macro.MacroState; +import dev.aether.macro.MacroStateManager; +import dev.aether.macro.MacroWorkerThread; +import dev.aether.modules.pathfinding.PathfindingManager; +import dev.aether.modules.rotation.RotationManager; +import dev.aether.modules.failsafe.FailsafeManager; +import dev.aether.util.BazaarUtils; +import dev.aether.util.ClientUtils; +import dev.aether.util.TablistUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.phys.Vec3; + +/** + * Orchestrates a full hands-off Experimentation Table session: walk to the + * table, open it, let the tick solvers play each experiment, claim, renew with + * bits + XP up to the configured cap, then restore whatever was running before. + */ +public final class ExperimentationManager { + + private static final long NAV_TIMEOUT_MS = 90_000L; + private static final long GAME_OPEN_TIMEOUT_MS = 4_000L; + private static final long GAME_PLAY_TIMEOUT_MS = 180_000L; + private static final long TABLE_OPEN_TIMEOUT_MS = 4_000L; + private static final int SESSION_LOOP_CAP = 40; + + private static volatile boolean running = false; + private static volatile boolean cancelRequested = false; + private static final Set dumpedMenus = java.util.Collections.synchronizedSet(new HashSet<>()); + + // The server's daily renewal counter, parsed from the "bonus charge (N/3)" + // chat line, so the cap holds even across sessions and relogs. + private static volatile int dailyRenewalsUsed = -1; + private static volatile long refusalAt = 0L; + private static volatile String refusalReason = ""; + private static volatile boolean xpLowHit = false; + private static final int XP_BOTTLE_SESSION_CAP = 2; + + private static final java.util.regex.Pattern XP_COST_PATTERN = + java.util.regex.Pattern.compile("starting cost: (\\d+) xp levels"); + private static final java.util.regex.Pattern RENEWAL_COUNT_PATTERN = + java.util.regex.Pattern.compile("bonus charge for the Experimentation Table! \\((\\d)/3\\)"); + + // Addons must be played before Superpairs - the server enforces this order. + private static final String[] GAME_KEYS = {"chronomatron", "ultrasequencer", "superpairs"}; + + private ExperimentationManager() { + } + + public static boolean isRunning() { + return running; + } + + /** Wired into AetherChatEvents; flips flags the worker session polls. */ + public static void onChatMessage(String plainText) { + if (plainText == null) { + return; + } + String msg = plainText.trim(); + // Never react to the mod's own chat output - our debug lines quote the + // server refusals and would re-trigger the refusal detector forever. + String lowerAll = msg.toLowerCase(); + if (lowerAll.contains("[debug]") || lowerAll.contains("[experiments]") || lowerAll.contains("aether >>")) { + return; + } + java.util.regex.Matcher matcher = RENEWAL_COUNT_PATTERN.matcher(msg); + if (matcher.find()) { + dailyRenewalsUsed = Integer.parseInt(matcher.group(1)); + return; + } + String lower = msg.toLowerCase(); + if (lower.contains("enough") && (lower.contains("xp") || lower.contains("level"))) { + xpLowHit = true; + refusalReason = msg; + refusalAt = System.currentTimeMillis(); + return; + } + if (lower.contains("only play practice mode") && AetherConfig.EXPERIMENTS_PRACTICE_MODE.get()) { + // Expected in practice mode - the server is telling us to right-click, + // which is exactly what we do. Not a reason to skip the game. + return; + } + if (lower.contains("only play practice mode") + || lower.contains("experiment is on cooldown") + || lower.contains("play the add-on experiments before")) { + refusalReason = msg; + refusalAt = System.currentTimeMillis(); + } + } + + public static void cancel() { + cancelRequested = true; + } + + /** UI capture: stand in front of the table looking at it, like rewarp points. */ + public static void saveTablePosition() { + Minecraft client = Minecraft.getInstance(); + if (client.player == null) { + return; + } + AetherConfig.EXPERIMENTS_TABLE_X.set(client.player.getBlockX()); + AetherConfig.EXPERIMENTS_TABLE_Y.set(client.player.getBlockY()); + AetherConfig.EXPERIMENTS_TABLE_Z.set(client.player.getBlockZ()); + AetherConfig.EXPERIMENTS_TABLE_YAW.set((double) client.player.getYRot()); + AetherConfig.EXPERIMENTS_TABLE_PITCH.set((double) client.player.getXRot()); + AetherConfig.EXPERIMENTS_TABLE_SET.set(true); + AetherConfig.save(); + ClientUtils.sendMessage(String.format( + "§aExperimentation spot saved: (%d, %d, %d), yaw %.0f, pitch %.0f.", + AetherConfig.EXPERIMENTS_TABLE_X.get(), + AetherConfig.EXPERIMENTS_TABLE_Y.get(), + AetherConfig.EXPERIMENTS_TABLE_Z.get(), + AetherConfig.EXPERIMENTS_TABLE_YAW.get(), + AetherConfig.EXPERIMENTS_TABLE_PITCH.get()), false); + } + + public static void manualTrigger() { + Minecraft client = Minecraft.getInstance(); + if (running) { + ClientUtils.sendMessage("§eExperimentation session already running.", false); + return; + } + if (!AetherConfig.AUTO_EXPERIMENTS.get()) { + ClientUtils.sendMessage("§cEnable Auto Experiments first - the solvers are gated on it.", false); + return; + } + running = true; + cancelRequested = false; + ExperimentUtils.setRewardTarget(0); + MacroWorkerThread.getInstance().submit("Experimentation", () -> runSession(client)); + } + + private static void runSession(Minecraft client) { + // The session is usually started from the mod GUI. Close it before + // entering our state: the tick handler stops any running macro state + // while a non-container screen is open, which would cancel this task. + closeNonContainerScreen(client); + MacroWorkerThread.sleepRandom(250, 100); + + dumpedMenus.clear(); + refusalAt = 0L; + boolean wasFarming = MacroStateManager.getCurrentState() == MacroState.State.FARMING; + MacroStateManager.setCurrentState(MacroState.State.EXPERIMENTING); + if (wasFarming) { + client.execute(() -> FarmingMacroManager.disable(client)); + MacroWorkerThread.sleepRandom(250, 100); + } + + int gamesPlayed = 0; + int renewalsUsed = 0; + int xpBottlesUsed = 0; + int gamesAtLastRenewal = -1; + Set exhausted = new HashSet<>(); + Set completed = new HashSet<>(); + try { + if (!ensureTableOpen(client)) { + return; + } + + for (int guard = 0; guard < SESSION_LOOP_CAP; guard++) { + if (shouldStop(client)) { + return; + } + if (!isTableScreen(client)) { + if (isRewardsScreen(client)) { + claimRewardsScreen(client); + } + // A leftover stakes/other menu makes the reopen click useless. + if (client.screen instanceof AbstractContainerScreen && !isGameScreen(client)) { + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + } + if (!ensureTableOpen(client)) { + return; + } + } + // Hypixel fills chest menus asynchronously; scanning too early + // sees an empty container. + waitForMenuItems(client); + + int playSlot = findPlayableExperiment(client, exhausted, completed); + if (playSlot >= 0) { + xpLowHit = false; + if (playExperiment(client, playSlot, exhausted, completed)) { + gamesPlayed++; + } else if (xpLowHit && AetherConfig.EXPERIMENTS_AUTO_BUY_XP.get() + && xpBottlesUsed < XP_BOTTLE_SESSION_CAP) { + // The table refused for XP levels; buy + splash a bottle + // and retry the same game at full tier. + xpLowHit = false; + xpBottlesUsed++; + buyAndSplashXpBottle(client); + } + continue; + } + + if (AetherConfig.EXPERIMENTS_PRACTICE_MODE.get()) { + // Practice needs no charges, so renewing would just burn bits. + ClientUtils.sendDebugMessage("[Experiments] practice mode: not renewing."); + break; + } + int cap = AetherConfig.EXPERIMENTS_RENEWALS_PER_DAY.get(); + int used = Math.max(renewalsUsed, Math.max(0, dailyRenewalsUsed)); + if (gamesPlayed == gamesAtLastRenewal) { + // The last renewal bought us nothing playable (usually not + // enough XP levels); stop instead of spending again. + ClientUtils.sendMessage("§eRenewal did not unlock anything playable - stopping.", false); + break; + } + if (used < cap && tryRenew(client)) { + gamesAtLastRenewal = gamesPlayed; + renewalsUsed = used + 1; + exhausted.clear(); + completed.clear(); + continue; + } + break; + } + + ClientUtils.sendMessage(String.format( + "§aExperimentation session done: %d game(s) played, %d renewal(s) used.", + gamesPlayed, renewalsUsed), false); + } finally { + client.execute(() -> { + if (client.screen instanceof AbstractContainerScreen) { + ExperimentUtils.closeScreen(); + } + }); + MacroWorkerThread.sleepRandom(300, 100); + if (wasFarming && !cancelRequested + && MacroStateManager.getCurrentState() == MacroState.State.EXPERIMENTING) { + MacroStateManager.setCurrentState(MacroState.State.FARMING); + client.execute(() -> FarmingMacroManager.enable(client, FarmingMacroManager.createMacroFromConfig())); + } else if (MacroStateManager.getCurrentState() == MacroState.State.EXPERIMENTING) { + MacroStateManager.setCurrentState(MacroState.State.OFF); + } + running = false; + } + } + + // -- Navigation ----------------------------------------------------------- + + private static boolean ensureTableOpen(Minecraft client) { + if (isTableScreen(client)) { + return true; + } + if (!AetherConfig.EXPERIMENTS_TABLE_SET.get()) { + ClientUtils.sendMessage("§cNo table spot saved. Set it in the GUI or open the table yourself first.", false); + return false; + } + + for (int attempt = 1; attempt <= 3; attempt++) { + if (shouldStop(client)) { + return false; + } + closeNonContainerScreen(client); + MacroWorkerThread.sleep(100); + if (!navigateToStand(client)) { + continue; + } + aimAtTable(client); + client.execute(ClientUtils::performUseClick); + if (waitFor(client, () -> isTableScreen(client), TABLE_OPEN_TIMEOUT_MS)) { + return true; + } + ClientUtils.sendDebugMessage("[Experiments] Table didn't open (attempt " + attempt + "/3)."); + } + ClientUtils.sendMessage("§cCould not open the Experimentation Table.", false); + return false; + } + + private static boolean navigateToStand(Minecraft client) { + Vec3 stand = new Vec3( + AetherConfig.EXPERIMENTS_TABLE_X.get() + 0.5, + AetherConfig.EXPERIMENTS_TABLE_Y.get(), + AetherConfig.EXPERIMENTS_TABLE_Z.get() + 0.5); + Vec3 pos = playerPos(client); + if (pos == null) { + return false; + } + if (pos.distanceTo(stand) <= 1.0) { + return true; + } + + ClientUtils.sendDebugMessage("[Experiments] Walking to table spot " + stand + "."); + // Exact block-centre goal with a tight tolerance: the default walk stops + // anywhere in the block and can overshoot past the table. + client.execute(() -> PathfindingManager.startConfiguredWalk( + client, stand, null, null, true, 0.25, true, false)); + MacroWorkerThread.sleepRandom(200, 60); + long deadline = System.currentTimeMillis() + NAV_TIMEOUT_MS; + while (PathfindingManager.isNavigating()) { + if (shouldStop(client) || System.currentTimeMillis() > deadline) { + PathfindingManager.stop(false); + return false; + } + MacroWorkerThread.sleep(100); + } + Vec3 after = playerPos(client); + boolean arrived = after != null && after.distanceTo(stand) <= 1.5; + if (!arrived) { + ClientUtils.sendDebugMessage("[Experiments] Walk ended away from the table spot."); + } + return arrived; + } + + private static void aimAtTable(Minecraft client) { + // Restore the exact view captured when the spot was set; that look was + // pointing at the table by construction. + float yaw = AetherConfig.EXPERIMENTS_TABLE_YAW.get().floatValue(); + float pitch = AetherConfig.EXPERIMENTS_TABLE_PITCH.get().floatValue(); + client.execute(() -> RotationManager.rotateToYawPitch( + client, yaw, pitch, AetherConfig.ROTATION_TIME.get(), true)); + long deadline = System.currentTimeMillis() + 3_000L; + while (RotationManager.isRotating() && System.currentTimeMillis() < deadline) { + MacroWorkerThread.sleep(25); + } + MacroWorkerThread.sleepRandom(120, 60); + } + + // -- Table menu ----------------------------------------------------------- + + private static boolean isTableScreen(Minecraft client) { + return client.screen instanceof AbstractContainerScreen screen + && stripped(screen.getTitle().getString()).toLowerCase().contains("experimentation"); + } + + private static boolean isGameScreen(Minecraft client) { + return client.screen instanceof AbstractContainerScreen screen + && (SuperpairsSolver.isGameScreen(screen) + || ChronomatronSolver.isGameScreen(screen) + || UltrasequencerSolver.isGameScreen(screen)); + } + + private static int findPlayableExperiment(Minecraft client, Set exhausted, Set completed) { + if (!(client.screen instanceof AbstractContainerScreen screen) || screen.getMenu() == null) { + return -1; + } + // Key order matters: addons first, Superpairs last. Completed games are + // never re-entered this session (until a renewal resets the charges). + for (String key : GAME_KEYS) { + if (exhausted.contains(key) || completed.contains(key)) { + continue; + } + for (int i = 0; i < ExperimentUtils.containerSlotCount(screen); i++) { + String name = ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)).toLowerCase(); + if (name.contains(key)) { + return i; + } + } + } + dumpMenuOnce(screen); + return -1; + } + + /** One-shot-per-menu diagnostic: log the menu contents when nothing matched. */ + private static void dumpMenuOnce(AbstractContainerScreen screen) { + if (!dumpedMenus.add(stripped(screen.getTitle().getString()))) { + return; + } + StringBuilder names = new StringBuilder(); + int containerSize = ExperimentUtils.containerSlotCount(screen); + for (int i = 0; i < containerSize; i++) { + String name = ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)); + if (!name.isEmpty()) { + names.append(i).append("='").append(name).append("' "); + } + } + ClientUtils.sendDebugMessage("[Experiments] No playable experiment matched. Menu '" + + stripped(screen.getTitle().getString()) + "': " + names); + } + + private static boolean playExperiment(Minecraft client, int slotIndex, Set exhausted, Set completed) { + AbstractContainerScreen table = client.screen instanceof AbstractContainerScreen s ? s : null; + if (table == null) { + return false; + } + String key = gameKeyOf(ExperimentUtils.stackName(ExperimentUtils.stackAt(table, slotIndex))); + refusalAt = 0L; + boolean practice = AetherConfig.EXPERIMENTS_PRACTICE_MODE.get(); + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(true)); + // Practice runs are started with a right-click, on the table entry as + // well as on a stakes tier. + client.execute(() -> { + if (client.screen == table) { + if (practice) { + ExperimentUtils.clickSlotRight(table, slotIndex); + } else { + ExperimentUtils.clickSlot(table, slotIndex); + } + } + }); + ClientUtils.sendDebugMessage("[Experiments] opening " + key + + (practice ? " in practice mode (right-click)" : "")); + + // The click can lead to: the game directly, the stakes (tier) menu, or a + // server refusal in chat. Poll for whichever happens first. + boolean stakesHandled = false; + long deadline = System.currentTimeMillis() + GAME_OPEN_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (shouldStop(client)) { + return false; + } + if (isGameScreen(client)) { + break; + } + if (recentRefusal()) { + if (xpLowHit) { + // Not the game's fault - don't write it off, the session + // loop buys XP and retries. + ClientUtils.sendDebugMessage("[Experiments] " + key + " blocked by XP level."); + closeStrayMenu(client); + return false; + } + markExhausted(client, exhausted, key, refusalReason); + return false; + } + if (!stakesHandled && isStakesScreen(client, key)) { + stakesHandled = true; + if (!pickStakesTier(client, key, exhausted)) { + return false; + } + deadline = System.currentTimeMillis() + GAME_OPEN_TIMEOUT_MS; + } + MacroWorkerThread.sleep(100); + } + if (!isGameScreen(client)) { + markExhausted(client, exhausted, key, "no game screen opened"); + return false; + } + + // The tick solvers play the game; wait until it ends (screen closes or + // returns to the table). + long playDeadline = System.currentTimeMillis() + GAME_PLAY_TIMEOUT_MS; + while (isGameScreen(client)) { + if (shouldStop(client) || System.currentTimeMillis() > playDeadline) { + client.execute(ExperimentUtils::closeScreen); + return false; + } + MacroWorkerThread.sleep(200); + } + if (key != null && !key.equals("superpairs")) { + // Completing an addon can unlock Superpairs; let it be retried. + exhausted.remove("superpairs"); + } + + // The server pops an "Experiment Over" rewards screen after the game; + // claim everything in it before heading back to the table. + waitFor(client, () -> isRewardsScreen(client), 2_500L); + if (isRewardsScreen(client)) { + claimRewardsScreen(client); + } + MacroWorkerThread.sleepRandom(400, 150); + + // Claim the finished game's rewards (one more click on its menu item), + // then remember it as done so the session never replays it. + if (key != null) { + claimExperiment(client, key); + completed.add(key); + } + return true; + } + + private static void claimExperiment(Minecraft client, String key) { + if (!isTableScreen(client) && !ensureTableOpen(client)) { + return; + } + waitForMenuItems(client); + if (!(client.screen instanceof AbstractContainerScreen screen)) { + return; + } + for (int i = 0; i < ExperimentUtils.containerSlotCount(screen); i++) { + String name = ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)).toLowerCase(); + if (name.contains(key)) { + final int claimIndex = i; + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(false)); + client.execute(() -> { + if (client.screen == screen) { + ExperimentUtils.clickSlot(screen, claimIndex); + } + }); + MacroWorkerThread.sleepRandom(600, 200); + closeStrayMenu(client); + return; + } + } + } + + private static boolean isRewardsScreen(Minecraft client) { + return client.screen instanceof AbstractContainerScreen screen + && stripped(screen.getTitle().getString()).toLowerCase().contains("experiment over"); + } + + /** Click every reward item in the "Experiment Over" screen, then close it. */ + private static void claimRewardsScreen(Minecraft client) { + if (!(client.screen instanceof AbstractContainerScreen screen) || screen.getMenu() == null) { + return; + } + waitForMenuItems(client); + dumpMenuOnce(screen); + int max = ExperimentUtils.containerSlotCount(screen); + for (int i = 0; i < max; i++) { + if (client.screen != screen) { + return; + } + ItemStack stack = ExperimentUtils.stackAt(screen, i); + if (stack.isEmpty() || ExperimentUtils.itemIdContains(stack, "glass") + || ExperimentUtils.itemIdContains(stack, "barrier")) { + continue; + } + String lowerName = ExperimentUtils.stackName(stack).toLowerCase(); + if (lowerName.isEmpty() || lowerName.contains("close") || lowerName.contains("go back")) { + continue; + } + final int claimIndex = i; + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(false)); + client.execute(() -> { + if (client.screen == screen) { + ExperimentUtils.clickSlot(screen, claimIndex); + } + }); + } + MacroWorkerThread.sleepRandom(400, 150); + if (isRewardsScreen(client)) { + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + } + } + + private static void closeStrayMenu(Minecraft client) { + if (client.screen instanceof AbstractContainerScreen && !isTableScreen(client) && !isGameScreen(client)) { + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + } + } + + // -- XP bottle ------------------------------------------------------------ + + private static void buyAndSplashXpBottle(Minecraft client) { + ClientUtils.sendMessage("§eXP level too low for the experiment - buying a Titanic Experience Bottle.", false); + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(300, 100); + + if (!BazaarUtils.executeBuy(client, "Titanic Experience Bottle", 1)) { + ClientUtils.sendMessage("§cCould not buy the XP bottle from Bazaar.", false); + return; + } + + // The bazaar can leave its screen open and the bought item takes a + // moment to land in the inventory. + closeNonContainerScreen(client); + client.execute(() -> { + if (client.screen instanceof AbstractContainerScreen) { + ExperimentUtils.closeScreen(); + } + }); + MacroWorkerThread.sleepRandom(300, 100); + + boolean held = false; + long holdDeadline = System.currentTimeMillis() + 3_000L; + while (System.currentTimeMillis() < holdDeadline) { + if (holdItemByName(client, "titanic experience bottle")) { + held = true; + break; + } + MacroWorkerThread.sleep(250); + } + if (!held) { + ClientUtils.sendMessage("§cBought the XP bottle but couldn't hold it.", false); + return; + } + + // Step back from the table so the throw can't hit it or its hitbox. + client.execute(() -> ClientUtils.setKeyMappingState(client.options.keyDown, true)); + MacroWorkerThread.sleep(350); + client.execute(() -> ClientUtils.setKeyMappingState(client.options.keyDown, false)); + MacroWorkerThread.sleepRandom(150, 60); + + // Look straight up so the splash lands on us, then throw. + float yaw = client.player == null ? 0f : client.player.getYRot(); + client.execute(() -> RotationManager.rotateToYawPitch( + client, yaw, -90f, AetherConfig.ROTATION_TIME.get(), true)); + long rotDeadline = System.currentTimeMillis() + 2_000L; + while (RotationManager.isRotating() && System.currentTimeMillis() < rotDeadline) { + MacroWorkerThread.sleep(25); + } + MacroWorkerThread.sleepRandom(150, 60); + + int before = client.player == null ? 0 : client.player.experienceLevel; + client.execute(ClientUtils::performUseClick); + MacroWorkerThread.sleep(900); + int after = client.player == null ? 0 : client.player.experienceLevel; + if (after <= before) { + // The throw can get eaten during rotation settle; one retry. + client.execute(ClientUtils::performUseClick); + MacroWorkerThread.sleep(900); + after = client.player == null ? 0 : client.player.experienceLevel; + } + ClientUtils.sendDebugMessage("[Experiments] XP bottle splashed: level " + before + " -> " + after + "."); + } + + /** Select the named item, swapping it into the hotbar first if needed. */ + private static boolean holdItemByName(Minecraft client, String nameNeedle) { + if (client.player == null) { + return false; + } + for (int i = 0; i < 9; i++) { + if (invNameContains(client, i, nameNeedle)) { + selectHotbar(client, i); + return true; + } + } + + int source = -1; + for (int i = 9; i < 36; i++) { + if (invNameContains(client, i, nameNeedle)) { + source = i; + break; + } + } + if (source < 0) { + return false; + } + int hotbar = 8; + for (int i = 0; i < 9; i++) { + ItemStack stack = client.player.getInventory().getItem(i); + if (stack == null || stack.isEmpty()) { + hotbar = i; + break; + } + } + + // The survival inventory menu maps main-inventory indices 9..35 to the + // same slot ids, so a SWAP click with the hotbar button moves the item. + final int sourceSlot = source; + final int hotbarButton = hotbar; + client.execute(() -> client.setScreen(new InventoryScreen(client.player))); + MacroWorkerThread.sleep(250); + client.execute(() -> { + if (client.screen instanceof AbstractContainerScreen invScreen) { + ClientUtils.performSlotClick(invScreen, sourceSlot, hotbarButton, ContainerInput.SWAP); + } + }); + MacroWorkerThread.sleep(200); + client.execute(() -> { + if (client.screen instanceof AbstractContainerScreen) { + ExperimentUtils.closeScreen(); + } + }); + MacroWorkerThread.sleep(150); + if (!invNameContains(client, hotbarButton, nameNeedle)) { + return false; + } + selectHotbar(client, hotbarButton); + return true; + } + + private static void selectHotbar(Minecraft client, int slot) { + client.execute(() -> FailsafeManager.selectHotbarSlot(client, slot)); + MacroWorkerThread.sleep(150); + } + + private static boolean invNameContains(Minecraft client, int invIndex, String needle) { + if (client.player == null) { + return false; + } + ItemStack stack = client.player.getInventory().getItem(invIndex); + if (stack == null || stack.isEmpty()) { + return false; + } + return TablistUtils.stripColors(stack.getHoverName().getString()).toLowerCase().contains(needle); + } + + private static boolean pickStakesTier(Minecraft client, String key, Set exhausted) { + if (!(client.screen instanceof AbstractContainerScreen stakes) || stakes.getMenu() == null) { + return false; + } + waitForMenuItems(client); + + // Tier buttons are named like "Supreme Experiment". Lore can't reliably + // tell locked/cooldown tiers apart, so collect them in menu order + // (ascending difficulty) and try from the highest down until one opens, + // using the server's refusal chat as the step-down signal. + java.util.List tiers = new java.util.ArrayList<>(); + int max = ExperimentUtils.containerSlotCount(stakes); + for (int i = 0; i < max; i++) { + ItemStack stack = ExperimentUtils.stackAt(stakes, i); + if (stack.isEmpty() || ExperimentUtils.itemIdContains(stack, "glass")) { + continue; + } + String lowerName = ExperimentUtils.stackName(stack).toLowerCase(); + if (lowerName.isEmpty() || lowerName.contains("close") + || lowerName.contains("go back") || lowerName.contains("practice")) { + continue; + } + // The lore states lock status outright: "Enchanting level too low!" + // vs "Click to play!"; don't waste clicks on locked tiers. + String lore = fullLoreOf(client, stack).toLowerCase(); + if (lore.contains("too low")) { + continue; + } + tiers.add(i); + } + if (tiers.isEmpty()) { + dumpMenuOnce(stakes); + markExhausted(client, exhausted, key, "no tier buttons found"); + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + return false; + } + + for (int c = tiers.size() - 1; c >= 0; c--) { + if (shouldStop(client)) { + return false; + } + // Hypixel can replace the screen instance between attempts; always + // click on the currently-open stakes screen. + if (!isStakesScreen(client, key) + || !(client.screen instanceof AbstractContainerScreen current)) { + break; + } + int tierIndex = tiers.get(c); + ItemStack tierStack = ExperimentUtils.stackAt(current, tierIndex); + String tierLore = fullLoreOf(client, tierStack); + ClientUtils.sendDebugMessage("[Experiments] Trying tier '" + + ExperimentUtils.stackName(tierStack) + "' for " + key + + ". Lore: " + tierLore); + + // Reward thresholds live in the lore; the solvers stop there. + int rewardTarget = ExperimentUtils.parseRewardTarget(tierLore); + ExperimentUtils.setRewardTarget(rewardTarget); + boolean practice = AetherConfig.EXPERIMENTS_PRACTICE_MODE.get(); + if (rewardTarget > 0) { + ClientUtils.sendDebugMessage("[Experiments] Max reward at series/chain of " + + rewardTarget + (practice ? " (practice run)" : "")); + } + + // Unaffordable tiers are silently ignored by the server - no chat, + // no screen change - so check the lore cost against our XP directly. + // Practice runs are free, so the cost never applies. + java.util.regex.Matcher costMatcher = XP_COST_PATTERN.matcher(tierLore.toLowerCase()); + if (!practice && costMatcher.find()) { + int required = Integer.parseInt(costMatcher.group(1)); + int have = client.player == null ? 0 : client.player.experienceLevel; + if (have < required) { + ClientUtils.sendDebugMessage("[Experiments] Tier needs " + required + + " XP levels, we have " + have + " - buying XP instead of stepping down."); + xpLowHit = true; + closeStrayMenu(client); + return false; + } + } + refusalAt = 0L; + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(false)); + client.execute(() -> { + if (client.screen == current) { + if (AetherConfig.EXPERIMENTS_PRACTICE_MODE.get()) { + ExperimentUtils.clickSlotRight(current, tierIndex); + } else { + ExperimentUtils.clickSlot(current, tierIndex); + } + } + }); + + long deadline = System.currentTimeMillis() + 2_500L; + while (System.currentTimeMillis() < deadline) { + if (isGameScreen(client)) { + return true; + } + if (recentRefusal()) { + break; + } + MacroWorkerThread.sleep(100); + } + if (isGameScreen(client)) { + return true; + } + if (xpLowHit) { + // XP shortage applies to every tier below too; bail so the + // session buys a bottle instead of stepping down. + closeStrayMenu(client); + return false; + } + } + + markExhausted(client, exhausted, key, "no tier would start (cooldown/charges?)"); + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + return false; + } + + private static boolean isStakesScreen(Minecraft client, String key) { + if (key == null || !(client.screen instanceof AbstractContainerScreen screen)) { + return false; + } + String title = stripped(screen.getTitle().getString()).trim().toLowerCase(); + return title.contains(key) && !title.contains("("); + } + + private static boolean recentRefusal() { + return refusalAt != 0L && System.currentTimeMillis() - refusalAt < 4_000L; + } + + private static void markExhausted(Minecraft client, Set exhausted, String key, String reason) { + if (key != null) { + exhausted.add(key); + ClientUtils.sendDebugMessage("[Experiments] " + key + " not playable (" + reason + "), skipping."); + } + // A stakes menu may be left open after a refusal; clear it. + if (client.screen instanceof AbstractContainerScreen && !isTableScreen(client) && !isGameScreen(client)) { + client.execute(ExperimentUtils::closeScreen); + MacroWorkerThread.sleepRandom(250, 100); + } + } + + private static void waitForMenuItems(Minecraft client) { + long deadline = System.currentTimeMillis() + 2_500L; + while (System.currentTimeMillis() < deadline) { + if (!(client.screen instanceof AbstractContainerScreen screen) || screen.getMenu() == null) { + return; + } + int named = 0; + int max = ExperimentUtils.containerSlotCount(screen); + for (int i = 0; i < max; i++) { + if (!ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)).isEmpty()) { + named++; + } + } + if (named >= 3) { + return; + } + MacroWorkerThread.sleep(100); + } + } + + private static String fullLoreOf(Minecraft client, ItemStack stack) { + if (stack == null || stack.isEmpty() || client.player == null) { + return ""; + } + StringBuilder text = new StringBuilder(); + for (Component line : stack.getTooltipLines(Item.TooltipContext.EMPTY, client.player, TooltipFlag.NORMAL)) { + // The lock/cost lines sit at the END of long lore; a small cap hides + // exactly the lines we filter on. + if (text.length() > 1500) { + break; + } + text.append(stripped(line.getString()).trim()).append(" | "); + } + return text.toString(); + } + + private static String gameKeyOf(String displayName) { + String lower = displayName.toLowerCase(); + for (String key : GAME_KEYS) { + if (lower.contains(key)) { + return key; + } + } + return null; + } + + // -- Renewal -------------------------------------------------------------- + + private static boolean tryRenew(Minecraft client) { + if (!(client.screen instanceof AbstractContainerScreen screen) || screen.getMenu() == null) { + return false; + } + int renewSlot = -1; + for (int i = 0; i < ExperimentUtils.containerSlotCount(screen); i++) { + String name = ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)).toLowerCase(); + if (name.contains("renew")) { + renewSlot = i; + break; + } + } + if (renewSlot < 0) { + return false; + } + + String cost = loreOf(client, ExperimentUtils.stackAt(screen, renewSlot)); + ClientUtils.sendDebugMessage("[Experiments] Renewing experiments. " + cost); + final int clickIndex = renewSlot; + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(true)); + client.execute(() -> { + if (client.screen == screen) { + ExperimentUtils.clickSlot(screen, clickIndex); + } + }); + MacroWorkerThread.sleep(1_000L); + + // Some renew flows show a confirm chest; click the green confirm if so. + if (client.screen instanceof AbstractContainerScreen confirm + && stripped(confirm.getTitle().getString()).toLowerCase().contains("confirm")) { + for (int i = 0; i < ExperimentUtils.containerSlotCount(confirm); i++) { + ItemStack stack = ExperimentUtils.stackAt(confirm, i); + String name = ExperimentUtils.stackName(stack).toLowerCase(); + if (name.contains("confirm") + || ExperimentUtils.itemIdContains(stack, "green", "terracotta")) { + final int confirmIndex = i; + MacroWorkerThread.sleep(ClientUtils.getGuiClickDelayMs(false)); + client.execute(() -> { + if (client.screen == confirm) { + ExperimentUtils.clickSlot(confirm, confirmIndex); + } + }); + break; + } + } + } + + return waitFor(client, () -> isTableScreen(client), TABLE_OPEN_TIMEOUT_MS); + } + + private static String loreOf(Minecraft client, ItemStack stack) { + if (stack == null || stack.isEmpty() || client.player == null) { + return ""; + } + StringBuilder text = new StringBuilder(); + for (Component line : stack.getTooltipLines(Item.TooltipContext.EMPTY, client.player, TooltipFlag.NORMAL)) { + String plain = stripped(line.getString()).trim(); + if (plain.toLowerCase().contains("bits") || plain.toLowerCase().contains("levels")) { + text.append(plain).append(" "); + } + } + return text.toString().trim(); + } + + // -- Helpers -------------------------------------------------------------- + + private static boolean shouldStop(Minecraft client) { + return cancelRequested + || MacroWorkerThread.shouldAbortTask(client, MacroState.State.EXPERIMENTING); + } + + private static boolean waitFor(Minecraft client, java.util.function.BooleanSupplier condition, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + if (shouldStop(client)) { + return false; + } + MacroWorkerThread.sleep(100); + } + return condition.getAsBoolean(); + } + + private static Vec3 playerPos(Minecraft client) { + return client.player == null ? null : client.player.position(); + } + + private static void closeNonContainerScreen(Minecraft client) { + client.execute(() -> { + if (client.screen != null && !(client.screen instanceof AbstractContainerScreen)) { + client.setScreen(null); + } + }); + } + + private static String stripped(String text) { + return text == null ? "" : text.replaceAll("(?i)§.", ""); + } +} diff --git a/src/main/java/dev/aether/modules/experimentation/SuperpairsSolver.java b/src/main/java/dev/aether/modules/experimentation/SuperpairsSolver.java new file mode 100644 index 0000000..e3083a9 --- /dev/null +++ b/src/main/java/dev/aether/modules/experimentation/SuperpairsSolver.java @@ -0,0 +1,274 @@ +package dev.aether.modules.experimentation; + +import java.util.HashMap; +import java.util.Map; + +import dev.aether.config.AetherConfig; +import dev.aether.util.ClientUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.item.ItemStack; + +/** + * Auto-plays Superpairs: reveal cards, remember what each slot held, and click + * a known pair together as soon as both halves are known. + *

+ * Card state comes from the board's own labels ("Click any button!" = + * face down, "Click a second button!" = face down mid-attempt) rather than the + * item id, and pairs are matched on display name + count rather than a full + * NBT comparison, because Hypixel's reward stacks are not byte-identical. + */ +public final class SuperpairsSolver { + + private static final int BOARD_START = 9; + private static final int BOARD_END = 44; + private static final long REVEAL_TIMEOUT_MS = 2500L; + private static final long STALL_TIMEOUT_MS = 8000L; + private static final long MISMATCH_FLIPBACK_MS = 1200L; + /** + * The board relabels the other cards a frame or two after the first card of + * an attempt is revealed. Never trust a missing mid-attempt marker inside + * this window, or we drop our own first card and desync from the server. + */ + private static final long MARKER_GRACE_MS = 1500L; + + private static final Map knownKey = new HashMap<>(); + private static long lastClickAt = 0L; + private static long clickDelay = 0L; + private static int pendingRevealSlot = -1; + private static long pendingRevealSince = 0L; + private static String attemptFirstKey = null; + private static int attemptFirstSlot = -1; + private static long attemptFirstAt = 0L; + private static long lastProgressAt = 0L; + private static boolean active = false; + /** Set once a populated board has been seen, so an empty sync frame is + * never mistaken for a finished board. */ + private static boolean boardSeen = false; + + private SuperpairsSolver() { + } + + public static void reset() { + knownKey.clear(); + pendingRevealSlot = -1; + clearAttempt(); + lastClickAt = 0L; + lastProgressAt = 0L; + active = false; + boardSeen = false; + } + + private static void clearAttempt() { + attemptFirstKey = null; + attemptFirstSlot = -1; + attemptFirstAt = 0L; + } + + public static boolean isGameScreen(AbstractContainerScreen screen) { + // Require the tier suffix: the stakes menu is titled just "Superpairs". + String title = screen.getTitle().getString().replaceAll("(?i)§.", "").trim(); + return title.matches("(?i)superpairs ?\\(.*"); + } + + public static void handleMenu(Minecraft client, AbstractContainerScreen screen) { + if (!AetherConfig.AUTO_EXPERIMENTS.get() || client.player == null) { + return; + } + if (!isGameScreen(screen)) { + if (active) { + reset(); + } + return; + } + + long now = System.currentTimeMillis(); + if (!active) { + reset(); + active = true; + lastProgressAt = now; + ClientUtils.sendDebugMessage("[SP] started, face-down cards: " + countHidden(screen) + + " of " + countPopulated(screen) + " populated"); + } + + // Absorb the outcome of the last click before deciding anything new. + if (pendingRevealSlot >= 0) { + ItemStack revealed = ExperimentUtils.stackAt(screen, pendingRevealSlot); + if (isRevealedReward(revealed)) { + String key = keyOf(revealed); + knownKey.put(pendingRevealSlot, key); + if (attemptFirstKey == null) { + attemptFirstKey = key; + attemptFirstSlot = pendingRevealSlot; + attemptFirstAt = now; + ClientUtils.sendDebugMessage("[SP] reveal#1 slot " + pendingRevealSlot + + " = " + key + " (known=" + knownKey.size() + ")"); + } else { + boolean matched = attemptFirstKey.equals(key); + ClientUtils.sendDebugMessage("[SP] reveal#2 slot " + pendingRevealSlot + + " = " + key + (matched ? " MATCH" : " MISMATCH vs slot " + + attemptFirstSlot + " " + attemptFirstKey)); + if (!matched) { + // Both cards flip back; clicking during that is ignored. + clickDelay = MISMATCH_FLIPBACK_MS + ExperimentUtils.nextClickDelay(); + } + clearAttempt(); + } + pendingRevealSlot = -1; + lastProgressAt = now; + } else if (now - pendingRevealSince > REVEAL_TIMEOUT_MS) { + ClientUtils.sendDebugMessage("[SP] reveal timeout on slot " + pendingRevealSlot + + " (shows '" + ExperimentUtils.stackName(revealed) + "')"); + pendingRevealSlot = -1; + } else { + return; + } + } + + // Corrective resync, but only once the board has had time to relabel. + boolean midAttempt = hasSecondButtonMarker(screen); + if (!midAttempt && attemptFirstKey != null && now - attemptFirstAt > MARKER_GRACE_MS) { + ClientUtils.sendDebugMessage("[SP] resync: attempt ended without a second reveal"); + clearAttempt(); + } + + // Hypixel re-sends the container mid-game; those frames arrive empty and + // must not read as "every card matched". + int populated = countPopulated(screen); + if (populated == 0) { + return; + } + boardSeen = true; + + int hiddenCount = countHidden(screen); + if (hiddenCount == 0) { + if (!boardSeen) { + return; + } + ClientUtils.sendDebugMessage("[SP] board complete (" + populated + " cards up)."); + finish(); + return; + } + if (now - lastProgressAt > STALL_TIMEOUT_MS) { + ClientUtils.sendDebugMessage("[SP] stalled (out of clicks?), closing."); + finish(); + return; + } + if (now - lastClickAt < clickDelay) { + return; + } + + int target = chooseTarget(screen); + if (target < 0) { + return; + } + String reason = attemptFirstKey != null + ? (knownKey.containsKey(target) ? "partner-of-first" : "learn-second") + : (knownKey.containsKey(target) ? "known-pair-start" : "learn-first"); + ClientUtils.sendDebugMessage("[SP] click slot " + target + " (" + reason + + ", mid=" + midAttempt + ", known=" + knownKey.size() + + ", hidden=" + hiddenCount + ")"); + ExperimentUtils.clickSlotMiddle(screen, target); + pendingRevealSlot = target; + pendingRevealSince = now; + lastClickAt = now; + clickDelay = ExperimentUtils.nextClickDelay(); + } + + private static int chooseTarget(AbstractContainerScreen screen) { + if (attemptFirstKey != null) { + // Mid-attempt: finish the pair if we already know where its twin is. + for (Map.Entry entry : knownKey.entrySet()) { + int slot = entry.getKey(); + if (slot != attemptFirstSlot && isFaceDown(screen, slot) + && attemptFirstKey.equals(entry.getValue())) { + return slot; + } + } + return firstUnknownFaceDown(screen); + } + + // Fresh attempt: start a pair we can complete immediately. + for (Map.Entry a : knownKey.entrySet()) { + if (!isFaceDown(screen, a.getKey())) { + continue; + } + for (Map.Entry b : knownKey.entrySet()) { + if (a.getKey().intValue() < b.getKey().intValue() + && isFaceDown(screen, b.getKey()) + && a.getValue().equals(b.getValue())) { + return a.getKey(); + } + } + } + return firstUnknownFaceDown(screen); + } + + private static int firstUnknownFaceDown(AbstractContainerScreen screen) { + int fallback = -1; + for (int i = BOARD_START; i <= BOARD_END; i++) { + if (!isFaceDown(screen, i)) { + continue; + } + if (!knownKey.containsKey(i)) { + return i; + } + if (fallback < 0) { + fallback = i; + } + } + return fallback; + } + + private static int countPopulated(AbstractContainerScreen screen) { + int count = 0; + for (int i = BOARD_START; i <= BOARD_END; i++) { + if (!ExperimentUtils.stackAt(screen, i).isEmpty()) { + count++; + } + } + return count; + } + + private static int countHidden(AbstractContainerScreen screen) { + int count = 0; + for (int i = BOARD_START; i <= BOARD_END; i++) { + if (isFaceDown(screen, i)) { + count++; + } + } + return count; + } + + /** Face-down cards are labelled "Click any/a second button!". */ + private static boolean isFaceDown(AbstractContainerScreen screen, int slot) { + return ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, slot)).startsWith("Click a"); + } + + private static boolean hasSecondButtonMarker(AbstractContainerScreen screen) { + for (int i = BOARD_START; i <= BOARD_END; i++) { + if (ExperimentUtils.stackName(ExperimentUtils.stackAt(screen, i)).startsWith("Click a second")) { + return true; + } + } + return false; + } + + private static boolean isRevealedReward(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return false; + } + String name = ExperimentUtils.stackName(stack); + return !name.isEmpty() && !name.startsWith("Click a"); + } + + /** Pair identity: what the player sees, not the raw NBT. */ + private static String keyOf(ItemStack stack) { + return ExperimentUtils.stackName(stack) + " x" + stack.getCount(); + } + + private static void finish() { + reset(); + ExperimentUtils.closeScreen(); + } +} diff --git a/src/main/java/dev/aether/modules/experimentation/UltrasequencerSolver.java b/src/main/java/dev/aether/modules/experimentation/UltrasequencerSolver.java new file mode 100644 index 0000000..7900798 --- /dev/null +++ b/src/main/java/dev/aether/modules/experimentation/UltrasequencerSolver.java @@ -0,0 +1,167 @@ +package dev.aether.modules.experimentation; + +import java.util.HashMap; +import java.util.Map; + +import dev.aether.config.AetherConfig; +import dev.aether.util.ClientUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.item.ItemStack; + +/** + * Auto-plays Ultrasequencer. During the show phase the board panes carry their + * order as a numeric display name; the numbers hide when the timer starts, so + * the slot for each number is remembered and clicked back in ascending order. + */ +public final class UltrasequencerSolver { + + private static final int BOARD_START = 9; + private static final int BOARD_END = 44; + private static final int MAX_XP_CLICKS = 20; + private static final int REWARD_CAP_CLICKS = 9; + + private static final int ROUND_SETTLE_MS = 450; + + private static final Map orderToSlot = new HashMap<>(); + private static int nextOrdinal = 0; + private static boolean wasShowPhase = false; + private static long inputStartAt = 0L; + private static String lastPhaseKey = ""; + private static long lastClickAt = 0L; + private static long clickDelay = 0L; + private static boolean captured = false; + private static boolean active = false; + + private UltrasequencerSolver() { + } + + public static void reset() { + orderToSlot.clear(); + nextOrdinal = 0; + lastClickAt = 0L; + captured = false; + active = false; + wasShowPhase = false; + inputStartAt = 0L; + lastPhaseKey = ""; + } + + public static boolean isGameScreen(AbstractContainerScreen screen) { + // Tier suffix required: bare "Ultrasequencer" is the stakes menu. + return screen.getTitle().getString().replaceAll("(?i)§.", "").trim() + .matches("(?i)ultrasequencer ?\\(.*"); + } + + public static void handleMenu(Minecraft client, AbstractContainerScreen screen) { + if (!AetherConfig.AUTO_EXPERIMENTS.get() || client.player == null) { + return; + } + if (!isGameScreen(screen)) { + if (active) { + reset(); + } + return; + } + if (!active) { + reset(); + active = true; + ClientUtils.sendDebugMessage("[Experiments] Ultrasequencer started."); + } + + logPhaseChange(screen); + if (!isInputPhase(screen)) { + wasShowPhase = true; + captureBoard(screen); + return; + } + if (!captured || orderToSlot.isEmpty()) { + return; + } + if (wasShowPhase) { + wasShowPhase = false; + inputStartAt = System.currentTimeMillis(); + } + // Same settle rule as Chronomatron: never click mid board swap. + if (System.currentTimeMillis() - inputStartAt < ROUND_SETTLE_MS) { + return; + } + + if (nextOrdinal >= orderToSlot.size()) { + // Round replayed fully; wait for the next show phase (captureBoard + // rearms) or the end of the game. + int maxClicks = maxClicks(); + if (orderToSlot.size() >= maxClicks) { + ClientUtils.sendDebugMessage("[Experiments] Ultrasequencer finished at " + + orderToSlot.size() + "/" + maxClicks + ", closing."); + reset(); + ExperimentUtils.closeScreen(); + } + return; + } + + long now = System.currentTimeMillis(); + if (now - lastClickAt < clickDelay) { + return; + } + Integer target = orderToSlot.get(nextOrdinal); + if (target == null) { + return; + } + ExperimentUtils.clickSlotMiddle(screen, target); + ClientUtils.sendDebugMessage("[US] click " + (nextOrdinal + 1) + "/" + orderToSlot.size() + + " slot=" + target); + lastClickAt = now; + clickDelay = ExperimentUtils.noteClickDelay(); + nextOrdinal++; + } + + /** Input phase = the countdown clock is in the marker slot. */ + private static boolean isInputPhase(AbstractContainerScreen screen) { + ItemStack marker = ExperimentUtils.stackAt(screen, ExperimentUtils.PHASE_SLOT); + return ExperimentUtils.itemIdContains(marker, "clock") + || ExperimentUtils.stackName(marker).startsWith(ExperimentUtils.PHASE_INPUT_PREFIX); + } + + private static void logPhaseChange(AbstractContainerScreen screen) { + ItemStack marker = ExperimentUtils.stackAt(screen, ExperimentUtils.PHASE_SLOT); + String key = ExperimentUtils.stackName(marker) + "|" + marker.getItem(); + if (key.equals(lastPhaseKey)) { + return; + } + lastPhaseKey = key; + ClientUtils.sendDebugMessage("[US] phase slot49='" + ExperimentUtils.stackName(marker) + + "' item=" + marker.getItem() + " input=" + isInputPhase(screen) + + " captured=" + orderToSlot.size()); + } + + private static void captureBoard(AbstractContainerScreen screen) { + Map found = new HashMap<>(); + for (int i = BOARD_START; i <= BOARD_END; i++) { + ItemStack stack = ExperimentUtils.stackAt(screen, i); + String name = ExperimentUtils.stackName(stack); + if (!stack.isEmpty() && name.matches("\\d+")) { + found.put(Integer.parseInt(name) - 1, i); + } + } + if (!found.isEmpty()) { + orderToSlot.clear(); + orderToSlot.putAll(found); + captured = true; + nextOrdinal = 0; + ClientUtils.sendDebugMessage("[Experiments] Ultrasequencer captured " + found.size() + + " numbers: " + new java.util.TreeMap<>(found)); + } + } + + private static int maxClicks() { + int loreTarget = ExperimentUtils.getRewardTarget(); + if (AetherConfig.EXPERIMENTS_STOP_AT_MAX_REWARD.get() && loreTarget > 0) { + return loreTarget; + } + if (AetherConfig.EXPERIMENTS_MAX_CLICKS.get()) { + return MAX_XP_CLICKS; + } + return REWARD_CAP_CLICKS - AetherConfig.EXPERIMENTS_SERUM_COUNT.get(); + } +} diff --git a/src/main/java/dev/aether/renderer/PositionHighlighter.java b/src/main/java/dev/aether/renderer/PositionHighlighter.java index b946410..523e25b 100644 --- a/src/main/java/dev/aether/renderer/PositionHighlighter.java +++ b/src/main/java/dev/aether/renderer/PositionHighlighter.java @@ -58,6 +58,9 @@ private static boolean hasVisibleGardenHighlights() { if (AetherConfig.AUTO_COMPOSTER_HIGHLIGHT.get()) { return true; } + if (AetherConfig.EXPERIMENTS_TABLE_HIGHLIGHT.get() && AetherConfig.EXPERIMENTS_TABLE_SET.get()) { + return true; + } for (RewarpPointPair pair : RewarpPointPairs.get()) { if ((pair.highlightStart && pair.hasStart()) || (pair.highlightEnd && pair.hasEnd())) { return true; @@ -152,6 +155,19 @@ public static void renderWorld(LevelRenderContext ctx) { 2.0f); } + if (AetherConfig.EXPERIMENTS_TABLE_HIGHLIGHT.get() && AetherConfig.EXPERIMENTS_TABLE_SET.get()) { + int x = AetherConfig.EXPERIMENTS_TABLE_X.get(); + int y = AetherConfig.EXPERIMENTS_TABLE_Y.get(); + int z = AetherConfig.EXPERIMENTS_TABLE_Z.get(); + + renderBlockHighlight(ctx, mc, textBuffer, + new AABB(x, y, z, x + 1, y + 1, z + 1), + "Experiments", + ARGB.color(200, 170, 90, 230), + ARGB.color(40, 170, 90, 230), + 2.0f); + } + for (RewarpPointPair pair : RewarpPointPairs.get()) { renderRewarpPair(ctx, mc, textBuffer, pair); } diff --git a/src/main/java/dev/aether/ui/providers/modules/ExperimentationRegistryProvider.java b/src/main/java/dev/aether/ui/providers/modules/ExperimentationRegistryProvider.java new file mode 100644 index 0000000..b42e04e --- /dev/null +++ b/src/main/java/dev/aether/ui/providers/modules/ExperimentationRegistryProvider.java @@ -0,0 +1,150 @@ +package dev.aether.ui; + +import dev.aether.config.AetherConfig; +import dev.aether.modules.experimentation.ExperimentationManager; +import dev.aether.notification.NotificationManager; +import dev.aether.ui.settings.ActionSetting; +import dev.aether.ui.settings.ModulesTab; +import dev.aether.ui.settings.PositionSetting; +import dev.aether.ui.settings.SettingGroup; +import dev.aether.ui.settings.SliderSetting; +import dev.aether.ui.settings.ToggleSetting; +import dev.aether.util.AetherLang; + +import java.util.List; + +public final class ExperimentationRegistryProvider extends AbstractModulesRegistryProvider { + public ExperimentationRegistryProvider() { + super(9); + } + + @Override + protected ModulesTab.SubTab createSubTab() { + SettingGroup group = SettingGroup.alwaysOn( + "Experimentation Settings", + "Solve and auto-click the Experimentation Table games") + .add(new ActionSetting("Run Session Now", ExperimentationManager::manualTrigger) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new PositionSetting("Table Spot", + () -> (double) AetherConfig.EXPERIMENTS_TABLE_X.get(), + v -> { + AetherConfig.EXPERIMENTS_TABLE_X.set((int) Math.round(v)); + AetherConfig.EXPERIMENTS_TABLE_SET.set(true); + AetherConfig.save(); + }, + () -> (double) AetherConfig.EXPERIMENTS_TABLE_Y.get(), + v -> { + AetherConfig.EXPERIMENTS_TABLE_Y.set((int) Math.round(v)); + AetherConfig.EXPERIMENTS_TABLE_SET.set(true); + AetherConfig.save(); + }, + () -> (double) AetherConfig.EXPERIMENTS_TABLE_Z.get(), + v -> { + AetherConfig.EXPERIMENTS_TABLE_Z.set((int) Math.round(v)); + AetherConfig.EXPERIMENTS_TABLE_SET.set(true); + AetherConfig.save(); + }, + () -> AetherConfig.EXPERIMENTS_TABLE_HIGHLIGHT.get(), + v -> { + AetherConfig.EXPERIMENTS_TABLE_HIGHLIGHT.set(v); + AetherConfig.save(); + }, + () -> { + ExperimentationManager.saveTablePosition(); + NotificationManager.success(AetherLang.localize("Table Spot Set"), + String.format("X: %d, Y: %d, Z: %d", + AetherConfig.EXPERIMENTS_TABLE_X.get(), + AetherConfig.EXPERIMENTS_TABLE_Y.get(), + AetherConfig.EXPERIMENTS_TABLE_Z.get())); + }) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new ToggleSetting("Practice Mode (free, no charges)", + () -> AetherConfig.EXPERIMENTS_PRACTICE_MODE.get(), + v -> { + AetherConfig.EXPERIMENTS_PRACTICE_MODE.set(v); + AetherConfig.save(); + }) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new ToggleSetting("Stop At Max Reward (from lore)", + () -> AetherConfig.EXPERIMENTS_STOP_AT_MAX_REWARD.get(), + v -> { + AetherConfig.EXPERIMENTS_STOP_AT_MAX_REWARD.set(v); + AetherConfig.save(); + }) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new ToggleSetting("Play To Max Clicks", + () -> AetherConfig.EXPERIMENTS_MAX_CLICKS.get(), + v -> { + AetherConfig.EXPERIMENTS_MAX_CLICKS.set(v); + AetherConfig.save(); + }) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get() + && !AetherConfig.EXPERIMENTS_STOP_AT_MAX_REWARD.get())) + .add(new SliderSetting("Metaphysical Serums Used", 0, 3, + () -> (float) AetherConfig.EXPERIMENTS_SERUM_COUNT.get(), + v -> { + AetherConfig.EXPERIMENTS_SERUM_COUNT.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get() + && !AetherConfig.EXPERIMENTS_MAX_CLICKS.get())) + .add(new ToggleSetting("Auto-Buy XP Bottle (Bazaar)", + () -> AetherConfig.EXPERIMENTS_AUTO_BUY_XP.get(), + v -> { + AetherConfig.EXPERIMENTS_AUTO_BUY_XP.set(v); + AetherConfig.save(); + }) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new SliderSetting("Bits+XP Renewals Per Day", 0, 3, + () -> (float) AetherConfig.EXPERIMENTS_RENEWALS_PER_DAY.get(), + v -> { + AetherConfig.EXPERIMENTS_RENEWALS_PER_DAY.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0) + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new SliderSetting("Note Click Delay Min", 50, 1000, + () -> (float) AetherConfig.EXPERIMENTS_NOTE_DELAY_MIN.get(), + v -> { + AetherConfig.EXPERIMENTS_NOTE_DELAY_MIN.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0).withSuffix("ms") + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new SliderSetting("Note Click Delay Max", 50, 2000, + () -> (float) AetherConfig.EXPERIMENTS_NOTE_DELAY_MAX.get(), + v -> { + AetherConfig.EXPERIMENTS_NOTE_DELAY_MAX.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0).withSuffix("ms") + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new SliderSetting("Click Delay Min", 50, 1000, + () -> (float) AetherConfig.EXPERIMENTS_CLICK_DELAY_MIN.get(), + v -> { + AetherConfig.EXPERIMENTS_CLICK_DELAY_MIN.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0).withSuffix("ms") + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())) + .add(new SliderSetting("Click Delay Max", 50, 2000, + () -> (float) AetherConfig.EXPERIMENTS_CLICK_DELAY_MAX.get(), + v -> { + AetherConfig.EXPERIMENTS_CLICK_DELAY_MAX.set(Math.round(v)); + AetherConfig.save(); + }) + .withDecimals(0).withSuffix("ms") + .visibleWhen(() -> AetherConfig.AUTO_EXPERIMENTS.get())); + + return MainGUIRegistry.toggleSubTab( + "Auto Experiments", + "Plays the Experimentation Table hands-off: Superpairs, addons, renewals", + () -> AetherConfig.AUTO_EXPERIMENTS.get(), + v -> { + AetherConfig.AUTO_EXPERIMENTS.set(v); + AetherConfig.save(); + }, + List.of(group)); + } +} diff --git a/src/main/resources/META-INF/services/dev.aether.ui.MainGUIRegistryProvider b/src/main/resources/META-INF/services/dev.aether.ui.MainGUIRegistryProvider index 5654311..4137052 100644 --- a/src/main/resources/META-INF/services/dev.aether.ui.MainGUIRegistryProvider +++ b/src/main/resources/META-INF/services/dev.aether.ui.MainGUIRegistryProvider @@ -38,3 +38,4 @@ dev.aether.ui.MenuColorsRegistryProvider dev.aether.ui.KeybindsSettingsRegistryProvider dev.aether.ui.LanguageSettingsRegistryProvider dev.aether.ui.ThemeOptionsSettingsRegistryProvider +dev.aether.ui.ExperimentationRegistryProvider