From 658abbfefc7f4a99124642eef0e8f6a238e45d50 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:05:35 +0200 Subject: [PATCH 1/3] feat(world-hop): pace hops against an estimated per-IP hop allowance Jagex limits hops and logins per IP; going over it returns "Too many login attempts" and LoginLockout then pauses all logins for 5-30 min. Nothing stopped scripts from draining that allowance in the first place. HopBudget models the allowance as a token bucket (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every HOPPING and LOGGING_IN transition spends a token, including manual hops. Microbot.hopToWorld waits for the next token once the allowance reaches the reserve, so hop-heavy scripts slow down instead of getting locked out. The client thread and the Swing thread get false instead of blocking. State is kept in ~/.runelite/microbot-hop-budget.properties, file-locked and keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A real lockout sets it to zero so the estimate corrects itself. The numbers are configurable under "World hop limit" in the Microbot settings, and /login on the agent server reports hopsAvailable and nextHopInMs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/AGENT_SERVER.md | 4 + docs/ARCHITECTURE.md | 1 + .../client/plugins/microbot/Microbot.java | 33 +++ .../plugins/microbot/MicrobotConfig.java | 65 +++++ .../plugins/microbot/MicrobotPlugin.java | 39 ++- .../agentserver/handler/LoginHandler.java | 6 + .../microbot/util/security/HopBudget.java | 232 ++++++++++++++++++ .../microbot/util/security/LoginManager.java | 1 + .../plugins/microbot/MicrobotConfigTest.java | 13 + .../microbot/MicrobotPluginHopCountTest.java | 29 +++ .../microbot/util/security/HopBudgetTest.java | 164 +++++++++++++ 11 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md index 65598af260b..a435707f8eb 100644 --- a/docs/AGENT_SERVER.md +++ b/docs/AGENT_SERVER.md @@ -492,6 +492,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` "loggedIn": true, "gameState": "LOGGED_IN", "loginAttemptActive": false, + "hopsAvailable": 372, + "nextHopInMs": 0, "loginDurationMs": 120000, "activeProfile": { "name": "player@email.com", @@ -514,6 +516,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` `loginIndex` and `loginError` are only present when `gameState` is `LOGIN_SCREEN`. When logged in, these fields are omitted. +`hopsAvailable` and `nextHopInMs` come from the world hop limiter (`HopBudget`). They are present whenever "Pace world hops" is enabled in the Microbot settings. `nextHopInMs` is how long `Microbot.hopToWorld` would wait right now, and is 0 while the allowance is above the login reserve. + #### POST /login Triggers a login using the active profile. **Blocks by default** until the login succeeds or fails, returning a definitive result. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90a58f51440..204451aee4c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,6 +31,7 @@ - `BlockingEventManager` uses exponential backoff when no events validate to reduce overhead; events are re-queued if execution fails. - Default Gradle `test` tasks are disabled in `runelite-client/build.gradle.kts`; use explicit tasks such as `:client:runUnitTests`, `:client:runTests`, `:client:runDebugTests`, `:client:runIntegrationTest`, or `:client:runClientThreadScanner`. - World hopping short-circuits if the player is interacting or already hopping; confirmation widget auto-click handled in `Microbot.hopToWorld`. +- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token; the client thread and Swing thread get `false` instead. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A "Too many login attempts" lockout (login index 33) sets it to zero. ## References - Queryable API guide: `../runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/QUERYABLE_API.md` diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index d8b98fdfee2..2ff220dcae8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -55,6 +55,7 @@ import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; import net.runelite.client.plugins.microbot.api.tileitem.Rs2TileItemCache; import net.runelite.client.plugins.microbot.api.tileobject.Rs2TileObjectCache; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.ui.overlay.infobox.InfoBoxManager; @@ -376,6 +377,9 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } + if (Microbot.getClient().getWorld() != worldNumber && !awaitHopBudget(worldNumber)) { + return false; + } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { if (Rs2Combat.inCombat()) { @@ -464,6 +468,35 @@ public static boolean hopToWorld(int worldNumber) { return success; } + static boolean awaitHopBudget(int worldNumber) { + HopBudget budget = HopBudget.getInstance(); + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } + if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", + worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); + return false; + } + log.info("Hop limit reached - waiting {}s before hopping to world {}", + TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); + String previousStatus = status; + try { + while (waitMs > 0) { + status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; + Thread.sleep(Math.min(waitMs, 1000L)); + waitMs = budget.millisUntilHopAllowed(); + } + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } finally { + status = previousStatus; + } + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java index dbf20321206..1070bb0023f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java @@ -7,6 +7,8 @@ import net.runelite.client.config.ConfigGroup; import net.runelite.client.config.ConfigItem; import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Range; +import net.runelite.client.plugins.microbot.util.security.HopBudget; @ConfigGroup(MicrobotConfig.configGroup) public interface MicrobotConfig extends Config @@ -272,4 +274,67 @@ default int inputIdleResumeMs() { return 1800; } + + @ConfigSection( + name = "World hop limit", + description = "Pace world hops so the per-IP login limit (\"Too many login attempts\") is never reached", + position = 4 + ) + String hopLimitSection = "hopLimitSection"; + + String keyEnableHopLimit = "enableHopLimit"; + @ConfigItem( + keyName = keyEnableHopLimit, + name = "Pace world hops", + description = "Wait before a scripted world hop when the estimated hop allowance runs low, instead of getting locked out. " + + "Shared by every client on the same IP or proxy.", + position = 0, + section = hopLimitSection + ) + default boolean enableHopLimit() + { + return true; + } + + String keyHopLimitCapacity = "hopLimitCapacity"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitCapacity, + name = "Hop allowance", + description = "Estimated number of hops and logins Jagex allows before locking the IP out", + position = 1, + section = hopLimitSection + ) + default int hopLimitCapacity() + { + return HopBudget.DEFAULT_CAPACITY; + } + + String keyHopLimitRefillPerHour = "hopLimitRefillPerHour"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitRefillPerHour, + name = "Hops regained per hour", + description = "Estimated number of hops the allowance regains every hour", + position = 2, + section = hopLimitSection + ) + default int hopLimitRefillPerHour() + { + return HopBudget.DEFAULT_REFILL_PER_HOUR; + } + + String keyHopLimitReserve = "hopLimitReserve"; + @Range(min = 0, max = 200) + @ConfigItem( + keyName = keyHopLimitReserve, + name = "Reserve for logins", + description = "Hops never spent on world hopping, kept for logging back in after breaks or disconnects", + position = 3, + section = hopLimitSection + ) + default int hopLimitReserve() + { + return HopBudget.DEFAULT_RESERVE; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 1ff435b816e..fc2e3aeb48e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -6,6 +6,7 @@ import net.runelite.api.*; import net.runelite.api.events.*; import net.runelite.api.gameval.InventoryID; +import net.runelite.client.RuneLite; import net.runelite.client.RuneLiteProperties; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; @@ -40,6 +41,7 @@ import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.NavigationButton; @@ -58,6 +60,7 @@ import java.awt.AWTException; import java.awt.Rectangle; import java.awt.image.BufferedImage; +import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -66,6 +69,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; @PluginDescriptor( name = PluginDescriptor.Default + "Microbot", description = "Microbot", @@ -101,6 +105,9 @@ public class MicrobotPlugin extends Plugin @Inject private MicrobotConfig microbotConfig; + @Inject + private ScheduledExecutorService executor; + private MicrobotTopLevelConfigPanel topLevelConfigPanel; private NavigationButton navButton; @@ -177,6 +184,8 @@ protected void startUp() throws AWTException InputArbiter.setDisabled(microbotConfig.disableInputYielding()); InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + HopBudget.getInstance().persistTo(new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey()); + applyHopBudgetConfig(); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); @@ -323,7 +332,10 @@ private int[] getShopContainerIds() @Subscribe public void onGameStateChanged(GameStateChanged gameStateChanged) { - + if (isLoginServerRequest(LoginManager.getLastKnownGameState(), gameStateChanged.getGameState())) + { + executor.execute(() -> HopBudget.getInstance().recordHop()); + } if (gameStateChanged.getGameState() == GameState.LOGGED_IN) { // Region-based login detection logic @@ -359,6 +371,25 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) LoginManager.setLastKnownGameState(gameStateChanged.getGameState()); } + static boolean isLoginServerRequest(GameState previous, GameState next) + { + if (next == GameState.HOPPING) + { + return previous != GameState.HOPPING; + } + return next == GameState.LOGGING_IN + && (previous == GameState.LOGIN_SCREEN || previous == GameState.LOGIN_SCREEN_AUTHENTICATOR); + } + + private void applyHopBudgetConfig() + { + HopBudget.getInstance().configure( + microbotConfig.enableHopLimit(), + microbotConfig.hopLimitCapacity(), + microbotConfig.hopLimitRefillPerHour(), + microbotConfig.hopLimitReserve()); + } + private static EnumSet normalizeWorldTypesForProfileComparison(EnumSet rawTypes) { EnumSet normalized = rawTypes == null @@ -518,6 +549,12 @@ public void onConfigChanged(ConfigChanged ev) case MicrobotConfig.keyInputIdleResumeMs: InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); break; + case MicrobotConfig.keyEnableHopLimit: + case MicrobotConfig.keyHopLimitCapacity: + case MicrobotConfig.keyHopLimitRefillPerHour: + case MicrobotConfig.keyHopLimitReserve: + applyHopBudgetConfig(); + break; case MicrobotConfig.keyEnableGameChatLogging: case MicrobotConfig.keyGameChatLogPattern: case MicrobotConfig.keyGameChatLogLevel: diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java index eeab31b4a88..252b1c10cec 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java @@ -7,6 +7,7 @@ import net.runelite.api.GameState; import net.runelite.client.config.ConfigProfile; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; @@ -79,6 +80,11 @@ private Map buildStatusMap() { if (loginCooldownMs > 0) { result.put("loginCooldownMs", loginCooldownMs); } + HopBudget hopBudget = HopBudget.getInstance(); + if (hopBudget.isEnabled()) { + result.put("hopsAvailable", hopBudget.available()); + result.put("nextHopInMs", hopBudget.millisUntilHopAllowed()); + } if (Microbot.isLoggedIn()) { long durationMs = LoginManager.getLoginDuration().toMillis(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java new file mode 100644 index 00000000000..10d2776a5ef --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -0,0 +1,232 @@ +package net.runelite.client.plugins.microbot.util.security; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import java.util.function.UnaryOperator; + +@Slf4j +public final class HopBudget { + public static final int DEFAULT_CAPACITY = 390; + public static final int DEFAULT_REFILL_PER_HOUR = 93; + public static final int DEFAULT_RESERVE = 20; + public static final String FILE_NAME = "microbot-hop-budget.properties"; + + private static final HopBudget INSTANCE = new HopBudget(System::currentTimeMillis, new MemoryStore()); + + private final LongSupplier clock; + private volatile Store store; + private volatile boolean enabled = true; + private volatile int capacity = DEFAULT_CAPACITY; + private volatile int refillPerHour = DEFAULT_REFILL_PER_HOUR; + private volatile int reserve = DEFAULT_RESERVE; + + HopBudget(LongSupplier clock, Store store) { + this.clock = clock; + this.store = store; + } + + public static HopBudget getInstance() { + return INSTANCE; + } + + public static String networkKey() { + String proxyHost = System.getProperty("socksProxyHost"); + if (proxyHost == null || proxyHost.isBlank()) { + return "direct"; + } + return "proxy." + proxyHost.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + } + + public void persistTo(Path file, String networkKey) { + store = new FileStore(file, networkKey, store); + } + + public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { + this.capacity = Math.max(1, capacity); + this.refillPerHour = Math.max(0, refillPerHour); + this.reserve = Math.min(Math.max(0, reserve), this.capacity - 1); + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public int available() { + return (int) Math.floor(current().tokens); + } + + public long millisUntilHopAllowed() { + if (!enabled) { + return 0L; + } + double missing = reserve + 1 - current().tokens; + if (missing <= 0) { + return 0L; + } + double perMilli = refillPerMilli(); + if (perMilli <= 0) { + return Long.MAX_VALUE; + } + return Math.max(1L, (long) Math.ceil(missing / perMilli)); + } + + public void recordHop() { + long now = clock.getAsLong(); + State next = store.update(state -> { + State refilled = refill(state, now); + return new State(Math.max(0, refilled.tokens - 1), now); + }); + if (enabled && next.tokens < reserve + 1) { + log.info("Hop limit nearly reached - {} hops left, keeping {} in reserve for logins", + (int) Math.floor(next.tokens), reserve); + } + } + + public void recordLockout() { + long now = clock.getAsLong(); + store.update(state -> new State(0, now)); + } + + private State current() { + return refill(store.update(UnaryOperator.identity()), clock.getAsLong()); + } + + private State refill(State state, long now) { + if (state == null) { + return new State(capacity, now); + } + long elapsed = Math.max(0L, now - state.updatedAt); + double tokens = Math.min(capacity, state.tokens + elapsed * refillPerMilli()); + return new State(tokens, now); + } + + private double refillPerMilli() { + return refillPerHour / (double) TimeUnit.HOURS.toMillis(1); + } + + static final class State { + final double tokens; + final long updatedAt; + + State(double tokens, long updatedAt) { + this.tokens = tokens; + this.updatedAt = updatedAt; + } + } + + interface Store { + State update(UnaryOperator change); + } + + static final class MemoryStore implements Store { + private State state; + + MemoryStore() { + } + + MemoryStore(State state) { + this.state = state; + } + + @Override + public synchronized State update(UnaryOperator change) { + state = change.apply(state); + return state; + } + } + + static final class FileStore implements Store { + private final Path file; + private final String tokensKey; + private final String updatedAtKey; + private final MemoryStore fallback; + private boolean warned; + + FileStore(Path file, String networkKey, Store seed) { + this.file = file; + this.tokensKey = networkKey + ".tokens"; + this.updatedAtKey = networkKey + ".updatedAt"; + this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); + } + + @Override + public synchronized State update(UnaryOperator change) { + boolean interrupted = Thread.interrupted(); + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, + StandardOpenOption.READ, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Properties properties = read(channel); + State current = parse(properties); + State next = change.apply(current); + if (next != current && next != null) { + properties.setProperty(tokensKey, Double.toString(next.tokens)); + properties.setProperty(updatedAtKey, Long.toString(next.updatedAt)); + write(channel, properties); + } + fallback.update(state -> next); + return next; + } + } catch (IOException | RuntimeException e) { + if (!warned) { + warned = true; + log.warn("Hop budget file {} unavailable, tracking hops for this client only", file, e); + } + return fallback.update(change); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private State parse(Properties properties) { + String tokens = properties.getProperty(tokensKey); + String updatedAt = properties.getProperty(updatedAtKey); + if (tokens == null || updatedAt == null) { + return null; + } + try { + return new State(Double.parseDouble(tokens), Long.parseLong(updatedAt)); + } catch (NumberFormatException e) { + return null; + } + } + + private static Properties read(FileChannel channel) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); + while (buffer.hasRemaining() && channel.read(buffer, buffer.position()) > 0) { + } + Properties properties = new Properties(); + properties.load(new StringReader(new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8))); + return properties; + } + + private static void write(FileChannel channel, Properties properties) throws IOException { + StringWriter writer = new StringWriter(); + properties.store(writer, null); + ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); + channel.truncate(0); + long position = 0; + while (buffer.hasRemaining()) { + position += channel.write(buffer, position); + } + channel.force(false); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java index da9f6b9ac83..12d048a073a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java @@ -243,6 +243,7 @@ public static boolean checkLoginLockout() { return lockout.isActive(); } if (lockout.recordLockout()) { + HopBudget.getInstance().recordLockout(); log.warn("Too many login attempts - pausing all logins for {}s (lockout #{})", TimeUnit.MILLISECONDS.toSeconds(lockout.remainingMillis()), lockout.getConsecutiveLockouts()); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java index 930770f6712..1d04928de5c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java @@ -1,7 +1,9 @@ package net.runelite.client.plugins.microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class MicrobotConfigTest @@ -13,4 +15,15 @@ public void inputYieldingIsDisabledByDefault() assertTrue(config.disableInputYielding()); } + + @Test + public void hopLimitIsOnByDefaultWithTheEstimatedAllowance() + { + MicrobotConfig config = new MicrobotConfig() { }; + + assertTrue(config.enableHopLimit()); + assertEquals(HopBudget.DEFAULT_CAPACITY, config.hopLimitCapacity()); + assertEquals(HopBudget.DEFAULT_REFILL_PER_HOUR, config.hopLimitRefillPerHour()); + assertEquals(HopBudget.DEFAULT_RESERVE, config.hopLimitReserve()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java new file mode 100644 index 00000000000..d7de1e4696b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java @@ -0,0 +1,29 @@ +package net.runelite.client.plugins.microbot; + +import net.runelite.api.GameState; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MicrobotPluginHopCountTest +{ + @Test + public void countsEachHopAndEachLoginAttemptOnce() + { + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.HOPPING)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN_AUTHENTICATOR, GameState.LOGGING_IN)); + } + + @Test + public void ignoresTransitionsThatDoNotReachTheLoginServer() + { + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.HOPPING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGING_IN, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOADING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOADING, GameState.LOGGED_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.LOGIN_SCREEN)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java new file mode 100644 index 00000000000..88f4fe095c0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java @@ -0,0 +1,164 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class HopBudgetTest { + private static final long ONE_TOKEN_MILLIS = (long) Math.ceil(TimeUnit.HOURS.toMillis(1) / 93.0); + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private final AtomicLong clock = new AtomicLong(1_000_000L); + + private HopBudget budget(int capacity, int reserve) { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, capacity, 93, reserve); + return budget; + } + + private static void hop(HopBudget budget, int times) { + for (int i = 0; i < times; i++) { + budget.recordHop(); + } + } + + @Test public void freshBudgetStartsFullAndAllowsHops() { + HopBudget budget = budget(390, 20); + assertEquals(390, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void hopsStopAtTheReserveAndResumeAfterOneTokenRefills() { + HopBudget budget = budget(30, 5); + hop(budget, 24); + assertEquals(6, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertEquals(5, budget.available()); + long wait = budget.millisUntilHopAllowed(); + assertTrue(Math.abs(wait - ONE_TOKEN_MILLIS) <= 1); + clock.addAndGet(wait - 2); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(2); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void refillNeverExceedsCapacity() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(TimeUnit.HOURS.toMillis(5)); + assertEquals(30, budget.available()); + } + + @Test public void lockoutEmptiesTheBudget() { + HopBudget budget = budget(390, 20); + budget.recordLockout(); + assertEquals(0, budget.available()); + assertTrue(budget.millisUntilHopAllowed() >= 20 * ONE_TOKEN_MILLIS); + } + + @Test public void disabledBudgetNeverWaitsButStillCounts() { + HopBudget budget = budget(10, 2); + budget.configure(false, 10, 93, 2); + hop(budget, 10); + assertEquals(0, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void reserveIsClampedBelowCapacity() { + HopBudget budget = budget(5, 50); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertTrue(budget.millisUntilHopAllowed() > 0); + } + + @Test public void clockGoingBackwardsDoesNotDrainOrRefill() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertEquals(20, budget.available()); + } + + @Test public void fileStoreIsSharedBetweenClientsOnTheSameNetwork() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + HopBudget proxied = budget(30, 5); + proxied.persistTo(file, "proxy.10.0.0.1"); + + hop(first, 4); + hop(second, 3); + assertEquals(23, first.available()); + assertEquals(23, second.available()); + assertEquals(30, proxied.available()); + + HopBudget restarted = budget(30, 5); + restarted.persistTo(file, "direct"); + assertEquals(23, restarted.available()); + } + + @Test public void interruptedThreadStillUsesTheSharedFileAndKeepsItsInterrupt() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + + Thread.currentThread().interrupt(); + try { + first.recordHop(); + assertEquals(29, first.available()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertEquals(29, second.available()); + } + + @Test public void corruptFileIsTreatedAsAFullBudget() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, "direct.tokens=abc\ndirect.updatedAt=\n".getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertEquals(30, budget.available()); + budget.recordHop(); + assertEquals(29, budget.available()); + } + + @Test public void unwritableFileFallsBackToThisClientOnly() throws Exception { + Path directory = folder.newFolder("blocked").toPath(); + HopBudget budget = budget(30, 5); + hop(budget, 2); + budget.persistTo(directory, "direct"); + assertEquals(28, budget.available()); + budget.recordHop(); + assertEquals(27, budget.available()); + } + + @Test public void networkKeyFollowsTheSocksProxy() { + String previous = System.getProperty("socksProxyHost"); + try { + System.clearProperty("socksProxyHost"); + assertEquals("direct", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "proxy.example.com"); + assertEquals("proxy.proxy.example.com", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "fe80::1"); + assertEquals("proxy.fe80__1", HopBudget.networkKey()); + } finally { + if (previous == null) { + System.clearProperty("socksProxyHost"); + } else { + System.setProperty("socksProxyHost", previous); + } + } + } +} From 798168f66c9c2592f36a6ce07ad2a40519efb589 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:21:06 +0200 Subject: [PATCH 2/3] fix(world-hop): close the F2P membership prompts that block the world switcher F2P accounts holding a bond get a chatbox prompt "Want your members-only features back?" (F2PBondRedeem). While it is open the world switcher never loads, so every Microbot.hopToWorld call failed with "World switcher did not load in time" until the prompt was closed by hand. MembershipPromptEvent is a BlockingEvent that clicks Close on F2PBondRedeem and on its sibling upsell prompts BondPrompt and MembershipBenefitsPromptHop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/BlockingEventManager.java | 1 + .../util/events/MembershipPromptEvent.java | 56 +++++++ .../events/MembershipPromptEventTest.java | 154 ++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java index 8a738ccaee2..d7e19d3e1ad 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java @@ -59,6 +59,7 @@ public BlockingEventManager() blockingEvents.add(new BankJagexPopupEvent()); blockingEvents.add(new PluginPauseEvent()); blockingEvents.add(new EnjoyRSChatboxEvent()); + blockingEvents.add(new MembershipPromptEvent()); blockingEvents.add(new DisableWorldSwitcherConfirmationEvent()); blockingEvents.add(new HideRoofsEvent()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java new file mode 100644 index 00000000000..b3be87e3ca5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java @@ -0,0 +1,56 @@ +package net.runelite.client.plugins.microbot.util.events; + +import net.runelite.api.Client; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.BlockingEvent; +import net.runelite.client.plugins.microbot.BlockingEventPriority; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +public class MembershipPromptEvent implements BlockingEvent { + + static final int[][] PROMPTS = { + {InterfaceID.F2PBondRedeem.UNIVERSE, InterfaceID.F2PBondRedeem.CLOSE}, + {InterfaceID.BondPrompt.UNIVERSE, InterfaceID.BondPrompt.CLOSE}, + {InterfaceID.MembershipBenefitsPromptHop.UNIVERSE, InterfaceID.MembershipBenefitsPromptHop.CLOSE_BUTTON}, + }; + + @Override + public boolean validate() { + return findCloseButton() != null; + } + + @Override + public boolean execute() { + Widget closeButton = findCloseButton(); + if (closeButton == null) return true; + + Rs2Widget.clickWidget(closeButton); + return Global.sleepUntil(() -> findCloseButton() == null, 10000); + } + + @Override + public BlockingEventPriority priority() { + return BlockingEventPriority.NORMAL; + } + + private static Widget findCloseButton() { + if (!Microbot.isLoggedIn()) return null; + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> findCloseButton(Microbot.getClient())) + .orElse(null); + } + + static Widget findCloseButton(Client client) { + if (client == null) return null; + for (int[] prompt : PROMPTS) { + Widget root = client.getWidget(prompt[0]); + if (root == null || root.isHidden()) continue; + Widget close = client.getWidget(prompt[1]); + if (close != null && !close.isHidden()) return close; + } + return null; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java new file mode 100644 index 00000000000..a982e875b2c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java @@ -0,0 +1,154 @@ +package net.runelite.client.plugins.microbot.util.events; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class MembershipPromptEventTest +{ + private Client client; + private ClientThread clientThread; + private Object previousClient; + private Object previousClientThread; + + @Before + public void before() throws Exception + { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + previousClient = swapStatic("client", client); + previousClientThread = swapStatic("clientThread", clientThread); + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + swapStatic("clientThread", previousClientThread); + } + + @Test + public void doesNotTouchClientThreadWhenNotLoggedIn() + { + for (GameState state : new GameState[]{GameState.LOGIN_SCREEN, GameState.LOGGING_IN, GameState.LOADING, GameState.HOPPING}) + { + when(client.getGameState()).thenReturn(state); + assertFalse(new MembershipPromptEvent().validate()); + } + verifyNoInteractions(clientThread); + } + + @Test + public void clientThreadTimeoutIsNotVisible() + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + when(clientThread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + + assertFalse(new MembershipPromptEvent().validate()); + } + + @Test + public void validatesWhenBondRedeemPromptIsOpen() throws Exception + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + runClientThreadInline(); + visible(InterfaceID.F2PBondRedeem.UNIVERSE); + visible(InterfaceID.F2PBondRedeem.CLOSE); + + assertTrue(new MembershipPromptEvent().validate()); + } + + @Test + public void findsTheCloseButtonOfEachPrompt() + { + for (int[] prompt : MembershipPromptEvent.PROMPTS) + { + Client promptClient = mock(Client.class); + Widget root = widget(false); + Widget close = widget(false); + when(promptClient.getWidget(prompt[0])).thenReturn(root); + when(promptClient.getWidget(prompt[1])).thenReturn(close); + + assertSame(close, MembershipPromptEvent.findCloseButton(promptClient)); + } + } + + @Test + public void ignoresMissingOrHiddenPrompts() + { + assertNull(MembershipPromptEvent.findCloseButton(null)); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + Widget hiddenRoot = widget(true); + Widget visibleRoot = widget(false); + Widget hiddenClose = widget(true); + Widget visibleClose = widget(false); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(hiddenRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(visibleClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(visibleRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(hiddenClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + } + + @Test + public void executeWithoutPromptCompletesWithoutWaiting() + { + when(client.getGameState()).thenReturn(GameState.LOGIN_SCREEN); + + assertTrue(new MembershipPromptEvent().execute()); + verify(client, never()).getWidget(anyInt()); + } + + @SuppressWarnings("unchecked") + private void runClientThreadInline() throws Exception + { + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + } + + private void visible(int componentId) + { + Widget widget = widget(false); + when(client.getWidget(componentId)).thenReturn(widget); + } + + private static Widget widget(boolean hidden) + { + Widget widget = mock(Widget.class); + when(widget.isHidden()).thenReturn(hidden); + return widget; + } + + private static Object swapStatic(String name, Object value) throws Exception + { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + Object previous = field.get(null); + field.set(null, value); + return previous; + } +} From de3e08b1f02799cf1dbbda14030e573df5c78512 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:21:07 +0200 Subject: [PATCH 3/3] fix(world-hop): count the first login after a client launch The tracked previous game state starts as UNKNOWN because MicrobotPlugin starts after the client already reached the login screen, so the first login after a launch went UNKNOWN -> LOGGING_IN and was not counted. Count every LOGGING_IN except one coming straight from HOPPING or LOGGING_IN, which also counts reconnects after a lost connection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../net/runelite/client/plugins/microbot/MicrobotPlugin.java | 3 ++- .../client/plugins/microbot/MicrobotPluginHopCountTest.java | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index fc2e3aeb48e..e14b5f104e6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -378,7 +378,8 @@ static boolean isLoginServerRequest(GameState previous, GameState next) return previous != GameState.HOPPING; } return next == GameState.LOGGING_IN - && (previous == GameState.LOGIN_SCREEN || previous == GameState.LOGIN_SCREEN_AUTHENTICATOR); + && previous != GameState.HOPPING + && previous != GameState.LOGGING_IN; } private void applyHopBudgetConfig() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java index d7de1e4696b..a7fa7c4b5a5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java @@ -14,6 +14,8 @@ public void countsEachHopAndEachLoginAttemptOnce() assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.HOPPING)); assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN, GameState.LOGGING_IN)); assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN_AUTHENTICATOR, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.UNKNOWN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.CONNECTION_LOST, GameState.LOGGING_IN)); } @Test