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