From c0a3a914d30b816e945700e51ee5869822fa7b99 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:31:20 +0200 Subject: [PATCH] fix(world-hop): refuse blocked hops before waiting and load the budget off the EDT - Check combat, bank, shop and dialogue before waiting for the hop limit, so a blocked hop is refused at once instead of after the wait. - Load the shared budget file on the plugin executor instead of the Swing thread during startUp, and read it once instead of twice. - Apply the future-timestamp repair when loading, so the client- and Swing-thread check refills after the clock goes backwards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 30 ++++++++++++++----- .../plugins/microbot/MicrobotPlugin.java | 3 +- .../microbot/util/security/HopBudget.java | 4 +-- .../microbot/MicrobotWorldHopTest.java | 23 ++++++++++++++ .../microbot/util/security/HopBudgetTest.java | 11 +++++++ 5 files changed, 60 insertions(+), 11 deletions(-) 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 6a045d8971e..d95a5de4089 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 @@ -383,17 +383,21 @@ 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; + if (Microbot.getClient().getWorld() != worldNumber) { + String blocker = Microbot.getClientThread().runOnClientThreadOptional(Microbot::hopBlocker).orElse(null); + if (blocker != null) { + log.error(blocker); + return false; + } + if (!awaitHopBudget(worldNumber)) { + return false; + } } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { - if (Rs2Combat.inCombat()) { - log.error("Player is in combat, cannot hop worlds"); - return false; - } - if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { - log.error("Blocking widget open (bank/shop/dialogue), cannot hop worlds"); + String blocker = hopBlocker(); + if (blocker != null) { + log.error(blocker); return false; } if (quickHopTargetWorld != null || Microbot.getClient().getGameState() != GameState.LOGGED_IN) { @@ -474,6 +478,16 @@ public static boolean hopToWorld(int worldNumber) { return success; } + private static String hopBlocker() { + if (Rs2Combat.inCombat()) { + return "Player is in combat, cannot hop worlds"; + } + if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { + return "Blocking widget open (bank/shop/dialogue), cannot hop worlds"; + } + return null; + } + static boolean awaitHopBudget(int worldNumber) { HopBudget budget = HopBudget.getInstance(); if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { 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 e14b5f104e6..0645c7b49e8 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 @@ -184,8 +184,9 @@ 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(); + executor.execute(() -> HopBudget.getInstance().persistTo( + new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey())); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); 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 index eb881a317a4..5eff0e7e990 100644 --- 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 @@ -86,7 +86,7 @@ private static String sanitize(String value) { public void persistTo(Path file, String networkKey) { store = new FileStore(file, networkKey, store); - store.update(UnaryOperator.identity()); + current(); } public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { @@ -207,7 +207,7 @@ static final class FileStore implements Store { this.lockFile = file.resolveSibling(file.getFileName() + ".lock"); this.tokensKey = networkKey + ".tokens"; this.updatedAtKey = networkKey + ".updatedAt"; - this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); + this.fallback = new MemoryStore(seed.peek()); } @Override diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java index 924e6d5976d..bec3f0839f7 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -5,6 +5,9 @@ import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -103,6 +106,26 @@ public void hopsImmediatelyWhenSwitcherAlreadyOpen() { verify(client).hopToWorld(target); } + @Test + public void blockedHopIsRefusedBeforeWaitingForTheHopLimit() { + HopBudget budget = mock(HopBudget.class); + when(budget.millisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + when(budget.cachedMillisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + try (MockedStatic hopBudget = Mockito.mockStatic(HopBudget.class); + MockedStatic combat = Mockito.mockStatic(Rs2Combat.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class)) { + hopBudget.when(HopBudget::getInstance).thenReturn(budget); + combat.when(Rs2Combat::inCombat).thenReturn(false); + bank.when(Rs2Bank::isOpen).thenReturn(true); + + assertFalse(Microbot.hopToWorld(302)); + } + + verify(client, never()).hopToWorld(any()); + } + @Test public void doesNotHopWhenSwitcherNeverLoads() { assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); 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 index 3f20e10c6a9..c6246246ae7 100644 --- 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 @@ -169,6 +169,17 @@ private static void hop(HopBudget budget, int times) { assertEquals(0L, budget.millisUntilHopAllowed()); } + @Test public void persistToRepairsAFutureTimestampForTheCachedCheck() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, ("direct.tokens=5.0\ndirect.updatedAt=" + (clock.get() + TimeUnit.HOURS.toMillis(1)) + "\n") + .getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertTrue(budget.cachedMillisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.cachedMillisUntilHopAllowed()); + } + @Test public void zeroRefillRateIsClampedSoWaitsStayFinite() { HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); budget.configure(true, 30, 0, 5);