Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/superpowers/specs/2026-07-22-auto-experimentation-design.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/dev/aether/bootstrap/AetherChatEvents.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/dev/aether/config/AetherConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/dev/aether/macro/MacroState.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public enum State {
EQUIPMENT,
GEORGE,
DROPPING_JUNK,
REWARPING
REWARPING,
EXPERIMENTING
}

public enum Location {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<String> notes = new ArrayList<>();
private static List<String> 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<String> 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<String> 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<String> litColours(AbstractContainerScreen<?> screen) {
List<String> 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();
}
}
Loading
Loading