From b5e3efc8cc4a38f581d513e085f30bc221336ab7 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:31:09 +0200 Subject: [PATCH 01/62] fix(microbot): requeue error batch when submit throws; close response first CodeRabbit on #1901: if building the upload request throws synchronously (e.g. a malformed -Dmicrobot.apiUrl) the drained batch was lost; requeue it before rethrowing. Close the HTTP response before running the rejection callback so a throwing callback cannot leak it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PKz6YjDRJdva762xPzTaia --- .../runelite/client/plugins/microbot/MicrobotApi.java | 8 +++++--- .../microbot/diagnostics/ScriptErrorReporter.java | 10 +++++++++- .../microbot/diagnostics/ScriptErrorReporterTest.java | 9 +++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java index 97d017fab7..4e2e4105b1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java @@ -127,12 +127,14 @@ public void onFailure(Call call, IOException e) @Override public void onResponse(Call call, Response response) { - if (!response.isSuccessful()) + boolean successful = response.isSuccessful(); + int code = response.code(); + response.close(); + if (!successful) { - log.debug("Error telemetry upload rejected: HTTP {}", response.code()); + log.debug("Error telemetry upload rejected: HTTP {}", code); onRejected.run(); } - response.close(); } }); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index ad4424a41d..d9a37b78c2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -153,7 +153,15 @@ private void send() payload.addProperty("osName", System.getProperty("os.name")); payload.addProperty("osArch", System.getProperty("os.arch")); payload.add("errors", errors); - microbotApi.submitErrors(payload, () -> requeue(errors)); + try + { + microbotApi.submitErrors(payload, () -> requeue(errors)); + } + catch (RuntimeException e) + { + requeue(errors); + throw e; + } } public void rememberPlayerName(String name) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index 4e2b75375a..f524a8d64b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -193,6 +193,15 @@ public void requeuesRejectedBatch() assertEquals(2, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); } + @Test + public void requeuesBatchWhenSubmitThrows() + { + org.mockito.Mockito.doThrow(new IllegalArgumentException("bad url")).when(api).submitErrors(any(), any()); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + assertEquals(1, reporter.pendingCount()); + } + @Test public void groupsMessagesThatOnlyDifferInNumbers() { From 6dbe3c431ac9c24862d04218e342852cffeb93fb Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:24:34 +0200 Subject: [PATCH 02/62] fix(player): guard Rs2Player accessors against a null local player Client.getLocalPlayer() is null on the login screen, during world hops and while loading. getWorldLocation_Internal and getPoseAnimation dereferenced it directly and threw NullPointerExceptions on the client thread. Guard those and the sibling accessors that read the local player the same way, returning the existing no-player fallback values. Co-Authored-By: Claude Opus 5.5 --- docs/entity-guides/README.md | 2 +- docs/entity-guides/worlds.md | 21 ++++++ .../microbot/util/player/Rs2Player.java | 34 +++++---- .../player/Rs2PlayerNullLocalPlayerTest.java | 71 +++++++++++++++++++ 4 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index 04b4844a0e..206edb58d5 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -11,7 +11,7 @@ Each guide lists known pitfalls when working with one specific game entity type. | Items (inventory, bank, ground, equipment, shops) | [items.md](items.md) | Any code calling `Rs2Inventory`, `Rs2Bank`, `Rs2Equipment`, `Rs2GroundItem`, `Rs2Shop`, or `Rs2DepositBox` interaction helpers, or any helper that takes a list of item names and applies a single action to all of them | | Movement (walker, minimap, pathing) | [movement.md](movement.md) | Any code calling or modifying `Rs2Walker`, `Rs2MiniMap`, shortest-path marker handling, or minimap/canvas walk-click logic | | Death (graves, Death's Office, recovery) | [death.md](death.md) | Any code calling or modifying `Rs2Death`, `DeathRecoveryEvent`, `DeathEvent`, or handling graves, retrieval fees, and post-death item recovery | -| Worlds (selection, ping, hopping) | [worlds.md](worlds.md) | Any code measuring world latency, testing world reachability, or selecting worlds based on network availability | +| Worlds (selection, ping, hopping) | [worlds.md](worlds.md) | Any code measuring world latency, testing world reachability, selecting worlds based on network availability, or reading the local player while logging in or hopping | Ground-item pickup: [propagate dispatch failures](items.md#11-propagate-ground-item-dispatch-failures) and [preserve explicit Take](items.md#12-preserve-an-explicit-ground-item-take-when-a-widget-is-selected). diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 8b5f436f73..995f64dc7d 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -19,3 +19,24 @@ int ping = Ping.tcpPing(InetAddress.getByName(world.getAddress())); **Where this applies:** `Ping`, `Rs2WorldUtil`, world selectors, login-world scoring, and any helper that tests world reachability. **Defensive check:** Test fallback with local sockets so the first port refuses the connection and the second succeeds. Live endpoint probes are useful evidence, but must not become a permanent world-to-port mapping. + +## 2. Treat the local player as absent during login, hop, and loading + +`Client.getLocalPlayer()` returns `null` on the login screen, while hopping, and during some loading states. Scripts keep ticking through those states, so any helper that reads the local player must check for `null` and return the value its callers already treat as "no player". + +**Why this matters:** `Rs2Player.getWorldLocation_Internal()` and `Rs2Player.getPoseAnimation()` dereferenced the local player directly. Telemetry for 2.6.30 recorded about 100 NullPointerExceptions from these lambdas in one day, many reached through `Rs2PlayerStateCache.refreshLocalPlayer()` from ordinary `Rs2Player.getWorldLocation()` calls. + +**Pattern to follow:** + +```java +// Wrong: throws on the client thread whenever the player is not loaded. +return Microbot.getClient().getLocalPlayer().getPoseAnimation(); + +// Right: read once, guard, and fall back to the documented "no player" value. +Player localPlayer = Microbot.getClient().getLocalPlayer(); +return localPlayer == null ? null : localPlayer.getPoseAnimation(); +``` + +**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`) and any new helper that reads the local player. + +**Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest`. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 658e97eb60..2cccf9cb1f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -914,9 +914,10 @@ public static boolean hasPlayerEquippedItem(Rs2PlayerModel rs2Player, List - Microbot.getClient().getLocalPlayer().getCombatLevel() - ).orElse(0); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getCombatLevel(); + }).orElse(0); } /** @@ -937,7 +938,10 @@ public static void updateCombatTime() { * @return The local player wrapped in an {@link Rs2PlayerModel}. */ public static Rs2PlayerModel getLocalPlayer() { - return getPlayers(player -> player.getId() == Microbot.getClient().getLocalPlayer().getId(), true).findFirst().orElse(null); + return getPlayers(player -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer != null && player.getId() == localPlayer.getId(); + }, true).findFirst().orElse(null); } /** @@ -1000,11 +1004,13 @@ private static Stream getPlayersMatchingCombatCriteria() { */ public static WorldPoint getWorldLocation_Internal(){ return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player player = Microbot.getClient().getLocalPlayer(); + if (player == null) return null; if (Microbot.getClient().getTopLevelWorldView().getScene().isInstance()) { - LocalPoint l = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), Microbot.getClient().getLocalPlayer().getWorldLocation()); + LocalPoint l = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), player.getWorldLocation()); return WorldPoint.fromLocalInstance(Microbot.getClient(), l); } - return Microbot.getClient().getLocalPlayer().getWorldLocation(); + return player.getWorldLocation(); }).orElse(null); } @@ -1164,7 +1170,8 @@ public static boolean isPlayerAreaIntersecting(WorldPoint targetPoint, int targe * @return The {@link LocalPoint} representing the player's current position. */ public static LocalPoint getLocalLocation() { - return Microbot.getClient().getLocalPlayer().getLocalLocation(); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getLocalLocation(); } /** @@ -1527,9 +1534,10 @@ public static int getAnimation() { * @return The pose animation ID of the player. */ public static int getPoseAnimation() { - return Microbot.getClientThread().runOnClientThreadOptional(() -> - Microbot.getClient().getLocalPlayer().getPoseAnimation() - ).orElse(-1); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getPoseAnimation(); + }).orElse(-1); } /** @@ -1726,7 +1734,8 @@ public static boolean hasStaminaActive() { * @return The graphic ID of the local player. */ public static int getGraphicId() { - return Microbot.getClient().getLocalPlayer().getGraphic(); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? -1 : localPlayer.getGraphic(); } /** @@ -1739,7 +1748,8 @@ public static int getGraphicId() { * @return {@code true} if the local player has the specified spot animation, {@code false} otherwise. */ public static boolean hasSpotAnimation(int graphicId) { - return Microbot.getClient().getLocalPlayer().hasSpotAnim(graphicId); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer != null && localPlayer.hasSpotAnim(graphicId); } /** diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java new file mode 100644 index 0000000000..4da36e9660 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java @@ -0,0 +1,71 @@ +package net.runelite.client.plugins.microbot.util.player; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.Scene; +import net.runelite.api.WorldView; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class Rs2PlayerNullLocalPlayerTest { + private final Map original = new HashMap<>(); + private Scene scene; + + @Before + public void setUp() throws Exception { + Client client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + WorldView worldView = mock(WorldView.class); + scene = mock(Scene.class); + replace("client", client); + replace("clientThread", clientThread); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + when(client.getLocalPlayer()).thenReturn(null); + when(client.getTopLevelWorldView()).thenReturn(worldView); + when(worldView.getScene()).thenReturn(scene); + } + + private void replace(String name, Object value) throws Exception { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + original.put(name, field.get(null)); + field.set(null, value); + } + + @After + public void tearDown() throws Exception { + for (Map.Entry entry : original.entrySet()) { + Field field = Microbot.class.getDeclaredField(entry.getKey()); + field.setAccessible(true); + field.set(null, entry.getValue()); + } + } + + @Test + public void worldLocationIsNullWithoutLocalPlayer() { + when(scene.isInstance()).thenReturn(false); + assertNull(Rs2Player.getWorldLocation_Internal()); + when(scene.isInstance()).thenReturn(true); + assertNull(Rs2Player.getWorldLocation_Internal()); + } + + @Test + public void accessorsReturnNoPlayerDefaultsWithoutLocalPlayer() { + assertEquals(-1, Rs2Player.getPoseAnimation()); + assertEquals(0, Rs2Player.getCombatLevel()); + assertNull(Rs2Player.getLocalLocation()); + assertEquals(-1, Rs2Player.getGraphicId()); + assertFalse(Rs2Player.hasSpotAnimation(1)); + } +} From 23c6f350dd9083644ca665374feb1ea02aac4203 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:32:23 +0200 Subject: [PATCH 03/62] fix(actor): return null positions when Rs2ActorModel wraps no actor new Rs2PlayerModel() wraps Client.getLocalPlayer(), which is null during login, world hops and loading. Callers already treat null world location and world view as no player, but the model dereferenced the null actor and threw NPEs from inside the client-thread lambda. Co-Authored-By: Claude Opus 5.5 --- .../microbot/api/actor/Rs2ActorModel.java | 12 ++++ .../Rs2PlayerModelNullLocalPlayerTest.java | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java index 79ed15df01..9b9a7b07bb 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java @@ -24,6 +24,9 @@ public class Rs2ActorModel implements Actor @Override public WorldView getWorldView() { + if (actor == null) { + return null; + } return Microbot.getClientThread().invoke(actor::getWorldView); } @@ -71,6 +74,9 @@ public int getHealthScale() @Override public WorldPoint getWorldLocation() { + if (actor == null) { + return null; + } return Microbot.getClientThread().invoke(() -> { WorldView worldView = actor.getWorldView(); if (worldView != null && !worldView.isTopLevel()) { @@ -83,6 +89,9 @@ public WorldPoint getWorldLocation() @Override public LocalPoint getLocalLocation() { + if (actor == null) { + return null; + } return actor.getLocalLocation(); } @@ -451,6 +460,9 @@ public long getHash() } public WorldPoint projectActorLocationToMainWorld() { + if (actor == null) { + return null; + } WorldPoint actorLocation = actor.getWorldLocation(); WorldView wv = actor.getWorldView(); LocalPoint localPoint = LocalPoint.fromWorld(wv, actorLocation); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java new file mode 100644 index 0000000000..43660953d3 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java @@ -0,0 +1,56 @@ +package net.runelite.client.plugins.microbot.api.player; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class Rs2PlayerModelNullLocalPlayerTest { + private final Map original = new HashMap<>(); + private ClientThread clientThread; + + @Before + public void setUp() throws Exception { + Client client = mock(Client.class); + clientThread = mock(ClientThread.class); + replace("client", client); + replace("clientThread", clientThread); + when(client.getLocalPlayer()).thenReturn(null); + } + + private void replace(String name, Object value) throws Exception { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + original.put(name, field.get(null)); + field.set(null, value); + } + + @After + public void tearDown() throws Exception { + for (Map.Entry entry : original.entrySet()) { + Field field = Microbot.class.getDeclaredField(entry.getKey()); + field.setAccessible(true); + field.set(null, entry.getValue()); + } + } + + @Test + public void positionAccessorsReturnNullWithoutLocalPlayer() { + Rs2PlayerModel player = new Rs2PlayerModel(); + assertNull(player.getWorldLocation()); + assertNull(player.getWorldView()); + assertNull(player.getLocalLocation()); + assertNull(player.projectActorLocationToMainWorld()); + verifyNoInteractions(clientThread); + } +} From e2f7b519f76e5322ab5d6bece7ed6dc40a95ae7e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:49:18 +0200 Subject: [PATCH 04/62] fix(shortestpath): skip minimap path overlay when the minimap clip is unavailable PathMinimapOverlay passed ShortestPathPlugin.getMinimapClipArea() straight to Graphics2D.setClip. That returns null when the minimap draw widget is missing or hidden, which removed the clip and made OverlayRenderer's graphics.getClip().equals(clip) throw a NullPointerException every frame. Co-Authored-By: Claude Opus 5.5 --- .../shortestpath/PathMinimapOverlay.java | 5 +- .../shortestpath/PathMinimapOverlayTest.java | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java index 1f8231026c..d7e4016f08 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java @@ -39,8 +39,11 @@ public Dimension render(Graphics2D graphics) { final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder == null) return null; + Shape minimapClipArea = plugin.getMinimapClipArea(); + if (minimapClipArea == null) return null; + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); - graphics.setClip(plugin.getMinimapClipArea()); + graphics.setClip(minimapClipArea); List pathPoints = pathfinder.getPath(); Color pathColor = pathfinder.isDone() ? plugin.colourPath : plugin.colourPathCalculating; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java new file mode 100644 index 0000000000..b460396f3d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java @@ -0,0 +1,65 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.lang.reflect.Constructor; +import java.util.Collections; +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathMinimapOverlayTest +{ + private Pathfinder previousPathfinder; + + @Before + public void saveStaticPathfinder() + { + previousPathfinder = ShortestPathPlugin.pathfinder; + } + + @After + public void restoreStaticPathfinder() + { + ShortestPathPlugin.pathfinder = previousPathfinder; + } + + @Test + public void hiddenMinimapKeepsGraphicsClip() throws Exception + { + ShortestPathPlugin plugin = mock(ShortestPathPlugin.class); + plugin.drawMinimap = true; + when(plugin.getMinimapClipArea()).thenReturn(null); + + Pathfinder pathfinder = mock(Pathfinder.class); + when(pathfinder.getPath()).thenReturn(Collections.emptyList()); + ShortestPathPlugin.pathfinder = pathfinder; + + Constructor constructor = PathMinimapOverlay.class + .getDeclaredConstructor(Client.class, ShortestPathPlugin.class, ShortestPathConfig.class); + constructor.setAccessible(true); + PathMinimapOverlay overlay = constructor.newInstance(mock(Client.class), plugin, mock(ShortestPathConfig.class)); + + Rectangle clip = new Rectangle(0, 0, 50, 50); + Graphics2D graphics = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB).createGraphics(); + graphics.setClip(clip); + try + { + overlay.render(graphics); + assertNotNull(graphics.getClip()); + assertEquals(clip, graphics.getClip()); + } + finally + { + graphics.dispose(); + } + } +} From f86f0142a7474aabea8ab922e02abb69b1dff5d5 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:58:42 +0200 Subject: [PATCH 05/62] fix(gameobject): return empty when the scene query anchor is null Rs2GameObject's WorldPoint-anchored list queries passed the anchor straight into LocalPoint.fromWorld, which throws when the player's world location is not yet known (login, hop, loading). findBank and the other no-anchor overloads feed Rs2Player.getWorldLocation() into these, so they crashed the calling script instead of finding nothing. Co-Authored-By: Claude Opus 5.5 --- .../util/gameobject/Rs2GameObject.java | 5 ++ .../Rs2GameObjectNullAnchorTest.java | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java index 6f968e72a6..21abdd8c99 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java @@ -727,6 +727,7 @@ public static List getTileObjects(Predicate predicate, L } public static List getTileObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { // POH fix: Rs2Player.getWorldLocation() returns the template tile inside a POH @@ -927,6 +928,7 @@ public static List getGameObjects(Predicate predicate, L } public static List getGameObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { // POH fix: fall back to player's real LocalLocation when the world anchor doesn't @@ -1109,6 +1111,7 @@ public static List getGroundObjects(Predicate predic } public static List getGroundObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { if (Microbot.getClient().getLocalPlayer() != null) { @@ -1297,6 +1300,7 @@ public static List getWallObjects(Predicate predicate, L } public static List getWallObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1485,6 +1489,7 @@ public static List getDecorativeObjects(Predicate getDecorativeObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java new file mode 100644 index 0000000000..991b9db3e5 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java @@ -0,0 +1,59 @@ +package net.runelite.client.plugins.microbot.util.gameobject; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2GameObjectNullAnchorTest { + + private MockedStatic microbot; + private MockedStatic player; + private Client client; + + @Before + public void setUp() { + client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(mock(Player.class)); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + player = Mockito.mockStatic(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenReturn(null); + } + + @After + public void tearDown() { + player.close(); + microbot.close(); + } + + @Test + public void findBankReturnsNullWhenPlayerLocationUnknown() { + assertNull(Rs2GameObject.findBank()); + verify(client, never()).getTopLevelWorldView(); + } + + @Test + public void worldPointQueriesReturnEmptyForNullAnchor() { + WorldPoint anchor = null; + assertTrue(Rs2GameObject.getTileObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getGameObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getGroundObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getWallObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getDecorativeObjects(o -> true, anchor, 10).isEmpty()); + verify(client, never()).getTopLevelWorldView(); + } +} From e8453bf2a4739b283998138e0a034fbc04e69947 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:17:57 +0200 Subject: [PATCH 06/62] fix(antiban): default the play style when action cooldown runs without one Rs2Antiban.actionCooldown() dereferenced playStyle unconditionally, so it threw a NullPointerException whenever usePlayStyle was on but no activity had been set (for example when overwriteScriptSettings makes the script's setup template return before setActivity, or after a forced reset). performActionCooldown now resolves a missing play style the same way setActivity does, from the current activity intensity (EXTREME after a reset) or RANDOM when randomIntervals is enabled. The cooldown timer overlay skips drawing when no play style is set. Co-Authored-By: Claude Opus 5.5 --- .../microbot/util/antiban/AntibanOverlay.java | 7 ++- .../microbot/util/antiban/Rs2Antiban.java | 23 ++++++- .../antiban/Rs2AntibanActionCooldownTest.java | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java index 4ced635ac6..39f9145509 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java @@ -3,6 +3,7 @@ import net.runelite.api.Actor; import net.runelite.api.Point; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.antiban.enums.PlayStyle; import net.runelite.client.ui.overlay.Overlay; import net.runelite.client.ui.overlay.OverlayPosition; import net.runelite.client.ui.overlay.components.ProgressPieComponent; @@ -118,7 +119,11 @@ private void drawTimerPieOverlay(Graphics2D graphics) { // Calculate the remaining time as a fraction of the total time - int totalTime = Rs2Antiban.getPlayStyle().getSecondaryTickInterval(); + PlayStyle playStyle = Rs2Antiban.getPlayStyle(); + if (playStyle == null) { + return; + } + int totalTime = playStyle.getSecondaryTickInterval(); int timeLeft = Rs2Antiban.getTIMEOUT(); float percent = (float) timeLeft / totalTime; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java index a8268a483e..b712ee43a7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java @@ -286,14 +286,31 @@ private static void logDebug(String message) { } } + static PlayStyle resolvePlayStyle() { + PlayStyle current = playStyle; + if (current != null) + return current; + + ActivityIntensity intensity = activityIntensity != null ? activityIntensity : ActivityIntensity.EXTREME; + current = Rs2AntibanSettings.randomIntervals ? PlayStyle.RANDOM : intensity.getPlayStyle(); + current.frequency = intensity.getFrequency(); + current.amplitude = intensity.getAmplitude(); + current.resetPlayStyle(); + playStyle = current; + logDebug("No play style set, defaulting to " + current.getName()); + return current; + } + private static void performActionCooldown() { + PlayStyle current = resolvePlayStyle(); + if (Rs2AntibanSettings.nonLinearIntervals) - playStyle.evolvePlayStyle(); + current.evolvePlayStyle(); if (Rs2AntibanSettings.behavioralVariability) - TIMEOUT = playStyle.getRandomTickInterval(); + TIMEOUT = current.getRandomTickInterval(); else - TIMEOUT = playStyle.getPrimaryTickInterval(); + TIMEOUT = current.getPrimaryTickInterval(); // The pause (universal antiban only) and the always-set active flag both happen only after // TIMEOUT is computed: if computing the interval ever throws, scripts must not be left diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java new file mode 100644 index 0000000000..5cb341875d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.util.antiban; + +import net.runelite.client.plugins.microbot.util.antiban.enums.ActivityIntensity; +import net.runelite.client.plugins.microbot.util.antiban.enums.PlayStyle; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class Rs2AntibanActionCooldownTest { + + @Before + public void setUp() { + Rs2Antiban.resetAntibanSettings(true); + Rs2AntibanSettings.usePlayStyle = true; + Rs2AntibanSettings.actionCooldownChance = 1.0; + Rs2Antiban.setTIMEOUT(0); + } + + @After + public void tearDown() { + Rs2Antiban.resetAntibanSettings(true); + Rs2Antiban.setTIMEOUT(0); + } + + @Test + public void actionCooldownWithoutActivityDefaultsToIntensityPlayStyle() { + Rs2Antiban.actionCooldown(); + + PlayStyle expected = ActivityIntensity.EXTREME.getPlayStyle(); + assertSame(expected, Rs2Antiban.getPlayStyle()); + assertEquals(expected.getPrimaryTickInterval(), Rs2Antiban.getTIMEOUT()); + assertTrue(Rs2AntibanSettings.actionCooldownActive); + } + + @Test + public void actionCooldownWithoutActivityUsesRandomPlayStyleWhenRandomIntervalsEnabled() { + Rs2AntibanSettings.randomIntervals = true; + Rs2AntibanSettings.behavioralVariability = true; + Rs2AntibanSettings.nonLinearIntervals = true; + + Rs2Antiban.actionCooldown(); + + assertSame(PlayStyle.RANDOM, Rs2Antiban.getPlayStyle()); + assertTrue(Rs2Antiban.getTIMEOUT() >= 1); + assertTrue(Rs2AntibanSettings.actionCooldownActive); + } + + @Test + public void actionCooldownKeepsExistingPlayStyle() { + PlayStyle.CAUTIOUS.resetPlayStyle(); + Rs2Antiban.setPlayStyle(PlayStyle.CAUTIOUS); + + Rs2Antiban.actionCooldown(); + + assertSame(PlayStyle.CAUTIOUS, Rs2Antiban.getPlayStyle()); + assertEquals(PlayStyle.CAUTIOUS.getPrimaryTickInterval(), Rs2Antiban.getTIMEOUT()); + } +} From 2c1216b07d3b26b9fe203cfb83551960692b9581 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:27:21 +0200 Subject: [PATCH 07/62] fix(walker): yield instead of NPE when player location is missing at walk tail processWalk's finish check dereferenced Rs2Player.getWorldLocation() directly, so a null location during a world hop or loading screen threw an NPE and the walk ended as an exception. Return MOVING (the same not-ready result walkWithStateInternal already uses) and guard the final canvas click and tail diagnostic the same way. Co-Authored-By: Claude Opus 5.5 --- .../plugins/microbot/util/walker/Rs2Walker.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 4c8d097a4a..2097797aca 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -3186,11 +3186,11 @@ && walkFastCanvas(recoverTarget)) { } } - if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= finishTh) { + WorldPoint finalPlayerLoc = Rs2Player.getWorldLocation(); + if (finalPlayerLoc != null && Rs2Tile.isTileReachable(finalTile) && finalPlayerLoc.distanceTo(finalTile) >= finishTh) { final WorldPoint canvasClickWp = finalTile; - WorldPoint finalPlayerLoc = Rs2Player.getWorldLocation(); boolean finalClick; - if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { + if (rawPath != null && !rawPath.isEmpty()) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, normalMinimapReach() - 1, rawAnchorIndex); @@ -3214,9 +3214,10 @@ && walkFastCanvas(recoverTarget)) { && Rs2Player.isMoving()) { exit = WalkExit.ROUTE_MOVE_IN_FLIGHT; } - WorldPoint pathLastForFinish = path.get(path.size() - 1); - int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); - int finalDist = Rs2Player.getWorldLocation().distanceTo(target); + int finishThreshold = tightFinishThreshold(target, path.get(path.size() - 1), distance); + WorldPoint finishPlayerLoc = Rs2Player.getWorldLocation(); + if (finishPlayerLoc == null) { return WalkerState.MOVING; } + int finalDist = finishPlayerLoc.distanceTo(target); if (finalDist <= finishThreshold) { setTarget(null, "rs2walker:processWalk:arrived-within-distance"); return WalkerState.ARRIVED; @@ -3315,7 +3316,7 @@ && walkFastCanvas(recoverTarget)) { } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", processWalkTail + 1, exit.wireName(offPathDeferDetail), - Rs2Player.getWorldLocation().distanceTo(target), partialPath); + finalDist, partialPath); continue; } } catch (Exception ex) { From 5618c97d5ee60b1c9d0e8fa1b373b263a6432f5b Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:48:43 +0200 Subject: [PATCH 08/62] fix(shortestpath): capture refresh boosted-levels array locally refreshTransports allocated refreshBoostedLevels and then filled it from a client-thread lambda that dereferenced the field. PathfinderConfig.refresh is called from several threads, so a concurrent refresh that finished first reset the field to null before the queued lambda ran, throwing an NPE on the client thread. The lambda and the verification hash now use a local array; the field is still published for hasRequiredLevels. Co-Authored-By: Claude Opus 5.5 --- .../pathfinder/PathfinderConfig.java | 26 +++++----- ...PathfinderConfigRequirementLevelsTest.java | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 4a426e3491..a52ed8760d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -639,17 +639,11 @@ private void refreshTransports(WorldPoint target, boolean inCombat) { } } - refreshBoostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + int[] boostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + refreshBoostedLevels = boostedLevels; Map varplayerValues = new HashMap<>(); Microbot.getClientThread().runOnClientThreadOptional(() -> { - for (int i = 0; i < SKILLS.length; i++) { - refreshBoostedLevels[i] = client.getBoostedSkillLevel(SKILLS[i]); - } - refreshBoostedLevels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); - Player localPlayer = client.getLocalPlayer(); - refreshBoostedLevels[Transport.COMBAT_LEVEL_INDEX] = - localPlayer == null ? 0 : localPlayer.getCombatLevel(); - refreshBoostedLevels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); + readRequirementLevels(client, boostedLevels); for (int id : varbitIds) { Microbot.getVarbitValue(id); } @@ -742,9 +736,9 @@ private void refreshTransports(WorldPoint target, boolean inCombat) { .sorted() .toArray(); int[] sortedSkillOrdinals = requiredSkillOrdinals.stream().mapToInt(Integer::intValue).sorted().toArray(); - int verificationHash = computeTransportRefreshVerificationHash(refreshBoostedLevels, sortedSkillOrdinals, + int verificationHash = computeTransportRefreshVerificationHash(boostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); - int[] verificationComponents = computeTransportRefreshVerificationComponents(refreshBoostedLevels, + int[] verificationComponents = computeTransportRefreshVerificationComponents(boostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); long verifyTime = System.currentTimeMillis() - verifyStart; long captureStart = System.currentTimeMillis(); @@ -1479,6 +1473,16 @@ static boolean meetsRequiredLevels(int[] requiredLevels, int[] currentLevels) { return true; } + static void readRequirementLevels(Client client, int[] levels) { + for (int i = 0; i < SKILLS.length; i++) { + levels[i] = client.getBoostedSkillLevel(SKILLS[i]); + } + levels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); + Player localPlayer = client.getLocalPlayer(); + levels[Transport.COMBAT_LEVEL_INDEX] = localPlayer == null ? 0 : localPlayer.getCombatLevel(); + levels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); + } + private int currentRequirementLevel(int index) { if (index >= 0 && index < SKILLS.length) { return client.getBoostedSkillLevel(SKILLS[index]); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java new file mode 100644 index 0000000000..6560a0077a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java @@ -0,0 +1,49 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.VarPlayer; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathfinderConfigRequirementLevelsTest { + + @Test + public void readsRequirementLevelsIntoCallerOwnedArray() { + Client client = mock(Client.class); + Player player = mock(Player.class); + when(client.getBoostedSkillLevel(any(Skill.class))).thenReturn(50); + when(client.getBoostedSkillLevel(Skill.AGILITY)).thenReturn(73); + when(client.getTotalLevel()).thenReturn(1500); + when(client.getLocalPlayer()).thenReturn(player); + when(player.getCombatLevel()).thenReturn(96); + when(client.getVarpValue(VarPlayer.QUEST_POINTS)).thenReturn(120); + + int[] levels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + PathfinderConfig.readRequirementLevels(client, levels); + + assertEquals(73, levels[Skill.AGILITY.ordinal()]); + assertEquals(50, levels[Skill.ATTACK.ordinal()]); + assertEquals(1500, levels[Transport.TOTAL_LEVEL_INDEX]); + assertEquals(96, levels[Transport.COMBAT_LEVEL_INDEX]); + assertEquals(120, levels[Transport.QUEST_POINTS_INDEX]); + } + + @Test + public void missingLocalPlayerReportsZeroCombatLevel() { + Client client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(null); + + int[] levels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + levels[Transport.COMBAT_LEVEL_INDEX] = 99; + PathfinderConfig.readRequirementLevels(client, levels); + + assertEquals(0, levels[Transport.COMBAT_LEVEL_INDEX]); + } +} From 4f1e6c1c8366ce766f8f199d407de531f216eada Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:56:37 +0200 Subject: [PATCH 09/62] fix(tileitem): return null location when ground item tile lost its world view Co-Authored-By: Claude Opus 5.5 --- .../api/tileitem/models/Rs2TileItemModel.java | 18 ++++++- .../Rs2TileItemModelDetachedTileTest.java | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java index bf25bae6c4..2c8a29e6b7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java @@ -107,11 +107,25 @@ public String getName() { } public WorldPoint getWorldLocation() { - return tile.getWorldLocation(); + if (tile == null) { + return null; + } + try { + return tile.getWorldLocation(); + } catch (NullPointerException ex) { + return null; + } } public LocalPoint getLocalLocation() { - return tile.getLocalLocation(); + if (tile == null) { + return null; + } + try { + return tile.getLocalLocation(); + } catch (NullPointerException ex) { + return null; + } } @Override diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java new file mode 100644 index 0000000000..e56d9d2346 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.api.tileitem; + +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2TileItemModelDetachedTileTest { + + @Test + public void locationAccessorsReturnNullWhenTileWorldViewIsGone() { + Tile tile = mock(Tile.class); + when(tile.getWorldLocation()).thenThrow(new NullPointerException("Cannot invoke \"net.runelite.api.WorldView.getBaseX()\" because \"wv\" is null")); + when(tile.getLocalLocation()).thenThrow(new NullPointerException()); + Rs2TileItemModel model = new Rs2TileItemModel(tile, mock(TileItem.class)); + + assertNull(model.getWorldLocation()); + assertNull(model.getLocalLocation()); + } + + @Test + public void locationAccessorsReturnNullWithoutTile() { + Rs2TileItemModel model = new Rs2TileItemModel(null, mock(TileItem.class)); + + assertNull(model.getWorldLocation()); + assertNull(model.getLocalLocation()); + } + + @Test + public void locationAccessorsDelegateToTile() { + Tile tile = mock(Tile.class); + WorldPoint worldPoint = new WorldPoint(3200, 3200, 0); + LocalPoint localPoint = new LocalPoint(6400, 6400, -1); + when(tile.getWorldLocation()).thenReturn(worldPoint); + when(tile.getLocalLocation()).thenReturn(localPoint); + Rs2TileItemModel model = new Rs2TileItemModel(tile, mock(TileItem.class)); + + assertEquals(worldPoint, model.getWorldLocation()); + assertEquals(localPoint, model.getLocalLocation()); + } +} From 15727188de1c0553bb8bc8c30fdd59ff59680609 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:30:48 +0200 Subject: [PATCH 10/62] docs(entity-guides): show the Optional fallback in the null local player example Co-Authored-By: Claude Opus 5.5 --- docs/entity-guides/worlds.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 995f64dc7d..9bf408dd75 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -30,11 +30,15 @@ int ping = Ping.tcpPing(InetAddress.getByName(world.getAddress())); ```java // Wrong: throws on the client thread whenever the player is not loaded. -return Microbot.getClient().getLocalPlayer().getPoseAnimation(); - -// Right: read once, guard, and fall back to the documented "no player" value. -Player localPlayer = Microbot.getClient().getLocalPlayer(); -return localPlayer == null ? null : localPlayer.getPoseAnimation(); +return Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getLocalPlayer().getPoseAnimation() +).orElse(-1); + +// Right: read once, yield an empty Optional, and let orElse supply the "no player" value. +return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getPoseAnimation(); +}).orElse(-1); ``` **Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`) and any new helper that reads the local player. From dc5909dcce5efaac21ade29af5e6683d1a0149ce Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:38:12 +0200 Subject: [PATCH 11/62] docs(entity-guides): cover Rs2ActorModel position accessors in the null local player gotcha Co-Authored-By: Claude Opus 5.5 --- docs/entity-guides/worlds.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 9bf408dd75..5731e148aa 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -41,6 +41,6 @@ return Microbot.getClientThread().runOnClientThreadOptional(() -> { }).orElse(-1); ``` -**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`) and any new helper that reads the local player. +**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`), the position accessors of `Rs2ActorModel` (`getWorldView`, `getWorldLocation`, `getLocalLocation`, `projectActorLocationToMainWorld`), which `new Rs2PlayerModel()` reaches with a null actor, and any new helper that reads the local player. -**Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest`. +**Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest` and `Rs2PlayerModelNullLocalPlayerTest`. From 107a05fad939411f5c3f95e5c21c8ca3dd3989ae Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:46:05 +0200 Subject: [PATCH 12/62] fix(gameobject): return null from single-object queries when the anchor is null Co-Authored-By: Claude Opus 5.5 --- .../microbot/util/gameobject/Rs2GameObject.java | 5 +++++ .../gameobject/Rs2GameObjectNullAnchorTest.java | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java index 21abdd8c99..47b8e89159 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java @@ -671,6 +671,7 @@ public static TileObject getTileObject(Predicate predicate, LocalPoi } public static TileObject getTileObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { // POH fix: see Rs2GameObject.getGameObject(Predicate, WorldPoint, int). @@ -866,6 +867,7 @@ public static GameObject getGameObject(Predicate predicate, LocalPoi } public static GameObject getGameObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { // POH fix: inside a POH instance, the default anchor passed in by the convenience @@ -1056,6 +1058,7 @@ public static GroundObject getGroundObject(Predicate predicate, Lo } public static GroundObject getGroundObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1245,6 +1248,7 @@ public static WallObject getWallObject(Predicate predicate, LocalPoi } public static WallObject getWallObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1434,6 +1438,7 @@ public static DecorativeObject getDecorativeObject(Predicate p } public static DecorativeObject getDecorativeObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java index 991b9db3e5..1dfb32e7c9 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java @@ -2,8 +2,10 @@ import net.runelite.api.Client; import net.runelite.api.Player; +import net.runelite.api.WorldView; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import org.junit.After; import org.junit.Before; @@ -30,6 +32,9 @@ public void setUp() { when(client.getLocalPlayer()).thenReturn(mock(Player.class)); microbot = Mockito.mockStatic(Microbot.class); microbot.when(Microbot::getClient).thenReturn(client); + Rs2PlayerStateCache stateCache = mock(Rs2PlayerStateCache.class); + when(stateCache.getLocalPlayerWorldView()).thenReturn(mock(WorldView.class)); + microbot.when(Microbot::getRs2PlayerStateCache).thenReturn(stateCache); player = Mockito.mockStatic(Rs2Player.class); player.when(Rs2Player::getWorldLocation).thenReturn(null); } @@ -56,4 +61,15 @@ public void worldPointQueriesReturnEmptyForNullAnchor() { assertTrue(Rs2GameObject.getDecorativeObjects(o -> true, anchor, 10).isEmpty()); verify(client, never()).getTopLevelWorldView(); } + + @Test + public void singleObjectQueriesReturnNullForNullAnchor() { + WorldPoint anchor = null; + assertNull(Rs2GameObject.getTileObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGameObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGroundObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getWallObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getDecorativeObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGameObject(1234, 10)); + } } From 8649f798d3b4ea0e25b43c6da0691b5a00f28416 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:54:22 +0200 Subject: [PATCH 13/62] fix(walker): guard remaining processWalk player-location reads against null Co-Authored-By: Claude Opus 5.5 --- .../microbot/util/walker/Rs2Walker.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 2097797aca..250bed6507 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -2818,11 +2818,11 @@ && walkFastCanvas(recoverTarget)) { break; } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); - int dist2d = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) { exit = WalkExit.PLAYER_LOCATION_NULL; break; } + int dist2d = currentWorldPoint.distanceTo2D(playerLoc); boolean approachPlannedTransportOrigin = shouldApproachPlannedTransportOrigin( - hasExplicitTransportStep(path, i), - currentWorldPoint, - Rs2Player.getWorldLocation(), + hasExplicitTransportStep(path, i), currentWorldPoint, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); if (dist2d > nextWalkingDistance || approachPlannedTransportOrigin) { tmarkPostTransport("post_transport_click_eligibility", target, @@ -2831,7 +2831,6 @@ && walkFastCanvas(recoverTarget)) { // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). - WorldPoint playerLoc = Rs2Player.getWorldLocation(); final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); // Checkpoint-style walking: once we set a minimap flag, let the player actually @@ -2861,7 +2860,7 @@ && walkFastCanvas(recoverTarget)) { } final WorldPoint posBeforeWait = playerLoc; sleepUntil(() -> - interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= interimPreclickTiles() + isPlayerWithin2D(interimFinal, interimPreclickTiles()) || !Rs2Player.isMoving(), INTERIM_MOVING_POLL_MS); WorldPoint posAfterWait = Rs2Player.getWorldLocation(); @@ -3096,7 +3095,7 @@ && walkFastCanvas(recoverTarget)) { // Keep stuck-detection honest: observed movement resets the movement timer. // Without this, isStuckTooLong() fires after long successful walks because // routeState.lastMovedTimeMs is only refreshed at processWalk entry (not during the loop). - if (posBefore.distanceTo2D(Rs2Player.getWorldLocation()) > 0) { + if (hasPlayerMovedFrom2D(posBefore)) { routeState.lastMovedTimeMs = System.currentTimeMillis(); routeState.stuckCount = 0; } @@ -6831,6 +6830,16 @@ public static boolean isNear(WorldPoint target) { return isNear(target, Rs2Player.getWorldLocation()); } + private static boolean isPlayerWithin2D(WorldPoint from, int tiles) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return playerLoc != null && from.distanceTo2D(playerLoc) <= tiles; + } + + private static boolean hasPlayerMovedFrom2D(WorldPoint from) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return playerLoc != null && from.distanceTo2D(playerLoc) > 0; + } + /** Snapshot variant (B2): the walk loop passes its pass-start position instead of re-reading. */ private static boolean isNear(WorldPoint target, WorldPoint playerLoc) { return playerLoc != null && playerLoc.equals(target); From bdd38cfeca69100243cef9b6d08259e92642e869 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:00:41 +0200 Subject: [PATCH 14/62] fix(queryable): skip entities without a location in within() Co-Authored-By: Claude Opus 5.5 --- .../plugins/microbot/api/AbstractEntityQueryable.java | 10 ++++++++-- .../api/AbstractEntityQueryableInterruptionTest.java | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java index d4e74dd460..5ed809726b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java @@ -59,7 +59,10 @@ public Q within(int distance) { } this.source = this.source - .filter(o -> o.getWorldLocation().distanceTo(playerLoc) <= distance); + .filter(o -> { + WorldPoint loc = o.getWorldLocation(); + return loc != null && loc.distanceTo(playerLoc) <= distance; + }); return (Q) this; } @@ -73,7 +76,10 @@ public Q within(WorldPoint anchor, int distance) { } this.source = this.source - .filter(o -> o.getWorldLocation().distanceTo(anchor) <= distance); + .filter(o -> { + WorldPoint loc = o.getWorldLocation(); + return loc != null && loc.distanceTo(anchor) <= distance; + }); return (Q) this; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java index 74693ca566..ad5f8c4ab3 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java @@ -88,6 +88,16 @@ public void nearestByAnchor_nullAnchorReturnsNullEvenWithInterruptibleStream() { assertNull(q.nearest(null, 10)); } + @Test + public void withinAnchor_skipsEntitiesWithoutLocation() { + TestEntity detached = new TestEntity(null); + TestEntity near = new TestEntity(new WorldPoint(3201, 3200, 0)); + + TestQueryable q = new TestQueryable(Stream.of(detached, near)); + java.util.List result = q.within(new WorldPoint(3200, 3200, 0), 5).toList(); + assertTrue(result.size() == 1 && result.get(0) == near); + } + // ---- Test fixtures ------------------------------------------------------- private static class TestQueryable extends AbstractEntityQueryable { From 7d9838afdcb374a5cdeee6c56e44fb74eb49e733 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:38:47 +0200 Subject: [PATCH 15/62] fix(client-thread): log client-thread timeouts as rate-limited warning runOnClientThreadOptional logged every TimeoutException at ERROR with a full stack trace. A single client-thread stall of 10s+ makes every waiting thread (blocking-event validators, script loops, the EDT) log its own ERROR, which is the largest remaining error-telemetry cluster. Callers already treat Optional.empty() as the timeout result. Timeouts now log one WARN without a stack per 30s window and DEBUG otherwise. ExecutionException still logs at ERROR. Timeout duration and return semantics are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../client/callback/ClientThread.java | 22 +++- .../ClientThreadTimeoutLoggingTest.java | 101 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java b/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java index 12ecf848cd..69fd69e722 100644 --- a/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java +++ b/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java @@ -34,6 +34,7 @@ import java.util.Iterator; import java.util.Optional; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import java.util.function.Supplier; @@ -47,6 +48,10 @@ public class ClientThread protected ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); public Future scheduledFuture; + private static final long TIMEOUT_WARNING_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30); + private final AtomicLong lastTimeoutWarningNanos = new AtomicLong(); + long clientThreadTimeoutMillis = 10000; + @Inject private Client client; @@ -85,13 +90,17 @@ public Optional runOnClientThreadOptional(Callable method) { final FutureTask task = new FutureTask<>(method); invoke(task); try { - return Optional.ofNullable(task.get(10000, TimeUnit.MILLISECONDS)); + return Optional.ofNullable(task.get(clientThreadTimeoutMillis, TimeUnit.MILLISECONDS)); } catch (InterruptedException | TimeoutException | ExecutionException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); return Optional.empty(); } task.cancel(true); + if (e instanceof TimeoutException) { + logClientThreadTimeout(); + return Optional.empty(); + } if (!Microbot.isDebug()) { log.error("Exception during task execution: {}: {}\n{}", e.getClass().getSimpleName(), e.getMessage(),e); } @@ -99,6 +108,17 @@ public Optional runOnClientThreadOptional(Callable method) { } } + private void logClientThreadTimeout() { + long now = System.nanoTime(); + long last = lastTimeoutWarningNanos.get(); + if ((last == 0 || now - last >= TIMEOUT_WARNING_INTERVAL_NANOS) && lastTimeoutWarningNanos.compareAndSet(last, now)) { + log.warn("Client thread did not run task within {}ms (caller thread {}); further timeouts suppressed for {}s", + clientThreadTimeoutMillis, Thread.currentThread().getName(), TimeUnit.NANOSECONDS.toSeconds(TIMEOUT_WARNING_INTERVAL_NANOS)); + } else { + log.debug("Client thread did not run task within {}ms (caller thread {})", clientThreadTimeoutMillis, Thread.currentThread().getName()); + } + } + /** * Run a method on the client thread, returning the result. * @param method diff --git a/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java b/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java new file mode 100644 index 0000000000..7c473e26ec --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java @@ -0,0 +1,101 @@ +package net.runelite.client.callback; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.stream.Collectors; +import net.runelite.api.Client; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ClientThreadTimeoutLoggingTest +{ + private ClientThread clientThread; + private Logger logger; + private ListAppender appender; + private volatile Thread gameThread; + + @Before + public void setUp() throws Exception + { + Client client = mock(Client.class); + when(client.isClientThread()).thenAnswer(i -> Thread.currentThread() == gameThread); + clientThread = new ClientThread(); + Field field = ClientThread.class.getDeclaredField("client"); + field.setAccessible(true); + field.set(clientThread, client); + clientThread.clientThreadTimeoutMillis = 20; + + logger = (Logger) LoggerFactory.getLogger(ClientThread.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @After + public void tearDown() + { + logger.detachAppender(appender); + } + + @Test + public void repeatedTimeoutsLogOneWarningWithoutStackAndNoError() + { + for (int i = 0; i < 5; i++) + { + Optional result = clientThread.runOnClientThreadOptional(() -> 1); + assertFalse(result.isPresent()); + } + + assertEquals(0, count(Level.ERROR)); + assertEquals(1, count(Level.WARN)); + ILoggingEvent warning = appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().get(); + assertNull(warning.getThrowableProxy()); + } + + @Test + public void taskFailureStillLogsError() throws Exception + { + clientThread.clientThreadTimeoutMillis = 10000; + gameThread = new Thread(() -> + { + while (!Thread.currentThread().isInterrupted()) + { + clientThread.invoke(); + Thread.onSpinWait(); + } + }); + gameThread.start(); + try + { + Optional result = clientThread.runOnClientThreadOptional(() -> + { + throw new IllegalStateException("boom"); + }); + assertFalse(result.isPresent()); + } + finally + { + gameThread.interrupt(); + gameThread.join(1000); + } + + assertEquals(1, count(Level.ERROR)); + assertEquals(0, count(Level.WARN)); + } + + private long count(Level level) + { + return appender.list.stream().filter(e -> e.getLevel() == level).collect(Collectors.counting()); + } +} From 4cbe7cc36045df4e58deb9aa2c0e078acee63998 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:48:31 +0200 Subject: [PATCH 16/62] fix(microbot): treat interrupted waits as cancellation, not errors Scripts interrupted on stop while waiting on the client thread or the EDT surfaced InterruptedException as ERROR logs and script error telemetry. Log interruption-caused failures at debug in Microbot.log, restore the interrupt flag in showMessage, and skip interrupted root causes in ScriptErrorReporter. Co-Authored-By: Claude Opus 5.5 --- .../client/plugins/microbot/Microbot.java | 19 ++++++- .../diagnostics/ScriptErrorReporter.java | 4 ++ .../microbot/MicrobotLogInterruptTest.java | 54 +++++++++++++++++++ .../diagnostics/ScriptErrorReporterTest.java | 8 +++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java 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 840cd92abd..fc951697a8 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 @@ -464,6 +464,9 @@ public static void showMessage(String message) { } else { SwingUtilities.invokeAndWait(messageRunnable); } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while displaying message {}", message); } catch (Exception ex) { log.error("Error displaying message {}:", message, ex); } @@ -487,6 +490,9 @@ public static void showMessage(String message, int disposeTime) { } else { SwingUtilities.invokeAndWait(messageRunnable); } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while displaying message {}", message); } catch (Exception ex) { log.error("Error displaying message {}:", message, ex); } @@ -714,7 +720,9 @@ public static void log(String message, Level level, Exception ex) { log.warn(message); break; case ERROR: - if (ex != null) { + if (isInterruption(ex)) { + log.debug(message, ex); + } else if (ex != null) { log.error(message, ex); } else { log.error(message); @@ -729,6 +737,15 @@ public static void log(String message, Level level, Exception ex) { } } + static boolean isInterruption(Throwable ex) { + for (Throwable t = ex; t != null; t = t.getCause() == t ? null : t.getCause()) { + if (t instanceof InterruptedException) { + return true; + } + } + return false; + } + /** * Opens a pop‑up interface with the specified title and description. *

diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index ad4424a41d..0f1b5f4498 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -89,6 +89,10 @@ protected void append(ILoggingEvent event) } IThrowableProxy root = rootCause(event.getThrowableProxy()); + if (root != null && InterruptedException.class.getName().equals(root.getClassName())) + { + return; + } List frames = frames(root); String fingerprint = root == null ? event.getLoggerName() + "|" + scrub(event.getMessage()).replaceAll("\\d+", "#") diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java new file mode 100644 index 0000000000..e68525c4f3 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java @@ -0,0 +1,54 @@ +package net.runelite.client.plugins.microbot; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import static org.junit.Assert.assertEquals; + +public class MicrobotLogInterruptTest +{ + private Logger logger; + private ListAppender appender; + private Level previousLevel; + + @Before + public void setUp() + { + logger = (Logger) LoggerFactory.getLogger(Microbot.class); + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @After + public void tearDown() + { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + + @Test + public void interruptedClientThreadWaitIsNotLoggedAsError() + { + Microbot.logStackTrace("AttackNpcScript", new RuntimeException("Interrupted waiting for client thread", new InterruptedException())); + + assertEquals(1, appender.list.size()); + assertEquals(Level.DEBUG, appender.list.get(0).getLevel()); + } + + @Test + public void otherFailuresStillLogError() + { + Microbot.logStackTrace("AttackNpcScript", new IllegalStateException("boom")); + + assertEquals(1, appender.list.size()); + assertEquals(Level.ERROR, appender.list.get(0).getLevel()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index 4e2b75375a..a7b044f932 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -193,6 +193,14 @@ public void requeuesRejectedBatch() assertEquals(2, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); } + @Test + public void ignoresInterruptedWaits() + { + log(Level.ERROR, "AttackNpcScript", new RuntimeException("Interrupted waiting for client thread", new InterruptedException())); + log(Level.ERROR, "Error displaying message {}:", new InterruptedException()); + assertEquals(0, reporter.pendingCount()); + } + @Test public void groupsMessagesThatOnlyDifferInNumbers() { From 403db979b1cb97f03074fa765204312fbc4514e6 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:57:04 +0200 Subject: [PATCH 17/62] fix(questhelper): initialise requirements before skill filtering SkillFiltering read getGeneralRequirements() from helpers whose setupRequirements() had not run yet, so helpers building List.of over requirement fields (CurrentAffairs, ArdougneEasy) threw NPE on the client thread when the quest list refreshed before login setup ran. Co-Authored-By: Claude Opus 5.5 --- .../questhelper/config/SkillFiltering.java | 1 + .../config/SkillFilteringTest.java | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java index 37718a347f..cea6e17aaa 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java @@ -73,6 +73,7 @@ public static boolean passesSkillFilter(QuestHelper questHelper) public static boolean questPassesSkillFilter(QuestHelper questHelper) { + questHelper.initializeRequirements(); List skillsToFilterOut = Arrays.stream(Skill.values()) .filter(skill -> "true".equals(questHelper.getConfigManager().getConfiguration(QuestHelperConfig.QUEST_BACKGROUND_GROUP, "skillfilter" + skill.getName()))) .collect(Collectors.toList()); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java new file mode 100644 index 0000000000..f695620e89 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java @@ -0,0 +1,45 @@ +package net.runelite.client.plugins.microbot.questhelper.config; + +import java.lang.reflect.Field; +import net.runelite.api.Skill; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperConfig; +import net.runelite.client.plugins.microbot.questhelper.helpers.quests.currentaffairs.CurrentAffairs; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SkillFilteringTest +{ + private ConfigManager configManager; + private CurrentAffairs helper; + + @Before + public void setUp() throws Exception + { + configManager = mock(ConfigManager.class); + helper = new CurrentAffairs(); + Field field = QuestHelper.class.getDeclaredField("configManager"); + field.setAccessible(true); + field.set(helper, configManager); + } + + @Test + public void initializesRequirementsBeforeReadingThem() + { + assertTrue(SkillFiltering.questPassesSkillFilter(helper)); + } + + @Test + public void filtersOutQuestRequiringFilteredSkill() + { + when(configManager.getConfiguration(QuestHelperConfig.QUEST_BACKGROUND_GROUP, "skillfilter" + Skill.SAILING.getName())) + .thenReturn("true"); + assertFalse(SkillFiltering.questPassesSkillFilter(helper)); + } +} From e8822faf18469896fb8a68a575170b4b93d9bbfb Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:06:34 +0200 Subject: [PATCH 18/62] fix(plugins): don't register hub plugins whose injector failed to build MicrobotPluginManager.instantiate logged and deleted the jar when Guice failed to create the plugin's child injector, but still returned the plugin. It was then added to PluginManager with a null injector, and MicrobotPluginListPanel.rebuildPluginList hit an NPE in PluginManager.getPluginConfigProxy. Throw PluginInstantiationException instead so loadPlugins records the failure and skips the plugin. Co-Authored-By: Claude Opus 5.5 --- .../externalplugins/MicrobotPluginManager.java | 4 ++-- .../MicrobotPluginManagerHealthTest.java | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java index 140759df90..0436d1ba2a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java @@ -564,7 +564,6 @@ private Plugin instantiate(Collection scannedPlugins, Class claz } catch (com.google.common.util.concurrent.ExecutionError e) { // Guice/Guava wraps NoClassDefFoundError here Throwable cause = e.getCause(); - StartupRecovery.pluginFailed(clazz.getSimpleName(), clazz.getAnnotation(PluginDescriptor.class).version(), "load", e); if (cause instanceof NoClassDefFoundError) { log.error("Missing class while loading plugin {}: {}", clazz.getSimpleName(), cause.toString()); } else { @@ -575,11 +574,12 @@ private Plugin instantiate(Collection scannedPlugins, Class claz if (jar != null) { jar.delete(); } + throw new PluginInstantiationException(e); } catch (Exception ex) { log.error("Incompatible plugin found: " + clazz.getSimpleName()); - StartupRecovery.pluginFailed(clazz.getSimpleName(), clazz.getAnnotation(PluginDescriptor.class).version(), "load", ex); File jar = getPluginJarFile(plugin.getClass().getSimpleName()); jar.delete(); + throw new PluginInstantiationException(ex); } log.debug("Loaded plugin {}", clazz.getSimpleName()); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java index 1c632fcd9a..afe5f2285f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java @@ -46,6 +46,10 @@ public static class JarDisabledFixturePlugin extends Plugin { public static class FutureClientFixturePlugin extends Plugin { } + @PluginDescriptor(name = "Injector Failure Fixture", isExternal = true, version = "1.0.0", minClientVersion = "0.0.1") + public static class InjectorFailureFixturePlugin extends Plugin { + } + private MicrobotPluginManager manager; private PluginManager pluginManager; private ConfigManager configManager; @@ -188,4 +192,11 @@ public void removeLeavesSameNamedPluginFromAnotherClassLoaderRunning() throws Ex verify(pluginManager, never()).remove(corePlugin); verify(configManager).unsetConfiguration(eq("microbotPluginVersions"), eq("plugin.HealthFixturePlugin")); } + + @Test + public void pluginWithoutInjectorIsNotRegistered() throws Exception { + assertTrue(loadPlugins(InjectorFailureFixturePlugin.class).isEmpty()); + + verify(pluginManager, never()).addPlugin(any(Plugin.class)); + } } From 15937912bebc0dca84c1c6e277c85e3129c43ac6 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:14:19 +0200 Subject: [PATCH 19/62] test(plugins): force injector failure regardless of global injector state Co-Authored-By: Claude Opus 5.5 --- .../externalplugins/MicrobotPluginManagerHealthTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java index afe5f2285f..78616bd640 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.externalplugins; import com.google.gson.Gson; +import com.google.inject.Binder; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; import net.runelite.client.events.ExternalPluginsChanged; @@ -48,6 +49,10 @@ public static class FutureClientFixturePlugin extends Plugin { @PluginDescriptor(name = "Injector Failure Fixture", isExternal = true, version = "1.0.0", minClientVersion = "0.0.1") public static class InjectorFailureFixturePlugin extends Plugin { + @Override + public void configure(Binder binder) { + throw new IllegalStateException("injector failure"); + } } private MicrobotPluginManager manager; From 42747bd1f099cc72cfa26dffa5097680204d508a Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:28:31 +0200 Subject: [PATCH 20/62] fix(coords): treat a null wrapped WorldPoint as unreachable in Rs2WorldPoint Rs2Player.getRs2WorldPoint() wraps Rs2Player.getWorldLocation(), which is null during login, hop and loading. pathTo then threw a NullPointerException from getPlane(). pathTo now returns null (no path) for a null source or target, distanceToPath reports Integer.MAX_VALUE, equals/hashCode/toString are null-safe, and Rs2Tile's nearest-walkable lookup returns null instead of reading the plane of a missing player location. Co-Authored-By: Claude Opus 5.5 --- docs/entity-guides/worlds.md | 2 + .../microbot/util/coords/Rs2WorldPoint.java | 11 +++-- .../plugins/microbot/util/tile/Rs2Tile.java | 2 +- .../coords/Rs2WorldPointNullPointTest.java | 46 +++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 5731e148aa..c5baf250df 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -44,3 +44,5 @@ return Microbot.getClientThread().runOnClientThreadOptional(() -> { **Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`), the position accessors of `Rs2ActorModel` (`getWorldView`, `getWorldLocation`, `getLocalLocation`, `projectActorLocationToMainWorld`), which `new Rs2PlayerModel()` reaches with a null actor, and any new helper that reads the local player. **Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest` and `Rs2PlayerModelNullLocalPlayerTest`. + +**Wrapped locations:** `Rs2Player.getRs2WorldPoint()` still returns an `Rs2WorldPoint`, but it can wrap a `null` `WorldPoint` in these states. `pathTo` returns `null` and `distanceToPath` returns `Integer.MAX_VALUE` for it; check `getWorldPoint() != null` before calling `getX()`, `getY()` or `getPlane()`, as in `Rs2WorldPointNullPointTest`. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java index afc1a52ed0..625742cc67 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Slf4j public class Rs2WorldPoint { @@ -52,6 +53,10 @@ public List pathTo(WorldPoint other) public List pathTo(WorldPoint other, boolean fullPath) { + if (worldPoint == null || other == null) + { + return null; + } Client client = Microbot.getClient(); if (getPlane() != other.getPlane()) { @@ -181,19 +186,19 @@ public boolean equals(Object obj) { if (this == obj) return true; if (obj instanceof Rs2WorldPoint) { Rs2WorldPoint other = (Rs2WorldPoint) obj; - return worldPoint.equals(other.worldPoint); + return Objects.equals(worldPoint, other.worldPoint); } return false; } @Override public int hashCode() { - return worldPoint.hashCode(); + return Objects.hashCode(worldPoint); } @Override public String toString() { - return worldPoint.toString(); + return String.valueOf(worldPoint); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 08b7db440a..2451dcf117 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -1031,7 +1031,7 @@ private static Rs2WorldPoint getNearestWalkableTileForObjectInternal(GameObject WorldView topLevelWorldView = Microbot.getClient().getTopLevelWorldView(); // Check if player and object are on the same plane - if (playerLocation.getPlane() != tileObject.getWorldLocation().getPlane()) { + if (playerLocation.getWorldPoint() == null || playerLocation.getPlane() != tileObject.getWorldLocation().getPlane()) { return null; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java new file mode 100644 index 0000000000..837945106b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java @@ -0,0 +1,46 @@ +package net.runelite.client.plugins.microbot.util.coords; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +public class Rs2WorldPointNullPointTest +{ + private static final WorldPoint TARGET = new WorldPoint(3200, 3200, 0); + + @Test + public void pathToReturnsNullWhenWrappedPointIsNull() + { + Rs2WorldPoint point = new Rs2WorldPoint(null); + + assertNull(point.pathTo(TARGET)); + assertNull(point.pathTo(TARGET, true)); + } + + @Test + public void pathToReturnsNullWhenTargetIsNull() + { + assertNull(new Rs2WorldPoint(TARGET).pathTo(null)); + } + + @Test + public void distanceToPathIsUnreachableWhenWrappedPointIsNull() + { + assertEquals(Integer.MAX_VALUE, new Rs2WorldPoint(null).distanceToPath(TARGET)); + } + + @Test + public void objectMethodsTolerateNullWrappedPoint() + { + Rs2WorldPoint empty = new Rs2WorldPoint(null); + + assertEquals(new Rs2WorldPoint(null), empty); + assertNotEquals(new Rs2WorldPoint(TARGET), empty); + assertNotEquals(empty, new Rs2WorldPoint(TARGET)); + assertEquals(0, empty.hashCode()); + assertEquals("null", empty.toString()); + } +} From 4e964750a3675fd9a2ee108653e6d4e871b7d198 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:37:30 +0200 Subject: [PATCH 21/62] fix(magic): fail cast instead of throwing when spell widget is missing MagicAction.getWidgetId/getActions dereferenced the result of the spell name lookup in the spellbook interface without a null check, so Rs2Magic.cast threw an NPE from the walker's teleport-spell transport when the widget could not be resolved. Both now return -1/null and cast/alch treat a missing widget as a failed cast. Co-Authored-By: Claude Opus 5.5 --- .../plugins/microbot/util/magic/Rs2Magic.java | 16 ++-- .../skillcalculator/skills/MagicAction.java | 16 +++- .../magic/Rs2MagicMissingSpellWidgetTest.java | 94 +++++++++++++++++++ 3 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java index 5c3b5bb813..f4d195f5a6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java @@ -28,7 +28,6 @@ import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; -import org.apache.commons.lang3.NotImplementedException; import java.awt.*; import java.awt.event.KeyEvent; @@ -169,19 +168,23 @@ public static boolean cast(MagicAction magicSpell, String option, int identifier menuAction = MenuAction.WIDGET_TARGET; } - if (magicSpell.getWidgetId() == -1) - throw new NotImplementedException("This spell has not been configured yet in the MagicAction.java class"); + int widgetId = magicSpell.getWidgetId(); + Widget spellWidget = widgetId == -1 ? null : Rs2Widget.getWidget(widgetId); + if (spellWidget == null) { + log("Unable to find the spell widget for " + magicSpell.getName()); + return false; + } Microbot.doInvoke(new NewMenuEntry() .option(option) .param0(-1) - .param1(magicSpell.getWidgetId()) + .param1(widgetId) .opcode(menuAction.getId()) .identifier(identifier) .itemId(-1) .target(magicSpell.getName()) , - new Rectangle(Rs2Widget.getWidget(magicSpell.getWidgetId()).getBounds())); + new Rectangle(spellWidget.getBounds())); //Rs2Reflection.invokeMenu(-1, magicSpell.getWidgetId(), menuAction.getId(), 1, -1, "Cast", "" + magicSpell.getName() + "", -1, -1); return true; } @@ -320,7 +323,8 @@ private static void interact(Rs2ItemModel item, Point point, String info) { } private static void alch(MagicAction magicSpell, Rs2ItemModel item, int sleepMin, int sleepMax) { - final Widget spellWidget = Rs2Widget.getWidget(magicSpell.getWidgetId()); + final int widgetId = magicSpell.getWidgetId(); + final Widget spellWidget = widgetId == -1 ? null : Rs2Widget.getWidget(widgetId); if (spellWidget == null) return; final Point point = new Point((int) spellWidget.getBounds().getCenterX(), (int) spellWidget.getBounds().getCenterY()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java index d5081ef8d7..6ed952af55 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java @@ -27,12 +27,12 @@ import lombok.AllArgsConstructor; import lombok.Getter; import net.runelite.api.SpriteID; +import net.runelite.api.widgets.Widget; import net.runelite.client.game.ItemManager; import net.runelite.client.plugins.microbot.util.magic.Rs2Spellbook; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import java.util.Arrays; -import java.util.stream.Collectors; @AllArgsConstructor @Getter @@ -259,10 +259,20 @@ public boolean isMembers(final ItemManager itemManager) } public int getWidgetId() { - return Rs2Widget.findWidget(name, Arrays.stream(Rs2Widget.getWidget(218, 0).getStaticChildren()).collect(Collectors.toList())).getId(); + Widget widget = findSpellWidget(); + return widget == null ? -1 : widget.getId(); } public String[] getActions() { - return Rs2Widget.findWidget(name, Arrays.stream(Rs2Widget.getWidget(218, 0).getStaticChildren()).collect(Collectors.toList())).getActions(); + Widget widget = findSpellWidget(); + return widget == null ? null : widget.getActions(); + } + + private Widget findSpellWidget() { + Widget spellbook = Rs2Widget.getWidget(218, 0); + if (spellbook == null || spellbook.getStaticChildren() == null) { + return null; + } + return Rs2Widget.findWidget(name, Arrays.asList(spellbook.getStaticChildren())); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java new file mode 100644 index 0000000000..5a684fa352 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java @@ -0,0 +1,94 @@ +package net.runelite.client.plugins.microbot.util.magic; + +import net.runelite.api.Client; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.awt.Rectangle; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2MagicMissingSpellWidgetTest { + + private MockedStatic widgets; + private MockedStatic microbot; + private MockedStatic tabs; + private MockedStatic magic; + + @Before + public void setUp() { + widgets = Mockito.mockStatic(Rs2Widget.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(mock(Client.class)); + tabs = Mockito.mockStatic(Rs2Tab.class); + magic = Mockito.mockStatic(Rs2Magic.class, Mockito.CALLS_REAL_METHODS); + magic.when(() -> Rs2Magic.canCast(any(MagicAction.class))).thenReturn(true); + } + + @After + public void tearDown() { + magic.close(); + tabs.close(); + microbot.close(); + widgets.close(); + } + + private void spellbookWithoutMatch() { + Widget root = mock(Widget.class); + when(root.getStaticChildren()).thenReturn(new Widget[]{mock(Widget.class)}); + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(root); + widgets.when(() -> Rs2Widget.findWidget(anyString(), anyList())).thenReturn(null); + } + + @Test + public void widgetLookupReturnsSentinelsWhenSpellWidgetMissing() { + spellbookWithoutMatch(); + assertEquals(-1, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertNull(MagicAction.VARROCK_TELEPORT.getActions()); + } + + @Test + public void widgetLookupReturnsSentinelsWhenSpellbookNotLoaded() { + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(null); + assertEquals(-1, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertNull(MagicAction.VARROCK_TELEPORT.getActions()); + } + + @Test + public void castFailsInsteadOfThrowingWhenSpellWidgetMissing() { + spellbookWithoutMatch(); + assertFalse(Rs2Magic.cast(MagicAction.VARROCK_TELEPORT, "cast", 1)); + microbot.verify(() -> Microbot.doInvoke(any(), any(Rectangle.class)), Mockito.never()); + } + + @Test + public void castFailsWhenResolvedWidgetDisappears() { + Widget root = mock(Widget.class); + Widget spell = mock(Widget.class); + when(spell.getId()).thenReturn(14286870); + when(root.getStaticChildren()).thenReturn(new Widget[]{spell}); + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(root); + widgets.when(() -> Rs2Widget.findWidget(eq("Varrock Teleport"), anyList())).thenReturn(spell); + widgets.when(() -> Rs2Widget.getWidget(anyInt())).thenReturn(null); + assertFalse(Rs2Magic.cast(MagicAction.VARROCK_TELEPORT, "cast", 1)); + microbot.verify(() -> Microbot.doInvoke(any(), any(Rectangle.class)), Mockito.never()); + } +} From e4aa68c9f186618a1465e4ea6be60b61e43319d5 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:46:55 +0200 Subject: [PATCH 22/62] fix(skillcalculator): exclude zero-xp magic actions from calculator Microbot added LUMBRIDGE_HOME_TELEPORT (0 xp) to MagicAction for Rs2Spells and Rs2Walker. The upstream skill calculator divides the needed xp by each action's xp, so opening the Magic calculator or changing the target level threw ArithmeticException: / by zero. Filter zero-xp actions out of the Magic calculator and assert every calculator action grants xp. Co-Authored-By: Claude Opus 5.5 --- .../plugins/skillcalculator/CalculatorType.java | 3 ++- .../skillcalculator/CalculatorTypeTest.java | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java index 32c247edcd..d2818e3edf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java @@ -24,6 +24,7 @@ */ package net.runelite.client.plugins.skillcalculator; +import java.util.Arrays; import javax.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.Getter; @@ -44,7 +45,7 @@ enum CalculatorType PRAYER(Skill.PRAYER, PrayerBonus.values(), PrayerAction.values()), CRAFTING(Skill.CRAFTING, null, CraftingAction.values()), FIREMAKING(Skill.FIREMAKING, FiremakingBonus.values(), FiremakingAction.values()), - MAGIC(Skill.MAGIC, null, MagicAction.values()), + MAGIC(Skill.MAGIC, null, Arrays.stream(MagicAction.values()).filter(action -> action.getXp() > 0).toArray(SkillAction[]::new)), FLETCHING(Skill.FLETCHING, null, FletchingAction.values()), WOODCUTTING(Skill.WOODCUTTING, WoodcuttingBonus.values(), WoodcuttingAction.values()), RUNECRAFT(Skill.RUNECRAFT, RunecraftBonus.values(), RunecraftAction.values()), diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java index 355aa3fa8f..257a6567e4 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java @@ -71,6 +71,21 @@ else if (currentXP == prevXP && currentName.compareTo(prevName) < 0) } } + @Test + public void skillActionsGrantXp() + { + for (final CalculatorType calculatorType : CalculatorType.values()) + { + for (final SkillAction skillAction : calculatorType.getSkillActions()) + { + if (skillAction.getXp() <= 0) + { + fail(calculatorType.getSkill().getName() + " skill action " + skillAction + " grants no xp."); + } + } + } + } + @Test public void testSkillBonusesMutuallyStack() { From d4f37649ed9a07c56834d39a63207beff4c758d4 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:54:34 +0200 Subject: [PATCH 23/62] fix(kourendlibrary): read player location on client thread when toggling hideButton The hideButton config handler called getLocalPlayer().getWorldLocation() inside a SwingUtilities.invokeLater lambda, which throws "must be called on client thread" on the EDT. Compute the region check on the client thread and only touch the toolbar on Swing. Co-Authored-By: Claude Opus 5.5 --- .../kourendlibrary/KourendLibraryPlugin.java | 16 ++- .../KourendLibraryPluginTest.java | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java index 318d34d188..69c2aab352 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java @@ -191,17 +191,13 @@ public void onConfigChanged(ConfigChanged ev) } else if (ev.getKey().equals("hideButton")) { - SwingUtilities.invokeLater(() -> + clientThread.invokeLater(() -> { - if (!config.hideButton()) - { - clientToolbar.addNavigation(navButton); - } - else + Player lp = client.getLocalPlayer(); + boolean inRegion = lp != null && lp.getWorldLocation().getRegionID() == REGION; + SwingUtilities.invokeLater(() -> { - Player lp = client.getLocalPlayer(); - boolean inRegion = lp != null && lp.getWorldLocation().getRegionID() == REGION; - if (inRegion) + if (!config.hideButton() || inRegion) { clientToolbar.addNavigation(navButton); } @@ -209,7 +205,7 @@ else if (ev.getKey().equals("hideButton")) { clientToolbar.removeNavigation(navButton); } - } + }); }); } else if (ev.getKey().equals("showTargetHintArrow")) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java new file mode 100644 index 0000000000..be98c1d2df --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java @@ -0,0 +1,98 @@ +package net.runelite.client.plugins.kourendlibrary; + +import com.google.inject.Guice; +import com.google.inject.testing.fieldbinder.Bind; +import com.google.inject.testing.fieldbinder.BoundFieldModule; +import javax.inject.Inject; +import javax.swing.SwingUtilities; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.overlay.OverlayManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class KourendLibraryPluginTest +{ + @Inject + KourendLibraryPlugin plugin; + + @Mock + @Bind + Client client; + + @Mock + @Bind + ClientThread clientThread; + + @Mock + @Bind + ClientToolbar clientToolbar; + + @Mock + @Bind + Library library; + + @Mock + @Bind + OverlayManager overlayManager; + + @Mock + @Bind + KourendLibraryOverlay overlay; + + @Mock + @Bind + KourendLibraryTutorialOverlay tutorialOverlay; + + @Mock + @Bind + KourendLibraryConfig config; + + @Mock + @Bind + ItemManager itemManager; + + @Before + public void before() + { + Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this); + } + + @Test + public void hideButtonReadsPlayerLocationOnClientThread() throws Exception + { + Player player = mock(Player.class); + when(player.getWorldLocation()).thenReturn(new WorldPoint(1632, 3807, 0)); + when(client.getLocalPlayer()).thenReturn(player); + when(config.hideButton()).thenReturn(true); + + ConfigChanged event = new ConfigChanged(); + event.setGroup(KourendLibraryConfig.GROUP_KEY); + event.setKey("hideButton"); + plugin.onConfigChanged(event); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(clientThread).invokeLater(task.capture()); + SwingUtilities.invokeAndWait(() -> { }); + verify(client, never()).getLocalPlayer(); + + task.getValue().run(); + SwingUtilities.invokeAndWait(() -> { }); + verify(clientToolbar).addNavigation(any()); + } +} From 411da77df06e2f3d75239b7af875c9cb223a3df2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:02:25 +0200 Subject: [PATCH 24/62] fix(world-selector): sort a copy of the cached world list WorldSelectorDialog sorted WorldService's shared WorldResult list in place, which raced with WorldHopperPlugin.pingInitialWorlds iterating the same ArrayList and threw ConcurrentModificationException. Co-Authored-By: Claude Opus 5.5 --- .../client/plugins/microbot/ui/WorldSelectorDialog.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java index aa0fc35419..404fa9e42f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java @@ -211,7 +211,7 @@ private void loadWorlds() { return; } - List worlds = worldResult.getWorlds(); + List worlds = new ArrayList<>(worldResult.getWorlds()); // Sort by world ID worlds.sort(Comparator.comparingInt(World::getId)); From 4f95f0897e8ab84dc8fbafc72c310c130f4d704a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:24:34 +0000 Subject: [PATCH 25/62] chore: remove example plugin and clean up dead code - Delete ExamplePlugin, ExampleScript, ExampleScriptOverlay from production source - Remove EXAMPLE enum entry from MicrobotPluginChoice and its import - Remove stale ExamplePlugin reference from Microbot.getActiveMicrobotPlugins() - Remove commented-out code blocks from MInventorySetupsPlugin and Rs2Walker Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_011cK9PbGbtPxZh6GUueLDNN --- .../client/plugins/microbot/Microbot.java | 5 +- .../breakhandlerv2/MicrobotPluginChoice.java | 5 +- .../microbot/example/ExamplePlugin.java | 45 --- .../microbot/example/ExampleScript.java | 377 ------------------ .../example/ExampleScriptOverlay.java | 88 ---- .../MInventorySetupsPlugin.java | 3 - .../microbot/util/walker/Rs2Walker.java | 1 - 7 files changed, 4 insertions(+), 520 deletions(-) delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java 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 840cd92abd..7bc89bb3c2 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 @@ -841,7 +841,7 @@ public static Injector getInjector() { * This method filters the active plugins managed by the `pluginManager` to include only those whose * package name contains "microbot" (case-insensitive). It further excludes certain plugins based on * their class names, such as "QuestHelperPlugin", "MInventorySetupsPlugin", "MicrobotPlugin", - * "ShortestPathPlugin", "AntibanPlugin", and "ExamplePlugin". + * "ShortestPathPlugin", and "AntibanPlugin". * * @return a list of active plugins belonging to the "microbot" package, excluding the specified plugins. */ @@ -852,8 +852,7 @@ public static List getActiveMicrobotPlugins() { && !x.getClass().getSimpleName().equalsIgnoreCase("MInventorySetupsPlugin") && !x.getClass().getSimpleName().equalsIgnoreCase("MicrobotPlugin") && !x.getClass().getSimpleName().equalsIgnoreCase("ShortestPathPlugin") - && !x.getClass().getSimpleName().equalsIgnoreCase("AntibanPlugin") - && !x.getClass().getSimpleName().equalsIgnoreCase("ExamplePlugin")) + && !x.getClass().getSimpleName().equalsIgnoreCase("AntibanPlugin")) .collect(Collectors.toList()); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java index 98048aeb46..bb5e7910bd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java @@ -5,7 +5,7 @@ import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerPlugin; import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2Plugin; -import net.runelite.client.plugins.microbot.example.ExamplePlugin; + import net.runelite.client.plugins.microbot.inventorysetups.MInventorySetupsPlugin; import net.runelite.client.plugins.microbot.mouserecorder.MouseMacroRecorderPlugin; import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; @@ -22,8 +22,7 @@ public enum MicrobotPluginChoice { MOUSE_MACRO_RECORDER("Mouse Macro Recorder", MouseMacroRecorderPlugin.class), INVENTORY_SETUPS("Inventory Setups", MInventorySetupsPlugin.class), BREAK_HANDLER("BreakHandler (v1)", BreakHandlerPlugin.class), - BREAK_HANDLER_V2("BreakHandler V2", BreakHandlerV2Plugin.class), - EXAMPLE("Example Plugin", ExamplePlugin.class); + BREAK_HANDLER_V2("BreakHandler V2", BreakHandlerV2Plugin.class); private final String displayName; private final Class pluginClass; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java deleted file mode 100644 index 9c6c48cf68..0000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.client.eventbus.Subscribe; -import net.runelite.client.events.ConfigChanged; -import net.runelite.client.plugins.Plugin; -import net.runelite.client.plugins.PluginDescriptor; -import net.runelite.client.ui.overlay.OverlayManager; - -import javax.inject.Inject; -import java.awt.*; - -@PluginDescriptor( - name = PluginDescriptor.Default + "Example Plugin", - description = "Performance test for GameObject composition retrieval", - tags = {"performance", "microbot", "test", "gameobject"}, - enabledByDefault = false -) -@Slf4j -public class ExamplePlugin extends Plugin { - @Inject - ExampleScript exampleScript; - @Inject - ExampleScriptOverlay exampleScriptOverlay; - @Inject - OverlayManager overlayManager; - - - @Override - protected void startUp() throws AWTException { - overlayManager.add(exampleScriptOverlay); - exampleScript.run(); - } - - @Override - protected void shutDown() { - overlayManager.remove(exampleScriptOverlay); - exampleScript.shutdown(); - } - - // on settings change - @Subscribe - public void onConfigChanged(final ConfigChanged event) { - } -} \ No newline at end of file diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java deleted file mode 100644 index 29cfb1c68c..0000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java +++ /dev/null @@ -1,377 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.*; -import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.Script; -import net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache; -import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; -import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; -import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; -import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; -import net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem; -import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; -import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; -import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; -import net.runelite.client.plugins.microbot.util.player.Rs2Player; -import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; -import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; -import net.runelite.client.plugins.skillcalculator.skills.MagicAction; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; - -@Slf4j -public class ExampleScript extends Script { - - private final List results = new ArrayList<>(); - - public boolean run() { - results.clear(); - mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { - try { - if (!Microbot.isLoggedIn()) return; - if (!super.run()) return; - - log.info("========================================"); - log.info(" MICROBOT POST-MERGE SMOKE TEST"); - log.info("========================================"); - - checkClientState(); - checkPlayerState(); - checkWorldViewAndThreading(); - checkNpcCache(); - checkTileObjectCache(); - checkGroundItemCache(); - checkPlayerCache(); - checkInventory(); - checkEquipment(); - checkWidgets(); - checkWalker(); - checkDialogue(); - checkLooting(); - checkHomeTeleport(); - - printSummary(); - shutdown(); - - } catch (Exception ex) { - log.error("[SmokeTest] Unexpected top-level error: ", ex); - shutdown(); - } - }, 0, 600, TimeUnit.MILLISECONDS); - - return true; - } - - private void check(String name, Runnable test) { - try { - test.run(); - results.add(new CheckResult(name, true, null)); - log.info("[PASS] {}", name); - } catch (Exception e) { - results.add(new CheckResult(name, false, e.getMessage())); - log.error("[FAIL] {} - {}", name, e.getMessage()); - } - } - - private void checkClientState() { - log.info("--- Client & Login State ---"); - - check("Microbot.isLoggedIn()", () -> { - assertTrue(Microbot.isLoggedIn(), "expected logged in"); - }); - - check("Client.getLocalPlayer() not null", () -> { - assertNotNull(Microbot.getClient().getLocalPlayer(), "local player is null"); - }); - - check("GameState is LOGGED_IN", () -> { - GameState state = Microbot.getClient().getGameState(); - assertTrue(state == GameState.LOGGED_IN || state == GameState.LOADING, - "expected LOGGED_IN or LOADING, got " + state); - }); - } - - private void checkPlayerState() { - log.info("--- Player State ---"); - - check("Rs2Player.getWorldLocation() valid", () -> { - WorldPoint wp = Rs2Player.getWorldLocation(); - assertNotNull(wp, "world location is null"); - assertTrue(wp.getX() > 0 && wp.getY() > 0, "world location coords are zero: " + wp); - }); - - check("Rs2Player.getLocalLocation() not null", () -> { - LocalPoint lp = Rs2Player.getLocalLocation(); - assertNotNull(lp, "local location is null"); - }); - - check("Rs2Player.getHealthPercentage() in range", () -> { - double hp = Rs2Player.getHealthPercentage(); - assertTrue(hp >= 0 && hp <= 100, "health percentage out of range: " + hp); - }); - - check("Rs2Player.getRealSkillLevel(HITPOINTS) > 0", () -> { - int level = Rs2Player.getRealSkillLevel(Skill.HITPOINTS); - assertTrue(level > 0, "hitpoints level is " + level); - }); - - check("Rs2Player.isAnimating() callable", () -> { - Rs2Player.isAnimating(); - }); - - check("Rs2Player.isMoving() callable", () -> { - Rs2Player.isMoving(); - }); - - check("Rs2Player.isInCombat() callable", () -> { - Rs2Player.isInCombat(); - }); - } - - private void checkWorldViewAndThreading() { - log.info("--- WorldView & Threading ---"); - - check("TopLevelWorldView not null", () -> { - WorldView wv = Microbot.getClient().getTopLevelWorldView(); - assertNotNull(wv, "top level world view is null"); - }); - - check("Player WorldView is top level", () -> { - Player player = Microbot.getClient().getLocalPlayer(); - assertNotNull(player, "local player is null"); - WorldView wv = Microbot.getClientThread().invoke(() -> player.getWorldView()); - assertNotNull(wv, "player world view is null"); - assertTrue(wv.isTopLevel(), "player world view is not top level"); - }); - - check("ClientThread.invoke() works", () -> { - String result = Microbot.getClientThread().invoke(() -> "ok"); - assertTrue("ok".equals(result), "invoke returned unexpected value: " + result); - }); - } - - private void checkNpcCache() { - log.info("--- NPC Cache ---"); - - check("Rs2NpcCache.getStream() not null", () -> { - Stream stream = Microbot.getRs2NpcCache().getStream(); - assertNotNull(stream, "npc stream is null"); - }); - - check("Rs2NpcCache stream has entries", () -> { - long count = Microbot.getRs2NpcCache().getStream().count(); - assertTrue(count > 0, "no NPCs in cache (are there NPCs nearby?)"); - }); - - check("First NPC has valid world location", () -> { - Rs2NpcModel npc = Microbot.getRs2NpcCache().getStream().findFirst().orElse(null); - assertNotNull(npc, "no NPC found"); - WorldPoint wp = npc.getWorldLocation(); - assertNotNull(wp, "NPC world location is null"); - }); - - check("Rs2NpcCache.query().nearest() works", () -> { - Microbot.getRs2NpcCache().query().nearest(); - }); - } - - private void checkTileObjectCache() { - log.info("--- Tile Object Cache ---"); - - check("Rs2TileObjectCache.getStream() not null", () -> { - Stream stream = Microbot.getRs2TileObjectCache().getStream(); - assertNotNull(stream, "tile object stream is null"); - }); - - check("Rs2TileObjectCache stream has entries", () -> { - long count = Microbot.getRs2TileObjectCache().getStream().count(); - assertTrue(count > 0, "no tile objects in cache"); - }); - - check("First TileObject has valid ID", () -> { - Rs2TileObjectModel obj = Microbot.getRs2TileObjectCache().getStream().findFirst().orElse(null); - assertNotNull(obj, "no tile object found"); - assertTrue(obj.getId() > 0, "tile object ID is " + obj.getId()); - }); - - check("Rs2TileObjectCache.query().nearest() works", () -> { - Microbot.getRs2TileObjectCache().query().nearest(); - }); - } - - private void checkGroundItemCache() { - log.info("--- Ground Item Cache ---"); - - check("Rs2TileItemCache.getStream() callable", () -> { - Microbot.getRs2TileItemCache().getStream(); - }); - } - - private void checkPlayerCache() { - log.info("--- Player Cache ---"); - - check("Rs2PlayerCache.getStream() callable", () -> { - Microbot.getRs2PlayerCache().getStream(); - }); - } - - private void checkInventory() { - log.info("--- Inventory ---"); - - check("Rs2Inventory.all() returns list", () -> { - List items = Rs2Inventory.all(); - assertNotNull(items, "inventory all() returned null"); - }); - - check("Rs2Inventory.count() >= 0", () -> { - int count = Rs2Inventory.count(); - assertTrue(count >= 0, "inventory count is negative: " + count); - }); - - check("Rs2Inventory.isFull() callable", () -> { - Rs2Inventory.isFull(); - }); - - check("Rs2Inventory.isEmpty() callable", () -> { - Rs2Inventory.isEmpty(); - }); - } - - private void checkEquipment() { - log.info("--- Equipment ---"); - - check("Rs2Equipment.equipment() callable", () -> { - Microbot.getClientThread().invoke(() -> Rs2Equipment.equipment()); - }); - - check("Rs2Equipment.isWearing() callable", () -> { - Rs2Equipment.isWearing(); - }); - } - - private void checkWidgets() { - log.info("--- Widgets ---"); - - check("Rs2Widget.getWidget(149, 0) callable", () -> { - Rs2Widget.getWidget(149, 0); - }); - } - - private void checkWalker() { - log.info("--- Walker ---"); - - check("Rs2Walker.canReach(own location) returns true", () -> { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - assertNotNull(playerLoc, "player location is null"); - boolean reachable = Rs2Walker.canReach(playerLoc); - assertTrue(reachable, "player cannot reach own tile"); - }); - } - - private void checkDialogue() { - log.info("--- Dialogue ---"); - - check("Rs2Dialogue.isInDialogue() callable", () -> { - Rs2Dialogue.isInDialogue(); - }); - } - - private void checkHomeTeleport() { - log.info("--- Home Teleport ---"); - - check("Home teleport starts animation", () -> { - boolean cast = Rs2Magic.cast(MagicAction.LUMBRIDGE_HOME_TELEPORT); - assertTrue(cast, "Rs2Magic.cast(LUMBRIDGE_HOME_TELEPORT) returned false"); - boolean animating = sleepUntil(Rs2Player::isAnimating, 3000); - assertTrue(animating, "player did not start home teleport animation"); - }); - } - - private void checkLooting() { - log.info("--- Looting ---"); - - Rs2ItemModel itemToDrop = Rs2Inventory.all().stream().findFirst().orElse(null); - if (itemToDrop == null) { - log.warn("[SKIP] Looting - inventory is empty, nothing to drop and loot"); - results.add(new CheckResult("Drop and loot item", false, "inventory is empty, cannot test looting")); - return; - } - - String itemName = itemToDrop.getName(); - int itemId = itemToDrop.getId(); - int countBefore = Rs2Inventory.count(itemId); - log.info("[Loot] Testing with item: {} (id={}, count={})", itemName, itemId, countBefore); - - check("Drop item from inventory", () -> { - boolean dropped = Rs2Inventory.drop(itemId); - assertTrue(dropped, "Rs2Inventory.drop() returned false"); - boolean disappeared = sleepUntil(() -> Rs2Inventory.count(itemId) < countBefore, 5000); - assertTrue(disappeared, "item did not leave inventory after drop"); - }); - - int countAfterDrop = Rs2Inventory.count(itemId); - - check("Loot item from ground", () -> { - boolean itemAppeared = sleepUntil(() -> Rs2GroundItem.exists(itemName, 5), 5000); - assertTrue(itemAppeared, "item did not appear on ground after drop"); - sleepUntil(() -> !Rs2Player.isAnimating() && !Rs2Player.isMoving(), 5000); - boolean looted = Rs2GroundItem.loot(itemName, 5); - assertTrue(looted, "Rs2GroundItem.loot() returned false"); - boolean pickedUp = sleepUntil(() -> Rs2Inventory.count(itemId) > countAfterDrop, 5000); - assertTrue(pickedUp, "item did not return to inventory after loot"); - }); - } - - private void printSummary() { - long passed = results.stream().filter(r -> r.passed).count(); - long failed = results.stream().filter(r -> !r.passed).count(); - long total = results.size(); - - log.info("========================================"); - log.info(" SMOKE TEST RESULTS: {}/{} PASSED", passed, total); - log.info("========================================"); - - if (failed > 0) { - log.warn("FAILURES:"); - results.stream() - .filter(r -> !r.passed) - .forEach(r -> log.warn(" [FAIL] {} - {}", r.name, r.error)); - } else { - log.info("All checks passed!"); - } - - log.info("========================================"); - } - - private static void assertTrue(boolean condition, String message) { - if (!condition) throw new AssertionError(message); - } - - private static void assertNotNull(Object obj, String message) { - if (obj == null) throw new AssertionError(message); - } - - private static class AssertionError extends RuntimeException { - AssertionError(String message) { - super(message); - } - } - - private static class CheckResult { - final String name; - final boolean passed; - final String error; - - CheckResult(String name, boolean passed, String error) { - this.name = name; - this.passed = passed; - this.error = error; - } - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java deleted file mode 100644 index 5549588731..0000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java +++ /dev/null @@ -1,88 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import net.runelite.api.Client; -import net.runelite.api.Perspective; -import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import net.runelite.client.plugins.microbot.util.reachable.Rs2Reachable; -import net.runelite.client.ui.overlay.Overlay; -import net.runelite.client.ui.overlay.OverlayLayer; -import net.runelite.client.ui.overlay.OverlayPosition; -import net.runelite.client.ui.overlay.OverlayUtil; - -import javax.inject.Inject; -import java.awt.*; -import java.util.ArrayList; -import java.util.List; - -public class ExampleScriptOverlay extends Overlay { - - private static final Color REACHABLE_COLOR = new Color(0, 255, 0, 50); - private static final Color REACHABLE_BORDER_COLOR = new Color(0, 255, 0, 150); - - @Inject - private Client client; - - @Inject - public ExampleScriptOverlay() { - setPosition(OverlayPosition.DYNAMIC); - setLayer(OverlayLayer.ABOVE_SCENE); - } - - @Override - public Dimension render(Graphics2D graphics) { - if (!Microbot.isLoggedIn()) - { - return null; - } - - WorldPoint playerLocation = WorldPoint.fromLocalInstance(client, client.getLocalPlayer().getLocalLocation()); - if (playerLocation == null) - { - return null; - } - - // Get reachable tiles as packed ints - var reachablePacked = Rs2Reachable.getReachableTiles(playerLocation); - if (reachablePacked == null || reachablePacked.isEmpty()) - { - return null; - } - - // Convert packed ints back to world points in current plane/instance - List reachableWorldPoints = new ArrayList<>(); - for (int packed : reachablePacked) - { - WorldPoint wp = WorldPointUtil.unpackWorldPoint(packed); - if (wp.getPlane() == playerLocation.getPlane()) - { - reachableWorldPoints.add(wp); - } - } - - for (WorldPoint worldPoint : reachableWorldPoints) - { - LocalPoint localPoint = LocalPoint.fromWorld(client, worldPoint); - if (localPoint == null) - { - continue; - } - - Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); - if (poly == null) - { - continue; - } - - graphics.setColor(REACHABLE_COLOR); - graphics.fillPolygon(poly); - - graphics.setColor(REACHABLE_BORDER_COLOR); - graphics.drawPolygon(poly); - } - - return null; - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java index 0186d53447..8848883603 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java @@ -277,9 +277,6 @@ public void startUp() // get current version of the plugin using properties file generated by build.gradle try { -// final Properties props = new Properties(); -// InputStream is = MInventorySetupsPlugin.class.getResourceAsStream("/invsetups_version.txt"); -// props.load(is); this.currentVersion = "1.20.2"; } catch (Exception e) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 4c8d097a4a..2e8480e49e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -3900,7 +3900,6 @@ public static void walkFastLocal(LocalPoint localPoint) { Microbot.doInvoke(entry, new Rectangle(1, 1, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight())); - //Rs2Reflection.invokeMenu(canvasX, canvasY, MenuAction.WALK.getId(), 0, -1, "Walk here", "", -1, -1); } public static boolean walkFastCanvas(WorldPoint worldPoint) { From 6c768e0c0936bc8837d28759fc93b49e8bb997da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:35:17 +0000 Subject: [PATCH 26/62] chore: remove dead loot-selling chain and NotImplemented stub Microbot.getAggregateLootRecords() always returned an empty list, so Rs2Bank.withdrawLootItems() deposited the whole inventory and withdrew nothing, and Rs2GrandExchange.sellLoot() could never sell. Remove the chain, the three deprecated dummy loot methods, the throwing Rs2Inventory.isForceNoShift() stub, and unused imports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011cK9PbGbtPxZh6GUueLDNN --- .../client/plugins/microbot/Microbot.java | 23 -------- .../plugins/microbot/util/bank/Rs2Bank.java | 54 ------------------- .../util/grandexchange/Rs2GrandExchange.java | 18 ------- .../microbot/util/inventory/Rs2Inventory.java | 10 ---- .../microbot/util/npc/Rs2NpcModel.java | 1 - .../microbot/util/player/Rs2PlayerModel.java | 1 - .../client-thread-guardrail-baseline.txt | 2 - 7 files changed, 109 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 7bc89bb3c2..69b921e5fc 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 @@ -33,7 +33,6 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginInstantiationException; import net.runelite.client.plugins.PluginManager; -import net.runelite.client.plugins.loottracker.LootTrackerRecord; import net.runelite.client.plugins.microbot.configs.SpecialAttackConfigs; import net.runelite.client.plugins.microbot.pouch.PouchScript; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; @@ -653,28 +652,6 @@ public static void click(Rectangle rectangle) { } } - @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) - public static List getAggregateLootRecords() { - return new ArrayList<>(); - } - - @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) - public static LootTrackerRecord getAggregateLootRecords(String npcName) { - return null; - } - - /** - * Calculates the total GE value of loot records for a specific NPC. - * This method uses reflection to access private methods and fields of the LootTrackerItem class. - * - * @param npcName name of the npc to get the loot records for - * @return total GE value of the loot records - */ - @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) - public static long getAggregateLootRecordsTotalGevalue(String npcName) { - return 0; - } - /** * Logs the stack trace of an exception to the console and chat. * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 0caa503509..1235f16b19 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -14,8 +14,6 @@ import net.runelite.api.widgets.Widget; import net.runelite.client.config.RuneScapeProfileType; import net.runelite.client.plugins.bank.BankPlugin; -import net.runelite.client.plugins.loottracker.LootTrackerItem; -import net.runelite.client.plugins.loottracker.LootTrackerRecord; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; @@ -2933,58 +2931,6 @@ public static boolean depositLootingBag(){ } - /** - * Withdraw items from the lootTrackerPlugin - * - * @param npcName the name of the npc - * @return {@code true} if successfully withdrew loot items, otherwise {@code false} - */ - public static boolean withdrawLootItems(String npcName, List itemsToNotSell) { - boolean isAtGe = Rs2GrandExchange.walkToGrandExchange(); - if (isAtGe) { - boolean isBankOpen = Rs2Bank.openBank(); - if (!isBankOpen) return false; - } - Rs2Bank.depositAll(); - boolean itemFound = false; - - boolean hasWithdrawAsNote = Rs2Bank.setWithdrawAsNote(); - if (!hasWithdrawAsNote) return false; - for (LootTrackerRecord lootTrackerRecord : Microbot.getAggregateLootRecords()) { - if (!lootTrackerRecord.getTitle().equalsIgnoreCase(npcName)) continue; - for (LootTrackerItem lootTrackerItem : lootTrackerRecord.getItems()) { - if (itemsToNotSell.stream().anyMatch(x -> x.trim().equalsIgnoreCase(lootTrackerItem.getName()))) - continue; - int itemId = lootTrackerItem.getId(); - ItemComposition itemComposition = Microbot.getClientThread().runOnClientThreadOptional(() -> - Microbot.getClient().getItemDefinition(lootTrackerItem.getId())).orElse(null); - if (itemComposition == null) return false; - if (Arrays.stream(itemComposition.getInventoryActions()).anyMatch(x -> x != null && x.equalsIgnoreCase("eat"))) - continue; - final boolean isNoted = itemComposition.getNote() == 799; - if (!itemComposition.isTradeable() && !isNoted) continue; - - if (isNoted) { - final int unnotedItemId = lootTrackerItem.getId() - 1; //get the unnoted id of the item - itemComposition = Microbot.getClientThread().runOnClientThreadOptional(() -> - Microbot.getClient().getItemDefinition(unnotedItemId)).orElse(null); - if (itemComposition == null) { - return false; - } - if (!itemComposition.isTradeable()) continue; - itemId = unnotedItemId; - } - - boolean didWithdraw = Rs2Bank.withdrawAll(itemId); - if (didWithdraw) { - itemFound = true; - } - } - } - Rs2Bank.closeBank(); - return itemFound; - } - private static Widget getBankSizeWidget() { return Rs2Widget.getWidget(ComponentID.BANK_ITEM_COUNT_TOP); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java index ec6085b4da..02e640cb27 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java @@ -756,24 +756,6 @@ public static boolean collectAllToBank() { return collectAll(true); } - /** - * sells all the tradeable loot items from a specific npc name - * - * @param npcName - * @return true if there is no more loot to sell - */ - public static boolean sellLoot(String npcName, List itemsToNotSell) { - - boolean withdrewLootItems = Rs2Bank.withdrawLootItems(npcName, itemsToNotSell); - - if (withdrewLootItems) { - return sellInventory(); - } - - - return false; - } - /** * Attempts to sell all tradeable items currently in the player's inventory on the Grand Exchange. *

diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java index f6372422d3..fde57b87b4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java @@ -27,7 +27,6 @@ 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 org.apache.commons.lang3.NotImplementedException; import org.slf4j.event.Level; import java.awt.*; @@ -1330,15 +1329,6 @@ public static boolean isEmpty() { return items().findAny().isEmpty(); } - /** - * Checks whether the inventory is configured to ignore whether shift interactions are enabled or not. - * - * @return True if the inventory ignores shift interactions, false otherwise. - */ - public static boolean isForceNoShift() { - throw new NotImplementedException("TODO"); - } - /** * Determines whether the inventory is full (all slots are occupied). * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java index 8f04373be2..9f3a1cf7b9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java @@ -10,7 +10,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.IEntity; import net.runelite.client.plugins.microbot.util.ActorModel; -import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.Nullable; import java.util.Arrays; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java index 51d62a5cfa..7bb4ef41f0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java @@ -8,7 +8,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.IEntity; import net.runelite.client.plugins.microbot.util.ActorModel; -import org.apache.commons.lang3.NotImplementedException; @Getter public class Rs2PlayerModel extends ActorModel implements Player { diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 9103011412..02163c2b2c 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -113,8 +113,6 @@ net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemConta net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.ItemContainer#getItems(): Item[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getContainerId(): int net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getItemContainer(): ItemContainer -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#getNote(): int -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#isTradeable(): boolean net.runelite.client.plugins.microbot.util.camera.Rs2Camera#getZoom(): int -> net.runelite.api.Client#getVarcIntValue(int): int net.runelite.client.plugins.microbot.util.camera.Rs2Camera#isTileOnScreen(LocalPoint): boolean -> net.runelite.api.Client#getPlane(): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#getWeaponAttackStyle(): String -> net.runelite.api.EnumComposition#getIntValue(int): int From fe8f08ce0da2a22296f57d5ccd5a128764960824 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:54:24 +0200 Subject: [PATCH 27/62] fix(plugins): keep loading other plugins when one throws a linkage error Catch Throwable (rethrowing ThreadDeath) when building a plugin's injector so a raw NoClassDefFoundError from a hub plugin becomes a PluginInstantiationException instead of aborting the rest of the batch. Test loads failing plugins next to a healthy one with a real parent injector so it no longer depends on global injector state. Co-Authored-By: Claude Opus 5.5 --- .../MicrobotPluginManager.java | 4 ++- .../MicrobotPluginManagerHealthTest.java | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java index 0436d1ba2a..3593f69aab 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java @@ -575,7 +575,9 @@ private Plugin instantiate(Collection scannedPlugins, Class claz jar.delete(); } throw new PluginInstantiationException(e); - } catch (Exception ex) { + } catch (ThreadDeath e) { + throw e; + } catch (Throwable ex) { log.error("Incompatible plugin found: " + clazz.getSimpleName()); File jar = getPluginJarFile(plugin.getClass().getSimpleName()); jar.delete(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java index 78616bd640..b137917e0c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java @@ -2,6 +2,9 @@ import com.google.gson.Gson; import com.google.inject.Binder; +import com.google.inject.Guice; +import com.google.inject.Injector; +import net.runelite.client.RuneLite; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; import net.runelite.client.events.ExternalPluginsChanged; @@ -55,6 +58,14 @@ public void configure(Binder binder) { } } + @PluginDescriptor(name = "Linkage Failure Fixture", isExternal = true, version = "1.0.0", minClientVersion = "0.0.1") + public static class LinkageFailureFixturePlugin extends Plugin { + @Override + public void configure(Binder binder) { + throw new NoClassDefFoundError("missing/ApiClass"); + } + } + private MicrobotPluginManager manager; private PluginManager pluginManager; private ConfigManager configManager; @@ -204,4 +215,21 @@ public void pluginWithoutInjectorIsNotRegistered() throws Exception { verify(pluginManager, never()).addPlugin(any(Plugin.class)); } + + @Test + public void failingPluginDoesNotStopOthersFromLoading() throws Exception { + Injector previous = RuneLite.getInjector(); + RuneLite.setInjector(Guice.createInjector()); + try { + List loaded = loadPlugins(InjectorFailureFixturePlugin.class, LinkageFailureFixturePlugin.class, HealthFixturePlugin.class); + + assertEquals(1, loaded.size()); + assertTrue(loaded.get(0) instanceof HealthFixturePlugin); + verify(pluginManager).addPlugin(loaded.get(0)); + verify(pluginManager, never()).addPlugin(any(InjectorFailureFixturePlugin.class)); + verify(pluginManager, never()).addPlugin(any(LinkageFailureFixturePlugin.class)); + } finally { + RuneLite.setInjector(previous); + } + } } From c46a74a5f775501055cb795cd38dc66c7f0e205c Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:07:58 +0200 Subject: [PATCH 28/62] fix(magic): fall back to the sprite lookup canCast uses when the spell name does not match Co-Authored-By: Claude Opus 5.5 --- .../skillcalculator/skills/MagicAction.java | 14 ++++++++++++-- .../magic/Rs2MagicMissingSpellWidgetTest.java | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java index 6ed952af55..94fc1a3f20 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java @@ -270,9 +270,19 @@ public String[] getActions() { private Widget findSpellWidget() { Widget spellbook = Rs2Widget.getWidget(218, 0); - if (spellbook == null || spellbook.getStaticChildren() == null) { + if (spellbook != null && spellbook.getStaticChildren() != null) { + Widget byName = Rs2Widget.findWidget(name, Arrays.asList(spellbook.getStaticChildren())); + if (byName != null) { + return byName; + } + } + Widget spellList = Rs2Widget.getWidget(218, 3); + if (spellList == null || spellList.getStaticChildren() == null) { return null; } - return Rs2Widget.findWidget(name, Arrays.asList(spellbook.getStaticChildren())); + return Arrays.stream(spellList.getStaticChildren()) + .filter(child -> child != null && child.getSpriteId() == sprite) + .findFirst() + .orElse(null); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java index 5a684fa352..aab11c1d58 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java @@ -91,4 +91,20 @@ public void castFailsWhenResolvedWidgetDisappears() { assertFalse(Rs2Magic.cast(MagicAction.VARROCK_TELEPORT, "cast", 1)); microbot.verify(() -> Microbot.doInvoke(any(), any(Rectangle.class)), Mockito.never()); } + + @Test + public void widgetLookupFallsBackToSpriteWhenNameDoesNotMatch() { + spellbookWithoutMatch(); + Widget spellList = mock(Widget.class); + Widget other = mock(Widget.class); + when(other.getSpriteId()).thenReturn(-1); + Widget spell = mock(Widget.class); + when(spell.getSpriteId()).thenReturn(MagicAction.VARROCK_TELEPORT.getSprite()); + when(spell.getId()).thenReturn(14286870); + when(spell.getActions()).thenReturn(new String[]{"Cast"}); + when(spellList.getStaticChildren()).thenReturn(new Widget[]{other, spell}); + widgets.when(() -> Rs2Widget.getWidget(218, 3)).thenReturn(spellList); + assertEquals(14286870, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertEquals("Cast", MagicAction.VARROCK_TELEPORT.getActions()[0]); + } } From 31176def376c9620a613fb6b4eef706b6c07d546 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:22:44 +0200 Subject: [PATCH 29/62] fix(kourendlibrary): read player location on client thread when toggling showTargetHintArrow Co-Authored-By: Claude Opus 5.5 --- .../kourendlibrary/KourendLibraryPlugin.java | 11 +++++++---- .../KourendLibraryPluginTest.java | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java index 69c2aab352..e114fc4241 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java @@ -210,12 +210,15 @@ else if (ev.getKey().equals("hideButton")) } else if (ev.getKey().equals("showTargetHintArrow")) { - if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation().getRegionID() != REGION) + clientThread.invokeLater(() -> { - return; - } + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation().getRegionID() != REGION) + { + return; + } - updateBookcaseHintArrow(); + updateBookcaseHintArrow(); + }); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java index be98c1d2df..9d3d86317b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java @@ -95,4 +95,22 @@ public void hideButtonReadsPlayerLocationOnClientThread() throws Exception SwingUtilities.invokeAndWait(() -> { }); verify(clientToolbar).addNavigation(any()); } + + @Test + public void showTargetHintArrowReadsPlayerLocationOnClientThread() + { + when(client.getLocalPlayer()).thenReturn(null); + + ConfigChanged event = new ConfigChanged(); + event.setGroup(KourendLibraryConfig.GROUP_KEY); + event.setKey("showTargetHintArrow"); + plugin.onConfigChanged(event); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(clientThread).invokeLater(task.capture()); + verify(client, never()).getLocalPlayer(); + + task.getValue().run(); + verify(client).getLocalPlayer(); + } } From 26c4342b373a48d200bc19232ab6cd9ade28773c Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Fri, 9 Oct 2026 21:37:48 -0700 Subject: [PATCH 30/62] fix(magic): account for blighted ice sacks in rune checks --- .../plugins/microbot/util/magic/Rs2Magic.java | 31 ++++++++++- .../util/magic/Rs2MagicBlightedSacksTest.java | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java index f4d195f5a6..2fb3e07999 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java @@ -23,6 +23,7 @@ import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; import net.runelite.client.plugins.microbot.util.player.Rs2PlayerModel; import net.runelite.client.plugins.microbot.util.settings.Rs2SpellBookSettings; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; @@ -591,7 +592,33 @@ public static Map getMissingRunes(Map reqRunes) } public static Map getMissingRunes(Spell spell, int casts, RuneFilter runeFilter) { - return getMissingRunes(getRequiredRunes(spell, casts), runeFilter); + int sacks = 0; + boolean inWilderness = Rs2Pvp.isInWilderness(); + if (inWilderness && isBlightedIceSpell(spell)) { + if (runeFilter.isIncludeInventory()) sacks += Rs2Inventory.itemQuantity(ItemID.BLIGHTED_SACK_ICEBARRAGE); + if (runeFilter.isIncludeBank()) sacks += Rs2Bank.count(ItemID.BLIGHTED_SACK_ICEBARRAGE); + } + int runeCasts = runeCastsAfterIceSacks(spell, casts, sacks, inWilderness); + return getMissingRunes(getRequiredRunes(spell, runeCasts), runeFilter); + } + + static boolean isBlightedIceSpell(Spell spell) { + if (spell == null || spell.getMagicAction() == null) return false; + switch (spell.getMagicAction()) { + case ICE_RUSH: + case ICE_BURST: + case ICE_BLITZ: + case ICE_BARRAGE: + return true; + default: + return false; + } + } + + /** Sacks cover one whole ice cast each; they must not be credited as generic runes. */ + static int runeCastsAfterIceSacks(Spell spell, int casts, int sacks, boolean inWilderness) { + if (!inWilderness || !isBlightedIceSpell(spell)) return Math.max(0, casts); + return Math.max(0, casts - Math.max(0, sacks)); } public static Map getMissingRunes(Spell spell, int casts) { @@ -618,7 +645,7 @@ public static boolean hasRequiredRunes(Map reqRunes) { } public static boolean hasRequiredRunes(Spell spell, int casts, RuneFilter runeFilter) { - return hasRequiredRunes(getRequiredRunes(spell, casts), runeFilter); + return getMissingRunes(spell, casts, runeFilter).isEmpty(); } public static boolean hasRequiredRunes(Spell spell, int casts) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java new file mode 100644 index 0000000000..4cd9f777b8 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.util.magic; + +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import java.util.HashMap; +import static org.junit.Assert.*; + +public class Rs2MagicBlightedSacksTest { + @Test public void oneSackPaysForOneWholeIceCast() { + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_RUSH, 1, 1, true)); + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BURST, 2, 2, true)); + assertEquals(3, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BLITZ, 5, 2, true)); + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BARRAGE, 1, 3, true)); + } + @Test public void sacksCannotCastOutsideWildernessOrOtherSpells() { + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BARRAGE, 1, 1, false)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.BLOOD_BARRAGE, 1, 1, true)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.WATER_STRIKE, 1, 1, true)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BURST, 1, -1, true)); + } + @Test public void runeChecksHonorInventoryAndBankFilters() { + try (MockedStatic pvp = Mockito.mockStatic(Rs2Pvp.class); + MockedStatic inventory = Mockito.mockStatic(Rs2Inventory.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic magic = Mockito.mockStatic(Rs2Magic.class, Mockito.CALLS_REAL_METHODS)) { + pvp.when(Rs2Pvp::isInWilderness).thenReturn(true); + inventory.when(() -> Rs2Inventory.itemQuantity(ItemID.BLIGHTED_SACK_ICEBARRAGE)).thenReturn(2); + bank.when(() -> Rs2Bank.count(ItemID.BLIGHTED_SACK_ICEBARRAGE)).thenReturn(3); + magic.when(() -> Rs2Magic.getRunes(Mockito.any(RuneFilter.class))).thenAnswer(call -> new HashMap<>()); + + RuneFilter inventoryOnly = RuneFilter.builder().includeBank(false).build(); + RuneFilter bankOnly = RuneFilter.builder().includeInventory(false).includeBank(true).build(); + RuneFilter both = RuneFilter.builder().includeBank(true).build(); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(2), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, inventoryOnly)); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(1), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, bankOnly)); + assertTrue(Rs2Magic.hasRequiredRunes(Rs2CombatSpells.ICE_BARRAGE, 4, both)); + assertFalse(Rs2Magic.hasRequiredRunes(Rs2CombatSpells.ICE_BARRAGE, 4, inventoryOnly)); + + pvp.when(Rs2Pvp::isInWilderness).thenReturn(false); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(4), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, both)); + assertEquals(Rs2CombatSpells.BLOOD_BARRAGE.getRequiredRunes(4), + Rs2Magic.getMissingRunes(Rs2CombatSpells.BLOOD_BARRAGE, 4, both)); + } + } +} From bc2c338a38c28a6f5b26c2c5bd7a68fd92b8db51 Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Fri, 9 Oct 2026 21:40:28 -0700 Subject: [PATCH 31/62] fix(autologin): stay logged out after terminal supply failure --- docs/entity-guides/README.md | 2 + docs/entity-guides/items.md | 11 +++ .../client/plugins/microbot/Script.java | 32 ++++++- .../plugins/microbot/ScriptStarted.java | 9 ++ .../accountselector/AutoLoginPlugin.java | 22 +++++ .../accountselector/AutoLoginScript.java | 27 +++++- .../accountselector/AutoLoginSuppression.java | 35 ++++++++ .../AutoLoginSuppressionRequest.java | 9 ++ .../microbot/util/Rs2InventorySetup.java | 17 ++-- .../microbot/util/player/Rs2Player.java | 41 +++++++++ .../plugins/microbot/ScriptStartedTest.java | 84 +++++++++++++++++++ .../AutoLoginPluginSupplyStopTest.java | 32 +++++++ .../AutoLoginSuppressionTest.java | 36 ++++++++ .../TerminalSupplyLogoutTest.java | 57 +++++++++++++ .../util/InventorySetupSupplyLogoutTest.java | 68 +++++++++++++++ 15 files changed, 472 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index 206edb58d5..cd9b345700 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -50,3 +50,5 @@ Each entity guide is a numbered list of gotchas. Each entry follows this structu ## Adding a new gotcha to an existing guide Append a new numbered entry. Do **not** delete or rewrite existing entries unless they are factually wrong — each entry corresponds to a real failure mode and removing it loses the institutional memory of why the rule exists. + +Required-supply stops must suppress AutoLogin without blocking the next run: [items gotcha 15](items.md#15-suppress-autologin-only-for-confirmed-terminal-supply-stops). diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 6d5f9468c3..2dc1f4c92b 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -261,3 +261,14 @@ Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient(). ``` **Where this applies:** `Rs2GrandExchange`, `GrandExchangeWidget`, `Rs2Dialogue`, `Rs2Bank` X-amount prompts, and any helper reading chatbox prompts or GE offer state. + + +## 15. Suppress AutoLogin only for confirmed terminal supply stops + +An ordinary logout can be a break or world hop, so it must keep normal AutoLogin behavior. After confirming a required supply is unavailable in a live bank mirror, call `Rs2Player.logoutWithoutAutoLogin()` off the client thread and cancel the owning run's scheduler. Do not set a global pause: it would prevent the next run from logging in. Partial-stack acceptance and optional supply fallbacks remain separate. + +**Why this matters:** AutoLogin otherwise logs back in after supply exhaustion and the stopped bank loop retries indefinitely. Suppression clears on a new gameplay script run, a manual login after the logout, or an AutoLogin restart. Background AutoLogin and BreakHandler starts do not clear it. + +**Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. `logoutWithoutAutoLoginStep()` performs one non-blocking input step for state-machine callers. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. + +**Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java index ff1bc11d37..c91d61c90d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java @@ -14,11 +14,13 @@ import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.jetbrains.annotations.NotNull; -import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; /** * Base class for Microbot automation scripts. @@ -26,7 +28,15 @@ */ @Slf4j public abstract class Script extends Global implements IScript { - protected ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10, + private final AtomicBoolean startAnnounced = new AtomicBoolean(); + + private void announceStart() { + if (Microbot.getEventBus() != null && startAnnounced.compareAndSet(false, true)) { + Microbot.getEventBus().post(new ScriptStarted(this)); + } + } + + protected ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(10, new ThreadFactory() { private final AtomicInteger threadNumber = new AtomicInteger(1); @Override @@ -36,7 +46,22 @@ public Thread newThread(@NotNull Runnable r) { t.setDaemon(true); return t; } - }); + }) { + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, + long delay, TimeUnit unit) { + // Announce before a zero-delay loop can report a terminal supply failure. + announceStart(); + return super.scheduleWithFixedDelay(command, initialDelay, delay, unit); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, + long period, TimeUnit unit) { + announceStart(); + return super.scheduleAtFixedRate(command, initialDelay, period, unit); + } + }; protected ScheduledFuture scheduledFuture; protected ScheduledFuture mainScheduledFuture; @@ -55,6 +80,7 @@ public boolean isRunning() { * Safe to call multiple times; no-ops if already shut down. */ public void shutdown() { + startAnnounced.set(false); ScriptHeartbeatRegistry.remove(this.getClass().getName()); if (mainScheduledFuture != null && !mainScheduledFuture.isDone()) { mainScheduledFuture.cancel(true); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java new file mode 100644 index 0000000000..f8b4e46ea5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot; + +import lombok.Value; + +/** Posted once per script run, when its first repeating task is scheduled. */ +@Value +public class ScriptStarted { + Script script; +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index 29d713196e..241bf88b53 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -3,6 +3,8 @@ import com.google.inject.Provides; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; +import net.runelite.api.events.GameStateChanged; +import net.runelite.client.eventbus.Subscribe; import net.runelite.client.Notifier; import net.runelite.client.callback.ClientThread; import net.runelite.client.config.ConfigManager; @@ -11,6 +13,8 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.ScriptStarted; +import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; import javax.inject.Inject; @@ -19,6 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", + version = "1.0.3", tags = {"account", "microbot", "login"}, enabledByDefault = false ) @@ -44,4 +49,21 @@ protected void shutDown() { accountSelectorScript.shutdown(); } + @Subscribe + public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { + accountSelectorScript.suppressAutoLogin(); + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) { + accountSelectorScript.observeSuppressionGameState(event.getGameState()); + } + + @Subscribe + public void onScriptStarted(ScriptStarted event) { + // Existing loops do not emit this event each tick. A new gameplay run opts back in. + if (event.getScript() instanceof AutoLoginScript || event.getScript() instanceof BreakHandlerScript) return; + accountSelectorScript.resumeAutoLogin(); + } + } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index 4f7211dc43..a746c6987c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -59,6 +59,20 @@ private enum LoginState { private Instant extendedSleepStartTime = null; private Instant lastLoginAttemptTime = null; private long lastExtendedSleepLoggedMinute = -1; + private final AutoLoginSuppression suppression = new AutoLoginSuppression(); + + void suppressAutoLogin() { + suppression.request(); + } + + void resumeAutoLogin() { + suppression.clear(); + } + + void observeSuppressionGameState(GameState gameState) { + suppression.observe(gameState); + } + public boolean run(AutoLoginConfig autoLoginConfig) { @@ -90,6 +104,10 @@ public boolean run(AutoLoginConfig autoLoginConfig) { * Main state machine for auto login processing. */ private void processAutoLoginStateMachine(AutoLoginConfig config) { + if (suppression.isSuppressed()) { + resetLoginState(); + return; + } switch (loginState) { case WAITING_FOR_LOGIN_SCREEN: handleWaitingForLoginScreenState(config); @@ -247,6 +265,7 @@ private void handleLoginExtendedSleepState(AutoLoginConfig config) { */ private void initiateLogin(AutoLoginConfig config) { try { + if (suppression.isSuppressed()) return; // start login watchdog if enabled and not already started if (config.enableLoginWatchdog() && loginWatchdogStartTime == null) { loginWatchdogStartTime = Instant.now(); @@ -326,6 +345,9 @@ private void initiateLogin(AutoLoginConfig config) { } } + // A terminal stop can arrive during world selection. + if (suppression.isSuppressed()) return; + // perform login attempt and track retry state retryCount++; lastLoginAttemptTime = Instant.now(); @@ -333,10 +355,10 @@ private void initiateLogin(AutoLoginConfig config) { boolean loginInitiated; if (targetWorld != -1) { log.info("Attempting login to selected world: {} (attempt {})", targetWorld, retryCount); - loginInitiated = LoginManager.login(targetWorld); + loginInitiated = !suppression.isSuppressed() && LoginManager.login(targetWorld); } else { log.info("Using default login (current world or last used) (attempt {})", retryCount); - loginInitiated = LoginManager.login(); + loginInitiated = !suppression.isSuppressed() && LoginManager.login(); } if (!loginInitiated) { @@ -500,6 +522,7 @@ public void shutdown() { log.info("Auto login script shutting down"); resetLoginState(); + suppression.clear(); super.shutdown(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java new file mode 100644 index 0000000000..05ba69ee81 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -0,0 +1,35 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.util.concurrent.atomic.AtomicReference; +import net.runelite.api.GameState; + +/** Tracks the requested logout separately from a subsequent manual login. */ +final class AutoLoginSuppression { + private enum State { ALLOWED, AWAITING_LOGOUT, LOGGED_OUT } + + private final AtomicReference state = new AtomicReference<>(State.ALLOWED); + + void request() { + state.compareAndSet(State.ALLOWED, State.AWAITING_LOGOUT); + } + + void observe(GameState gameState) { + state.updateAndGet(current -> { + if (current == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { + return State.LOGGED_OUT; + } + if (current == State.LOGGED_OUT && gameState == GameState.LOGGED_IN) { + return State.ALLOWED; + } + return current; + }); + } + + boolean isSuppressed() { + return state.get() != State.ALLOWED; + } + + void clear() { + state.set(State.ALLOWED); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java new file mode 100644 index 0000000000..d157c74db3 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot.accountselector; + +/** + * Scripts post this event before a terminal logout to prevent automatic re-login. + * AutoLogin resumes when another script starts, after a manual login, or on AutoLogin restart. + * Ordinary logouts must not post this event. + */ +public final class AutoLoginSuppressionRequest { +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 4de721260d..3adf0d66c9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -383,7 +383,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } - Microbot.pauseAllScripts.compareAndSet(false, true); + stopForMissingRequiredSupply(item.getName()); logSetup(Level.WARN, "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", item.getName(), bankAvail, invQty, setupTotal, desiredWithdraw, @@ -414,7 +414,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } - Microbot.pauseAllScripts.compareAndSet(false, true); + stopForMissingRequiredSupply(item.getName()); logSetup(Level.WARN, "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", item.getName(), bankAvail, invQty, setupTotal, withdrawQuantity, @@ -863,10 +863,10 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); logSetup(Level.WARN, - "missing gear %s | bank=%d | inv=%d | need=%d — pausing", + "missing gear %s | bank=%d | inv=%d | need=%d — stopping", item.getName(), bankGear, invGear, item.getQuantity()); - Microbot.pauseAllScripts.compareAndSet(false, true); - continue; + stopForMissingRequiredSupply(item.getName()); + return false; } if (inInventory) { @@ -901,6 +901,13 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { return doesEquipmentMatch(); } + private void stopForMissingRequiredSupply(String itemName) { + Microbot.status = "Missing required setup supply: " + itemName; + Rs2Player.logoutWithoutAutoLogin(); + // A later script start must not revive this run's failing bank loop. + if (_mainScheduler != null) _mainScheduler.cancel(false); + } + /** * Wears the equipment items defined in the inventory setup. * Iterates through the equipment setup and equips the items to the player. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 2cccf9cb1f..0058f2e9a9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -14,6 +14,8 @@ import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetInfo; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; @@ -475,6 +477,45 @@ public static boolean isRunEnabled() { /** * Logs the player out of the game */ + public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, INPUT_BLOCKED } + + /** One input step for an explicit script stop; ordinary logout() remains unchanged. */ + public static TerminalLogoutState logoutWithoutAutoLoginStep() { + if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); + return terminalLogoutInputStep(); + } + + private static TerminalLogoutState terminalLogoutInputStep() { + if (!Microbot.isLoggedIn()) return TerminalLogoutState.LOGGED_OUT; + if (Rs2Bank.isOpen()) { + return Rs2Widget.clickChildWidget(786434, 11) + ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; + } + logout(); + return TerminalLogoutState.LOGOUT_REQUESTED; + } + + /** + * Best-effort terminal logout after a confirmed supply failure, off the client thread. + * Suppression stays armed even if logout input fails. Resume by manually logging in + * after the logout, starting another script, or restarting AutoLogin. + * The return value distinguishes requested input from observed logout completion. + */ + public static TerminalLogoutState logoutWithoutAutoLogin() { + if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + // Post once: a newer script start during bank closure must not be re-blocked. + Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); + TerminalLogoutState state = terminalLogoutInputStep(); + if (state == TerminalLogoutState.BANK_CLOSING) { + if (!sleepUntil(() -> !Rs2Bank.isOpen(), 2400)) return state; + state = terminalLogoutInputStep(); + } + if (state == TerminalLogoutState.LOGOUT_REQUESTED + && sleepUntil(() -> !Microbot.isLoggedIn(), 5000)) return TerminalLogoutState.LOGGED_OUT; + return state; + } + public static void logout() { if (!Microbot.isLoggedIn()) return; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java new file mode 100644 index 0000000000..056bb6ecbd --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java @@ -0,0 +1,84 @@ +package net.runelite.client.plugins.microbot; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class ScriptStartedTest { + @Test public void zeroDelaySupplyFailureIsNotClearedByItsOwnStartAnnouncement() throws Exception { + EventBus bus = new EventBus(); + Block block = new Block(); + bus.register(block); + Fixture script = new Fixture(); + CountDownLatch failurePosted = new CountDownLatch(1); + try (MockedStatic microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getEventBus).thenReturn(bus); + ScheduledFuture task = script.scheduleFailure(bus, failurePosted); + assertTrue(failurePosted.await(2, TimeUnit.SECONDS)); + assertTrue(block.blocked.get()); + task.cancel(false); + } finally { + script.scheduledExecutorService.shutdownNow(); + bus.unregister(block); + } + } + + @Test public void schedulingAnnouncesStartWithoutWaitingForLoginAndOnlyOncePerRun() { + EventBus bus = new EventBus(); + Starts starts = new Starts(); + bus.register(starts); + Fixture script = new Fixture(); + try (MockedStatic microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getEventBus).thenReturn(bus); + ScheduledFuture first = script.schedule(false); + ScheduledFuture auxiliary = script.schedule(true); + assertEquals(1, starts.count); + assertSame(script, starts.last); + first.cancel(false); + auxiliary.cancel(false); + script.shutdown(); + script.schedule(false).cancel(false); + assertEquals(2, starts.count); + } finally { + script.scheduledExecutorService.shutdownNow(); + bus.unregister(starts); + } + } + + public static final class Starts { + int count; + Script last; + @Subscribe public void onScriptStarted(ScriptStarted event) { + count++; + last = event.getScript(); + } + } + + public static final class Block { + final AtomicBoolean blocked = new AtomicBoolean(); + @Subscribe public void onScriptStarted(ScriptStarted event) { blocked.set(false); } + @Subscribe public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { blocked.set(true); } + } + + private static final class Fixture extends Script { + ScheduledFuture scheduleFailure(EventBus bus, CountDownLatch posted) { + return scheduledExecutorService.scheduleWithFixedDelay(() -> { + bus.post(new AutoLoginSuppressionRequest()); + posted.countDown(); + }, 0, 1, TimeUnit.DAYS); + } + ScheduledFuture schedule(boolean fixedRate) { + return fixedRate + ? scheduledExecutorService.scheduleAtFixedRate(() -> { }, 1, 1, TimeUnit.DAYS) + : scheduledExecutorService.scheduleWithFixedDelay(() -> { }, 1, 1, TimeUnit.DAYS); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java new file mode 100644 index 0000000000..be62f005fa --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import net.runelite.api.events.GameStateChanged; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.ScriptStarted; +import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; +import org.junit.Test; +import static org.mockito.Mockito.*; + +public class AutoLoginPluginSupplyStopTest { + @Test public void terminalStopAndManualGameStateAreForwarded() { + AutoLoginPlugin plugin = new AutoLoginPlugin(); + plugin.accountSelectorScript = mock(AutoLoginScript.class); + plugin.onAutoLoginSuppressionRequest(new AutoLoginSuppressionRequest()); + verify(plugin.accountSelectorScript).suppressAutoLogin(); + GameStateChanged event = new GameStateChanged(); + event.setGameState(GameState.LOGIN_SCREEN); + plugin.onGameStateChanged(event); + verify(plugin.accountSelectorScript).observeSuppressionGameState(GameState.LOGIN_SCREEN); + } + + @Test public void onlyGameplayRunResumesAutoLogin() { + AutoLoginPlugin plugin = new AutoLoginPlugin(); + plugin.accountSelectorScript = mock(AutoLoginScript.class); + plugin.onScriptStarted(new ScriptStarted(mock(AutoLoginScript.class))); + plugin.onScriptStarted(new ScriptStarted(mock(BreakHandlerScript.class))); + verify(plugin.accountSelectorScript, never()).resumeAutoLogin(); + plugin.onScriptStarted(new ScriptStarted(mock(Script.class))); + verify(plugin.accountSelectorScript).resumeAutoLogin(); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java new file mode 100644 index 0000000000..d932dbbebb --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -0,0 +1,36 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import org.junit.Test; +import static org.junit.Assert.*; + +public class AutoLoginSuppressionTest { + @Test public void ordinaryLogoutAndWorldHopDoNotSuppressAutoLogin() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + for (GameState state : new GameState[] {GameState.LOGGED_IN, GameState.LOADING, + GameState.HOPPING, GameState.LOGIN_SCREEN, GameState.LOGGING_IN, GameState.LOGGED_IN}) { + suppression.observe(state); + assertFalse(suppression.isSuppressed()); + } + } + + @Test public void restartCanClearSuppressionWithoutManualLogin() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.request(); + suppression.observe(GameState.LOGIN_SCREEN); + assertTrue(suppression.isSuppressed()); + suppression.clear(); + assertFalse(suppression.isSuppressed()); + } + @Test public void terminalLogoutRemainsSuppressedUntilManualLoginAfterLogout() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.request(); + suppression.observe(GameState.LOGGED_IN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGIN_SCREEN); + suppression.observe(GameState.LOADING); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java new file mode 100644 index 0000000000..495c997945 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java @@ -0,0 +1,57 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.api.Client; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class TerminalSupplyLogoutTest { + @Test public void newScriptDuringBankCloseStaysUnblockedAndLogoutIsObserved() { + AtomicBoolean loggedIn = new AtomicBoolean(true); + AtomicBoolean blocked = new AtomicBoolean(); + Client client = mock(Client.class); + EventBus bus = mock(EventBus.class); + doAnswer(call -> { blocked.set(true); return null; }).when(bus).post(any(AutoLoginSuppressionRequest.class)); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var widget = mockStatic(Rs2Widget.class); + var player = mockStatic(Rs2Player.class, CALLS_REAL_METHODS)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getEventBus).thenReturn(bus); + microbot.when(Microbot::isLoggedIn).thenAnswer(call -> loggedIn.get()); + bank.when(Rs2Bank::isOpen).thenReturn(true, false); + widget.when(() -> Rs2Widget.clickChildWidget(786434, 11)).thenAnswer(call -> { + blocked.set(false); // another script starts while the previous bank is closing + return true; + }); + player.when(Rs2Player::logout).thenAnswer(call -> { loggedIn.set(false); return null; }); + assertEquals(Rs2Player.TerminalLogoutState.LOGGED_OUT, Rs2Player.logoutWithoutAutoLogin()); + assertFalse(blocked.get()); + verify(bus, times(1)).post(any(AutoLoginSuppressionRequest.class)); + player.verify(Rs2Player::logout, times(1)); + } + } + + @Test public void blockedBankCloseDoesNotClaimLogoutOrDispatchLogout() { + Client client = mock(Client.class); + EventBus bus = mock(EventBus.class); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var widget = mockStatic(Rs2Widget.class); + var player = mockStatic(Rs2Player.class, CALLS_REAL_METHODS)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getEventBus).thenReturn(bus); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + bank.when(Rs2Bank::isOpen).thenReturn(true); + assertEquals(Rs2Player.TerminalLogoutState.INPUT_BLOCKED, Rs2Player.logoutWithoutAutoLogin()); + verify(bus).post(any(AutoLoginSuppressionRequest.class)); + player.verify(Rs2Player::logout, never()); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java new file mode 100644 index 0000000000..5f6a8143ae --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java @@ -0,0 +1,68 @@ +package net.runelite.client.plugins.microbot.util; + +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; +import java.util.function.Predicate; +import java.util.stream.Stream; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsItem; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsStackCompareID; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class InventorySetupSupplyLogoutTest { + @Test public void requiredInventoryItemMissingRequestsTerminalLogout() { + checkMissing(false, true, true); + } + + @Test public void requiredGearMissingRequestsTerminalLogout() { + checkMissing(true, true, true); + } + + @Test public void bankMirrorNotReadyDoesNotRequestLogout() { + checkMissing(false, false, false); + checkMissing(true, false, false); + } + + private void checkMissing(boolean gear, boolean mirrorReady, boolean expectLogout) { + boolean pausedBefore = Microbot.pauseAllScripts.getAndSet(false); + InventorySetup setup = mock(InventorySetup.class); + InventorySetupsItem row = new InventorySetupsItem(100, "Required supply", 1, true, + InventorySetupsStackCompareID.None, false, -1); + when(setup.getInventory()).thenReturn(gear ? Collections.emptyList() : Collections.singletonList(row)); + when(setup.getEquipment()).thenReturn(gear ? Collections.singletonList(row) : Collections.emptyList()); + Client client = mock(Client.class); + ClientThread thread = mock(ClientThread.class); + when(thread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var inventory = mockStatic(Rs2Inventory.class); + var equipment = mockStatic(Rs2Equipment.class); + var player = mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(thread); + bank.when(Rs2Bank::isOpen).thenReturn(true); + bank.when(() -> Rs2Bank.verifyBankMirrorAfterOpen(true, 0)).thenReturn(mirrorReady); + inventory.when(Rs2Inventory::items).thenAnswer(call -> Stream.empty()); + inventory.when(() -> Rs2Inventory.items(any(Predicate.class))).thenAnswer(call -> Stream.empty()); + equipment.when(Rs2Equipment::all).thenAnswer(call -> Stream.empty()); + ScheduledFuture scheduler = mock(ScheduledFuture.class); + Rs2InventorySetup loader = new Rs2InventorySetup(setup, scheduler); + assertFalse(gear ? loader.loadEquipment(false) : loader.loadInventory(false)); + player.verify(Rs2Player::logoutWithoutAutoLogin, times(expectLogout ? 1 : 0)); + verify(scheduler, times(expectLogout ? 1 : 0)).cancel(false); + assertFalse(Microbot.pauseAllScripts.get()); // A subsequent run must not inherit a global pause. + } finally { + Microbot.pauseAllScripts.set(pausedBefore); + } + } +} From 6bb7e87898dfe1b7214f7927f61d2e811003c230 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:52:10 +0200 Subject: [PATCH 32/62] fix: keep deprecated loot API shims used by Microbot-Hub Restore Microbot.getAggregateLootRecords, getAggregateLootRecordsTotalGevalue, Rs2GrandExchange.sellLoot and Rs2Bank.withdrawLootItems as deprecated shims with their previous behavior, plus their guardrail baseline lines, since Hub plugins (DemonicGorillaKiller, vorkath) still call them. Drop the stray blank import line and note in the docs that ExamplePlugin now lives in the Hub. Co-Authored-By: Claude Opus 5.5 --- docs/AGENTIC_TESTING_LOOP.md | 2 +- docs/AGENT_SERVER.md | 2 + .../client/plugins/microbot/Microbot.java | 23 ++++++++ .../breakhandlerv2/MicrobotPluginChoice.java | 1 - .../plugins/microbot/util/bank/Rs2Bank.java | 55 +++++++++++++++++++ .../util/grandexchange/Rs2GrandExchange.java | 19 +++++++ .../client-thread-guardrail-baseline.txt | 2 + 7 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/AGENTIC_TESTING_LOOP.md b/docs/AGENTIC_TESTING_LOOP.md index c1f428e197..82d5e468a2 100644 --- a/docs/AGENTIC_TESTING_LOOP.md +++ b/docs/AGENTIC_TESTING_LOOP.md @@ -419,7 +419,7 @@ Claude is multimodal. A screenshot of the game client showing a stuck character, ### ExampleScript as the reference test -The existing `ExampleScript` already validates all core APIs (caches, inventory, equipment, widgets, walker, dialogue, teleport, looting). It's the natural first target for the agentic loop. Future scripts can follow the same `check()` pattern and write `TestResult` output. +`ExampleScript` is no longer bundled with the client; it now comes from the Microbot-Hub example plugin (same package), which is an empty loop template. It is still a convenient first target for exercising the harness. Scripts that need real coverage should follow a `check()` pattern and write `TestResult` output. --- diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md index ca216f344e..65598af260 100644 --- a/docs/AGENT_SERVER.md +++ b/docs/AGENT_SERVER.md @@ -667,6 +667,8 @@ curl -X POST -H "Content-Type: application/json" \ ### Script Lifecycle +The examples below use the Microbot-Hub `ExamplePlugin` (`net.runelite.client.plugins.microbot.example`); it is no longer bundled with the client. + #### GET /scripts Lists all microbot plugins with their active/enabled status. 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 69b921e5fc..7bc89bb3c2 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 @@ -33,6 +33,7 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginInstantiationException; import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.loottracker.LootTrackerRecord; import net.runelite.client.plugins.microbot.configs.SpecialAttackConfigs; import net.runelite.client.plugins.microbot.pouch.PouchScript; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; @@ -652,6 +653,28 @@ public static void click(Rectangle rectangle) { } } + @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) + public static List getAggregateLootRecords() { + return new ArrayList<>(); + } + + @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) + public static LootTrackerRecord getAggregateLootRecords(String npcName) { + return null; + } + + /** + * Calculates the total GE value of loot records for a specific NPC. + * This method uses reflection to access private methods and fields of the LootTrackerItem class. + * + * @param npcName name of the npc to get the loot records for + * @return total GE value of the loot records + */ + @Deprecated(since = "Use LootTrackerPlugin.getAggregateLootRecords()", forRemoval = true) + public static long getAggregateLootRecordsTotalGevalue(String npcName) { + return 0; + } + /** * Logs the stack trace of an exception to the console and chat. * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java index bb5e7910bd..e8b6583bf6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java @@ -5,7 +5,6 @@ import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerPlugin; import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2Plugin; - import net.runelite.client.plugins.microbot.inventorysetups.MInventorySetupsPlugin; import net.runelite.client.plugins.microbot.mouserecorder.MouseMacroRecorderPlugin; import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 1235f16b19..2e90cd28c8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -14,6 +14,8 @@ import net.runelite.api.widgets.Widget; import net.runelite.client.config.RuneScapeProfileType; import net.runelite.client.plugins.bank.BankPlugin; +import net.runelite.client.plugins.loottracker.LootTrackerItem; +import net.runelite.client.plugins.loottracker.LootTrackerRecord; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; @@ -2931,6 +2933,59 @@ public static boolean depositLootingBag(){ } + /** + * Withdraw items from the lootTrackerPlugin + * + * @param npcName the name of the npc + * @return {@code true} if successfully withdrew loot items, otherwise {@code false} + */ + @Deprecated + public static boolean withdrawLootItems(String npcName, List itemsToNotSell) { + boolean isAtGe = Rs2GrandExchange.walkToGrandExchange(); + if (isAtGe) { + boolean isBankOpen = Rs2Bank.openBank(); + if (!isBankOpen) return false; + } + Rs2Bank.depositAll(); + boolean itemFound = false; + + boolean hasWithdrawAsNote = Rs2Bank.setWithdrawAsNote(); + if (!hasWithdrawAsNote) return false; + for (LootTrackerRecord lootTrackerRecord : Microbot.getAggregateLootRecords()) { + if (!lootTrackerRecord.getTitle().equalsIgnoreCase(npcName)) continue; + for (LootTrackerItem lootTrackerItem : lootTrackerRecord.getItems()) { + if (itemsToNotSell.stream().anyMatch(x -> x.trim().equalsIgnoreCase(lootTrackerItem.getName()))) + continue; + int itemId = lootTrackerItem.getId(); + ItemComposition itemComposition = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getItemDefinition(lootTrackerItem.getId())).orElse(null); + if (itemComposition == null) return false; + if (Arrays.stream(itemComposition.getInventoryActions()).anyMatch(x -> x != null && x.equalsIgnoreCase("eat"))) + continue; + final boolean isNoted = itemComposition.getNote() == 799; + if (!itemComposition.isTradeable() && !isNoted) continue; + + if (isNoted) { + final int unnotedItemId = lootTrackerItem.getId() - 1; //get the unnoted id of the item + itemComposition = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getItemDefinition(unnotedItemId)).orElse(null); + if (itemComposition == null) { + return false; + } + if (!itemComposition.isTradeable()) continue; + itemId = unnotedItemId; + } + + boolean didWithdraw = Rs2Bank.withdrawAll(itemId); + if (didWithdraw) { + itemFound = true; + } + } + } + Rs2Bank.closeBank(); + return itemFound; + } + private static Widget getBankSizeWidget() { return Rs2Widget.getWidget(ComponentID.BANK_ITEM_COUNT_TOP); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java index 02e640cb27..a233384adb 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java @@ -756,6 +756,25 @@ public static boolean collectAllToBank() { return collectAll(true); } + /** + * sells all the tradeable loot items from a specific npc name + * + * @param npcName + * @return true if there is no more loot to sell + */ + @Deprecated + public static boolean sellLoot(String npcName, List itemsToNotSell) { + + boolean withdrewLootItems = Rs2Bank.withdrawLootItems(npcName, itemsToNotSell); + + if (withdrewLootItems) { + return sellInventory(); + } + + + return false; + } + /** * Attempts to sell all tradeable items currently in the player's inventory on the Grand Exchange. *

diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 02163c2b2c..9103011412 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -113,6 +113,8 @@ net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemConta net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.ItemContainer#getItems(): Item[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getContainerId(): int net.runelite.client.plugins.microbot.util.bank.Rs2Bank#updateLocalBank(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getItemContainer(): ItemContainer +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#getNote(): int +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#isTradeable(): boolean net.runelite.client.plugins.microbot.util.camera.Rs2Camera#getZoom(): int -> net.runelite.api.Client#getVarcIntValue(int): int net.runelite.client.plugins.microbot.util.camera.Rs2Camera#isTileOnScreen(LocalPoint): boolean -> net.runelite.api.Client#getPlane(): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#getWeaponAttackStyle(): String -> net.runelite.api.EnumComposition#getIntValue(int): int From 555a87157bf283c7c410ce553f7fe2976718306e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:59:52 +0200 Subject: [PATCH 33/62] fix(autologin): regenerate guardrail baseline and drop unused logout step Remove the uncalled logoutWithoutAutoLoginStep, move the logout() Javadoc back onto logout(), and re-key the client-thread guardrail baseline for the shifted Rs2Player lambdas plus the logoutWithoutAutoLogin isClientThread check. Co-Authored-By: Claude Opus 5.5 --- .../plugins/microbot/util/player/Rs2Player.java | 13 +++---------- .../client-thread-guardrail-baseline.txt | 9 +++++---- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 0058f2e9a9..141e5b061a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -474,18 +474,8 @@ public static boolean isRunEnabled() { return Microbot.getVarbitPlayerValue(173) == 1; } - /** - * Logs the player out of the game - */ public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, INPUT_BLOCKED } - /** One input step for an explicit script stop; ordinary logout() remains unchanged. */ - public static TerminalLogoutState logoutWithoutAutoLoginStep() { - if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; - Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); - return terminalLogoutInputStep(); - } - private static TerminalLogoutState terminalLogoutInputStep() { if (!Microbot.isLoggedIn()) return TerminalLogoutState.LOGGED_OUT; if (Rs2Bank.isOpen()) { @@ -516,6 +506,9 @@ public static TerminalLogoutState logoutWithoutAutoLogin() { return state; } + /** + * Logs the player out of the game + */ public static void logout() { if (!Microbot.isLoggedIn()) return; diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 9103011412..1ae64d7b04 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -580,12 +580,13 @@ net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean - net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.player.Rs2Player#isInteracting(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#isInteracting(): boolean -> net.runelite.api.Player#isInteracting(): boolean -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$30(Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$30(Rs2PlayerModel): boolean -> net.runelite.api.Player#getId(): int -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getPlayers$19(boolean, Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$32(Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$32(Rs2PlayerModel): boolean -> net.runelite.api.Player#getId(): int +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getPlayers$21(boolean, Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isInMemberWorld$8(World): boolean -> net.runelite.api.Client#getWorld(): int -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isStandingOnGameObject$45(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isStandingOnGameObject$47(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$waitForXpDrop$2(int, Skill, boolean): boolean -> net.runelite.api.Client#getSkillExperience(Skill): int +net.runelite.client.plugins.microbot.util.player.Rs2Player#logoutWithoutAutoLogin(): Rs2Player$TerminalLogoutState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#waitForXpDrop(Skill, int, boolean): boolean -> net.runelite.api.Client#getSkillExperience(Skill): int net.runelite.client.plugins.microbot.util.player.Rs2Player$1#(): void -> net.runelite.api.Skill#ordinal(): int net.runelite.client.plugins.microbot.util.player.Rs2Player$1#(): void -> net.runelite.api.Skill#values(): Skill[] From ad236626cbc2f250bd9f0c7842ef8645b934c118 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:07:00 +0200 Subject: [PATCH 34/62] Remove stale logoutWithoutAutoLoginStep reference from items guide Co-Authored-By: Claude Opus 5.5 --- docs/entity-guides/items.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 2dc1f4c92b..95e1e80f9a 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -269,6 +269,6 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin **Why this matters:** AutoLogin otherwise logs back in after supply exhaustion and the stopped bank loop retries indefinitely. Suppression clears on a new gameplay script run, a manual login after the logout, or an AutoLogin restart. Background AutoLogin and BreakHandler starts do not clear it. -**Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. `logoutWithoutAutoLoginStep()` performs one non-blocking input step for state-machine callers. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. +**Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. **Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. From ada79763de45b165319f5b5793cb8ef739eb1ce5 Mon Sep 17 00:00:00 2001 From: JogOnJohn <223066319+JogOnJohn@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:45:02 +1100 Subject: [PATCH 35/62] Expose Hunter plugin instance for AutoHunter dependency --- .../net/runelite/client/plugins/hunter/HunterPlugin.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java index 8c81e90a11..d89b54d215 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java @@ -63,6 +63,12 @@ ) public class HunterPlugin extends Plugin { + @Override + public com.google.inject.Module getPublicModule() + { + return binder -> binder.bind(HunterPlugin.class).toProvider(com.google.inject.util.Providers.of(this)); + } + @Inject private Client client; From 3ba2f8761616e33842e1e7ecc6e8bd6a4b5e359d Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:15:17 +0200 Subject: [PATCH 36/62] Align Hunter getPublicModule with repo convention Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/hunter/HunterPlugin.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java index d89b54d215..165ce12f40 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/hunter/HunterPlugin.java @@ -26,7 +26,9 @@ import com.google.common.collect.BiMap; import com.google.common.collect.ImmutableBiMap; +import com.google.inject.Module; import com.google.inject.Provides; +import com.google.inject.util.Providers; import java.time.Instant; import java.util.HashMap; import java.util.Iterator; @@ -63,12 +65,6 @@ ) public class HunterPlugin extends Plugin { - @Override - public com.google.inject.Module getPublicModule() - { - return binder -> binder.bind(HunterPlugin.class).toProvider(com.google.inject.util.Providers.of(this)); - } - @Inject private Client client; @@ -118,6 +114,12 @@ HunterConfig provideConfig(ConfigManager configManager) return configManager.getConfig(HunterConfig.class); } + @Override + public Module getPublicModule() + { + return b -> b.bind(HunterPlugin.class).toProvider(Providers.of(this)); + } + @Override protected void startUp() { From 657f840b4106852463be3354f76f9be8a0d94a25 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:46:42 +0200 Subject: [PATCH 37/62] fix(dialogue): guard getQuestion against dialog closing between check and fetch Rs2Dialogue.getQuestion() re-fetched DIALOG_OPTION,1 after hasSelectAnOption() and dereferenced it without a null check, so a dialog closing between the two client-thread reads threw an NPE into the calling script loop. Read the title text and option children in a single client-thread call with null guards, and apply the same guards to hasDialogueOptionTitle, getDialogueOptions and getCombinationOptions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/dialogues/Rs2Dialogue.java | 51 ++++--- .../Rs2DialogueClosingWidgetTest.java | 142 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 15 +- 3 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java index 20f3783d5e..29f3bfd337 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java @@ -153,11 +153,25 @@ public static boolean hasSelectAnOption() { public static String getQuestion() { if (!hasSelectAnOption()) return null; - Widget[] dynamicWidgetOptions = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1).getDynamicChildren(); - if (dynamicWidgetOptions != null && dynamicWidgetOptions.length > 0) { - return Rs2UiHelper.stripColTags(dynamicWidgetOptions[0].getText()); - } - return null; + String text = getDialogOptionTitleText(); + return text == null ? null : Rs2UiHelper.stripColTags(text); + } + + private static String getDialogOptionTitleText() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget dialogueOption = Microbot.getClient().getWidget(InterfaceID.DIALOG_OPTION, 1); + if (dialogueOption == null) return null; + Widget[] children = dialogueOption.getDynamicChildren(); + if (children == null || children.length == 0 || children[0] == null) return null; + return children[0].getText(); + }).orElse(null); + } + + private static Widget[] getDialogOptionChildren() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget dialogueOption = Microbot.getClient().getWidget(InterfaceID.DIALOG_OPTION, 1); + return dialogueOption == null ? null : dialogueOption.getDynamicChildren(); + }).orElse(new Widget[0]); } /** @@ -193,15 +207,13 @@ public static boolean hasQuestion(String text) { public static boolean hasDialogueOptionTitle(String text, boolean exact) { if (!hasSelectAnOption()) return false; - Widget dialogueOption = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1); - if (dialogueOption == null) return false; - Widget[] dynamicWidgetOptions = dialogueOption.getDynamicChildren(); - if (dynamicWidgetOptions == null || dynamicWidgetOptions.length == 0 || dynamicWidgetOptions[0] == null) return false; + String title = getDialogOptionTitleText(); + if (title == null) return false; if (exact) { - return dynamicWidgetOptions[0].getText().equalsIgnoreCase(text); + return title.equalsIgnoreCase(text); } else { - return dynamicWidgetOptions[0].getText().toLowerCase().contains(text.toLowerCase()); + return title.toLowerCase().contains(text.toLowerCase()); } } @@ -225,17 +237,16 @@ public static List getDialogueOptions() { if (!hasSelectAnOption()) return Collections.emptyList(); List out = new ArrayList<>(); - Widget dialogueOption = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1); - if (dialogueOption == null) return new ArrayList<>(); - Widget[] dynamicWidgetOptions = dialogueOption.getDynamicChildren(); + Widget[] dynamicWidgetOptions = getDialogOptionChildren(); // Skip the first dynamic widget option, as it is never an option for (int i = 1; i < dynamicWidgetOptions.length; i++) { - if (dynamicWidgetOptions[i].getText().isBlank()) { + Widget option = dynamicWidgetOptions[i]; + if (option == null || option.getText() == null || option.getText().isBlank()) { continue; } - out.add(dynamicWidgetOptions[i]); + out.add(option); } return out; @@ -524,8 +535,12 @@ public static List getCombinationOptions() { if (!hasCombinationDialogue()) return Collections.emptyList(); List options = new ArrayList<>(); - if (Rs2Widget.isWidgetVisible(270, 13)) { - for (Widget widget : Rs2Widget.getWidget(270, 14).getStaticChildren()) { + if (!Rs2Widget.isWidgetVisible(270, 13)) return options; + + Widget combinationList = Rs2Widget.getWidget(270, 14); + Widget[] children = combinationList == null ? null : combinationList.getStaticChildren(); + if (children != null) { + for (Widget widget : children) { if (widget != null && widget.getActions() != null && widget.getActions().length > 0) { options.add(widget); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java new file mode 100644 index 0000000000..932e0e5347 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java @@ -0,0 +1,142 @@ +package net.runelite.client.plugins.microbot.util.dialogues; + +import net.runelite.api.Client; +import net.runelite.api.widgets.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2DialogueClosingWidgetTest { + + private MockedStatic widgets; + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() { + widgets = Mockito.mockStatic(Rs2Widget.class); + widgets.when(() -> Rs2Widget.isWidgetVisible(InterfaceID.DIALOG_OPTION, 1)).thenReturn(true); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + client = mock(Client.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + microbot.when(Microbot::getClient).thenReturn(client); + } + + @After + public void tearDown() { + microbot.close(); + widgets.close(); + } + + private void dialogOpenAtCheck(Widget atCheck, Widget atFetch) { + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1)).thenReturn(atCheck); + when(client.getWidget(InterfaceID.DIALOG_OPTION, 1)).thenReturn(atFetch); + } + + private void dialogOpen(Widget container) { + dialogOpenAtCheck(container, container); + } + + private static Widget textWidget(String text) { + Widget widget = mock(Widget.class); + when(widget.getText()).thenReturn(text); + return widget; + } + + private static Widget optionContainer(Widget... children) { + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(children); + return container; + } + + @Test + public void getQuestionReturnsNullWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpenAtCheck(open, null); + + assertNull(Rs2Dialogue.getQuestion()); + } + + @Test + public void hasQuestionReturnsFalseWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpenAtCheck(open, null); + + assertFalse(Rs2Dialogue.hasQuestion("pay")); + } + + @Test + public void getQuestionReturnsNullWhenChildrenAreEmptyOrNull() { + Widget empty = optionContainer(); + dialogOpen(empty); + assertNull(Rs2Dialogue.getQuestion()); + + Widget nullFirst = optionContainer((Widget) null); + dialogOpen(nullFirst); + assertNull(Rs2Dialogue.getQuestion()); + + Widget nullText = optionContainer(textWidget(null)); + dialogOpen(nullText); + assertNull(Rs2Dialogue.getQuestion()); + } + + @Test + public void getQuestionStripsColourTags() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpen(open); + + assertEquals("Pay 200 coins?", Rs2Dialogue.getQuestion()); + assertTrue(Rs2Dialogue.hasQuestion("200 coins")); + } + + @Test + public void getDialogueOptionsSkipsMissingChildrenAndText() { + Widget yes = textWidget("Yes"); + Widget container = optionContainer(textWidget("Pay?"), null, textWidget(null), textWidget(" "), yes); + dialogOpen(container); + + List options = Rs2Dialogue.getDialogueOptions(); + assertEquals(1, options.size()); + assertEquals(yes, options.get(0)); + } + + @Test + public void getDialogueOptionsReturnsEmptyWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay?"), textWidget("Yes")); + dialogOpenAtCheck(open, null); + + assertTrue(Rs2Dialogue.getDialogueOptions().isEmpty()); + } + + @Test + public void hasDialogueOptionTitleReturnsFalseWhenTitleTextMissing() { + Widget container = optionContainer(textWidget(null), textWidget("Yes")); + dialogOpen(container); + + assertFalse(Rs2Dialogue.hasDialogueOptionTitle("Pay")); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 9103011412..5aa159a7a1 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -145,20 +145,15 @@ net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#openDepositBo net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#clickOption(String, boolean): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getCombinationOptions(): List -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getCombinationOptions(): List -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getDialogueOptions(): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getDialogueOptions(): List -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getQuestion(): String -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getQuestion(): String -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getStrippedWidgetText(int, int): String -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#handleQuestOptionDialogueSelection(): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasDialogueOptionTitle(String, boolean): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasDialogueOptionTitle(String, boolean): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasSelectAnOption(): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$3(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$4(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getCombinationOption$9(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getName(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getDialogueOption$1(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$hasDialogueOption$2(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$5(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$6(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getCombinationOption$11(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getName(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getDialogueOption$3(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$hasDialogueOption$4(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#(): void -> net.runelite.api.EquipmentInventorySlot#getSlotIdx(): int net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#(): void -> net.runelite.api.EquipmentInventorySlot#values(): EquipmentInventorySlot[] net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#equipment(): ItemContainer -> net.runelite.api.Client#getItemContainer(int): ItemContainer From 7c4533ce6b3807db2df7abf24f86fe4c11258ccf Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:56:41 +0200 Subject: [PATCH 38/62] fix(widget): guard null root widget in Rs2Widget.clickWidget and searchChildren clickWidget(text, widgetId, child, exact) passed the result of getWidget() into findWidget even when the interface was not loaded, so searchChildren dereferenced a null widget in matchesText. Return false for a null root, make both searchChildren overloads return null for a null input, and avoid List.of(null) in getProcessingWidgetKeyCode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/widget/Rs2Widget.java | 7 +- .../util/widget/Rs2WidgetNullRootTest.java | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java index 1c84b4b7e5..6a9372abb9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java @@ -44,6 +44,7 @@ public static boolean clickWidget(String text, Optional widgetId, int c widget = findWidget(text, null, exact); } else { Widget rootWidget = getWidget(widgetId.get(), childId); + if (rootWidget == null) return false; List rootWidgets = new ArrayList<>(); rootWidgets.add(rootWidget); widget = findWidget(text, rootWidgets, exact); @@ -234,6 +235,7 @@ public static Widget findWidget(String text, List children, boolean exac * @return The widget containing the specified text, or null if no match is found. */ public static Widget searchChildren(String text, Widget child, boolean exact) { + if (child == null) return null; if (matchesText(child, text, exact)) return child; Widget[][] childGroups = {child.getChildren(), child.getNestedChildren(), child.getDynamicChildren(), child.getStaticChildren()}; @@ -325,6 +327,7 @@ public static Widget findWidget(int spriteId, List children) { * @return The widget with the specified sprite ID, or null if not found. */ public static Widget searchChildren(int spriteId, Widget child) { + if (child == null) return null; if (matchesSpriteId(child, spriteId)) return child; Widget[][] childGroups = {child.getChildren(), child.getNestedChildren(), child.getDynamicChildren(), child.getStaticChildren()}; @@ -483,7 +486,9 @@ public static Map getWidgetsKeyMap(int widgetGroupId, int widge private static Integer getProcessingWidgetKeyCode(String actionText) { log.debug("Searching for processing widget with action text: {}", actionText); - Widget optionWidget = findWidget(actionText, List.of(getWidget(InterfaceID.SKILLMULTI, 0)), false); + Widget mainWidget = getWidget(InterfaceID.SKILLMULTI, 0); + if (mainWidget == null) return null; + Widget optionWidget = findWidget(actionText, List.of(mainWidget), false); if (optionWidget == null) return null; return getProcessingWidgetKeyCode(optionWidget); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java new file mode 100644 index 0000000000..99604bd9d2 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java @@ -0,0 +1,66 @@ +package net.runelite.client.plugins.microbot.util.widget; + +import net.runelite.api.Client; +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 org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WidgetNullRootTest { + + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() throws Exception { + client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> + Optional.ofNullable(((Callable) inv.getArgument(0)).call())); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void clickWidgetReturnsFalseWhenRootWidgetNotLoaded() { + when(client.getWidget(270, 13)).thenReturn(null); + assertFalse(Rs2Widget.clickWidget("Make", Optional.of(270), 13, false)); + microbot.verify(Microbot::getMouse, Mockito.never()); + } + + @Test + public void findWidgetSkipsNullEntriesInChildren() { + Widget match = mock(Widget.class); + when(match.getText()).thenReturn("Make"); + when(match.getName()).thenReturn(""); + assertSame(match, Rs2Widget.findWidget("Make", Arrays.asList(null, match), true)); + assertNull(Rs2Widget.findWidget("Make", Arrays.asList((Widget) null), true)); + } + + @Test + public void searchChildrenReturnsNullForNullWidget() { + assertNull(Rs2Widget.searchChildren("Make", null, false)); + assertNull(Rs2Widget.searchChildren(1234, null)); + } +} From 55e86fdf11872056b2ff2b30268d995f44dfbb87 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:12:56 +0200 Subject: [PATCH 39/62] fix(walker): read interacting actor location on the client thread interactingActorNearWalkablePath() runs on the walker thread during isStuckTooLong() and called Actor.getWorldLocation() directly, which throws IllegalStateException("must be called on client thread") and aborts the walk. Read the location via runOnClientThreadOptional and treat an empty result as not near. Drops the now-resolved ClientThreadGuardrailTest baseline entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/walker/Rs2Walker.java | 2 +- .../Rs2WalkerInteractingActorThreadTest.java | 106 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 1 - 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 6514b871a8..e83f9d0b7e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -7108,7 +7108,7 @@ private static boolean interactingActorNearWalkablePath() { if (actor == null) { return false; } - WorldPoint loc = actor.getWorldLocation(); + WorldPoint loc = Microbot.getClientThread().runOnClientThreadOptional(actor::getWorldLocation).orElse(null); if (loc == null) { return false; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java new file mode 100644 index 0000000000..14e7bce65e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java @@ -0,0 +1,106 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Actor; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WalkerInteractingActorThreadTest { + + private MockedStatic microbot; + private MockedStatic pathApi; + private MockedStatic player; + private final AtomicBoolean onClientThread = new AtomicBoolean(false); + private ClientThread clientThread; + + @Before + public void setUp() { + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + onClientThread.set(true); + try { + return Optional.ofNullable(callable.call()); + } finally { + onClientThread.set(false); + } + }); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + Rs2ActiveRouteStatus status = mock(Rs2ActiveRouteStatus.class); + when(status.isPresent()).thenReturn(true); + when(status.getWalkablePath()).thenReturn(Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3201, 3200, 0), + new WorldPoint(3202, 3200, 0))); + pathApi = Mockito.mockStatic(Rs2PathApi.class); + pathApi.when(Rs2PathApi::getActiveRouteStatus).thenReturn(status); + + player = Mockito.mockStatic(Rs2Player.class); + } + + @After + public void tearDown() { + player.close(); + pathApi.close(); + microbot.close(); + } + + private Actor clientThreadOnlyActor(WorldPoint location) { + Actor actor = mock(Actor.class); + when(actor.getWorldLocation()).thenAnswer(inv -> { + if (!onClientThread.get()) { + throw new IllegalStateException("must be called on client thread"); + } + return location; + }); + return actor; + } + + private static boolean invoke() throws Exception { + Method method = Rs2Walker.class.getDeclaredMethod("interactingActorNearWalkablePath"); + method.setAccessible(true); + return (boolean) method.invoke(null); + } + + @Test + public void readsInteractingActorLocationOnClientThread() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3201, 3202, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + assertTrue(invoke()); + } + + @Test + public void actorFarFromPathIsNotNear() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3220, 3220, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + assertFalse(invoke()); + } + + @Test + public void emptyClientThreadResultIsTreatedAsNotNear() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3201, 3202, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + Mockito.doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + assertFalse(invoke()); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 9103011412..449c521555 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -751,7 +751,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isClientThread(): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int From 4135fbd5b6384230f606033f8695037f097df5bc Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:23:35 +0200 Subject: [PATCH 40/62] fix(bank): guard null player location in nearest-bank and distance helpers Rs2Player.getWorldLocation() returns null during loading, world hops and logout. Rs2Bank.getNearestBank(), getPathToNearestBank(), isNearBank(...) and walkToBank(...) passed it straight into Rs2RouteRequest.toAny/to (requireNonNull on start) or WorldPoint.distanceTo, throwing NPEs. Return no bank for a null start point, treat a null location as not near a bank, and make Rs2Walker.getDistanceBetween return Integer.MAX_VALUE when either endpoint is null. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 2 + .../plugins/microbot/util/bank/Rs2Bank.java | 12 +++-- .../microbot/util/walker/Rs2Walker.java | 1 + .../bank/Rs2BankNullPlayerLocationTest.java | 54 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index c5baf250df..79113de21e 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -46,3 +46,5 @@ return Microbot.getClientThread().runOnClientThreadOptional(() -> { **Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest` and `Rs2PlayerModelNullLocalPlayerTest`. **Wrapped locations:** `Rs2Player.getRs2WorldPoint()` still returns an `Rs2WorldPoint`, but it can wrap a `null` `WorldPoint` in these states. `pathTo` returns `null` and `distanceToPath` returns `Integer.MAX_VALUE` for it; check `getWorldPoint() != null` before calling `getX()`, `getY()` or `getPlane()`, as in `Rs2WorldPointNullPointTest`. + +**Location consumers:** `Rs2Player.getWorldLocation()` itself returns `null` in these states. Guard it before passing it to `Rs2RouteRequest.to`/`toAny` (which reject a null start) or `WorldPoint.distanceTo`. `Rs2Bank.getNearestBank`, `getNearestBankRoute` and `getPathToNearestBank` return no bank, `isNearBank` returns `false`, and `Rs2Walker.getDistanceBetween` returns `Integer.MAX_VALUE` for a null endpoint, as in `Rs2BankNullPlayerLocationTest`. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 2e90cd28c8..5790a890fd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -2397,6 +2397,7 @@ private static NearestBankRoute getPathAndBankToNearestBank(WorldPoint worldPoin private static NearestBankRoute getPathAndBankToNearestBank( WorldPoint worldPoint, int maxObjectSearchRadius, Boolean useBankItems) { + if (worldPoint == null) return null; Microbot.log("Finding nearest bank..."); Set allBanks = Arrays.stream(BankLocation.values()) @@ -2561,8 +2562,9 @@ public static boolean walkToBank(BankLocation bankLocation, boolean toggleRun) { Rs2Player.toggleRunEnergy(toggleRun); Microbot.status = "Walking to nearest bank " + bankLocation.toString(); walkUntilBankReady(bankLocation); - return readyBankObject(bankLocation) != null - || bankLocation.getWorldPoint().distanceTo(Rs2Player.getWorldLocation()) <= 4; + if (readyBankObject(bankLocation) != null) return true; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + return playerLocation != null && bankLocation.getWorldPoint().distanceTo(playerLocation) <= 4; } /** @@ -2573,6 +2575,7 @@ public static boolean walkToBank(BankLocation bankLocation, boolean toggleRun) { */ public static boolean isNearBank(int distance) { WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return false; return Arrays.stream(BankLocation.values()) .anyMatch(b -> b.getWorldPoint().getPlane() == playerLocation.getPlane() && b.getWorldPoint().distanceTo2D(playerLocation) <= distance); @@ -2586,8 +2589,9 @@ public static boolean isNearBank(int distance) { * @return true if Rs2Player location is less than distance away from the bank location */ public static boolean isNearBank(BankLocation bankLocation, int distance) { - int distanceToBank = Rs2Walker.getDistanceBetween(Rs2Player.getWorldLocation(), bankLocation.getWorldPoint()); - return distanceToBank <= distance; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return false; + return Rs2Walker.getDistanceBetween(playerLocation, bankLocation.getWorldPoint()) <= distance; } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 6514b871a8..fc653482c7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -7208,6 +7208,7 @@ public static WorldPoint nearestReachable(WorldPoint start, Collection player; + + @Before + public void setUp() { + player = Mockito.mockStatic(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenReturn(null); + } + + @After + public void tearDown() { + player.close(); + } + + @Test + public void nearestBankLookupsReturnNoBankWithoutPlayerLocation() { + assertNull(Rs2Bank.getNearestBank()); + assertNull(Rs2Bank.getNearestBank(null)); + assertNull(Rs2Bank.getNearestBank(null, 20)); + assertNull(Rs2Bank.getNearestBankRoute(null)); + assertTrue(Rs2Bank.getPathToNearestBank().isEmpty()); + } + + @Test + public void isNearBankIsFalseWithoutPlayerLocation() { + assertFalse(Rs2Bank.isNearBank(10)); + assertFalse(Rs2Bank.isNearBank(BankLocation.GRAND_EXCHANGE, 10)); + } + + @Test + public void distanceBetweenIsUnreachableForNullEndpoint() { + WorldPoint point = new WorldPoint(3164, 3487, 0); + assertEquals(Integer.MAX_VALUE, Rs2Walker.getDistanceBetween(null, point)); + assertEquals(Integer.MAX_VALUE, Rs2Walker.getDistanceBetween(point, null)); + } +} From 8a0aef54d79918910e1883c8ae8d3ed1678a3642 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:37:03 +0200 Subject: [PATCH 41/62] fix(agentserver): serialize JDK immutable collections in agent responses Agent server responses put Collections.emptyList() (e.g. from Rs2WidgetInspector.describeWidget) into Map bodies. Gson resolves the runtime type Collections$EmptyList and tries to reflectively access its private constructor, which Java 17 modules reject, turning /widgets/describe into a 500. Register Collection and Map hierarchy serializers on the shared agent server Gson so every handler serializes any JDK collection or map view without reflection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../agentserver/AgentServerPlugin.java | 29 ++++++- .../agentserver/AgentServerGsonTest.java | 87 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index 607bc2c990..9ad807146d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -2,6 +2,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializer; import com.google.inject.Provides; import com.sun.net.httpserver.HttpServer; import lombok.extern.slf4j.Slf4j; @@ -23,7 +26,9 @@ import java.nio.file.Paths; import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -56,12 +61,34 @@ public class AgentServerPlugin extends Plugin { private net.runelite.client.plugins.microbot.agentserver.uds.UdsHttpServer udsServer; private ExecutorService executor; private Thread shutdownHook; - private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private final Gson gson = createGson(); private java.util.concurrent.ScheduledExecutorService stealthScheduler; private volatile boolean stealthActive = false; private static final long STEALTH_IDLE_GRACE_MS = 20_000; + static Gson createGson() { + JsonSerializer> collectionSerializer = (src, type, ctx) -> { + JsonArray array = new JsonArray(); + for (Object element : src) { + array.add(ctx.serialize(element)); + } + return array; + }; + JsonSerializer> mapSerializer = (src, type, ctx) -> { + JsonObject object = new JsonObject(); + for (Map.Entry entry : src.entrySet()) { + object.add(String.valueOf(entry.getKey()), ctx.serialize(entry.getValue())); + } + return object; + }; + return new GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter(Collection.class, collectionSerializer) + .registerTypeHierarchyAdapter(Map.class, mapSerializer) + .create(); + } + private static Path defaultUdsSocketPath() { return Paths.get(System.getProperty("user.home"), ".runelite", ".agent.sock"); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java new file mode 100644 index 0000000000..302ba20ece --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.agentserver; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class AgentServerGsonTest { + + private final Gson gson = AgentServerPlugin.createGson(); + + static class Description { + String name = "Inventory"; + List actions = Collections.emptyList(); + List children = Collections.emptyList(); + } + + @Test + public void serializesWidgetDescribeResponseWithEmptyList() { + Map response = new LinkedHashMap<>(); + response.put("groupId", 149); + response.put("childId", 0); + response.put("count", 0); + response.put("widgets", Collections.emptyList()); + + JsonObject json = gson.fromJson(gson.toJson(response), JsonObject.class); + + assertEquals(149, json.get("groupId").getAsInt()); + assertEquals(0, json.getAsJsonArray("widgets").size()); + } + + @Test + public void serializesImmutableJdkCollectionsAndMaps() { + Map response = new LinkedHashMap<>(); + response.put("singleton", Collections.singletonList("a")); + response.put("listOf", List.of(1, 2)); + response.put("unmodifiable", Collections.unmodifiableList(new ArrayList<>(Arrays.asList("x", "y")))); + response.put("setOf", Set.of("s")); + response.put("emptyMap", Collections.emptyMap()); + response.put("mapOf", Map.of("k", List.of())); + + JsonObject json = gson.fromJson(gson.toJson(response), JsonObject.class); + + assertEquals("a", json.getAsJsonArray("singleton").get(0).getAsString()); + assertEquals(2, json.getAsJsonArray("listOf").get(1).getAsInt()); + assertEquals("y", json.getAsJsonArray("unmodifiable").get(1).getAsString()); + assertEquals("s", json.getAsJsonArray("setOf").get(0).getAsString()); + assertEquals(0, json.getAsJsonObject("emptyMap").size()); + assertEquals(0, json.getAsJsonObject("mapOf").getAsJsonArray("k").size()); + } + + @Test + public void serializesNestedEmptyListFields() { + Map response = new LinkedHashMap<>(); + response.put("widgets", Collections.singletonList(new Description())); + + JsonArray widgets = gson.fromJson(gson.toJson(response), JsonObject.class).getAsJsonArray("widgets"); + + JsonObject widget = widgets.get(0).getAsJsonObject(); + assertEquals("Inventory", widget.get("name").getAsString()); + assertEquals(0, widget.getAsJsonArray("actions").size()); + assertEquals(0, widget.getAsJsonArray("children").size()); + } + + @Test + public void deserializesRequestBodiesUnchanged() { + @SuppressWarnings("unchecked") + Map parsed = gson.fromJson("{\"name\":\"x\",\"args\":[1,2],\"opts\":{\"a\":true}}", LinkedHashMap.class); + + assertEquals("x", parsed.get("name")); + assertTrue(parsed.get("args") instanceof List); + assertEquals(2, ((List) parsed.get("args")).size()); + assertTrue(parsed.get("opts") instanceof Map); + assertEquals(true, ((Map) parsed.get("opts")).get("a")); + } +} From 5828a0945be37fa751bf6cba77be2a9e8c86be37 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:55:20 +0200 Subject: [PATCH 42/62] fix(walker): strike out refused transports so replans stop reselecting them CollisionMap added every transport neighbour without consulting learned blocked edges, and the executor never recorded transport failures, so a transport the catalog over-admits (wrong members flag, unplanted spirit tree, stale build) was replanned until the tail-iteration cap. Two refusals of the same transport edge in one walk (landing unresolved while still at the origin, or the recovery on-origin dispatch returning false) now learn the edge as a walk-scoped block and replan. Forward and reverse neighbour expansion skip transports whose edge is a learned block; static blocked_edges.tsv rows are not consulted, so paid gates keep their own transport. Blocks are withdrawn at the next walk start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shortestpath/pathfinder/CollisionMap.java | 7 + .../pathfinder/PathfinderConfig.java | 5 + .../microbot/util/walker/Rs2Walker.java | 13 ++ .../util/walker/Rs2WalkerTransports.java | 54 +++++++ .../transport/TransportRefusalLedger.java | 59 ++++++++ .../walker/TransportRefusalStrikeOutTest.java | 143 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index e40d9123eb..7228884b95 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -283,6 +283,10 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig if (isMoa) moaVisited++; continue; } + if (transport.getDestination() != null && config.isLearnedBlockedTransport( + node.packedPosition, WorldPointUtil.packWorldPoint(transport.getDestination()))) { + continue; + } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (config.isIgnoreTeleportAndItems()) { @@ -394,6 +398,9 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P if (visitedBackward.get(originPacked)) { continue; } + if (config.isLearnedBlockedTransport(originPacked, node.packedPosition)) { + continue; + } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (config.isIgnoreTeleportAndItems()) { continue; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index a52ed8760d..a36662c612 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -848,6 +848,11 @@ public boolean isBlockedTransportStep(int originPacked, int destinationPacked) { return isBlockedTransportStep(originPacked, destinationPacked, blockedTransportEdgesPacked); } + public boolean isLearnedBlockedTransport(int originPacked, int destinationPacked) { + return !learnedBlockedEdgeKeys.isEmpty() + && learnedBlockedEdgeKeys.contains(transportEdgeKey(originPacked, destinationPacked)); + } + static boolean isBlockedTransportStep(int originPacked, int destinationPacked, Set blockedEdges) { if (blockedEdges == null || blockedEdges.isEmpty()) { return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 6514b871a8..13142102b8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -86,6 +86,7 @@ import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; @@ -1325,6 +1326,7 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance // sealed goal), collapsed to a 1-tile path, and the retry burned itself on it while the // unlearn arrived two lines later. withdrawWalkScopedDoorBlocks(); + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -4442,7 +4444,18 @@ private static ObstacleResolution resolveRecoveryObstacle(List rawPa && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { WebWalkLog.spInfo("recovery_on_origin_transport | origin={} player={} rawIdx={}", compactWorldPoint(rawPath.get(ri)), compactWorldPoint(playerLoc), ri); + WorldPoint edgeOrigin = rawPath.get(ri); + WorldPoint edgeDestination = rawPath.get(ri + 1); + int strikesBefore = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); if (handleTransports(rawPath, ri)) { + Rs2WalkerTransports.clearTransportRefusals(edgeOrigin, edgeDestination); + return ObstacleResolution.interacted(); + } + int strikesAfter = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); + boolean struckOut = strikesAfter == strikesBefore + ? Rs2WalkerTransports.registerTransportRefusal(edgeOrigin, edgeDestination, "recovery-dispatch") + : TransportRefusalLedger.isStrikeOut(strikesAfter); + if (struckOut) { return ObstacleResolution.interacted(); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java index f6840fc947..1f3cf0da33 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java @@ -81,6 +81,7 @@ import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; @@ -836,8 +837,12 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i POST_HANDLE_OBJECT_LANDING_WAIT_MS, compactWorldPoint(destWait), compactWorldPoint(afterInteraction)); + if (isStillAtTransportOrigin(afterInteraction, transport.getOrigin(), destWait)) { + registerTransportRefusal(transport.getOrigin(), destWait, "landing-unresolved"); + } } if (landedAfterObject) { + transportRefusalLedger.clear(transport.getOrigin(), destWait); markShortSamePlaneTransportHandled(transport, object); return finishHandledTransport(transport); } @@ -849,6 +854,55 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i return false; } + private static final TransportRefusalLedger transportRefusalLedger = new TransportRefusalLedger(); + + static boolean isStillAtTransportOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { + if (player == null || origin == null || destination == null || player.getPlane() != origin.getPlane()) { + return false; + } + int toOrigin = player.distanceTo2D(origin); + if (toOrigin > 1) { + return false; + } + return player.getPlane() != destination.getPlane() || toOrigin < player.distanceTo2D(destination); + } + + static int transportRefusalStrikes(WorldPoint origin, WorldPoint destination) { + return transportRefusalLedger.strikes(origin, destination); + } + + static void clearTransportRefusals(WorldPoint origin, WorldPoint destination) { + transportRefusalLedger.clear(origin, destination); + } + + static boolean registerTransportRefusal(WorldPoint origin, WorldPoint destination, String mode) { + if (origin == null || destination == null) { + return false; + } + int strikes = transportRefusalLedger.registerRefusal(origin, destination); + if (!TransportRefusalLedger.isStrikeOut(strikes)) { + WebWalkLog.spInfo("transport_refused | origin={} dest={} mode={} strike={}/{}", + compactWorldPoint(origin), compactWorldPoint(destination), mode, + strikes, TransportRefusalLedger.STRIKE_LIMIT); + return false; + } + if (Rs2PathApi.learnBlockedEdge(origin, destination, "transport-refused (" + mode + ")")) { + transportRefusalLedger.recordWalkScopedBlock(origin, destination); + } + WebWalkLog.spInfo("transport_strike_out | origin={} dest={} mode={} — {} refusals; " + + "blocking transport for this walk and replanning", + compactWorldPoint(origin), compactWorldPoint(destination), mode, + TransportRefusalLedger.STRIKE_LIMIT); + recalculatePath(); + return true; + } + + static void withdrawWalkScopedTransportBlocks() { + for (WorldPoint[] edge : transportRefusalLedger.drainWalkScopedBlocks()) { + Rs2PathApi.unlearnBlockedEdge(edge[0], edge[1], "walk-scoped transport strike-out expired"); + } + } + private static boolean waitForPostHandleObjectLanding(Transport transport, WorldPoint destWait, int maxInclusive) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java new file mode 100644 index 0000000000..6b53e3beff --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java @@ -0,0 +1,59 @@ +package net.runelite.client.plugins.microbot.util.walker.transport; + +import net.runelite.api.coords.WorldPoint; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +public final class TransportRefusalLedger { + + public static final int STRIKE_LIMIT = 2; + + private final Map, Integer> strikes = new ConcurrentHashMap<>(); + private final ConcurrentLinkedQueue walkScopedBlocks = new ConcurrentLinkedQueue<>(); + + public int registerRefusal(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return strikes.merge(List.of(origin, destination), 1, Integer::sum); + } + + public static boolean isStrikeOut(int strikeCount) { + return strikeCount == STRIKE_LIMIT; + } + + public int strikes(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return strikes.getOrDefault(List.of(origin, destination), 0); + } + + public void clear(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return; + } + strikes.remove(List.of(origin, destination)); + } + + public void recordWalkScopedBlock(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return; + } + walkScopedBlocks.add(new WorldPoint[]{origin, destination}); + } + + public List drainWalkScopedBlocks() { + strikes.clear(); + List drained = new ArrayList<>(); + WorldPoint[] edge; + while ((edge = walkScopedBlocks.poll()) != null) { + drained.add(edge); + } + return drained; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java new file mode 100644 index 0000000000..2b573b6ae2 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java @@ -0,0 +1,143 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TransportRefusalStrikeOutTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3222, 3218, 0); + private static final WorldPoint FALLBACK_ORIGIN = new WorldPoint(3222, 3220, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3222, 9618, 0); + + private static SplitFlagMap collisionMap; + + @BeforeClass + public static void loadMap() + { + collisionMap = SplitFlagMap.fromResources(); + } + + @Test + public void secondRefusalStrikesOut() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertFalse("only the exact refusal that reaches the limit strikes out", + TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertEquals(0, ledger.registerRefusal(null, DESTINATION)); + } + + @Test + public void successClearsStrikesAndWalkStartDrainsBlocks() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + ledger.registerRefusal(ORIGIN, DESTINATION); + ledger.clear(ORIGIN, DESTINATION); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + + ledger.recordWalkScopedBlock(ORIGIN, DESTINATION); + List drained = ledger.drainWalkScopedBlocks(); + assertEquals(1, drained.size()); + assertEquals(ORIGIN, drained.get(0)[0]); + assertEquals(DESTINATION, drained.get(0)[1]); + assertEquals("walk start resets strikes", 0, ledger.strikes(ORIGIN, DESTINATION)); + assertTrue(ledger.drainWalkScopedBlocks().isEmpty()); + } + + @Test + public void transportRefusedTwiceIsAvoidedByTheReplan() throws Exception + { + Transport refused = new Transport( + ORIGIN, DESTINATION, "refused", TransportType.TRANSPORT, false, + "Climb-down", "Trapdoor", 1001, 1); + Transport fallback = new Transport( + FALLBACK_ORIGIN, DESTINATION, "fallback", TransportType.TRANSPORT, false, + "Climb-down", "Ladder", 1002, 5); + Map> catalog = new HashMap<>(); + catalog.put(ORIGIN, Set.of(refused)); + catalog.put(FALLBACK_ORIGIN, Set.of(fallback)); + PathfinderConfig config = configWithTransports(catalog); + + List before = plan(config); + assertEquals(List.of(ORIGIN, DESTINATION), before); + + TransportRefusalLedger ledger = new TransportRefusalLedger(); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(config.learnBlockedEdge(ORIGIN, DESTINATION, "transport-refused")); + + List after = plan(config); + assertEquals(DESTINATION, after.get(after.size() - 1)); + assertTrue("replan must board the fallback transport", after.contains(FALLBACK_ORIGIN)); + for (int i = 0; i + 1 < after.size(); i++) + { + assertFalse("replan must not reuse the refused edge", + after.get(i).equals(ORIGIN) && after.get(i + 1).equals(DESTINATION)); + } + + assertTrue(config.unlearnBlockedEdge(ORIGIN, DESTINATION, "walk ended")); + assertEquals(List.of(ORIGIN, DESTINATION), plan(config)); + } + + @Test + public void staticBlockedEdgeDoesNotHideItsOwnTransport() throws Exception + { + PathfinderConfig config = configWithTransports(Collections.emptyMap()); + int gateOrigin = WorldPointUtil.packWorldPoint(3267, 3227, 0); + int gateDestination = WorldPointUtil.packWorldPoint(3268, 3227, 0); + assertTrue(config.isBlockedTransportEdge(gateOrigin, gateDestination)); + assertFalse(config.isLearnedBlockedTransport(gateOrigin, gateDestination)); + } + + private static List plan(PathfinderConfig config) + { + Pathfinder pathfinder = new Pathfinder(config, ORIGIN, Set.of(DESTINATION)); + pathfinder.run(); + return pathfinder.getPath(); + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransports(Map> catalog) throws Exception + { + PathfinderConfig config = new PathfinderConfig( + collisionMap, new HashMap<>(catalog), Collections.emptyList(), null, null); + Field cutoff = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + cutoff.setAccessible(true); + cutoff.setLong(config, 10_000L); + + Field transportsField = PathfinderConfig.class.getDeclaredField("transports"); + transportsField.setAccessible(true); + ((Map>) transportsField.get(config)).putAll(catalog); + + Field packedField = PathfinderConfig.class.getDeclaredField("transportsPacked"); + packedField.setAccessible(true); + PrimitiveIntHashMap> packed = + (PrimitiveIntHashMap>) packedField.get(config); + for (Map.Entry> entry : catalog.entrySet()) + { + packed.put(WorldPointUtil.packWorldPoint(entry.getKey()), entry.getValue()); + } + return config; + } +} From 8d6bf49a76d34d1d2c61ac6e5ef4b8d94aa7d4f1 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:47 +0200 Subject: [PATCH 43/62] fix(walker): count recovery transport refusals only after a real click handleSelectedTransport also returns false while it deliberately waits or defers: the 10s stationary-door suppression, an edge already crossed, a Barrows dig from beside the mound, a plane mismatch, or spirit-tree travel switched off. Recovery step (2) counted each of those as a refusal, so two passes could block a working catalog door for the rest of the walk. The dispatcher now bumps a click serial whenever an executor actually issues the transport interaction, and step (2) registers a strike only when that serial moved during the call and the player is still at the edge origin, matching the landing-unresolved check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/walker/Rs2Walker.java | 15 +++++++-- .../util/walker/Rs2WalkerTransports.java | 33 ++++++++++++++----- .../transport/TransportRefusalLedger.java | 16 +++++++++ .../walker/TransportRefusalStrikeOutTest.java | 19 +++++++++++ 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 13142102b8..a0c78b0f44 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -4447,14 +4447,23 @@ && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH WorldPoint edgeOrigin = rawPath.get(ri); WorldPoint edgeDestination = rawPath.get(ri + 1); int strikesBefore = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); + long clicksBefore = Rs2WalkerTransports.transportClickSerial(); if (handleTransports(rawPath, ri)) { Rs2WalkerTransports.clearTransportRefusals(edgeOrigin, edgeDestination); return ObstacleResolution.interacted(); } int strikesAfter = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); - boolean struckOut = strikesAfter == strikesBefore - ? Rs2WalkerTransports.registerTransportRefusal(edgeOrigin, edgeDestination, "recovery-dispatch") - : TransportRefusalLedger.isStrikeOut(strikesAfter); + boolean struckOut; + if (strikesAfter != strikesBefore) { + struckOut = TransportRefusalLedger.isStrikeOut(strikesAfter); + } else if (Rs2WalkerTransports.isRefusedRecoveryDispatch( + Rs2WalkerTransports.transportClickSerial() != clicksBefore, + Rs2Player.getWorldLocation(), edgeOrigin, edgeDestination)) { + struckOut = Rs2WalkerTransports.registerTransportRefusal( + edgeOrigin, edgeDestination, "recovery-dispatch"); + } else { + struckOut = false; + } if (struckOut) { return ObstacleResolution.interacted(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java index 1f3cf0da33..dfb733aad3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java @@ -856,15 +856,23 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i private static final TransportRefusalLedger transportRefusalLedger = new TransportRefusalLedger(); + private static final AtomicLong transportClickSerial = new AtomicLong(); + + private static void recordTransportClick() { + transportClickSerial.incrementAndGet(); + } + + static long transportClickSerial() { + return transportClickSerial.get(); + } + + static boolean isRefusedRecoveryDispatch(boolean clicked, WorldPoint player, + WorldPoint origin, WorldPoint destination) { + return TransportRefusalLedger.isRefusedDispatch(clicked, player, origin, destination); + } + static boolean isStillAtTransportOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { - if (player == null || origin == null || destination == null || player.getPlane() != origin.getPlane()) { - return false; - } - int toOrigin = player.distanceTo2D(origin); - if (toOrigin > 1) { - return false; - } - return player.getPlane() != destination.getPlane() || toOrigin < player.distanceTo2D(destination); + return TransportRefusalLedger.isStillAtOrigin(player, origin, destination); } static int transportRefusalStrikes(WorldPoint origin, WorldPoint destination) { @@ -1092,6 +1100,7 @@ private static boolean handleObject(Transport transport, TileObject tileObject, if (!Rs2GameObject.interact(tileObject, action)) { return false; } + recordTransportClick(); // Unlike the other exception handlers, a toll-gate interaction is not complete merely // because the menu action was issued: it may first server-walk from several tiles away and // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so @@ -2227,6 +2236,10 @@ private static boolean attemptObserved(Transport transport, BooleanSupplier acti recordTransportAttempt(transport); } boolean ok = action.getAsBoolean(); + if (ok) + { + recordTransportClick(); + } if (leaguesActive) { recordTransportResult(transport, ok); @@ -2247,6 +2260,10 @@ private static boolean attemptObservedWithoutAttemptRecord(Transport transport, } boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); boolean ok = action.getAsBoolean(); + if (ok) + { + recordTransportClick(); + } if (leaguesActive) { recordTransportResult(transport, ok); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java index 6b53e3beff..b7a9affafe 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java @@ -26,6 +26,22 @@ public static boolean isStrikeOut(int strikeCount) { return strikeCount == STRIKE_LIMIT; } + public static boolean isStillAtOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { + if (player == null || origin == null || destination == null || player.getPlane() != origin.getPlane()) { + return false; + } + int toOrigin = player.distanceTo2D(origin); + if (toOrigin > 1) { + return false; + } + return player.getPlane() != destination.getPlane() || toOrigin < player.distanceTo2D(destination); + } + + public static boolean isRefusedDispatch(boolean clicked, WorldPoint player, + WorldPoint origin, WorldPoint destination) { + return clicked && isStillAtOrigin(player, origin, destination); + } + public int strikes(WorldPoint origin, WorldPoint destination) { if (origin == null || destination == null) { return 0; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java index 2b573b6ae2..ebb34e0c6f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java @@ -48,6 +48,25 @@ public void secondRefusalStrikesOut() assertEquals(0, ledger.registerRefusal(null, DESTINATION)); } + @Test + public void recoveryDispatchStrikesOnlyAfterAClickThatLeftThePlayerAtTheOrigin() + { + WorldPoint door = new WorldPoint(3201, 3169, 0); + WorldPoint inside = new WorldPoint(3202, 3169, 0); + assertTrue(TransportRefusalLedger.isRefusedDispatch(true, door, door, inside)); + assertTrue(TransportRefusalLedger.isRefusedDispatch(true, ORIGIN, ORIGIN, DESTINATION)); + + assertFalse("waiting or deliberately deferring is not a refusal", + TransportRefusalLedger.isRefusedDispatch(false, door, door, inside)); + assertFalse("already across the edge", + TransportRefusalLedger.isRefusedDispatch(true, inside, door, inside)); + assertFalse("walked away from the origin", + TransportRefusalLedger.isRefusedDispatch(true, new WorldPoint(3198, 3169, 0), door, inside)); + assertFalse("plane mismatch", + TransportRefusalLedger.isRefusedDispatch(true, new WorldPoint(3201, 3169, 1), door, inside)); + assertFalse(TransportRefusalLedger.isRefusedDispatch(true, null, door, inside)); + } + @Test public void successClearsStrikesAndWalkStartDrainsBlocks() { From 90881ca3ddc5552748f7953807903f10d1b7bc1f Mon Sep 17 00:00:00 2001 From: droplugins <50-combat-pit@icloud.com> Date: Sat, 10 Oct 2026 10:17:06 -0700 Subject: [PATCH 44/62] fix(autologin): opt in to supply logout and expire pending suppression --- docs/entity-guides/items.md | 4 +- .../accountselector/AutoLoginPlugin.java | 2 +- .../accountselector/AutoLoginSuppression.java | 58 +++++++++++++------ .../microbot/util/Rs2InventorySetup.java | 29 +++++++++- .../microbot/util/player/Rs2Player.java | 2 +- .../AutoLoginSuppressionTest.java | 53 +++++++++++++++++ .../util/InventorySetupSupplyLogoutTest.java | 28 +++++++-- 7 files changed, 145 insertions(+), 31 deletions(-) diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 95e1e80f9a..b882157b17 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -265,10 +265,10 @@ Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient(). ## 15. Suppress AutoLogin only for confirmed terminal supply stops -An ordinary logout can be a break or world hop, so it must keep normal AutoLogin behavior. After confirming a required supply is unavailable in a live bank mirror, call `Rs2Player.logoutWithoutAutoLogin()` off the client thread and cancel the owning run's scheduler. Do not set a global pause: it would prevent the next run from logging in. Partial-stack acceptance and optional supply fallbacks remain separate. +An ordinary logout can be a break or world hop, so it must keep normal AutoLogin behavior. After confirming a required supply is unavailable in a live bank mirror, call `Rs2Player.logoutWithoutAutoLogin()` off the client thread and cancel the owning run's scheduler. Existing Inventory Setup callers retain their global-pause behavior. Scripts that prefer terminal logout must opt in per loader with `withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP)`; do not globally change every plugin's supply policy. Partial-stack acceptance and optional supply fallbacks remain separate. **Why this matters:** AutoLogin otherwise logs back in after supply exhaustion and the stopped bank loop retries indefinitely. Suppression clears on a new gameplay script run, a manual login after the logout, or an AutoLogin restart. Background AutoLogin and BreakHandler starts do not clear it. -**Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. +**Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. Uncompleted requests expire after 15 seconds using a monotonic clock, including when no game-state event occurs; observed terminal logouts do not expire. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. **Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index 241bf88b53..f6cb6476b5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -23,7 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", - version = "1.0.3", + version = "1.0.4", tags = {"account", "microbot", "login"}, enabledByDefault = false ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java index 05ba69ee81..255776e279 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -1,35 +1,57 @@ package net.runelite.client.plugins.microbot.accountselector; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; import net.runelite.api.GameState; /** Tracks the requested logout separately from a subsequent manual login. */ final class AutoLoginSuppression { private enum State { ALLOWED, AWAITING_LOGOUT, LOGGED_OUT } - private final AtomicReference state = new AtomicReference<>(State.ALLOWED); + static final long AWAIT_LOGOUT_MILLIS = 15_000L; + private final LongSupplier clock; + private State state = State.ALLOWED; + private long requestedAt; - void request() { - state.compareAndSet(State.ALLOWED, State.AWAITING_LOGOUT); + AutoLoginSuppression() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime())); } - void observe(GameState gameState) { - state.updateAndGet(current -> { - if (current == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { - return State.LOGGED_OUT; - } - if (current == State.LOGGED_OUT && gameState == GameState.LOGGED_IN) { - return State.ALLOWED; - } - return current; - }); + AutoLoginSuppression(LongSupplier clock) { + this.clock = clock; } - boolean isSuppressed() { - return state.get() != State.ALLOWED; + synchronized void request() { + expirePendingRequest(); + if (state == State.ALLOWED) { + requestedAt = clock.getAsLong(); + state = State.AWAITING_LOGOUT; + } } - void clear() { - state.set(State.ALLOWED); + synchronized void observe(GameState gameState) { + expirePendingRequest(); + if (state == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { + state = State.LOGGED_OUT; + } else if (state == State.LOGGED_OUT && gameState == GameState.LOGGED_IN) { + state = State.ALLOWED; + } + } + + synchronized boolean isSuppressed() { + // Also expire during polling when no game-state transition has occurred. + expirePendingRequest(); + return state != State.ALLOWED; + } + + synchronized void clear() { + state = State.ALLOWED; + } + + private void expirePendingRequest() { + if (state == State.AWAITING_LOGOUT + && clock.getAsLong() - requestedAt >= AWAIT_LOGOUT_MILLIS) { + state = State.ALLOWED; + } } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 3adf0d66c9..b0a98b7c4a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -198,6 +198,23 @@ private static void addRuneLikePouchRows(List out, List _mainScheduler; + /** Required-supply failure behavior; existing callers retain the global pause. */ + public enum MissingSupplyPolicy { PAUSE_ALL_SCRIPTS, LOGOUT_AND_STOP } + + private MissingSupplyPolicy missingSupplyPolicy = MissingSupplyPolicy.PAUSE_ALL_SCRIPTS; + + /** + * Selects behavior for confirmed required-supply shortages on this loader only. + * Set this policy before loading; terminal logout must be explicitly requested by the owning script. + * + * @param policy the required-supply failure policy + * @return this loader for chaining after either constructor + */ + public Rs2InventorySetup withMissingSupplyPolicy(MissingSupplyPolicy policy) { + missingSupplyPolicy = Objects.requireNonNull(policy, "policy"); + return this; + } + /** * Constructor to initialize the Rs2InventorySetup with a specific setup name and scheduler. * @@ -863,10 +880,12 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); logSetup(Level.WARN, - "missing gear %s | bank=%d | inv=%d | need=%d — stopping", - item.getName(), bankGear, invGear, item.getQuantity()); + "missing gear %s | bank=%d | inv=%d | need=%d — %s", + item.getName(), bankGear, invGear, item.getQuantity(), + missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP ? "stopping" : "pausing"); stopForMissingRequiredSupply(item.getName()); - return false; + if (missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP) return false; + continue; } if (inInventory) { @@ -902,6 +921,10 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { } private void stopForMissingRequiredSupply(String itemName) { + if (missingSupplyPolicy == MissingSupplyPolicy.PAUSE_ALL_SCRIPTS) { + Microbot.pauseAllScripts.compareAndSet(false, true); + return; + } Microbot.status = "Missing required setup supply: " + itemName; Rs2Player.logoutWithoutAutoLogin(); // A later script start must not revive this run's failing bank loop. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 141e5b061a..3caa11536b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -488,7 +488,7 @@ private static TerminalLogoutState terminalLogoutInputStep() { /** * Best-effort terminal logout after a confirmed supply failure, off the client thread. - * Suppression stays armed even if logout input fails. Resume by manually logging in + * Pending suppression expires after 15 seconds if logout never completes. Resume by manually logging in * after the logout, starting another script, or restarting AutoLogin. * The return value distinguishes requested input from observed logout completion. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java index d932dbbebb..2a92f48e67 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -2,6 +2,7 @@ import net.runelite.api.GameState; import org.junit.Test; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.Assert.*; public class AutoLoginSuppressionTest { @@ -33,4 +34,56 @@ public class AutoLoginSuppressionTest { suppression.observe(GameState.LOGGED_IN); assertFalse(suppression.isSuppressed()); } + @Test public void failedLogoutExpiresWithoutAnyGameStateEvent() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + assertTrue(suppression.isSuppressed()); + clock.incrementAndGet(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void laterUnrelatedLogoutDoesNotConsumeExpiredRequest() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.observe(GameState.LOGIN_SCREEN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void delayedLogoutWithinDeadlineRemainsSuppressedIndefinitely() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.observe(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 100); + suppression.observe(GameState.LOGIN_SCREEN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void repeatedPendingRequestsDoNotExtendDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.request(); + clock.incrementAndGet(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void newRequestAfterExpiryGetsItsOwnDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.request(); + assertTrue(suppression.isSuppressed()); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2); + assertFalse(suppression.isSuppressed()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java index 5f6a8143ae..e05d8d1aaa 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java @@ -21,19 +21,32 @@ public class InventorySetupSupplyLogoutTest { @Test public void requiredInventoryItemMissingRequestsTerminalLogout() { - checkMissing(false, true, true); + checkMissing(false, true, true, true); } @Test public void requiredGearMissingRequestsTerminalLogout() { - checkMissing(true, true, true); + checkMissing(true, true, true, true); } @Test public void bankMirrorNotReadyDoesNotRequestLogout() { - checkMissing(false, false, false); - checkMissing(true, false, false); + checkMissing(false, false, false, true); + checkMissing(true, false, false, true); } - private void checkMissing(boolean gear, boolean mirrorReady, boolean expectLogout) { + @Test public void existingInventoryCallerPausesWithoutLoggingOutOrCancelling() { + checkMissing(false, true, false, false); + } + + @Test public void existingGearCallerPausesWithoutLoggingOutOrCancelling() { + checkMissing(true, true, false, false); + } + + @Test public void defaultPolicyDoesNotPauseForAnUnreadyBankMirror() { + checkMissing(false, false, false, false); + checkMissing(true, false, false, false); + } + + private void checkMissing(boolean gear, boolean mirrorReady, boolean expectLogout, boolean optIn) { boolean pausedBefore = Microbot.pauseAllScripts.getAndSet(false); InventorySetup setup = mock(InventorySetup.class); InventorySetupsItem row = new InventorySetupsItem(100, "Required supply", 1, true, @@ -57,10 +70,13 @@ private void checkMissing(boolean gear, boolean mirrorReady, boolean expectLogou equipment.when(Rs2Equipment::all).thenAnswer(call -> Stream.empty()); ScheduledFuture scheduler = mock(ScheduledFuture.class); Rs2InventorySetup loader = new Rs2InventorySetup(setup, scheduler); + if (optIn) { + assertSame(loader, loader.withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP)); + } assertFalse(gear ? loader.loadEquipment(false) : loader.loadInventory(false)); player.verify(Rs2Player::logoutWithoutAutoLogin, times(expectLogout ? 1 : 0)); verify(scheduler, times(expectLogout ? 1 : 0)).cancel(false); - assertFalse(Microbot.pauseAllScripts.get()); // A subsequent run must not inherit a global pause. + assertEquals(!optIn && mirrorReady, Microbot.pauseAllScripts.get()); } finally { Microbot.pauseAllScripts.set(pausedBefore); } From 474c2a7d1aecd7c189e42d3e77430c857cb150f7 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:31:34 +0200 Subject: [PATCH 45/62] fix(shortestpath): make farmed spirit tree destinations opt-in The five farming-patch spirit trees (Port Sarim, Etceteria, Brimhaven, Hosidius, Farming Guild) only require Tree Gnome Village and The Grand Tree in spirit_trees.tsv, and their toggles defaulted to true, so every account with both quests planned routes through trees it never grew. Patch varbits are only readable near the patch, so remote planning cannot gate on them. Default the toggles to false and reword the section and descriptions so players enable only patches they have grown. Saved user values are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shortestpath/ShortestPathConfig.java | 24 ++-- .../FarmedSpiritTreeDefaultsTest.java | 118 ++++++++++++++++++ 2 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index 79c5a60fb5..0f8c8c76fc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -828,8 +828,8 @@ default boolean bankTripWhenCacheUnavailable() { } @ConfigSection( - name = "Spirit tree teleports", - description = "Toggle which spirit tree destinations to use", + name = "Farmed spirit trees (opt-in)", + description = "Enable only the farming-patch spirit trees you have grown yourself", position = 5, closedByDefault = true ) @@ -838,56 +838,56 @@ default boolean bankTripWhenCacheUnavailable() { @ConfigItem( keyName = "spiritTreeEtceteria", name = "Etceteria", - description = "Use the spirit tree teleport to Etceteria", + description = "Use the spirit tree in Etceteria. Only enable if you have grown a spirit tree in this patch", position = 0, section = sectionSpiritTrees ) default boolean spiritTreeEtceteria() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeBrimhaven", name = "Brimhaven", - description = "Use the spirit tree teleport to Brimhaven", + description = "Use the spirit tree in Brimhaven. Only enable if you have grown a spirit tree in this patch", position = 1, section = sectionSpiritTrees ) default boolean spiritTreeBrimhaven() { - return true; + return false; } @ConfigItem( keyName = "spiritTreePortSarim", name = "Port Sarim", - description = "Use the spirit tree teleport to Port Sarim", + description = "Use the spirit tree in Port Sarim. Only enable if you have grown a spirit tree in this patch", position = 2, section = sectionSpiritTrees ) default boolean spiritTreePortSarim() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeHosidius", name = "Hosidius", - description = "Use the spirit tree teleport to Hosidius", + description = "Use the spirit tree in Hosidius. Only enable if you have grown a spirit tree in this patch", position = 3, section = sectionSpiritTrees ) default boolean spiritTreeHosidius() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeFarmingGuild", name = "Farming Guild", - description = "Use the spirit tree teleport to the Farming Guild", + description = "Use the spirit tree in the Farming Guild. Only enable if you have grown a spirit tree in this patch", position = 4, section = sectionSpiritTrees ) default boolean spiritTreeFarmingGuild() { - return true; + return false; } @ConfigSection( diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java new file mode 100644 index 0000000000..46761f6335 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java @@ -0,0 +1,118 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathConfig; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class FarmedSpiritTreeDefaultsTest +{ + private static final List QUEST_TREES = Arrays.asList( + new WorldPoint(2542, 3170, 0), + new WorldPoint(2461, 3444, 0), + new WorldPoint(2555, 3259, 0), + new WorldPoint(3185, 3508, 0)); + + @Test + public void farmedTreeTogglesDefaultOff() + { + ShortestPathConfig defaults = new ShortestPathConfig() { }; + assertFalse(defaults.spiritTreeEtceteria()); + assertFalse(defaults.spiritTreeBrimhaven()); + assertFalse(defaults.spiritTreePortSarim()); + assertFalse(defaults.spiritTreeHosidius()); + assertFalse(defaults.spiritTreeFarmingGuild()); + assertTrue(defaults.useSpiritTrees()); + } + + @Test + public void defaultConfigAdmitsOnlyQuestSpiritTrees() throws Exception + { + PathfinderConfig config = new PathfinderConfig( + null, Collections.emptyMap(), Collections.emptyList(), null, null, + Rs2TransportPlanningPolicy.INSTANCE); + ShortestPathConfig defaults = new ShortestPathConfig() { }; + set(config, "useSpiritTreeEtceteria", defaults.spiritTreeEtceteria()); + set(config, "useSpiritTreeBrimhaven", defaults.spiritTreeBrimhaven()); + set(config, "useSpiritTreePortSarim", defaults.spiritTreePortSarim()); + set(config, "useSpiritTreeHosidius", defaults.spiritTreeHosidius()); + set(config, "useSpiritTreeFarmingGuild", defaults.spiritTreeFarmingGuild()); + + Field farmedField = PathfinderConfig.class.getDeclaredField("SPIRIT_TREE_DESTINATIONS_ORDERED"); + farmedField.setAccessible(true); + List farmed = Arrays.asList((WorldPoint[]) farmedField.get(null)); + Method enabled = PathfinderConfig.class.getDeclaredMethod("isSpiritTreeRouteEnabled", Transport.class); + enabled.setAccessible(true); + + List spiritTrees = Transport.loadAllFromResources().values().stream() + .flatMap(Set::stream) + .filter(t -> t.getType() == TransportType.SPIRIT_TREE) + .collect(Collectors.toList()); + assertFalse(spiritTrees.isEmpty()); + + int farmedRoutes = 0; + for (Transport t : spiritTrees) + { + if (near(t.getOrigin(), farmed) || near(t.getDestination(), farmed)) + { + farmedRoutes++; + assertFalse("farmed spirit tree admitted by default: " + t.getOrigin() + " -> " + t.getDestination(), + (boolean) enabled.invoke(config, t)); + } + } + assertTrue(farmedRoutes > 0); + + for (WorldPoint from : QUEST_TREES) + { + for (WorldPoint to : QUEST_TREES) + { + if (from.equals(to)) + { + continue; + } + boolean admitted = spiritTrees.stream() + .filter(t -> t.getOrigin() != null && t.getOrigin().distanceTo2D(from) <= 5) + .filter(t -> to.equals(t.getDestination())) + .anyMatch(t -> invoke(enabled, config, t)); + assertTrue("quest spirit tree route missing: " + from + " -> " + to, admitted); + } + } + } + + private static boolean near(WorldPoint point, List targets) + { + return point != null && targets.stream().anyMatch(target -> point.distanceTo2D(target) <= 5); + } + + private static boolean invoke(Method method, PathfinderConfig config, Transport transport) + { + try + { + return (boolean) method.invoke(config, transport); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + private static void set(PathfinderConfig config, String name, boolean value) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField(name); + field.setAccessible(true); + field.setBoolean(config, value); + } +} From 5a763ec25faf60d500f176b50bd52b649e11319f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:51:11 +0200 Subject: [PATCH 46/62] fix(shortestpath): flag Port Sarim rat-pit manhole and ladder as members-only The manhole at 3018,3231 and the rat-pit exit ladder had an empty isMembers column, so PathfinderConfig.useTransport admitted them on free worlds and the walker routed Port Sarim to Draynor through a members-only area it could never enter (issue #1898). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/shortestpath/transports.tsv | 4 +- .../shortestpath/WalkerRouteCorpusTest.java | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index cb95816d09..e70121326d 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -1156,8 +1156,8 @@ 3026 3247 1 3025 3248 0 Climb-down;Staircase;9584 3025 3248 0 3026 3247 1 Climb-up;Staircase;9582 3025 3249 0 3026 3247 1 Climb-up;Staircase;9582 -3018 3231 0 2962 9650 0 Climb-down;Manhole;10321 -2962 9650 0 3018 3233 0 Climb-up;Ladder;10309 +3018 3231 0 2962 9650 0 Climb-down;Manhole;10321 Y +2962 9650 0 3018 3233 0 Climb-up;Ladder;10309 Y # Wizard Tower 3105 3162 0 3104 9576 0 Climb-down;Ladder;2147 diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index 959e6d95d0..fa3606b772 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -1,6 +1,8 @@ package net.runelite.client.plugins.microbot.shortestpath; +import net.runelite.api.Client; import net.runelite.api.Quest; +import net.runelite.api.WorldType; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; @@ -11,8 +13,10 @@ import org.junit.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -24,6 +28,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Route-regression corpus: real end-to-end routes computed with the real pathfinder, collision map and @@ -821,6 +827,56 @@ public void draynorRoadPassesTheClosedSewerTrapdoorWithoutUsingIt() { assertFalse(path.stream().anyMatch(point -> point.getY() > 9000)); } + private static final WorldPoint PORT_SARIM_MANHOLE = new WorldPoint(3018, 3231, 0); + private static final WorldPoint DRAYNOR_BANK = new WorldPoint(3092, 3245, 0); + + private static List portSarimRatPitTransports() { + return allTransports.values().stream() + .flatMap(Set::stream) + .filter(t -> t.getObjectId() == 10321 || t.getObjectId() == 10309) + .filter(t -> t.getOrigin() != null && t.getDestination() != null) + .filter(t -> t.getOrigin().equals(PORT_SARIM_MANHOLE) + || t.getOrigin().equals(new WorldPoint(2962, 9650, 0))) + .collect(Collectors.toList()); + } + + @Test + public void portSarimRatPitManholeAndLadderAreMembersOnly() { + List rows = portSarimRatPitTransports(); + assertEquals("both Port Sarim rat-pit rows must stay in the catalog", 2, rows.size()); + for (Transport row : rows) { + assertTrue("rat-pit transport must be members-only: " + row, row.isMembers()); + } + } + + @Test + public void portSarimRatPitManholeIsNotAdmittedOnFreeWorld() throws Exception { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.noneOf(WorldType.class)); + PathfinderConfig config = new PathfinderConfig( + collisionMap, new HashMap<>(), Collections.emptyList(), client, null); + Method useTransport = PathfinderConfig.class.getDeclaredMethod( + "useTransport", Transport.class, boolean.class); + useTransport.setAccessible(true); + + List rows = portSarimRatPitTransports(); + assertFalse(rows.isEmpty()); + for (Transport row : rows) { + assertFalse("free world must not admit " + row, (Boolean) useTransport.invoke(config, row, false)); + } + } + + @Test + public void portSarimToDraynorBankOnFreeWorldStaysOnTheSurface() { + Pathfinder pathfinder = runPathfinder(configWith(t -> unrestricted(t) && !t.isMembers()), + new WorldPoint(3018, 3229, 0), DRAYNOR_BANK); + List path = pathfinder.getPath(); + + assertTrue(arrives(path, DRAYNOR_BANK, 2)); + assertFalse(selectsTransportObject(pathfinder, 10321)); + assertFalse(path.stream().anyMatch(point -> point.getY() > 9000)); + } + @Test public void zanarisShedDoorRequiresLostCity() { Transport shed = allTransports.getOrDefault(new WorldPoint(3202, 3169, 0), Collections.emptySet()).stream() From c173f7d204879b7e6f37d0e83b9c3566da14eb1e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:12:46 +0200 Subject: [PATCH 47/62] fix(walker): strike transports on first interaction and on repeated recovery fall-throughs The previous round only counted a recovery refusal when a click serial moved, and the serial moved only after a whole handler succeeded. A spirit tree that clicked Travel but could not pick the destination, and the Lumbridge Swamp shed door whose origin was walled off, never counted, so both still looped until the tail-iteration cap. Every dispatch now records a TransportDispatchTrace: whether the first game interaction was issued, which deliberate skip applied (stationary door settling, already at destination, spirit-tree travel off, plane mismatch, Barrows approach, terminal already attempted or approaching) and which refusal applied (origin unreachable, action missing, object missing, interact failed, handler failed, unsupported). The dispatcher strikes an edge when it interacted and the player is still at the origin (limit 2). Recovery step (2) additionally counts refusals without any interaction (limit 3). Deliberate skips never strike. Executor tests drive handleSelectedTransport and the recovery dispatch with static mocks: the spirit tree and the walled origin both strike out and the replan takes the fallback, while spirit-tree travel off and a plane mismatch never strike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/walker/Rs2Walker.java | 23 +- .../util/walker/Rs2WalkerTransports.java | 201 ++++++++++--- .../transport/TransportDispatchTrace.java | 95 ++++++ .../transport/TransportRefusalLedger.java | 27 +- .../walker/TransportRefusalExecutorTest.java | 282 ++++++++++++++++++ .../walker/TransportRefusalStrikeOutTest.java | 79 ++++- 6 files changed, 630 insertions(+), 77 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index a0c78b0f44..0b8db1f58a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -86,7 +86,6 @@ import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; -import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; @@ -4444,27 +4443,7 @@ private static ObstacleResolution resolveRecoveryObstacle(List rawPa && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { WebWalkLog.spInfo("recovery_on_origin_transport | origin={} player={} rawIdx={}", compactWorldPoint(rawPath.get(ri)), compactWorldPoint(playerLoc), ri); - WorldPoint edgeOrigin = rawPath.get(ri); - WorldPoint edgeDestination = rawPath.get(ri + 1); - int strikesBefore = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); - long clicksBefore = Rs2WalkerTransports.transportClickSerial(); - if (handleTransports(rawPath, ri)) { - Rs2WalkerTransports.clearTransportRefusals(edgeOrigin, edgeDestination); - return ObstacleResolution.interacted(); - } - int strikesAfter = Rs2WalkerTransports.transportRefusalStrikes(edgeOrigin, edgeDestination); - boolean struckOut; - if (strikesAfter != strikesBefore) { - struckOut = TransportRefusalLedger.isStrikeOut(strikesAfter); - } else if (Rs2WalkerTransports.isRefusedRecoveryDispatch( - Rs2WalkerTransports.transportClickSerial() != clicksBefore, - Rs2Player.getWorldLocation(), edgeOrigin, edgeDestination)) { - struckOut = Rs2WalkerTransports.registerTransportRefusal( - edgeOrigin, edgeDestination, "recovery-dispatch"); - } else { - struckOut = false; - } - if (struckOut) { + if (Rs2WalkerTransports.dispatchRecoveryTransport(rawPath, ri)) { return ObstacleResolution.interacted(); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java index dfb733aad3..1cd33ee95d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java @@ -81,6 +81,7 @@ import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace; import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; @@ -152,6 +153,92 @@ private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int max + static final class DispatchResult { + final boolean handled; + final TransportDispatchTrace.Verdict verdict; + final TransportDispatchTrace trace; + final boolean struckOut; + + DispatchResult(boolean handled, TransportDispatchTrace.Verdict verdict, + TransportDispatchTrace trace, boolean struckOut) { + this.handled = handled; + this.verdict = verdict; + this.trace = trace; + this.struckOut = struckOut; + } + } + + private static final ThreadLocal DISPATCH_TRACE = new ThreadLocal<>(); + + static DispatchResult dispatchSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection) { + TransportDispatchTrace previous = DISPATCH_TRACE.get(); + TransportDispatchTrace trace = new TransportDispatchTrace(); + DISPATCH_TRACE.set(trace); + boolean handled; + try { + handled = handleSelectedTransport(path, indexOfStartPoint, selection); + } finally { + if (previous != null) { + DISPATCH_TRACE.set(previous); + } else { + DISPATCH_TRACE.remove(); + } + } + Transport transport = selection != null ? selection.getLocalExecutionTransport() : null; + WorldPoint origin = transport != null ? transport.getOrigin() : null; + WorldPoint destination = transport != null ? transport.getDestination() : null; + TransportDispatchTrace.Verdict verdict = + trace.verdict(handled, Rs2Player.getWorldLocation(), origin, destination); + boolean struckOut = false; + if (verdict == TransportDispatchTrace.Verdict.HANDLED) { + transportRefusalLedger.clear(origin, destination); + } else if (verdict == TransportDispatchTrace.Verdict.REFUSED_AFTER_INTERACTION) { + struckOut = registerTransportRefusal(origin, destination, "interaction-refused"); + } + return new DispatchResult(handled, verdict, trace, struckOut); + } + + static boolean dispatchRecoveryTransport(List path, int index) { + Optional selection = + Rs2PathApi.getActiveTransportSelection(path, index); + if (selection.isEmpty()) { + return false; + } + DispatchResult result = dispatchSelectedTransport(path, index, selection.get(), false); + if (result.handled || result.struckOut) { + return true; + } + if (result.verdict != TransportDispatchTrace.Verdict.REFUSED_WITHOUT_INTERACTION) { + return false; + } + Transport transport = selection.get().getLocalExecutionTransport(); + return registerTransportFallThrough(transport.getOrigin(), transport.getDestination(), + result.trace.reason()); + } + + private static void noteTransportInteraction() { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.interacted(); + } + } + + private static void noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip reason) { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.skip(reason); + } + } + + private static void noteRefusal(TransportDispatchTrace.Refusal reason) { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.refuse(reason); + } + } + /** * Executes the exact transport retained by the active route through its registered Microbot executor. * Candidate discovery must happen through immutable route steps, never by rescanning the mutable @@ -162,6 +249,7 @@ static boolean handleSelectedTransport(List path, int indexOfStartPoint, Rs2PathApi.ActiveTransportSelection selection) { if (selection == null || !selection.isExecutable()) { + noteRefusal(TransportDispatchTrace.Refusal.UNSUPPORTED); if (selection != null) { WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", selection.getEdge().getType(), @@ -178,6 +266,7 @@ static boolean handleSelectedTransport(List path, } if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.STATIONARY_DOOR_SETTLING); return false; } if (log.isDebugEnabled()) { @@ -217,6 +306,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.PLANE_MISMATCH); continue; } @@ -231,12 +321,14 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", transport.getDisplayInfo(), OFFSET, transport.getDestination()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } } @@ -259,6 +351,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i transport.getDisplayInfo(), compactWorldPoint(transport.getOrigin()), compactWorldPoint(transport.getDestination())); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } @@ -288,6 +381,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.PLANE_MISMATCH); break; // plane won't change across iterations, so break instead of continue } @@ -303,6 +397,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { // Digging is tile-sensitive. Let the ordinary path click finish the // approach instead of firing the spade from an adjacent mound tile. + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.BARROWS_APPROACH); return false; } boolean dug = attemptObserved(transport, @@ -332,6 +427,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", transport.getType(), compactWorldPoint(transport.getOrigin()), compactWorldPoint(transport.getDestination())); + noteRefusal(TransportDispatchTrace.Refusal.UNSUPPORTED); break originLoop; } @@ -374,6 +470,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (!markTerminalTravelAttempt(transport)) { log.debug("[Walker] terminal travel edge already attempted this walk: {}", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_ALREADY_ATTEMPTED); break originLoop; } if (!npcAction.equalsIgnoreCase(transport.getAction())) { @@ -449,11 +546,13 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i transport.getName(), transport.getAction(), compactWorldPoint(transport.getDestination()), transport.getDisplayInfo()); + noteRefusal(TransportDispatchTrace.Refusal.ACTION_MISSING); break originLoop; } if (!markTerminalTravelAttempt(transport)) { log.debug("[Walker] terminal travel edge already attempted this walk: {}", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_ALREADY_ATTEMPTED); break originLoop; } prepareTransportObjectForInteraction(terminalObject); @@ -472,6 +571,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } } else { WorldPoint originTile = path.get(i); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_APPROACH); boolean clicked = Rs2Walker.walkFastCanvas(originTile); if (!clicked) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); @@ -570,6 +670,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (transport.getType() == TransportType.SPIRIT_TREE) { if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { log.debug("[Walker] skip spirit tree transport — setting is off"); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.SPIRIT_TREE_TRAVEL_DISABLED); continue; } if (attemptObserved(transport, () -> handleSpiritTree(transport))) { @@ -638,6 +739,9 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i WorldPoint plFairy = Rs2Player.getWorldLocation(); WorldPoint tdFairy = transport.getDestination(); boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); + if (alreadyAtFairyDest) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); + } if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); @@ -768,6 +872,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { if (requiresReachableObjectOrigin(Boolean.TRUE.equals(SERVER_APPROACH_ALLOWED.get())) && !Rs2Tile.isTileReachable(transport.getOrigin())) { + noteRefusal(TransportDispatchTrace.Refusal.ORIGIN_UNREACHABLE); break; } } @@ -785,7 +890,9 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", transport.getOrigin(), object.getId(), comp.getName(), transportAction); final int closedId = object.getId(); - Rs2GameObject.interact(object, "Open"); + if (Rs2GameObject.interact(object, "Open")) { + noteTransportInteraction(); + } Rs2Player.waitForAnimation(2000); TileObject reopened = Rs2GameObject.getAll(o -> { if (o.getId() == closedId) return false; @@ -799,8 +906,11 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } } - String interactionAction = resolveTransportObjectAction(object, transportActions) - .orElse(transportAction); + Optional resolvedAction = resolveTransportObjectAction(object, transportActions); + if (resolvedAction.isEmpty()) { + noteRefusal(TransportDispatchTrace.Refusal.ACTION_MISSING); + } + String interactionAction = resolvedAction.orElse(transportAction); if (!Objects.equals(interactionAction, transportAction)) { log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", interactionAction, transportAction, object.getWorldLocation(), object.getId()); @@ -837,17 +947,14 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i POST_HANDLE_OBJECT_LANDING_WAIT_MS, compactWorldPoint(destWait), compactWorldPoint(afterInteraction)); - if (isStillAtTransportOrigin(afterInteraction, transport.getOrigin(), destWait)) { - registerTransportRefusal(transport.getOrigin(), destWait, "landing-unresolved"); - } } if (landedAfterObject) { - transportRefusalLedger.clear(transport.getOrigin(), destWait); markShortSamePlaneTransportHandled(transport, object); return finishHandledTransport(transport); } return false; } + noteRefusal(TransportDispatchTrace.Refusal.OBJECT_MISSING); } } } @@ -856,33 +963,10 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i private static final TransportRefusalLedger transportRefusalLedger = new TransportRefusalLedger(); - private static final AtomicLong transportClickSerial = new AtomicLong(); - - private static void recordTransportClick() { - transportClickSerial.incrementAndGet(); - } - - static long transportClickSerial() { - return transportClickSerial.get(); - } - - static boolean isRefusedRecoveryDispatch(boolean clicked, WorldPoint player, - WorldPoint origin, WorldPoint destination) { - return TransportRefusalLedger.isRefusedDispatch(clicked, player, origin, destination); - } - - static boolean isStillAtTransportOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { - return TransportRefusalLedger.isStillAtOrigin(player, origin, destination); - } - static int transportRefusalStrikes(WorldPoint origin, WorldPoint destination) { return transportRefusalLedger.strikes(origin, destination); } - static void clearTransportRefusals(WorldPoint origin, WorldPoint destination) { - transportRefusalLedger.clear(origin, destination); - } - static boolean registerTransportRefusal(WorldPoint origin, WorldPoint destination, String mode) { if (origin == null || destination == null) { return false; @@ -894,15 +978,38 @@ static boolean registerTransportRefusal(WorldPoint origin, WorldPoint destinatio strikes, TransportRefusalLedger.STRIKE_LIMIT); return false; } + strikeOutTransport(origin, destination, mode, TransportRefusalLedger.STRIKE_LIMIT); + return true; + } + + static boolean registerTransportFallThrough(WorldPoint origin, WorldPoint destination, String reason) { + if (origin == null || destination == null) { + return false; + } + int fallThroughs = transportRefusalLedger.registerFallThrough(origin, destination); + if (!TransportRefusalLedger.isFallThroughStrikeOut(fallThroughs)) { + WebWalkLog.spInfo("transport_fall_through | origin={} dest={} reason={} strike={}/{}", + compactWorldPoint(origin), compactWorldPoint(destination), reason, + fallThroughs, TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT); + return false; + } + strikeOutTransport(origin, destination, "fall-through " + reason, + TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT); + return true; + } + + static int transportFallThroughs(WorldPoint origin, WorldPoint destination) { + return transportRefusalLedger.fallThroughs(origin, destination); + } + + private static void strikeOutTransport(WorldPoint origin, WorldPoint destination, String mode, int limit) { if (Rs2PathApi.learnBlockedEdge(origin, destination, "transport-refused (" + mode + ")")) { transportRefusalLedger.recordWalkScopedBlock(origin, destination); } WebWalkLog.spInfo("transport_strike_out | origin={} dest={} mode={} — {} refusals; " + "blocking transport for this walk and replanning", - compactWorldPoint(origin), compactWorldPoint(destination), mode, - TransportRefusalLedger.STRIKE_LIMIT); + compactWorldPoint(origin), compactWorldPoint(destination), mode, limit); recalculatePath(); - return true; } static void withdrawWalkScopedTransportBlocks() { @@ -1098,9 +1205,10 @@ private static boolean handleObject(Transport transport, TileObject tileObject, ensureRequiredItemBeforeTransport(transport); WorldPoint before = Rs2Player.getWorldLocation(); if (!Rs2GameObject.interact(tileObject, action)) { + noteRefusal(TransportDispatchTrace.Refusal.INTERACT_FAILED); return false; } - recordTransportClick(); + noteTransportInteraction(); // Unlike the other exception handlers, a toll-gate interaction is not complete merely // because the menu action was issued: it may first server-walk from several tiles away and // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so @@ -1212,10 +1320,17 @@ static boolean handleSelectedTransport(List path, int indexOfStartPoint, Rs2PathApi.ActiveTransportSelection selection, boolean allowServerApproach) { + return dispatchSelectedTransport(path, indexOfStartPoint, selection, allowServerApproach).handled; + } + + static DispatchResult dispatchSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection, + boolean allowServerApproach) { Boolean previous = SERVER_APPROACH_ALLOWED.get(); SERVER_APPROACH_ALLOWED.set(allowServerApproach); try { - return handleSelectedTransport(path, indexOfStartPoint, selection); + return dispatchSelectedTransport(path, indexOfStartPoint, selection); } finally { if (Boolean.TRUE.equals(previous)) { SERVER_APPROACH_ALLOWED.set(Boolean.TRUE); @@ -2238,7 +2353,11 @@ private static boolean attemptObserved(Transport transport, BooleanSupplier acti boolean ok = action.getAsBoolean(); if (ok) { - recordTransportClick(); + noteTransportInteraction(); + } + else + { + noteRefusal(TransportDispatchTrace.Refusal.HANDLER_FAILED); } if (leaguesActive) { @@ -2262,7 +2381,11 @@ private static boolean attemptObservedWithoutAttemptRecord(Transport transport, boolean ok = action.getAsBoolean(); if (ok) { - recordTransportClick(); + noteTransportInteraction(); + } + else + { + noteRefusal(TransportDispatchTrace.Refusal.HANDLER_FAILED); } if (leaguesActive) { @@ -2388,6 +2511,7 @@ private static boolean handleSpiritTree(Transport transport) { if (!interactResult) { return false; } + noteTransportInteraction(); } boolean result = interactWithAdventureLog(transport); @@ -2807,6 +2931,7 @@ private static boolean handleQuetzal(Transport transport) { Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { + noteTransportInteraction(); Rs2Player.waitForWalking(); WorldPoint dest = transport.getDestination(); String mapLabel = resolveQuetzalMapOptionLabel(transport); @@ -2858,6 +2983,7 @@ private static boolean handleCharterShip(Transport transport) { Rs2NpcModel npc = Rs2Npc.getNpc(npcName); log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { + noteTransportInteraction(); Rs2Player.waitForWalking(); if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4), 5000)) { return false; @@ -3122,6 +3248,7 @@ private static boolean handleGlider(Transport transport) { // Interact with the gnome glider NPC if (Rs2Npc.interact(gnome, action)) { + noteTransportInteraction(); sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java new file mode 100644 index 0000000000..a0a1d3f1a1 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java @@ -0,0 +1,95 @@ +package net.runelite.client.plugins.microbot.util.walker.transport; + +import net.runelite.api.coords.WorldPoint; + +public final class TransportDispatchTrace { + + public enum Verdict { + HANDLED, + REFUSED_AFTER_INTERACTION, + INTERACTION_LEFT_ORIGIN, + DELIBERATE_SKIP, + REFUSED_WITHOUT_INTERACTION, + NOT_ATTEMPTED + } + + public enum DeliberateSkip { + STATIONARY_DOOR_SETTLING, + ALREADY_AT_DESTINATION, + SPIRIT_TREE_TRAVEL_DISABLED, + PLANE_MISMATCH, + BARROWS_APPROACH, + TERMINAL_ALREADY_ATTEMPTED, + TERMINAL_APPROACH + } + + public enum Refusal { + ORIGIN_UNREACHABLE, + ACTION_MISSING, + OBJECT_MISSING, + INTERACT_FAILED, + HANDLER_FAILED, + UNSUPPORTED + } + + private boolean interacted; + private DeliberateSkip deliberateSkip; + private Refusal refusal; + + public void interacted() { + interacted = true; + } + + public void skip(DeliberateSkip reason) { + if (deliberateSkip == null) { + deliberateSkip = reason; + } + } + + public void refuse(Refusal reason) { + if (refusal == null) { + refusal = reason; + } + } + + public boolean hasInteracted() { + return interacted; + } + + public DeliberateSkip deliberateSkip() { + return deliberateSkip; + } + + public Refusal refusal() { + return refusal; + } + + public Verdict verdict(boolean handled, WorldPoint player, WorldPoint origin, WorldPoint destination) { + return classify(handled, interacted, deliberateSkip, refusal, + TransportRefusalLedger.isStillAtOrigin(player, origin, destination)); + } + + public static Verdict classify(boolean handled, boolean interacted, DeliberateSkip deliberateSkip, + Refusal refusal, boolean stillAtOrigin) { + if (handled) { + return Verdict.HANDLED; + } + if (interacted) { + return stillAtOrigin ? Verdict.REFUSED_AFTER_INTERACTION : Verdict.INTERACTION_LEFT_ORIGIN; + } + if (deliberateSkip != null) { + return Verdict.DELIBERATE_SKIP; + } + if (refusal != null) { + return Verdict.REFUSED_WITHOUT_INTERACTION; + } + return Verdict.NOT_ATTEMPTED; + } + + public String reason() { + if (deliberateSkip != null) { + return deliberateSkip.name(); + } + return refusal != null ? refusal.name() : ""; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java index b7a9affafe..0b629c620f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java @@ -11,8 +11,10 @@ public final class TransportRefusalLedger { public static final int STRIKE_LIMIT = 2; + public static final int FALL_THROUGH_STRIKE_LIMIT = 3; private final Map, Integer> strikes = new ConcurrentHashMap<>(); + private final Map, Integer> fallThroughs = new ConcurrentHashMap<>(); private final ConcurrentLinkedQueue walkScopedBlocks = new ConcurrentLinkedQueue<>(); public int registerRefusal(WorldPoint origin, WorldPoint destination) { @@ -22,10 +24,21 @@ public int registerRefusal(WorldPoint origin, WorldPoint destination) { return strikes.merge(List.of(origin, destination), 1, Integer::sum); } + public int registerFallThrough(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return fallThroughs.merge(List.of(origin, destination), 1, Integer::sum); + } + public static boolean isStrikeOut(int strikeCount) { return strikeCount == STRIKE_LIMIT; } + public static boolean isFallThroughStrikeOut(int fallThroughCount) { + return fallThroughCount == FALL_THROUGH_STRIKE_LIMIT; + } + public static boolean isStillAtOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { if (player == null || origin == null || destination == null || player.getPlane() != origin.getPlane()) { return false; @@ -37,11 +50,6 @@ public static boolean isStillAtOrigin(WorldPoint player, WorldPoint origin, Worl return player.getPlane() != destination.getPlane() || toOrigin < player.distanceTo2D(destination); } - public static boolean isRefusedDispatch(boolean clicked, WorldPoint player, - WorldPoint origin, WorldPoint destination) { - return clicked && isStillAtOrigin(player, origin, destination); - } - public int strikes(WorldPoint origin, WorldPoint destination) { if (origin == null || destination == null) { return 0; @@ -49,11 +57,19 @@ public int strikes(WorldPoint origin, WorldPoint destination) { return strikes.getOrDefault(List.of(origin, destination), 0); } + public int fallThroughs(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return fallThroughs.getOrDefault(List.of(origin, destination), 0); + } + public void clear(WorldPoint origin, WorldPoint destination) { if (origin == null || destination == null) { return; } strikes.remove(List.of(origin, destination)); + fallThroughs.remove(List.of(origin, destination)); } public void recordWalkScopedBlock(WorldPoint origin, WorldPoint destination) { @@ -65,6 +81,7 @@ public void recordWalkScopedBlock(WorldPoint origin, WorldPoint destination) { public List drainWalkScopedBlocks() { strikes.clear(); + fallThroughs.clear(); List drained = new ArrayList<>(); WorldPoint[] edge; while ((edge = walkScopedBlocks.poll()) != null) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java new file mode 100644 index 0000000000..459b172664 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java @@ -0,0 +1,282 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.Scene; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportRefusalExecutorTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3222, 3218, 0); + private static final WorldPoint FALLBACK_ORIGIN = new WorldPoint(3222, 3220, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3222, 9618, 0); + + private final List> statics = new ArrayList<>(); + private final List learnedEdges = new ArrayList<>(); + private PathfinderConfig config; + private WorldPoint playerLocation; + private boolean spiritTreeTravelEnabled; + private boolean originReachable; + private int travelClicks; + private Rs2PathApi.ActiveTransportSelection selection; + + @Before + public void setUp() + { + playerLocation = ORIGIN; + spiritTreeTravelEnabled = true; + originReachable = true; + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); + } + + @After + public void tearDown() + { + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); + for (int i = statics.size() - 1; i >= 0; i--) + { + statics.get(i).close(); + } + statics.clear(); + } + + @Test + public void spiritTreeThatClicksTravelButCannotPickTheDestinationIsStruckAndReplanned() throws Exception + { + Transport spiritTree = new Transport(ORIGIN, DESTINATION, "Tree Gnome Village", + TransportType.SPIRIT_TREE, true, "Travel", "Spirit tree", 1293, 1); + install(spiritTree); + assertEquals(List.of(ORIGIN, DESTINATION), TransportRefusalStrikeOutTest.plan(config, ORIGIN, DESTINATION)); + + Rs2WalkerTransports.DispatchResult first = dispatch(); + assertFalse(first.handled); + assertEquals(TransportDispatchTrace.Verdict.REFUSED_AFTER_INTERACTION, first.verdict); + assertFalse(first.struckOut); + assertTrue("Travel was clicked", travelClicks > 0); + assertEquals(1, Rs2WalkerTransports.transportRefusalStrikes(ORIGIN, DESTINATION)); + + Rs2WalkerTransports.DispatchResult second = dispatch(); + assertTrue(second.struckOut); + assertEquals(List.of(edge(ORIGIN, DESTINATION)), learnedEdges); + assertReplanAvoidsRefusedEdge(); + } + + @Test + public void recoveryOriginUnreachableBailIsStruckAfterRepeatedFallThroughs() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + playerLocation = new WorldPoint(ORIGIN.getX() - 1, ORIGIN.getY(), 0); + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 1; pass < TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT; pass++) + { + assertFalse("pass " + pass + " must fall through", Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + assertEquals(pass, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + } + assertTrue(learnedEdges.isEmpty()); + + assertTrue("third fall-through strikes the edge out", Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + assertEquals(List.of(edge(ORIGIN, DESTINATION)), learnedEdges); + assertReplanAvoidsRefusedEdge(); + } + + @Test + public void recoveryDispatchClassifiesTheOriginUnreachableBailAsARefusal() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + + Rs2WalkerTransports.DispatchResult result = dispatch(); + assertEquals(TransportDispatchTrace.Verdict.REFUSED_WITHOUT_INTERACTION, result.verdict); + assertEquals(TransportDispatchTrace.Refusal.ORIGIN_UNREACHABLE, result.trace.refusal()); + assertEquals("a fall-through outside recovery is not counted", 0, + Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + } + + @Test + public void spiritTreeTravelSwitchedOffIsADeliberateSkipThatNeverStrikes() throws Exception + { + Transport spiritTree = new Transport(ORIGIN, DESTINATION, "Tree Gnome Village", + TransportType.SPIRIT_TREE, true, "Travel", "Spirit tree", 1293, 1); + install(spiritTree); + spiritTreeTravelEnabled = false; + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 0; pass < 6; pass++) + { + assertFalse(Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + } + assertEquals(0, travelClicks); + assertEquals(0, Rs2WalkerTransports.transportRefusalStrikes(ORIGIN, DESTINATION)); + assertEquals(0, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + assertTrue(learnedEdges.isEmpty()); + assertEquals(TransportDispatchTrace.DeliberateSkip.SPIRIT_TREE_TRAVEL_DISABLED, dispatch().trace.deliberateSkip()); + } + + @Test + public void planeMismatchIsADeliberateSkipThatNeverStrikes() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + playerLocation = new WorldPoint(ORIGIN.getX(), ORIGIN.getY(), 1); + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 0; pass < 6; pass++) + { + assertFalse(Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + } + assertEquals(0, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + assertTrue(learnedEdges.isEmpty()); + assertEquals(TransportDispatchTrace.Verdict.DELIBERATE_SKIP, dispatch().verdict); + } + + private Rs2WalkerTransports.DispatchResult dispatch() + { + return Rs2WalkerTransports.dispatchSelectedTransport(List.of(ORIGIN, DESTINATION), 0, selection, false); + } + + private void assertReplanAvoidsRefusedEdge() + { + List after = TransportRefusalStrikeOutTest.plan(config, ORIGIN, DESTINATION); + assertEquals(DESTINATION, after.get(after.size() - 1)); + assertTrue("replan must board the fallback transport", after.contains(FALLBACK_ORIGIN)); + for (int i = 0; i + 1 < after.size(); i++) + { + assertFalse("replan must not reuse the refused edge", + after.get(i).equals(ORIGIN) && after.get(i + 1).equals(DESTINATION)); + } + } + + private static String edge(WorldPoint origin, WorldPoint destination) + { + return origin + "->" + destination; + } + + private void install(Transport refused) throws Exception + { + Transport fallback = new Transport(FALLBACK_ORIGIN, DESTINATION, "fallback", TransportType.TRANSPORT, false, + "Climb-down", "Ladder", 1002, 5); + Map> catalog = new HashMap<>(); + catalog.put(ORIGIN, Set.of(refused)); + catalog.put(FALLBACK_ORIGIN, Set.of(fallback)); + config = TransportRefusalStrikeOutTest.configWithTransports(catalog); + selection = selectionFor(refused); + + Client client = mock(Client.class); + WorldView worldView = mock(WorldView.class); + Scene scene = mock(Scene.class); + when(client.getTopLevelWorldView()).thenReturn(worldView); + when(client.isClientThread()).thenReturn(true); + when(worldView.getScene()).thenReturn(scene); + when(worldView.contains(any(WorldPoint.class))).thenReturn(true); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + + MockedStatic microbot = open(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + MockedStatic player = open(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenAnswer(inv -> playerLocation); + + MockedStatic pathApi = open(Rs2PathApi.class); + pathApi.when(() -> Rs2PathApi.getActiveTransportSelection(any(), anyInt())) + .thenAnswer(inv -> Optional.of(selection)); + pathApi.when(Rs2PathApi::isSpiritTreeTravelEnabled).thenAnswer(inv -> spiritTreeTravelEnabled); + pathApi.when(() -> Rs2PathApi.learnBlockedEdge(any(), any(), anyString())).thenAnswer(inv -> { + WorldPoint origin = inv.getArgument(0); + WorldPoint destination = inv.getArgument(1); + learnedEdges.add(edge(origin, destination)); + return config.learnBlockedEdge(origin, destination, inv.getArgument(2)); + }); + pathApi.when(() -> Rs2PathApi.unlearnBlockedEdge(any(), any(), anyString())).thenAnswer(inv -> + config.unlearnBlockedEdge(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2))); + + open(Rs2LeaguesTransport.class); + open(Rs2Widget.class); + open(Rs2DoorDetection.class); + + MockedStatic tile = open(Rs2Tile.class); + tile.when(() -> Rs2Tile.isTileReachable(any(WorldPoint.class))).thenAnswer(inv -> originReachable); + + GameObject sceneObject = mock(GameObject.class); + when(sceneObject.getId()).thenReturn(refused.getObjectId()); + when(sceneObject.getWorldLocation()).thenReturn(ORIGIN); + MockedStatic gameObjects = open(Rs2GameObject.class); + gameObjects.when(() -> Rs2GameObject.findObjectById(anyInt())).thenReturn(sceneObject); + gameObjects.when(() -> Rs2GameObject.interact(any(TileObject.class), eq("Travel"))).thenAnswer(inv -> { + travelClicks++; + return true; + }); + gameObjects.when(() -> Rs2GameObject.getAll(any(), any(WorldPoint.class), anyInt())).thenAnswer(inv -> + refused.getType() == TransportType.SPIRIT_TREE ? List.of() : List.of(sceneObject)); + } + + private MockedStatic open(Class type) + { + MockedStatic mocked = Mockito.mockStatic(type); + statics.add(mocked); + return mocked; + } + + private static Rs2PathApi.ActiveTransportSelection selectionFor(Transport transport) throws Exception + { + Method toEdge = Rs2PathApi.class.getDeclaredMethod("toTransportEdge", Transport.class); + toEdge.setAccessible(true); + Object edge = toEdge.invoke(null, transport); + Constructor ctor = Rs2PathApi.ActiveTransportSelection.class + .getDeclaredConstructor(int.class, edge.getClass(), Transport.class); + ctor.setAccessible(true); + return ctor.newInstance(0, edge, transport); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java index ebb34e0c6f..c4ee3bfa83 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java @@ -8,6 +8,10 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.DeliberateSkip; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.Refusal; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.Verdict; import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; import org.junit.BeforeClass; import org.junit.Test; @@ -34,7 +38,16 @@ public class TransportRefusalStrikeOutTest @BeforeClass public static void loadMap() { - collisionMap = SplitFlagMap.fromResources(); + collisionMap = sharedCollisionMap(); + } + + static synchronized SplitFlagMap sharedCollisionMap() + { + if (collisionMap == null) + { + collisionMap = SplitFlagMap.fromResources(); + } + return collisionMap; } @Test @@ -49,22 +62,57 @@ public void secondRefusalStrikesOut() } @Test - public void recoveryDispatchStrikesOnlyAfterAClickThatLeftThePlayerAtTheOrigin() + public void interactionRefusalRequiresThePlayerToStillBeAtTheOrigin() { WorldPoint door = new WorldPoint(3201, 3169, 0); WorldPoint inside = new WorldPoint(3202, 3169, 0); - assertTrue(TransportRefusalLedger.isRefusedDispatch(true, door, door, inside)); - assertTrue(TransportRefusalLedger.isRefusedDispatch(true, ORIGIN, ORIGIN, DESTINATION)); + assertTrue(TransportRefusalLedger.isStillAtOrigin(door, door, inside)); + assertTrue(TransportRefusalLedger.isStillAtOrigin(ORIGIN, ORIGIN, DESTINATION)); - assertFalse("waiting or deliberately deferring is not a refusal", - TransportRefusalLedger.isRefusedDispatch(false, door, door, inside)); assertFalse("already across the edge", - TransportRefusalLedger.isRefusedDispatch(true, inside, door, inside)); + TransportRefusalLedger.isStillAtOrigin(inside, door, inside)); assertFalse("walked away from the origin", - TransportRefusalLedger.isRefusedDispatch(true, new WorldPoint(3198, 3169, 0), door, inside)); + TransportRefusalLedger.isStillAtOrigin(new WorldPoint(3198, 3169, 0), door, inside)); assertFalse("plane mismatch", - TransportRefusalLedger.isRefusedDispatch(true, new WorldPoint(3201, 3169, 1), door, inside)); - assertFalse(TransportRefusalLedger.isRefusedDispatch(true, null, door, inside)); + TransportRefusalLedger.isStillAtOrigin(new WorldPoint(3201, 3169, 1), door, inside)); + assertFalse(TransportRefusalLedger.isStillAtOrigin(null, door, inside)); + } + + @Test + public void fallThroughsStrikeOutOnTheirOwnHigherLimit() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + for (int i = 1; i < TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT; i++) + { + assertFalse(TransportRefusalLedger.isFallThroughStrikeOut(ledger.registerFallThrough(ORIGIN, DESTINATION))); + } + assertTrue(TransportRefusalLedger.isFallThroughStrikeOut(ledger.registerFallThrough(ORIGIN, DESTINATION))); + assertEquals("fall-throughs do not count as interaction strikes", 0, ledger.strikes(ORIGIN, DESTINATION)); + ledger.clear(ORIGIN, DESTINATION); + assertEquals(0, ledger.fallThroughs(ORIGIN, DESTINATION)); + } + + @Test + public void dispatchClassificationSeparatesDeliberateSkipsFromRefusals() + { + assertEquals(Verdict.HANDLED, + TransportDispatchTrace.classify(true, true, null, Refusal.HANDLER_FAILED, true)); + assertEquals(Verdict.REFUSED_AFTER_INTERACTION, + TransportDispatchTrace.classify(false, true, null, Refusal.HANDLER_FAILED, true)); + assertEquals(Verdict.INTERACTION_LEFT_ORIGIN, + TransportDispatchTrace.classify(false, true, null, null, false)); + for (DeliberateSkip skip : DeliberateSkip.values()) + { + assertEquals(skip.name(), Verdict.DELIBERATE_SKIP, + TransportDispatchTrace.classify(false, false, skip, Refusal.ORIGIN_UNREACHABLE, true)); + } + for (Refusal refusal : Refusal.values()) + { + assertEquals(refusal.name(), Verdict.REFUSED_WITHOUT_INTERACTION, + TransportDispatchTrace.classify(false, false, null, refusal, true)); + } + assertEquals(Verdict.NOT_ATTEMPTED, + TransportDispatchTrace.classify(false, false, null, null, true)); } @Test @@ -131,16 +179,21 @@ public void staticBlockedEdgeDoesNotHideItsOwnTransport() throws Exception private static List plan(PathfinderConfig config) { - Pathfinder pathfinder = new Pathfinder(config, ORIGIN, Set.of(DESTINATION)); + return plan(config, ORIGIN, DESTINATION); + } + + static List plan(PathfinderConfig config, WorldPoint start, WorldPoint goal) + { + Pathfinder pathfinder = new Pathfinder(config, start, Set.of(goal)); pathfinder.run(); return pathfinder.getPath(); } @SuppressWarnings("unchecked") - private static PathfinderConfig configWithTransports(Map> catalog) throws Exception + static PathfinderConfig configWithTransports(Map> catalog) throws Exception { PathfinderConfig config = new PathfinderConfig( - collisionMap, new HashMap<>(catalog), Collections.emptyList(), null, null); + sharedCollisionMap(), new HashMap<>(catalog), Collections.emptyList(), null, null); Field cutoff = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); cutoff.setAccessible(true); cutoff.setLong(config, 10_000L); From f50629a2c1d5b6b63896bc1eb6d99416998eaa5f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:31:51 +0200 Subject: [PATCH 48/62] test(release): add live release E2E smoke harness Adds a hidden Release Smoke Harness plugin, scripts/run-release-e2e.sh and docs/RELEASE_E2E.md so a maintainer can run quick or full F2P smoke checks against the release candidate jar before merging development into main. The harness reuses TestRunnerPlugin/test mode, runs ordered checks with per-check and total timeouts, skip handling, cleanups that restore game state, and writes result.json plus a Markdown summary. TestRunnerPlugin gains microbot.test.world to log in to a chosen world. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- AGENTS.md | 3 + docs/AGENTIC_TESTING_LOOP.md | 1 + docs/INDEX.md | 1 + docs/RELEASE_E2E.md | 91 ++ .../microbot/testing/TestRunnerPlugin.java | 40 +- .../testing/release/CheckContext.java | 70 ++ .../testing/release/CheckFailedException.java | 7 + .../testing/release/CheckOutcome.java | 32 + .../release/CheckSkippedException.java | 7 + .../microbot/testing/release/CheckStatus.java | 12 + .../testing/release/ClientLogScanner.java | 102 ++ .../testing/release/ReleaseCheck.java | 69 ++ .../testing/release/ReleaseCheckRunner.java | 275 +++++ .../testing/release/ReleaseSmokeChecks.java | 958 ++++++++++++++++++ .../release/ReleaseSmokeHarnessPlugin.java | 165 +++ .../testing/release/ReleaseSmokeLive.java | 276 +++++ .../testing/release/ReleaseSmokeReport.java | 83 ++ .../testing/release/ReleaseSmokeResult.java | 50 + .../microbot/testing/release/ReleaseTier.java | 23 + .../testing/TestRunnerPluginTest.java | 9 + .../testing/release/ClientLogScannerTest.java | 62 ++ .../release/ReleaseCheckRunnerTest.java | 223 ++++ .../release/ReleaseSmokeResultTest.java | 155 +++ scripts/run-release-e2e.sh | 149 +++ 24 files changed, 2861 insertions(+), 2 deletions(-) create mode 100644 docs/RELEASE_E2E.md create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java create mode 100755 scripts/run-release-e2e.sh diff --git a/AGENTS.md b/AGENTS.md index 99bb0b26f4..127d7313e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ RuneLite fork with a hidden always-on plugin hosting automation scripts. Composi - Offline client-thread lookup: `./microbot-cli ct `. - Test mode: `-Dmicrobot.test.mode=true -Dmicrobot.test.script=` → results in `~/.runelite/test-results/`. Protocol: `docs/AGENTIC_TESTING_LOOP.md`. +## Releases +- Before merging `development` -> `main` (which runs `release.yml`), run `scripts/run-release-e2e.sh full` (or `quick` at minimum) against the release candidate and paste the generated `summary.md` into the release PR. See `docs/RELEASE_E2E.md`. + ## In-game settings Use the settings search bar — tab indices shift on updates. Verify changes via `./microbot-cli varbit `. diff --git a/docs/AGENTIC_TESTING_LOOP.md b/docs/AGENTIC_TESTING_LOOP.md index 82d5e468a2..f2c7fc7a85 100644 --- a/docs/AGENTIC_TESTING_LOOP.md +++ b/docs/AGENTIC_TESTING_LOOP.md @@ -70,6 +70,7 @@ java -jar microbot.jar \ | `microbot.test.script` | Plugin descriptor name to auto-enable | required | | `microbot.test.timeout` | Max runtime in ms before forced exit | `120000` | | `microbot.test.output` | Directory for results/screenshots | `~/.runelite/test-results` | +| `microbot.test.world` | Log in to this world with the active profile instead of enabling AutoLogin | unset | ### Components diff --git a/docs/INDEX.md b/docs/INDEX.md index 89f7541861..5ad15d61c9 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,5 +7,6 @@ - [Runtime CLI](MICROBOT_CLI.md) - [Agent Server](AGENT_SERVER.md) - [Agentic Testing Loop](AGENTIC_TESTING_LOOP.md) +- [Release E2E Smoke Test](RELEASE_E2E.md) - [Entity Guides](entity-guides/README.md) - [Gallery](gallery.md) diff --git a/docs/RELEASE_E2E.md b/docs/RELEASE_E2E.md new file mode 100644 index 0000000000..4d2df60b60 --- /dev/null +++ b/docs/RELEASE_E2E.md @@ -0,0 +1,91 @@ +# Release E2E Smoke Test + +A live smoke test that runs the release candidate client against a real logged-in account before `development` is merged into `main`. That merge triggers `release.yml`, which publishes the jar. The test needs a display and a logged-in game account, so it cannot run in GitHub CI. It is a local gate. + +The test reuses the existing test-mode plumbing: `TestRunnerPlugin` logs the account in, starts the hidden `Release Smoke Harness` plugin, and the harness writes its results under `~/.runelite/test-results/`. That is the same flow the [F2P web walker harness](F2P_WEBWALKER_HARNESS.md) uses. + +## Account requirements + +- An F2P account that has finished Tutorial Island, saved as the active Microbot profile. +- A few coins and a few junk items in the inventory. The bank check deposits one item and withdraws it again. It looks for, in order: Burnt meat, Burnt fish, Burnt bread, Cowhide, Egg, Bones, Ashes, Bowl, Pot, Jug, Feather. It prefers an item that already has a stack in the bank, because a full F2P bank (400 stacks) refuses new item types. +- No bank PIN, or the PIN saved in the profile. +- Any F2P location is fine to start from. The harness walks to each start point itself, using Lumbridge castle and bank, the Lumbridge castle doors, Varrock west bank and the Grand Exchange. +- Log in to an F2P world. Set `MICROBOT_E2E_WORLD` to one, for example `383`. Otherwise AutoLogin uses its own world settings, and it defaults to members worlds. + +All actions are reversible and safe. The harness does no trading, drops nothing, places no Grand Exchange offers, does no combat and stays out of the Wilderness. Each check registers cleanups that put back what it changed: doors, bank contents, the open tab, the world, and Hub plugins it installed. If a cleanup fails, the check is marked failed. + +## Run + +```bash +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh quick +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh full +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh quick bank npc-dialogue +DISPLAY=:0 scripts/run-release-e2e.sh full --jar /path/to/microbot-2.6.30.jar +scripts/run-release-e2e.sh --list +``` + +By default the script builds `:client:shadowJar` and runs that jar, so the test exercises the shaded artifact rather than the Gradle classpath. Use `--jar` or `MICROBOT_E2E_JAR` to test the exact release candidate jar. It exits non-zero when any check fails or does not run. + +| Variable | Meaning | +|---|---| +| `MICROBOT_E2E_HOME` | `user.home` for the client, for an isolated runtime home that holds the saved profile | +| `MICROBOT_E2E_WORLD` | World to log in to. `TestRunnerPlugin` logs in with the active profile instead of enabling AutoLogin | +| `MICROBOT_E2E_EXPECTED_VERSION` | Version the client must report. Defaults to `microbot.version` from `gradle.properties`; set it empty to skip the comparison | +| `MICROBOT_E2E_TOTAL_TIMEOUT_MS` | Total budget for all checks. Defaults to 15 min (quick) and 35 min (full) | +| `MICROBOT_E2E_HOP_WORLD` | Target world for `world-hop`. Defaults to the least crowded plain F2P world in the same region | +| `MICROBOT_E2E_HUB_PLUGIN` | Hub plugin for `hub-plugins`. Defaults to `AttackRangesPlusPlugin`, which only draws an overlay | +| `MICROBOT_E2E_GRADLE` | Build command, for example `flock systemd-run --user --scope -p MemoryMax=6G ./gradlew` | +| `MICROBOT_E2E_OUTPUT_DIR` | Result directory. Defaults to `/.runelite/test-results/release-e2e` | +| `--fail-fast` | Stop at the first failure. `logout` still runs | + +The client runs with telemetry disabled, so test runs do not end up in the production error reports. + +## Checks + +| ID | Tier | Covers | +|---|---|---| +| `boot` | quick | `microbot.version` matches the expected version, every built-in always-on plugin is active, and `client.log` has no unexpected `ERROR` lines since startup | +| `login` | quick | `LOGGED_IN`, player location known, world known, login finished within `loginTimeoutMs` of JVM start | +| `state-caches` | quick | At the Lumbridge castle hall: all skills plausible; inventory cache equals the client container; cached varp 173 and a varbit equal the client values; NPC, object, ground item and player queryables return results | +| `widgets` | quick | Clicks the Skills and Inventory tab stones (widget click, verified with `Rs2Tab`) and finds the "Total level" text with `Rs2Widget.findWidget` | +| `walker-transport` | quick | Short walk in the castle courtyard; walk through a door the harness closes first (castle entrance tower), which must end with `ARRIVED` on the far side; walk up the castle stairs to the bank on plane 2 | +| `bank` | quick | Nearest bank, `openBank`, `depositOne` and `withdrawOne` on one junk item, `closeBank`, inventory identical to before | +| `npc-dialogue` | quick | Talks to a Lumbridge banker, reads the text, picks "What is this place?", continues, then walks away to close the dialogue. The bank must not open | +| `object-interaction` | quick | Opens and closes (or closes and opens) a Lumbridge castle door and confirms it is back in its original state | +| `agent-server` | quick | `/state`, `/inventory`, `/widgets/describe` and `/skills` with the token, plus the opaque 404 for a request without it. Starts the plugin for the check if it is off | +| `walker-medium` | full | Lumbridge courtyard to Varrock west bank. The walker may teleport, and `maxStepJump` records any jump | +| `grand-exchange` | full | Opens the Grand Exchange through the clerk and closes it. No offers | +| `world-hop` | full | `Microbot.hopToWorld` to another plain F2P world and back | +| `hub-plugins` | full | Plugin Hub manifest loaded with valid entries; install, start, stop and uninstall the Hub plugin (start and stop only if it is already installed) | +| `script-lifecycle` | full | Starts and stops `StateMachineExamplePlugin` twice through `Microbot.startPlugin`/`stopPlugin`: the loop runs, no thread is still running script code after stop, the pool stays at the `Script` core size, and the script is in `StateMachineScript.getRegistry()` while running | +| `logout` | quick, always runs | Stops AutoLogin, logs out with `Rs2Player.logout`, then scans the whole run's `client.log` for unexpected `ERROR` lines | + +The quick tier runs checks 1-9 and `logout`. The full tier adds the rest. Check ids passed on the command line run regardless of tier, in catalog order. + +## Reading results + +The script prints `summary.md`, a Markdown table you can paste into the release PR, and leaves `result.json` next to it: + +- `verdict`, `exitCode`, plus `passed`, `failed`, `skipped` and `notRun` counts. +- `results[]`: `id`, `description`, `tier`, `status` (`PASSED`, `FAILED`, `SKIPPED` or `NOT_RUN`), `durationMs`, `detail` (why it failed or was skipped), `details` (what the check observed, such as walk states, door ids, counts and dialogue transcripts) and `cleanupErrors`. +- `checks[]`, `errors` and `logTail` keep the generic `TestResult` format used by the other harnesses. + +`SKIPPED` means the account cannot exercise the check, for example the bank is full and no junk item has a bank stack, the profile has no bank PIN, or the Grand Exchange clerk refuses this account. It is not a failure, but say so in the PR. `NOT_RUN` comes from `--fail-fast` or from running out of the total budget, and it fails the run. + +When a check fails, look at `detail`, then at `details` in `result.json`, then at `/.runelite/logs/client.log`. Lines from the harness are tagged `[ReleaseE2E]` and run on `ReleaseE2E-` threads. To rerun one check, pass its id: `scripts/run-release-e2e.sh quick bank`. + +## Log allowlist + +`boot` and `logout` fail on any `ERROR` line in the current run's `client.log`, from the last `starting up, args:` banner onward. Known harmless lines are listed in `ClientLogScanner.DEFAULT_ALLOWLIST`, and each entry has a reason. Keep the list short. Add an entry only for a line that is expected and harmless during a healthy run, never to hide a real failure, and give it a unit test in `ClientLogScannerTest`. + +## Adding a check + +1. Add a method to `ReleaseSmokeChecks` taking a `CheckContext`. Walk to a fixed start point first with `walkToStart`. Use `context.require(condition, message)` for assertions, `context.skip(reason)` when the account cannot run it, and `context.detail(key, value)` to record what you observed. +2. Register a cleanup with `context.onCleanup(name, action)` before changing anything in game. Cleanups run in reverse order after the body, including after a timeout, and a cleanup that throws fails the check. +3. Add it to `all()` with `ReleaseCheck.quick(...)` or `ReleaseCheck.full(...)`, an id, a one-line description and a timeout long enough for its setup walk. Keep `logout` last. +4. Add the id to `CHECK_IDS` in `scripts/run-release-e2e.sh` and to the table above. +5. Follow the client-thread rules: game reads go through `Rs2*` helpers or `runOnClientThreadOptional`, queryables use the `*OnClientThread` terminals, and waits use `sleepUntil` with a timeout. + +## Release checklist + +Before merging the release PR (`development` -> `main`), build the release candidate, run `scripts/run-release-e2e.sh full` (or `quick` at minimum) against it, and paste `summary.md` into the release PR. A failing check blocks the release until it is fixed or written up in the PR as a known issue. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java index 50ad781823..81f7a78c06 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java @@ -12,6 +12,7 @@ import net.runelite.client.plugins.PluginInstantiationException; import net.runelite.client.plugins.PluginManager; import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; +import net.runelite.client.plugins.microbot.util.security.LoginManager; import javax.inject.Inject; import javax.swing.*; @@ -31,6 +32,7 @@ public class TestRunnerPlugin extends Plugin { private static final String PROP_TEST_MODE = "microbot.test.mode"; private static final String PROP_TEST_SCRIPT = "microbot.test.script"; private static final String PROP_TEST_TIMEOUT = "microbot.test.timeout"; + private static final String PROP_TEST_WORLD = "microbot.test.world"; @Inject private PluginManager pluginManager; @@ -39,7 +41,7 @@ public class TestRunnerPlugin extends Plugin { private Client client; private ScheduledExecutorService executor; - private boolean testStarted = false; + private volatile boolean testStarted = false; @Override protected void startUp() { @@ -49,7 +51,12 @@ protected void startUp() { executor = Executors.newSingleThreadScheduledExecutor(); prepareTargetPlugin(); - enableAutoLogin(); + int world = getTestWorld(); + if (world > 0) { + scheduleWorldLogin(world); + } else { + enableAutoLogin(); + } long timeout = getTimeout(); executor.schedule(() -> { @@ -79,6 +86,35 @@ private void prepareTargetPlugin() { } } + private void scheduleWorldLogin(int world) { + log.info("[TestRunner] Logging in to world {} with the active profile", world); + executor.scheduleWithFixedDelay(() -> { + if (testStarted || client.getGameState() != GameState.LOGIN_SCREEN) { + return; + } + try { + LoginManager.login(world); + } catch (Exception e) { + log.warn("[TestRunner] Login attempt to world {} failed: {}", world, e.getMessage()); + } + }, 5, 20, TimeUnit.SECONDS); + } + + static int parseWorld(String value) { + if (value == null || value.isBlank()) { + return -1; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static int getTestWorld() { + return parseWorld(System.getProperty(PROP_TEST_WORLD)); + } + private void enableAutoLogin() { for (Plugin plugin : pluginManager.getPlugins()) { if (plugin instanceof AutoLoginPlugin) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java new file mode 100644 index 0000000000..9b6998c432 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java @@ -0,0 +1,70 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CheckContext { + + @FunctionalInterface + public interface Cleanup { + void run() throws Exception; + } + + public static final class NamedCleanup { + final String name; + final Cleanup action; + + NamedCleanup(String name, Cleanup action) { + this.name = name; + this.action = action; + } + } + + private final String checkId; + private final Map details = Collections.synchronizedMap(new LinkedHashMap<>()); + private final List cleanups = Collections.synchronizedList(new ArrayList<>()); + + public CheckContext(String checkId) { + this.checkId = checkId; + } + + public String getCheckId() { + return checkId; + } + + public void detail(String key, Object value) { + details.put(key, value == null ? null : String.valueOf(value)); + } + + public void onCleanup(String name, Cleanup action) { + cleanups.add(new NamedCleanup(name, action)); + } + + public void require(boolean condition, String message) { + if (!condition) { + throw new CheckFailedException(message); + } + } + + public void skip(String reason) { + throw new CheckSkippedException(reason); + } + + Map snapshotDetails() { + synchronized (details) { + return new LinkedHashMap<>(details); + } + } + + List cleanupsInReverseOrder() { + List copy; + synchronized (cleanups) { + copy = new ArrayList<>(cleanups); + } + Collections.reverse(copy); + return copy; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java new file mode 100644 index 0000000000..2cb62b4971 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java @@ -0,0 +1,7 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public class CheckFailedException extends RuntimeException { + public CheckFailedException(String message) { + super(message); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java new file mode 100644 index 0000000000..5af8d0fe08 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CheckOutcome { + public int order; + public String id; + public String description; + public ReleaseTier tier; + public CheckStatus status; + public long durationMs; + public long timeoutMs; + public String startedAt; + public String detail; + public Map details = new LinkedHashMap<>(); + public List cleanupErrors = new ArrayList<>(); + + public static CheckOutcome notRun(int order, ReleaseCheck check, String reason) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = check.getId(); + outcome.description = check.getDescription(); + outcome.tier = check.getTier(); + outcome.timeoutMs = check.getTimeoutMs(); + outcome.status = CheckStatus.NOT_RUN; + outcome.detail = reason; + return outcome; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java new file mode 100644 index 0000000000..d11b1a76cb --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java @@ -0,0 +1,7 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public class CheckSkippedException extends RuntimeException { + public CheckSkippedException(String reason) { + super(reason); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java new file mode 100644 index 0000000000..81e2f16674 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java @@ -0,0 +1,12 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public enum CheckStatus { + PASSED, + FAILED, + SKIPPED, + NOT_RUN; + + public boolean isGreen() { + return this == PASSED || this == SKIPPED; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java new file mode 100644 index 0000000000..9b85198f4e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java @@ -0,0 +1,102 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public final class ClientLogScanner { + + public static final class AllowRule { + private final Pattern pattern; + private final String reason; + + public AllowRule(String regex, String reason) { + this.pattern = Pattern.compile(regex); + this.reason = reason; + } + + public boolean matches(String line) { + return pattern.matcher(line).find(); + } + + public String getReason() { + return reason; + } + + public String getRegex() { + return pattern.pattern(); + } + } + + public static final List DEFAULT_ALLOWLIST = List.of( + new AllowRule("ERROR n\\.r\\.c\\.p\\.m\\.s\\.StateMachineExampleScript - \\[StateMachineExample\\] Error in state [A-Z_]+: Interrupted waiting for client thread", + "script-lifecycle stops the example script mid-scan; the script logs the interrupt it is sent at ERROR") + ); + + static final String STARTUP_MARKER = ") starting up, args:"; + + private static final Pattern ERROR_LINE = Pattern.compile( + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} \\S+ \\[[^\\]]*\\] ERROR "); + + private ClientLogScanner() { + } + + public static Path defaultLogFile() { + return Paths.get(System.getProperty("user.home"), ".runelite", "logs", "client.log"); + } + + public static List readCurrentRun(Path logFile) throws IOException { + if (!Files.exists(logFile)) { + return List.of(); + } + return currentRun(Files.readAllLines(logFile, StandardCharsets.UTF_8)); + } + + public static List currentRun(List lines) { + for (int index = lines.size() - 1; index >= 0; index--) { + if (lines.get(index).contains(STARTUP_MARKER)) { + return new ArrayList<>(lines.subList(index, lines.size())); + } + } + return new ArrayList<>(lines); + } + + public static boolean isErrorLine(String line) { + return ERROR_LINE.matcher(line).find(); + } + + public static List findErrors(List lines, List allowlist) { + List errors = new ArrayList<>(); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + if (!isErrorLine(line) || isAllowed(line, allowlist)) { + continue; + } + String next = index + 1 < lines.size() ? lines.get(index + 1) : ""; + if (!next.isEmpty() && !isLogLine(next)) { + errors.add(line + " | " + next.trim()); + } else { + errors.add(line); + } + } + return errors; + } + + static boolean isAllowed(String line, List allowlist) { + for (AllowRule rule : allowlist) { + if (rule.matches(line)) { + return true; + } + } + return false; + } + + private static boolean isLogLine(String line) { + return line.length() > 20 && Character.isDigit(line.charAt(0)) && line.charAt(4) == '-' && line.charAt(10) == ' '; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java new file mode 100644 index 0000000000..a4db8d6d87 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.Objects; + +public final class ReleaseCheck { + + @FunctionalInterface + public interface Body { + void run(CheckContext context) throws Exception; + } + + private final String id; + private final String description; + private final ReleaseTier tier; + private final long timeoutMs; + private final boolean alwaysRun; + private final Body body; + + private ReleaseCheck(String id, String description, ReleaseTier tier, long timeoutMs, boolean alwaysRun, Body body) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Check id is required"); + } + if (timeoutMs <= 0) { + throw new IllegalArgumentException("Check timeout must be positive for " + id); + } + this.id = id; + this.description = Objects.requireNonNull(description, "description"); + this.tier = Objects.requireNonNull(tier, "tier"); + this.timeoutMs = timeoutMs; + this.alwaysRun = alwaysRun; + this.body = Objects.requireNonNull(body, "body"); + } + + public static ReleaseCheck quick(String id, String description, long timeoutMs, Body body) { + return new ReleaseCheck(id, description, ReleaseTier.QUICK, timeoutMs, false, body); + } + + public static ReleaseCheck full(String id, String description, long timeoutMs, Body body) { + return new ReleaseCheck(id, description, ReleaseTier.FULL, timeoutMs, false, body); + } + + public ReleaseCheck alwaysRun() { + return new ReleaseCheck(id, description, tier, timeoutMs, true, body); + } + + public String getId() { + return id; + } + + public String getDescription() { + return description; + } + + public ReleaseTier getTier() { + return tier; + } + + public long getTimeoutMs() { + return timeoutMs; + } + + public boolean isAlwaysRun() { + return alwaysRun; + } + + public Body getBody() { + return body; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java new file mode 100644 index 0000000000..2f9b3c3473 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java @@ -0,0 +1,275 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import java.util.stream.Collectors; + +public class ReleaseCheckRunner { + + public static final class Options { + private boolean failFast; + private long totalTimeoutMs = 600_000L; + private long cleanupTimeoutMs = 60_000L; + private long terminationWaitMs = 5_000L; + + public Options failFast(boolean failFast) { + this.failFast = failFast; + return this; + } + + public Options totalTimeoutMs(long totalTimeoutMs) { + this.totalTimeoutMs = totalTimeoutMs; + return this; + } + + public Options cleanupTimeoutMs(long cleanupTimeoutMs) { + this.cleanupTimeoutMs = cleanupTimeoutMs; + return this; + } + + public Options terminationWaitMs(long terminationWaitMs) { + this.terminationWaitMs = terminationWaitMs; + return this; + } + + public boolean isFailFast() { + return failFast; + } + + public long getTotalTimeoutMs() { + return totalTimeoutMs; + } + } + + private final Options options; + private final LongSupplier clock; + + public ReleaseCheckRunner(Options options) { + this(options, System::currentTimeMillis); + } + + ReleaseCheckRunner(Options options, LongSupplier clock) { + this.options = options; + this.clock = clock; + } + + public static List select(List all, ReleaseTier tier, Collection requestedIds) { + Set known = new HashSet<>(); + for (ReleaseCheck check : all) { + if (!known.add(normalize(check.getId()))) { + throw new IllegalArgumentException("Duplicate release E2E check id: " + check.getId()); + } + } + + Set requested = new LinkedHashSet<>(); + if (requestedIds != null) { + for (String id : requestedIds) { + if (id != null && !id.isBlank()) { + requested.add(normalize(id)); + } + } + } + + if (requested.isEmpty()) { + return all.stream() + .filter(check -> tier.includes(check.getTier())) + .collect(Collectors.toList()); + } + + List unknown = requested.stream() + .filter(id -> !known.contains(id)) + .collect(Collectors.toList()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Unknown release E2E check id(s) " + unknown + "; known ids: " + + all.stream().map(ReleaseCheck::getId).collect(Collectors.joining(", "))); + } + + return all.stream() + .filter(check -> requested.contains(normalize(check.getId()))) + .collect(Collectors.toList()); + } + + public List run(List checks, Consumer listener) { + List outcomes = new ArrayList<>(); + long deadline = clock.getAsLong() + options.totalTimeoutMs; + String stopReason = null; + + for (int index = 0; index < checks.size(); index++) { + ReleaseCheck check = checks.get(index); + int order = index + 1; + + if (stopReason == null && Thread.currentThread().isInterrupted()) { + stopReason = "harness interrupted"; + } + + long remaining = deadline - clock.getAsLong(); + if (stopReason == null && remaining <= 0) { + stopReason = "total timeout of " + options.totalTimeoutMs + "ms exceeded"; + } + + CheckOutcome outcome; + if (stopReason != null && !check.isAlwaysRun()) { + outcome = CheckOutcome.notRun(order, check, "Not run: " + stopReason); + } else { + long budget = check.isAlwaysRun() ? check.getTimeoutMs() : Math.min(check.getTimeoutMs(), remaining); + outcome = runOne(order, check, budget); + if (outcome.status == CheckStatus.FAILED && options.failFast && stopReason == null) { + stopReason = "fail-fast after " + check.getId() + " failed"; + } + } + + outcomes.add(outcome); + if (listener != null) { + listener.accept(outcome); + } + } + return outcomes; + } + + CheckOutcome runOne(int order, ReleaseCheck check, long budgetMs) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = check.getId(); + outcome.description = check.getDescription(); + outcome.tier = check.getTier(); + outcome.timeoutMs = budgetMs; + outcome.startedAt = Instant.ofEpochMilli(clock.getAsLong()).toString(); + + CheckContext context = new CheckContext(check.getId()); + long start = clock.getAsLong(); + ExecutorService executor = Executors.newSingleThreadExecutor(daemonFactory("ReleaseE2E-" + check.getId())); + Future future = executor.submit(() -> { + check.getBody().run(context); + return null; + }); + + boolean harnessInterrupted = false; + try { + future.get(Math.max(1L, budgetMs), TimeUnit.MILLISECONDS); + outcome.status = CheckStatus.PASSED; + } catch (TimeoutException e) { + future.cancel(true); + outcome.status = CheckStatus.FAILED; + outcome.detail = budgetMs < check.getTimeoutMs() + ? "Timed out after " + budgetMs + "ms (remaining total budget; check timeout " + check.getTimeoutMs() + "ms)" + : "Timed out after " + budgetMs + "ms"; + } catch (ExecutionException e) { + classify(outcome, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + harnessInterrupted = true; + outcome.status = CheckStatus.FAILED; + outcome.detail = "Harness interrupted while running check"; + } finally { + executor.shutdownNow(); + } + + boolean wasInterrupted = Thread.interrupted() || harnessInterrupted; + try { + if (!executor.awaitTermination(options.terminationWaitMs, TimeUnit.MILLISECONDS)) { + outcome.cleanupErrors.add("check thread still running after cancellation"); + } + } catch (InterruptedException e) { + wasInterrupted = true; + } + + runCleanups(context, outcome); + outcome.details = context.snapshotDetails(); + outcome.durationMs = clock.getAsLong() - start; + + if (!outcome.cleanupErrors.isEmpty()) { + String cleanupMessage = "Cleanup failed: " + String.join("; ", outcome.cleanupErrors); + if (outcome.status != CheckStatus.FAILED) { + outcome.status = CheckStatus.FAILED; + outcome.detail = outcome.detail == null ? cleanupMessage : outcome.detail + " | " + cleanupMessage; + } else { + outcome.detail = outcome.detail + " | " + cleanupMessage; + } + } + + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + return outcome; + } + + private void runCleanups(CheckContext context, CheckOutcome outcome) { + List cleanups = context.cleanupsInReverseOrder(); + if (cleanups.isEmpty()) { + return; + } + + ExecutorService executor = Executors.newSingleThreadExecutor(daemonFactory("ReleaseE2E-cleanup-" + context.getCheckId())); + try { + Future> future = executor.submit(() -> { + List errors = new ArrayList<>(); + for (CheckContext.NamedCleanup cleanup : cleanups) { + try { + cleanup.action.run(); + } catch (Throwable t) { + errors.add(cleanup.name + ": " + describe(t)); + } + } + return errors; + }); + outcome.cleanupErrors.addAll(future.get(options.cleanupTimeoutMs, TimeUnit.MILLISECONDS)); + } catch (TimeoutException e) { + outcome.cleanupErrors.add("cleanup timed out after " + options.cleanupTimeoutMs + "ms"); + } catch (ExecutionException e) { + outcome.cleanupErrors.add(describe(e.getCause())); + } catch (InterruptedException e) { + outcome.cleanupErrors.add("cleanup interrupted"); + Thread.currentThread().interrupt(); + } finally { + executor.shutdownNow(); + } + } + + private static void classify(CheckOutcome outcome, Throwable cause) { + if (cause instanceof CheckSkippedException) { + outcome.status = CheckStatus.SKIPPED; + outcome.detail = cause.getMessage(); + } else if (cause instanceof CheckFailedException) { + outcome.status = CheckStatus.FAILED; + outcome.detail = cause.getMessage(); + } else { + outcome.status = CheckStatus.FAILED; + outcome.detail = describe(cause); + } + } + + static String describe(Throwable t) { + if (t == null) { + return "unknown error"; + } + String message = t.getMessage(); + return t.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message); + } + + private static String normalize(String id) { + return id.trim().toLowerCase(Locale.ROOT); + } + + private static ThreadFactory daemonFactory(String name) { + return runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java new file mode 100644 index 0000000000..e9e8857c17 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java @@ -0,0 +1,958 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.runelite.api.GameState; +import net.runelite.api.Skill; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.RuneLiteProperties; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; +import net.runelite.client.plugins.microbot.agentserver.AgentServerConfig; +import net.runelite.client.plugins.microbot.agentserver.AgentServerPlugin; +import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; +import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManifest; +import net.runelite.client.plugins.microbot.statemachine.StateMachineScript; +import net.runelite.client.plugins.microbot.statemachineexample.StateMachineExamplePlugin; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +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.walker.Rs2Walker; +import net.runelite.client.game.WorldService; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; +import net.runelite.http.api.worlds.World; +import net.runelite.http.api.worlds.WorldResult; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.distance; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.format; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.location; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.requireLoggedIn; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.waitFor; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.walkOrFail; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.walkToStart; + +public final class ReleaseSmokeChecks { + + static final WorldPoint LUMBRIDGE_HALL = new WorldPoint(3215, 3218, 0); + static final WorldPoint LUMBRIDGE_COURTYARD = new WorldPoint(3222, 3218, 0); + static final WorldPoint LUMBRIDGE_BANK = new WorldPoint(3208, 3220, 2); + static final WorldPoint VARROCK_WEST_BANK = new WorldPoint(3185, 3436, 0); + static final WorldPoint GRAND_EXCHANGE = new WorldPoint(3165, 3484, 0); + static final List DOOR_CANDIDATES = List.of( + new WorldPoint(3226, 3214, 0), + new WorldPoint(3226, 3223, 0), + new WorldPoint(3207, 3217, 0)); + static final List JUNK_ITEMS = List.of( + "Burnt meat", "Burnt fish", "Burnt bread", "Cowhide", "Egg", "Bones", "Ashes", "Bowl", "Pot", "Jug", "Feather"); + static final String DEFAULT_HUB_PLUGIN = "AttackRangesPlusPlugin"; + static final String DIALOGUE_OPTION = "What is this place"; + static final String SKILLS_TEXT = "Total level"; + static final int F2P_BANK_SLOTS = 400; + static final int SCRIPT_POOL_CORE_SIZE = 10; + + public static final class Settings { + public String expectedVersion = ""; + public long loginTimeoutMs = 240_000L; + public String hubPlugin = DEFAULT_HUB_PLUGIN; + public int hopWorld = -1; + } + + private final Settings settings; + private final PluginManager pluginManager; + private final ConfigManager configManager; + private final MicrobotPluginManager microbotPluginManager; + private final WorldService worldService; + + public ReleaseSmokeChecks(Settings settings, PluginManager pluginManager, ConfigManager configManager, + MicrobotPluginManager microbotPluginManager, WorldService worldService) { + this.settings = settings; + this.pluginManager = pluginManager; + this.configManager = configManager; + this.microbotPluginManager = microbotPluginManager; + this.worldService = worldService; + } + + public List all() { + List checks = new ArrayList<>(); + checks.add(ReleaseCheck.quick("boot", "Client version, core plugins, clean startup log", 30_000L, this::boot)); + checks.add(ReleaseCheck.quick("login", "Logged in with a known player location", 30_000L, this::login)); + checks.add(ReleaseCheck.quick("state-caches", "Skills, items, varbits/varps and entity queryables at Lumbridge castle", 360_000L, this::stateAndCaches)); + checks.add(ReleaseCheck.quick("widgets", "Tab switching by widget click and widget text search", 60_000L, this::widgets)); + checks.add(ReleaseCheck.quick("walker-transport", "Short walk, closed door and Lumbridge castle stairs", 300_000L, this::walkerTransport)); + checks.add(ReleaseCheck.quick("bank", "Open nearest bank, deposit and withdraw one junk item", 180_000L, this::bank)); + checks.add(ReleaseCheck.quick("npc-dialogue", "Talk to a banker, read and pick a dialogue option, exit", 120_000L, this::npcDialogue)); + checks.add(ReleaseCheck.quick("object-interaction", "Open and close a door, restoring its state", 180_000L, this::objectInteraction)); + checks.add(ReleaseCheck.quick("agent-server", "Agent Server state, inventory, widgets and skills endpoints", 60_000L, this::agentServer)); + checks.add(ReleaseCheck.full("walker-medium", "Lumbridge to Varrock west bank", 420_000L, this::walkerMedium)); + checks.add(ReleaseCheck.full("grand-exchange", "Open and close the Grand Exchange without offers", 300_000L, this::grandExchange)); + checks.add(ReleaseCheck.full("world-hop", "Hop to another F2P world and back", 180_000L, this::worldHop)); + checks.add(ReleaseCheck.full("hub-plugins", "Plugin Hub manifest, install/start/stop/uninstall a harmless plugin", 180_000L, this::hubPlugins)); + checks.add(ReleaseCheck.full("script-lifecycle", "Start and stop a built-in script twice without leaked threads", 90_000L, this::scriptLifecycle)); + checks.add(ReleaseCheck.quick("logout", "Clean logout and full-run client.log scan", 60_000L, this::logout).alwaysRun()); + return checks; + } + + void boot(CheckContext context) throws IOException { + String version = RuneLiteProperties.getMicrobotVersion(); + context.detail("microbotVersion", version); + context.detail("microbotCommit", RuneLiteProperties.getMicrobotCommit()); + context.detail("runeliteVersion", RuneLiteProperties.getVersion()); + context.require(version != null && !version.isBlank() && !version.contains("${") && !"0.0.0".equals(version), + "Microbot version is not stamped: '" + version + "'"); + if (settings.expectedVersion != null && !settings.expectedVersion.isBlank()) { + context.require(settings.expectedVersion.equals(version), + "Expected Microbot version " + settings.expectedVersion + " but client reports " + version); + } + + List inactive = new ArrayList<>(); + List active = new ArrayList<>(); + for (Plugin plugin : pluginManager.getPlugins()) { + PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); + if (descriptor == null || !descriptor.alwaysOn() || descriptor.isExternal()) { + continue; + } + if (pluginManager.isPluginActive(plugin)) { + active.add(plugin.getClass().getSimpleName()); + } else { + inactive.add(plugin.getClass().getSimpleName()); + } + } + context.detail("alwaysOnActive", active); + context.require(active.stream().anyMatch("MicrobotPlugin"::equals), "MicrobotPlugin is not active"); + context.require(inactive.isEmpty(), "Always-on plugins not active: " + inactive); + + List errors = ClientLogScanner.findErrors( + ClientLogScanner.readCurrentRun(ClientLogScanner.defaultLogFile()), ClientLogScanner.DEFAULT_ALLOWLIST); + context.detail("startupErrorLines", errors.size()); + context.require(errors.isEmpty(), "ERROR lines during startup: " + summarize(errors)); + } + + void login(CheckContext context) { + long uptime = ManagementFactory.getRuntimeMXBean().getUptime(); + context.detail("uptimeAtHarnessStartMs", uptime); + requireLoggedIn(context); + context.require(Microbot.getClient().getGameState() == GameState.LOGGED_IN, "Game state is " + Microbot.getClient().getGameState()); + context.require(uptime <= settings.loginTimeoutMs, "Login took longer than " + settings.loginTimeoutMs + "ms (uptime " + uptime + "ms)"); + WorldPoint location = location(); + context.require(location != null, "Player location is unknown"); + int world = Microbot.getClient().getWorld(); + context.detail("location", format(location)); + context.detail("world", world); + context.detail("membersWorld", Rs2Player.isInMemberWorld()); + context.require(world > 0, "World number is not known"); + } + + void stateAndCaches(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_HALL, 4, 300_000L); + + Map levels = new LinkedHashMap<>(); + int total = 0; + for (Skill skill : Skill.values()) { + int level = Rs2Player.getRealSkillLevel(skill); + levels.put(skill.getName(), level); + total += level; + context.require(level >= 1 && level <= 99, "Implausible " + skill.getName() + " level " + level); + } + context.require(Rs2Player.getRealSkillLevel(Skill.HITPOINTS) >= 10, "Hitpoints below 10"); + context.detail("skills", levels.size()); + context.detail("totalLevel", total); + + List inventory = Rs2Inventory.all(); + int rawInventory = Microbot.getClientThread().runOnClientThreadOptional(() -> { + net.runelite.api.ItemContainer container = Microbot.getClient().getItemContainer(net.runelite.api.InventoryID.INVENTORY); + if (container == null) { + return 0; + } + int count = 0; + for (net.runelite.api.Item item : container.getItems()) { + if (item != null && item.getId() > 0) { + count++; + } + } + return count; + }).orElse(-1); + context.detail("inventoryItems", inventory.size()); + context.require(inventory.size() == rawInventory, "Inventory cache has " + inventory.size() + " items, client container has " + rawInventory); + context.require(!inventory.isEmpty(), "Inventory is empty; the release account needs coins and a few junk items"); + context.detail("equipmentItems", Rs2Equipment.items().size()); + + int runVarp = Microbot.getVarbitPlayerValue(173); + int rawRunVarp = Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getVarpValue(173)).orElse(-1); + int stoneVarbit = Microbot.getVarbitValue(VarbitID.RESIZABLE_STONE_ARRANGEMENT); + int rawStoneVarbit = Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getClient().getVarbitValue(VarbitID.RESIZABLE_STONE_ARRANGEMENT)).orElse(-1); + context.detail("runVarp", runVarp + "/" + rawRunVarp); + context.detail("stoneArrangementVarbit", stoneVarbit + "/" + rawStoneVarbit); + context.require(runVarp == rawRunVarp, "Cached varp 173 " + runVarp + " differs from client " + rawRunVarp); + context.require(stoneVarbit == rawStoneVarbit, "Cached varbit differs from client: " + stoneVarbit + " vs " + rawStoneVarbit); + context.require(Rs2Player.isRunEnabled() == (rawRunVarp == 1), "Rs2Player.isRunEnabled disagrees with varp 173"); + + WorldPoint here = location(); + List npcs = Microbot.getRs2NpcCache().query().within(here, 20).toListOnClientThread(); + List objects = Microbot.getRs2TileObjectCache().query().within(here, 15).toListOnClientThread(); + List groundItems = Microbot.getRs2TileItemCache().query().within(here, 20).toListOnClientThread(); + List players = Microbot.getRs2PlayerCache().query().toListOnClientThread(); + List npcNames = Microbot.getClientThread().runOnClientThreadOptional(() -> npcs.stream() + .map(Rs2NpcModel::getName).filter(Objects::nonNull).distinct().sorted().collect(Collectors.toList())) + .orElse(List.of()); + List itemNames = Microbot.getClientThread().runOnClientThreadOptional(() -> groundItems.stream() + .map(Rs2TileItemModel::getName).filter(Objects::nonNull).distinct().sorted().collect(Collectors.toList())) + .orElse(List.of()); + context.detail("npcs", npcs.size() + " " + npcNames); + context.detail("objects", objects.size()); + context.detail("groundItems", groundItems.size() + " " + itemNames); + context.detail("otherPlayers", players.size()); + context.require(npcs.size() >= 3, "Expected at least 3 NPCs near Lumbridge castle hall, found " + npcs.size()); + context.require(objects.size() >= 20, "Expected at least 20 tile objects near Lumbridge castle hall, found " + objects.size()); + context.require(!groundItems.isEmpty(), "Expected the Lumbridge kitchen item spawns in the ground item cache"); + context.require(Rs2Player.getLocalPlayer() != null, "Local player model unavailable"); + Rs2NpcModel nearestNpc = Microbot.getRs2NpcCache().query().within(here, 20).nearestOnClientThread(); + context.require(nearestNpc != null, "nearestOnClientThread returned no NPC although the list was non-empty"); + } + + void widgets(CheckContext context) { + requireLoggedIn(context); + InterfaceTab original = Rs2Tab.getCurrentTab(); + context.detail("originalTab", original); + context.onCleanup("restore tab", () -> { + if (original != null && original != InterfaceTab.NOTHING_SELECTED && Rs2Tab.getCurrentTab() != original) { + Rs2Tab.switchTo(original); + } + }); + + int topLevel = Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getTopLevelInterfaceId()).orElse(-1); + context.detail("topLevelInterface", topLevel); + clickTabStone(context, topLevel, "Skills", InterfaceTab.SKILLS); + + context.require(waitFor(() -> Rs2Widget.hasWidget(SKILLS_TEXT), 3000), "Rs2Widget.hasWidget('" + SKILLS_TEXT + "') returned false on the skills tab"); + Widget totalLevel = Rs2Widget.findWidget(SKILLS_TEXT, false); + context.require(totalLevel != null, "Widget text search for '" + SKILLS_TEXT + "' found nothing"); + String text = Microbot.getClientThread().runOnClientThreadOptional(totalLevel::getText).orElse(""); + context.detail("searchHit", (totalLevel.getId() >>> 16) + ":" + (totalLevel.getId() & 0xFFFF) + " '" + text.replace("
", " ") + "'"); + context.require(text.contains(SKILLS_TEXT), "Search hit does not carry the text '" + SKILLS_TEXT + "': '" + text + "'"); + + clickTabStone(context, topLevel, "Inventory", InterfaceTab.INVENTORY); + } + + private void clickTabStone(CheckContext context, int topLevel, String action, InterfaceTab expected) { + Optional stone = Microbot.getClientThread().runOnClientThreadOptional(() -> { + for (int child = 0; child < 200; child++) { + Widget widget = Microbot.getClient().getWidget(topLevel, child); + if (widget == null || widget.isHidden() || widget.getActions() == null) { + continue; + } + for (String candidate : widget.getActions()) { + if (action.equalsIgnoreCase(candidate)) { + return new int[]{topLevel, child}; + } + } + } + return null; + }); + context.require(stone.isPresent(), "No visible tab stone with action '" + action + "' under interface " + topLevel); + boolean clicked = Rs2Widget.clickWidget(stone.get()[0], stone.get()[1]); + boolean switched = waitFor(() -> Rs2Tab.getCurrentTab() == expected, 5000); + context.detail(action + "Stone", stone.get()[0] + ":" + stone.get()[1] + " clicked=" + clicked + " tab=" + Rs2Tab.getCurrentTab()); + context.require(switched, "Clicking the " + action + " tab stone did not switch to " + expected + " (now " + Rs2Tab.getCurrentTab() + ")"); + } + + void walkerTransport(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_HALL, 6, 240_000L); + List failures = new ArrayList<>(); + step(failures, () -> walkOrFail(context, "shortWalk", LUMBRIDGE_COURTYARD, 1, 60_000L)); + step(failures, () -> walkThroughDoor(context)); + step(failures, () -> { + ReleaseSmokeLive.WalkResult stairs = walkOrFail(context, "stairsWalk", LUMBRIDGE_BANK, 3, 120_000L); + context.require(stairs.end.getPlane() == 2, "Stairs walk ended on plane " + stairs.end.getPlane()); + }); + context.require(failures.isEmpty(), String.join(" | ", failures)); + } + + private static void step(List failures, Runnable step) { + try { + step.run(); + } catch (CheckFailedException e) { + failures.add(e.getMessage()); + } + } + + private void walkThroughDoor(CheckContext context) { + List attempts = new ArrayList<>(); + for (WorldPoint anchor : DOOR_CANDIDATES) { + ReleaseSmokeLive.WalkResult approach = ReleaseSmokeLive.walk(anchor, 4, 90_000L); + if (!approach.arrived(4)) { + attempts.add(format(anchor) + ": approach " + approach.describe()); + continue; + } + Optional door = ReleaseSmokeLive.doorAt(anchor, 1); + if (door.isEmpty()) { + attempts.add(format(anchor) + ": no door found"); + continue; + } + boolean initiallyClosed = door.get().isClosed(); + context.detail("door", door.get().describe()); + if (!ReleaseSmokeLive.setDoorState(context, "doorPrepare", anchor, true, 6000)) { + attempts.add(format(anchor) + ": could not close door before walking"); + continue; + } + AtomicBoolean restored = new AtomicBoolean(false); + context.onCleanup("restore door " + format(anchor), () -> { + if (restored.get()) { + return; + } + if (distance(location(), anchor) > 6) { + ReleaseSmokeLive.walk(anchor, 3, 90_000L); + } + if (!ReleaseSmokeLive.setDoorState(context, "doorRestore", anchor, initiallyClosed, 6000)) { + throw new IllegalStateException("door at " + format(anchor) + " not restored to " + (initiallyClosed ? "closed" : "open")); + } + }); + ReleaseSmokeLive.Door closed = ReleaseSmokeLive.doorAt(anchor, 1).orElseThrow(); + Optional orientation = ReleaseSmokeLive.wallOrientation(closed.location); + WorldPoint across = orientation.map(value -> ReleaseSmokeLive.acrossWall(closed.location, value)).orElse(null); + context.require(across != null, "Cannot read wall orientation for " + closed.describe()); + boolean doorTileReachable = ReleaseSmokeLive.reachable(closed.location); + WorldPoint target = doorTileReachable ? across : closed.location; + context.detail("doorWalkTarget", format(target) + " (orientation " + orientation.get() + ", doorTileReachable=" + doorTileReachable + ")"); + ReleaseSmokeLive.WalkResult through = ReleaseSmokeLive.walk(target, 0, 90_000L); + context.detail("doorWalk", through.describe()); + context.detail("doorAfterWalk", ReleaseSmokeLive.doorAt(anchor, 1).map(ReleaseSmokeLive.Door::describe).orElse("none")); + restored.set(ReleaseSmokeLive.setDoorState(context, "doorRestore", anchor, initiallyClosed, 6000)); + context.require(through.arrived(0), "Walk through closed door to " + format(target) + " failed: " + through.describe()); + return; + } + throw new CheckFailedException("No usable door near Lumbridge castle: " + attempts); + } + + void bank(CheckContext context) { + requireLoggedIn(context); + BankLocation nearest = Rs2Bank.getNearestBank(LUMBRIDGE_BANK); + context.detail("nearestBank", nearest == null ? "null" : nearest.name()); + context.require(nearest != null, "Rs2Bank.getNearestBank returned null at Lumbridge"); + if (distance(location(), nearest.getWorldPoint()) > 6) { + walkOrFail(context, "bankWalk", nearest.getWorldPoint(), 4, 150_000L); + } + + Map before = inventorySnapshot(); + context.onCleanup("close bank", () -> { + if (Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + if (!waitFor(() -> !Rs2Bank.isOpen(), 5000)) { + throw new IllegalStateException("bank still open"); + } + } + }); + + boolean opened = Rs2Bank.openBank() && waitFor(Rs2Bank::isOpen, 10_000); + if (!opened && Rs2Bank.isBankPinWidgetVisible()) { + context.skip("Bank PIN prompt shown and no PIN configured for the profile"); + } + context.require(opened || Rs2Bank.isOpen(), "Bank did not open"); + waitFor(() -> !Rs2Bank.bankItems().isEmpty(), 5000); + int bankSize = Rs2Bank.bankItems() == null ? 0 : Rs2Bank.bankItems().size(); + context.detail("bankItemStacks", bankSize); + + Rs2ItemModel junk = pickJunk(); + if (junk == null) { + context.skip("No junk item in inventory (expected one of " + JUNK_ITEMS + ")"); + } + int itemId = junk.getId(); + boolean alreadyBanked = Rs2Bank.count(itemId) > 0; + context.detail("junkItem", junk.getName() + "#" + itemId + (alreadyBanked ? " (stack exists in bank)" : " (new bank slot)")); + context.onCleanup("withdraw junk back", () -> { + int missing = before.getOrDefault(itemId, 0) - Rs2Inventory.itemQuantity(itemId); + if (missing > 0 && Rs2Bank.isOpen()) { + if (missing == 1) { + Rs2Bank.withdrawOne(itemId); + } else { + Rs2Bank.withdrawX(itemId, missing); + } + if (!waitFor(() -> Rs2Inventory.itemQuantity(itemId) >= before.getOrDefault(itemId, 0), 5000)) { + throw new IllegalStateException("junk item not withdrawn back"); + } + } + }); + + boolean noted = Rs2Bank.hasWithdrawAsNote(); + context.detail("withdrawAsNote", noted); + if (noted) { + context.require(Rs2Bank.setWithdrawAsItem(), "Could not switch withdraw mode to items"); + context.onCleanup("restore withdraw-as-note", () -> { + if (Rs2Bank.isOpen() && !Rs2Bank.hasWithdrawAsNote()) { + Rs2Bank.setWithdrawAsNote(); + } + }); + } + + int invBefore = Rs2Inventory.itemQuantity(itemId); + int bankBefore = Rs2Bank.count(itemId); + context.detail("countsBefore", "inventory=" + invBefore + ", bank=" + bankBefore); + context.require(Rs2Bank.depositOne(itemId), "depositOne returned false"); + boolean deposited = waitFor(() -> Rs2Inventory.itemQuantity(itemId) == invBefore - 1, 6000); + if (!deposited && !alreadyBanked && !Rs2Player.isMember() && bankSize >= F2P_BANK_SLOTS) { + context.skip("Bank holds " + bankSize + " stacks (F2P limit " + F2P_BANK_SLOTS + ") and no junk item already has a bank stack"); + } + context.require(deposited, "Inventory count did not drop after depositOne (" + Rs2Inventory.itemQuantity(itemId) + ")"); + context.require(waitFor(() -> Rs2Bank.count(itemId) == bankBefore + 1, 6000), + "Bank count did not rise after deposit (" + bankBefore + " -> " + Rs2Bank.count(itemId) + ")"); + + context.require(Rs2Bank.withdrawOne(itemId), "withdrawOne returned false"); + context.require(waitFor(() -> Rs2Inventory.itemQuantity(itemId) == invBefore, 6000), + "Inventory count not restored after withdraw (" + Rs2Inventory.itemQuantity(itemId) + " vs " + invBefore + ")"); + Rs2Bank.closeBank(); + context.require(waitFor(() -> !Rs2Bank.isOpen(), 5000), "Bank still open after closeBank"); + + Map after = inventorySnapshot(); + context.require(before.equals(after), "Inventory not restored: before=" + before + " after=" + after); + } + + private static Map inventorySnapshot() { + Map snapshot = new HashMap<>(); + for (Rs2ItemModel item : Rs2Inventory.all()) { + snapshot.merge(item.getId(), item.getQuantity(), Integer::sum); + } + return snapshot; + } + + private static Rs2ItemModel pickJunk() { + List candidates = new ArrayList<>(); + List items = Rs2Inventory.all(); + for (String name : JUNK_ITEMS) { + for (Rs2ItemModel item : items) { + if (name.equalsIgnoreCase(item.getName()) && !item.isNoted()) { + candidates.add(item); + } + } + } + return candidates.stream() + .filter(item -> Rs2Bank.count(item.getId()) > 0) + .findFirst() + .orElse(candidates.isEmpty() ? null : candidates.get(0)); + } + + void npcDialogue(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_BANK, 4, 180_000L); + context.onCleanup("leave dialogue", () -> { + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + if (!waitFor(() -> !Rs2Dialogue.isInDialogue(), 6000)) { + throw new IllegalStateException("still in dialogue"); + } + } + }); + + net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel banker = Rs2Npc.getNpc("Banker", true); + context.require(banker != null, "No banker found near Lumbridge bank"); + context.require(Rs2Npc.interact(banker, "Talk-to"), "Talk-to banker interaction failed"); + context.require(waitFor(Rs2Dialogue::isInDialogue, 10_000), "Dialogue did not open after Talk-to"); + + List transcript = new ArrayList<>(); + boolean pickedOption = false; + for (int step = 0; step < 8 && Rs2Dialogue.isInDialogue(); step++) { + if (Rs2Dialogue.hasSelectAnOption()) { + List options = dialogueOptions(); + transcript.add("options=" + options); + if (pickedOption) { + break; + } + boolean hasOption = options.stream().anyMatch(option -> option.contains(DIALOGUE_OPTION)); + context.require(hasOption, "Expected banker option '" + DIALOGUE_OPTION + "' in " + options); + context.require(Rs2Dialogue.keyPressForDialogueOption(DIALOGUE_OPTION, false), "Could not select dialogue option"); + pickedOption = true; + waitFor(() -> !Rs2Dialogue.hasSelectAnOption(), 3000); + continue; + } + if (Rs2Dialogue.hasContinue()) { + String text = Rs2Dialogue.getDialogueText(); + transcript.add("text=" + (text == null ? "" : text.length() > 60 ? text.substring(0, 60) : text)); + Rs2Dialogue.clickContinue(); + waitFor(() -> !Objects.equals(text, Rs2Dialogue.getDialogueText()) || !Rs2Dialogue.isInDialogue(), 4000); + continue; + } + waitFor(() -> Rs2Dialogue.hasContinue() || Rs2Dialogue.hasSelectAnOption() || !Rs2Dialogue.isInDialogue(), 3000); + } + context.detail("transcript", transcript); + context.require(pickedOption, "Never reached the banker option menu: " + transcript); + + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + } + context.require(waitFor(() -> !Rs2Dialogue.isInDialogue(), 6000), "Dialogue did not close"); + context.require(!Rs2Bank.isOpen(), "Bank opened during the banker dialogue"); + } + + private static List dialogueOptions() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> Rs2Dialogue.getDialogueOptions().stream() + .map(Widget::getText).filter(Objects::nonNull).collect(Collectors.toList())).orElse(List.of()); + } + + private static void leaveDialogue() { + WorldPoint here = location(); + if (here == null) { + return; + } + for (WorldPoint step : List.of(here.dx(1), here.dx(-1), here.dy(1), here.dy(-1))) { + if (ReleaseSmokeLive.reachable(step)) { + Rs2Walker.walkFastCanvas(step); + return; + } + } + } + + void objectInteraction(CheckContext context) { + requireLoggedIn(context); + List attempts = new ArrayList<>(); + for (WorldPoint anchor : DOOR_CANDIDATES) { + ReleaseSmokeLive.WalkResult approach = ReleaseSmokeLive.walk(anchor, 3, 150_000L); + if (!approach.arrived(3)) { + attempts.add(format(anchor) + ": approach " + approach.describe()); + continue; + } + Optional door = ReleaseSmokeLive.doorAt(anchor, 1); + if (door.isEmpty()) { + attempts.add(format(anchor) + ": no door"); + continue; + } + boolean initiallyClosed = door.get().isClosed(); + context.detail("door", door.get().describe()); + context.onCleanup("restore door " + format(anchor), () -> { + if (!ReleaseSmokeLive.setDoorState(context, "restore", anchor, initiallyClosed, 6000)) { + throw new IllegalStateException("door at " + format(anchor) + " not restored"); + } + }); + boolean first = ReleaseSmokeLive.setDoorState(context, "toggle1", anchor, !initiallyClosed, 6000); + if (!first) { + attempts.add(format(anchor) + ": first toggle had no effect"); + continue; + } + boolean second = ReleaseSmokeLive.setDoorState(context, "toggle2", anchor, initiallyClosed, 6000); + context.require(second, "Door at " + format(anchor) + " did not return to its original state"); + return; + } + throw new CheckFailedException("No door could be toggled: " + attempts); + } + + void agentServer(CheckContext context) throws Exception { + requireLoggedIn(context); + String bindMode = configManager.getConfiguration(AgentServerConfig.GROUP, "bindMode"); + if ("UDS".equalsIgnoreCase(bindMode)) { + context.skip("Agent Server is configured for UDS; this check speaks TCP only"); + } + AgentServerPlugin plugin = Microbot.getPlugin(AgentServerPlugin.class); + context.require(plugin != null, "AgentServerPlugin not registered"); + if (!pluginManager.isPluginActive(plugin)) { + context.detail("startedForCheck", true); + Microbot.startPlugin(plugin); + context.require(waitFor(() -> pluginManager.isPluginActive(plugin), 10_000), "Agent Server plugin did not start"); + context.onCleanup("stop agent server", () -> { + Microbot.stopPlugin(plugin); + if (!waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000)) { + throw new IllegalStateException("agent server still active"); + } + }); + } + String portValue = configManager.getConfiguration(AgentServerConfig.GROUP, AgentServerConfig.KEY_PORT); + int port = portValue == null || portValue.isBlank() ? 8081 : Integer.parseInt(portValue.trim()); + String token = configManager.getConfiguration(AgentServerConfig.GROUP, AgentServerConfig.KEY_TOKEN); + context.require(token != null && !token.isBlank(), "Agent Server token not configured"); + context.detail("port", port); + + int unauthenticatedStatus = status("http://127.0.0.1:" + port + "/state", null); + context.detail("unauthenticatedStatus", unauthenticatedStatus); + context.require(unauthenticatedStatus == 404, + "Agent Server should answer an unauthenticated request with the opaque 404, got HTTP " + unauthenticatedStatus); + + JsonObject state = getJson(port, token, "/state").getAsJsonObject(); + context.require(state.has("loggedIn") && state.get("loggedIn").getAsBoolean(), "/state does not report loggedIn: " + state); + JsonObject position = state.getAsJsonObject("player").getAsJsonObject("position"); + WorldPoint reported = new WorldPoint(position.get("x").getAsInt(), position.get("y").getAsInt(), position.get("plane").getAsInt()); + context.detail("statePosition", format(reported)); + context.require(distance(reported, location()) <= 5, "/state position " + format(reported) + " differs from " + format(location())); + + JsonObject inventory = getJson(port, token, "/inventory").getAsJsonObject(); + int reportedCount = inventory.get("count").getAsInt(); + context.detail("inventoryCount", reportedCount); + context.require(reportedCount == Rs2Inventory.count(), "/inventory count " + reportedCount + " != " + Rs2Inventory.count()); + + JsonObject describe = getJson(port, token, "/widgets/describe?groupId=" + net.runelite.api.gameval.InterfaceID.INVENTORY + "&childId=0&depth=2").getAsJsonObject(); + context.detail("widgetsDescribed", describe.get("count").getAsInt()); + context.require(describe.get("count").getAsInt() > 0, "/widgets/describe returned no widgets for the inventory"); + + JsonElement skills = getJson(port, token, "/skills"); + int skillCount = countSkills(skills); + context.detail("skills", skillCount); + context.require(skillCount >= 23, "/skills returned " + skillCount + " skills"); + } + + private static int countSkills(JsonElement skills) { + if (skills.isJsonArray()) { + return skills.getAsJsonArray().size(); + } + JsonObject object = skills.getAsJsonObject(); + if (object.has("skills")) { + JsonElement inner = object.get("skills"); + return inner.isJsonArray() ? inner.getAsJsonArray().size() : inner.getAsJsonObject().size(); + } + return object.size(); + } + + private static int status(String url, String token) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(15000); + if (token != null) { + connection.setRequestProperty("X-Agent-Token", token); + } + try { + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + private static JsonElement getJson(int port, String token, String path) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(30000); + connection.setRequestProperty("X-Agent-Token", token); + try { + int code = connection.getResponseCode(); + InputStream stream = code < 400 ? connection.getInputStream() : connection.getErrorStream(); + String body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + if (code != 200) { + throw new CheckFailedException(path + " returned HTTP " + code + ": " + truncate(body, 200)); + } + return new JsonParser().parse(body); + } finally { + connection.disconnect(); + } + } + + void walkerMedium(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_COURTYARD, 6, 300_000L); + ReleaseSmokeLive.WalkResult walk = walkOrFail(context, "mediumWalk", VARROCK_WEST_BANK, 5, 360_000L); + context.detail("maxStepJump", walk.maxJump + " " + walk.maxJumpEdge); + context.detail("teleportObserved", walk.maxJump > 30); + } + + void grandExchange(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "geWalk", GRAND_EXCHANGE, 6, 240_000L); + context.onCleanup("close grand exchange", () -> { + if (Rs2GrandExchange.isOpen()) { + Rs2GrandExchange.closeExchange(); + if (!waitFor(() -> !Rs2GrandExchange.isOpen(), 5000)) { + throw new IllegalStateException("grand exchange still open"); + } + } + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + } + }); + boolean opened = Rs2GrandExchange.openExchange(); + if (!opened && Rs2Dialogue.isInDialogue()) { + String text = Rs2Dialogue.getDialogueText(); + context.detail("clerkDialogue", text); + context.skip("Grand Exchange clerk refused to open the exchange: " + text); + } + context.require(opened && Rs2GrandExchange.isOpen(), "Grand Exchange interface did not open"); + Rs2GrandExchange.closeExchange(); + context.require(waitFor(() -> !Rs2GrandExchange.isOpen(), 5000), "Grand Exchange did not close"); + } + + void worldHop(CheckContext context) { + requireLoggedIn(context); + int original = Microbot.getClient().getWorld(); + context.detail("originalWorld", original); + WorldResult worlds = worldService.getWorlds(); + context.require(worlds != null && !worlds.getWorlds().isEmpty(), "World list service returned no worlds"); + int target = settings.hopWorld > 0 ? settings.hopWorld : pickF2pWorld(worlds.getWorlds(), original); + context.require(target > 0, "No other F2P world available to hop to"); + context.detail("targetWorld", target); + context.onCleanup("return to original world", () -> { + if (Microbot.isLoggedIn() && Microbot.getClient().getWorld() != original) { + if (!Microbot.hopToWorld(original)) { + throw new IllegalStateException("could not hop back to world " + original); + } + } + }); + + boolean switcherOpenBefore = worldSwitcherOpen(); + context.detail("worldSwitcherOpenBefore", switcherOpenBefore); + long start = System.currentTimeMillis(); + boolean hopped = Microbot.hopToWorld(target); + context.detail("hopOutMs", System.currentTimeMillis() - start); + if (!hopped || Microbot.getClient().getWorld() != target) { + boolean switcherOpenAfter = worldSwitcherOpen(); + boolean retried = Microbot.hopToWorld(target) && Microbot.getClient().getWorld() == target; + context.detail("worldSwitcherOpenAfterFirstAttempt", switcherOpenAfter); + context.detail("secondAttemptHopped", retried); + throw new CheckFailedException("Microbot.hopToWorld(" + target + ") did not hop (world switcher open before: " + + switcherOpenBefore + ", after: " + switcherOpenAfter + ", second attempt hopped: " + retried + ")"); + } + context.require(waitFor(() -> Microbot.getClient().getGameState() == GameState.LOGGED_IN && location() != null, 15_000), + "Not logged in with a location after hopping"); + + start = System.currentTimeMillis(); + boolean back = Microbot.hopToWorld(original); + context.detail("hopBackMs", System.currentTimeMillis() - start); + context.require(back && Microbot.getClient().getWorld() == original, "Hop back to world " + original + " failed"); + } + + private static boolean worldSwitcherOpen() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget buttons = Microbot.getClient().getWidget(net.runelite.api.gameval.InterfaceID.Worldswitcher.BUTTONS); + return buttons != null && !buttons.isHidden(); + }).orElse(false); + } + + static int pickF2pWorld(List worlds, int current) { + int currentLocation = worlds.stream() + .filter(world -> world.getId() == current) + .map(World::getLocation) + .findFirst() + .orElse(-1); + return worlds.stream() + .filter(world -> world.getId() != current) + .filter(world -> world.getTypes() == null || world.getTypes().isEmpty()) + .filter(world -> "-".equals(world.getActivity())) + .filter(world -> world.getPlayers() > 0 && world.getPlayers() < 1500) + .sorted(Comparator.comparing((World world) -> world.getLocation() != currentLocation) + .thenComparingInt(World::getPlayers)) + .map(World::getId) + .findFirst() + .orElse(-1); + } + + void hubPlugins(CheckContext context) { + boolean loaded = waitFor(() -> !microbotPluginManager.getManifestMap().isEmpty(), 60_000); + Map manifests = microbotPluginManager.getManifestMap(); + context.detail("manifestEntries", manifests.size()); + context.require(loaded && manifests.size() >= 20, "Plugin Hub manifest not loaded (" + manifests.size() + " entries)"); + long withJarInfo = manifests.values().stream() + .filter(manifest -> manifest.getInternalName() != null && manifest.getVersion() != null) + .count(); + context.require(withJarInfo == manifests.size(), "Some manifest entries are missing internalName/version"); + + String internalName = settings.hubPlugin; + MicrobotPluginManifest manifest = manifests.get(internalName); + context.require(manifest != null, "Hub plugin " + internalName + " not in manifest"); + context.detail("hubPlugin", internalName + " " + manifest.getVersion()); + if (manifest.isDisable()) { + context.skip("Hub plugin " + internalName + " is disabled upstream"); + } + + Optional existing = findExternal(internalName); + if (existing.isPresent()) { + context.detail("preinstalled", true); + boolean wasActive = pluginManager.isPluginActive(existing.get()); + cyclePlugin(context, existing.get()); + if (wasActive) { + Microbot.startPlugin(existing.get()); + context.require(waitFor(() -> pluginManager.isPluginActive(existing.get()), 10_000), "Could not restore active state"); + } + return; + } + + context.onCleanup("uninstall " + internalName, () -> { + if (findExternal(internalName).isPresent()) { + microbotPluginManager.remove(manifest); + if (findExternal(internalName).isPresent()) { + throw new IllegalStateException(internalName + " still registered after remove"); + } + } + }); + long start = System.currentTimeMillis(); + microbotPluginManager.install(manifest, null); + boolean installed = waitFor(() -> findExternal(internalName).isPresent(), 30_000); + context.detail("installMs", System.currentTimeMillis() - start); + context.require(installed, "Hub plugin " + internalName + " did not load after install"); + Plugin plugin = findExternal(internalName).orElseThrow(); + context.detail("activeAfterInstall", pluginManager.isPluginActive(plugin)); + cyclePlugin(context, plugin); + + microbotPluginManager.remove(manifest); + context.require(findExternal(internalName).isEmpty(), "Hub plugin still registered after uninstall"); + context.require(microbotPluginManager.getInstalledPluginVersion(internalName).isEmpty(), + "Installed version still recorded after uninstall"); + } + + private void cyclePlugin(CheckContext context, Plugin plugin) { + boolean startReturned = Microbot.startPlugin(plugin); + boolean started = waitFor(() -> pluginManager.isPluginActive(plugin), 10_000); + context.detail("startPluginReturned", startReturned); + context.require(started, plugin.getClass().getSimpleName() + " did not start"); + boolean stopReturned = Microbot.stopPlugin(plugin); + boolean stopped = waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000); + context.detail("stopPluginReturned", stopReturned); + context.require(stopped, plugin.getClass().getSimpleName() + " did not stop"); + } + + private Optional findExternal(String internalName) { + return pluginManager.getPlugins().stream() + .filter(plugin -> plugin.getClass().getSimpleName().equalsIgnoreCase(internalName)) + .filter(plugin -> { + PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); + return descriptor != null && descriptor.isExternal(); + }) + .findFirst(); + } + + void scriptLifecycle(CheckContext context) { + StateMachineExamplePlugin plugin = Microbot.getPlugin(StateMachineExamplePlugin.class); + context.require(plugin != null, "StateMachineExamplePlugin not registered"); + if (pluginManager.isPluginActive(plugin)) { + context.skip("State Machine Example is already running; not interfering with a user session"); + } + context.onCleanup("stop state machine example", () -> { + if (pluginManager.isPluginActive(plugin)) { + Microbot.stopPlugin(plugin); + if (!waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000)) { + throw new IllegalStateException("example plugin still active"); + } + } + }); + + List poolSizes = new ArrayList<>(); + List registry = new ArrayList<>(); + List startStates = new ArrayList<>(); + for (int cycle = 1; cycle <= 2; cycle++) { + int current = cycle; + Microbot.startPlugin(plugin); + context.require(waitFor(() -> pluginManager.isPluginActive(plugin), 10_000), "Cycle " + cycle + ": plugin did not start"); + context.require(waitFor(() -> exampleScript().isPresent(), 5000), "Cycle " + cycle + ": script not among Microbot.getActiveScripts()"); + StateMachineScript script = exampleScript().orElseThrow(); + long loopsAtStart = script.getSnapshot() == null ? 0 : script.getSnapshot().loopCount(); + startStates.add(script.getCurrentState() == null ? "null" : script.getCurrentState().name()); + boolean looping = waitFor(() -> script.isRunning() && script.getSnapshot() != null + && script.getSnapshot().loopCount() >= loopsAtStart + 3, 15_000); + context.require(looping, "Cycle " + cycle + ": script loop did not run (" + describeExample() + ")"); + registry.add(StateMachineScript.getRegistry().containsValue(script) ? "registered" : "missing"); + Microbot.stopPlugin(plugin); + context.require(waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000), "Cycle " + cycle + ": plugin did not stop"); + context.require(waitFor(() -> !script.isRunning(), 5000), "Cycle " + current + ": script loop still running after stop"); + context.require(waitFor(() -> busyScriptThreads().isEmpty(), 5000), + "Cycle " + cycle + ": script threads still executing after stop: " + busyScriptThreads()); + poolSizes.add(scriptThreads().size()); + } + context.detail("scriptPoolThreadsAfterStop", poolSizes); + context.detail("stateMachineRegistry", registry); + context.detail("stateAtStart", startStates); + context.require(poolSizes.stream().allMatch(size -> size <= SCRIPT_POOL_CORE_SIZE), + "Script pool threads exceed the Script executor core size " + SCRIPT_POOL_CORE_SIZE + ": " + poolSizes); + context.require(!registry.contains("missing"), + "StateMachineScript was not in StateMachineScript.getRegistry() while running after a restart: " + registry); + } + + private static Optional> exampleScript() { + return Microbot.getActiveScripts().stream() + .filter(script -> script instanceof StateMachineScript) + .filter(script -> script.getClass().getSimpleName().equals("StateMachineExampleScript")) + .>map(script -> (StateMachineScript) script) + .findFirst(); + } + + private static String describeExample() { + return exampleScript().map(script -> "running=" + script.isRunning() + ", state=" + script.getCurrentState() + + ", loops=" + (script.getSnapshot() == null ? "?" : script.getSnapshot().loopCount())).orElse("not registered"); + } + + private static List scriptThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.isAlive() && thread.getName().startsWith("StateMachineExampleScript-")) + .collect(Collectors.toList()); + } + + private static List busyScriptThreads() { + List busy = new ArrayList<>(); + for (Map.Entry entry : Thread.getAllStackTraces().entrySet()) { + if (!entry.getKey().isAlive()) { + continue; + } + for (StackTraceElement frame : entry.getValue()) { + if (frame.getClassName().contains(".statemachineexample.")) { + busy.add(entry.getKey().getName()); + break; + } + } + } + return busy; + } + + void logout(CheckContext context) throws IOException { + AutoLoginPlugin autoLogin = Microbot.getPlugin(AutoLoginPlugin.class); + if (autoLogin != null && pluginManager.isPluginActive(autoLogin)) { + Microbot.stopPlugin(autoLogin); + context.detail("autoLoginStopped", waitFor(() -> !pluginManager.isPluginActive(autoLogin), 10_000)); + } + if (Microbot.isLoggedIn()) { + context.detail("tabBeforeLogout", Rs2Tab.getCurrentTab()); + context.detail("worldSwitcherOpenBeforeLogout", worldSwitcherOpen()); + if (Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + waitFor(() -> !Rs2Bank.isOpen(), 5000); + } + Rs2Player.logout(); + boolean loggedOut = waitFor(() -> Microbot.getClient().getGameState() == GameState.LOGIN_SCREEN, 20_000); + context.detail("gameStateAfterLogout", Microbot.getClient().getGameState()); + context.require(loggedOut, "Still logged in after Rs2Player.logout (" + Microbot.getClient().getGameState() + ")"); + } else { + context.detail("alreadyLoggedOut", true); + } + + List lines = ClientLogScanner.readCurrentRun(ClientLogScanner.defaultLogFile()); + List errors = ClientLogScanner.findErrors(lines, ClientLogScanner.DEFAULT_ALLOWLIST); + context.detail("logLinesScanned", lines.size()); + context.detail("errorLines", errors.size()); + context.require(errors.isEmpty(), errors.size() + " ERROR line(s) in client.log during the run: " + summarize(errors)); + } + + static String summarize(List errors) { + return errors.stream().limit(5).map(line -> truncate(line, 300)).collect(Collectors.joining(" || ")) + + (errors.size() > 5 ? " (+" + (errors.size() - 5) + " more)" : ""); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max) + "..."; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java new file mode 100644 index 0000000000..1c8b57c243 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java @@ -0,0 +1,165 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.RuneLiteProperties; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.game.WorldService; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.plugins.microbot.testing.TestResultWriter; + +import javax.inject.Inject; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +@PluginDescriptor( + name = ReleaseSmokeHarnessPlugin.SCRIPT_NAME, + description = "Runs the release end-to-end smoke checks in test mode", + tags = {"microbot", "test", "release", "e2e"}, + enabledByDefault = false, + hidden = true +) +@Slf4j +public class ReleaseSmokeHarnessPlugin extends Plugin { + + static final String SCRIPT_NAME = "Release Smoke Harness"; + static final String PREFIX = "microbot.test.release."; + static final long DEFAULT_QUICK_TOTAL_MS = 900_000L; + static final long DEFAULT_FULL_TOTAL_MS = 2_100_000L; + + @Inject + private PluginManager pluginManager; + + @Inject + private ConfigManager configManager; + + @Inject + private MicrobotPluginManager microbotPluginManager; + + @Inject + private WorldService worldService; + + private ExecutorService executor; + + @Override + protected void startUp() { + if (!isHarnessTarget()) { + return; + } + executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("ReleaseE2E-harness-%d") + .build()); + executor.submit(this::runHarness); + } + + @Override + protected void shutDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + private void runHarness() { + ReleaseSmokeResult result = new ReleaseSmokeResult(SCRIPT_NAME); + Path outputDir = Paths.get(TestResultWriter.getDefaultOutputDir()); + try { + ReleaseTier tier = ReleaseTier.parse(property("tier", "quick")); + List requested = parseIds(property("checks", "")); + boolean failFast = Boolean.parseBoolean(property("failFast", "false")); + long totalTimeout = longProperty("totalTimeoutMs", tier == ReleaseTier.FULL ? DEFAULT_FULL_TOTAL_MS : DEFAULT_QUICK_TOTAL_MS); + + ReleaseSmokeChecks.Settings settings = new ReleaseSmokeChecks.Settings(); + settings.expectedVersion = property("expectedVersion", ""); + settings.hubPlugin = property("hubPlugin", ReleaseSmokeChecks.DEFAULT_HUB_PLUGIN); + settings.hopWorld = (int) longProperty("hopWorld", -1); + settings.loginTimeoutMs = longProperty("loginTimeoutMs", settings.loginTimeoutMs); + + result.tier = tier.name(); + result.failFast = failFast; + result.totalTimeoutMs = totalTimeout; + result.requestedChecks = requested; + result.expectedVersion = settings.expectedVersion; + result.microbotVersion = RuneLiteProperties.getMicrobotVersion(); + result.microbotCommit = RuneLiteProperties.getMicrobotCommit(); + result.runeliteVersion = RuneLiteProperties.getVersion(); + + ReleaseSmokeChecks checks = new ReleaseSmokeChecks(settings, pluginManager, configManager, microbotPluginManager, worldService); + List selected = ReleaseCheckRunner.select(checks.all(), tier, requested); + + if (!sleepUntil(() -> ReleaseSmokeLive.location() != null, 60_000)) { + result.addError("Player location never became available after login"); + } + result.world = Microbot.getClient().getWorld(); + + log.info("[ReleaseE2E] Running {} check(s), tier={}, failFast={}, totalTimeoutMs={}: {}", selected.size(), tier, failFast, + totalTimeout, selected.stream().map(ReleaseCheck::getId).collect(Collectors.joining(", "))); + ReleaseCheckRunner runner = new ReleaseCheckRunner(new ReleaseCheckRunner.Options() + .failFast(failFast) + .totalTimeoutMs(totalTimeout)); + runner.run(selected, outcome -> { + result.record(outcome); + log.info("[ReleaseE2E] {} {} in {}ms{}", outcome.id, outcome.status, outcome.durationMs, + outcome.detail == null ? "" : ": " + outcome.detail); + }); + result.finish("completed"); + } catch (IllegalArgumentException e) { + result.addError(e.getMessage()); + result.finish("invalid_arguments"); + } catch (Throwable t) { + log.warn("[ReleaseE2E] Harness crashed", t); + result.addError(ReleaseCheckRunner.describe(t)); + result.finish("crash"); + } + writeAndExit(result, outputDir); + } + + private static void writeAndExit(ReleaseSmokeResult result, Path outputDir) { + TestResultWriter.write(result, outputDir.toString()); + try { + Path summary = ReleaseSmokeReport.write(result, outputDir); + log.info("[ReleaseE2E] Summary written to {}\n{}", summary, ReleaseSmokeReport.render(result)); + } catch (Exception e) { + log.warn("[ReleaseE2E] Could not write summary", e); + } + System.exit(result.exitCode); + } + + static List parseIds(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return Arrays.stream(value.split("[,\\s]+")) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .collect(Collectors.toList()); + } + + private static String property(String key, String defaultValue) { + String value = System.getProperty(PREFIX + key); + return value == null || value.isBlank() ? defaultValue : value.trim(); + } + + private static long longProperty(String key, long defaultValue) { + try { + return Long.parseLong(property(key, String.valueOf(defaultValue))); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static boolean isHarnessTarget() { + return "true".equals(System.getProperty("microbot.test.mode")) + && System.getProperty("microbot.test.script", "").contains(SCRIPT_NAME); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java new file mode 100644 index 0000000000..8cfc316234 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java @@ -0,0 +1,276 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import net.runelite.api.GameState; +import net.runelite.api.Tile; +import net.runelite.api.WallObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.api.tileobject.models.TileObjectType; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WalkerState; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.BooleanSupplier; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +final class ReleaseSmokeLive { + + static final class WalkResult { + final String state; + final WorldPoint end; + final int distance; + final long durationMs; + final int maxJump; + final String maxJumpEdge; + + WalkResult(String state, WorldPoint end, int distance, long durationMs, int maxJump, String maxJumpEdge) { + this.state = state; + this.end = end; + this.distance = distance; + this.durationMs = durationMs; + this.maxJump = maxJump; + this.maxJumpEdge = maxJumpEdge; + } + + boolean arrived(int tolerance) { + return WalkerState.ARRIVED.name().equals(state) && distance <= tolerance; + } + + String describe() { + return "state=" + state + ", end=" + format(end) + ", distance=" + distance + ", " + durationMs + "ms"; + } + } + + static final class Door { + final WorldPoint location; + final int id; + final String name; + final List actions; + + Door(WorldPoint location, int id, String name, List actions) { + this.location = location; + this.id = id; + this.name = name; + this.actions = actions; + } + + boolean isClosed() { + return actions.stream().anyMatch("Open"::equalsIgnoreCase); + } + + boolean isOpen() { + return actions.stream().anyMatch("Close"::equalsIgnoreCase); + } + + String describe() { + return name + "#" + id + "@" + format(location) + actions; + } + } + + private ReleaseSmokeLive() { + } + + static WorldPoint location() { + return Rs2Player.getWorldLocation(); + } + + static boolean loggedIn() { + return Microbot.getClient().getGameState() == GameState.LOGGED_IN && Microbot.isLoggedIn(); + } + + static void requireLoggedIn(CheckContext context) { + context.require(loggedIn(), "Client is not logged in (game state " + Microbot.getClient().getGameState() + ")"); + } + + static boolean waitFor(BooleanSupplier condition, int timeoutMs) { + return sleepUntil(condition, timeoutMs); + } + + static WalkResult walk(WorldPoint destination, int tolerance, long timeoutMs) { + ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("ReleaseE2E-walk-%d") + .setDaemon(true) + .build()); + long start = System.currentTimeMillis(); + Future future = executor.submit(() -> Rs2Walker.walkWithState(destination, tolerance)); + WorldPoint previous = location(); + int maxJump = 0; + String maxJumpEdge = null; + String state; + try { + long deadline = start + timeoutMs; + while (!future.isDone() && System.currentTimeMillis() < deadline) { + if (Thread.currentThread().isInterrupted()) { + break; + } + sleepUntil(future::isDone, 600); + WorldPoint current = location(); + if (previous != null && current != null) { + int jump = current.distanceTo2D(previous); + if (jump > maxJump) { + maxJump = jump; + maxJumpEdge = format(previous) + "->" + format(current); + } + } + if (current != null) { + previous = current; + } + } + if (future.isDone()) { + state = future.get().name(); + } else { + future.cancel(true); + Rs2Walker.setTarget(null); + state = Thread.currentThread().isInterrupted() ? "INTERRUPTED" : "TIMEOUT"; + } + } catch (InterruptedException e) { + future.cancel(true); + Rs2Walker.setTarget(null); + Thread.currentThread().interrupt(); + state = "INTERRUPTED"; + } catch (Exception e) { + Rs2Walker.setTarget(null); + state = "ERROR:" + ReleaseCheckRunner.describe(e.getCause() == null ? e : e.getCause()); + } finally { + executor.shutdownNow(); + } + WorldPoint end = location(); + return new WalkResult(state, end, distance(end, destination), System.currentTimeMillis() - start, maxJump, maxJumpEdge); + } + + static WalkResult walkOrFail(CheckContext context, String label, WorldPoint destination, int tolerance, long timeoutMs) { + WalkResult result = walk(destination, tolerance, timeoutMs); + context.detail(label, result.describe()); + context.require(result.arrived(tolerance), label + " to " + format(destination) + " failed: " + result.describe()); + return result; + } + + static WalkResult walkToStart(CheckContext context, String label, WorldPoint destination, int tolerance, long timeoutMs) { + WorldPoint current = location(); + if (current != null && current.getPlane() == destination.getPlane() && current.distanceTo(destination) <= tolerance) { + context.detail(label, "already within " + tolerance + " of " + format(destination)); + return null; + } + return walkOrFail(context, label, destination, tolerance, timeoutMs); + } + + static List doorsNear(WorldPoint anchor, int radius) { + List models = Microbot.getRs2TileObjectCache().query() + .withNames("Door", "Gate", "Large door") + .within(anchor, radius) + .toListOnClientThread(); + return models.stream() + .filter(model -> model.getTileObjectType() == TileObjectType.WALL) + .map(ReleaseSmokeLive::toDoor) + .filter(door -> door.isOpen() || door.isClosed()) + .sorted(Comparator.comparingInt(door -> door.location.distanceTo(anchor))) + .collect(Collectors.toList()); + } + + static Optional doorAt(WorldPoint anchor, int radius) { + return doorsNear(anchor, radius).stream().findFirst(); + } + + private static Door toDoor(Rs2TileObjectModel model) { + String[] actions = model.getObjectComposition().getActions(); + List cleaned = actions == null ? List.of() : Arrays.stream(actions) + .filter(action -> action != null && !action.isBlank()) + .collect(Collectors.toList()); + return new Door(model.getWorldLocation(), model.getId(), model.getName(), cleaned); + } + + static boolean clickDoor(WorldPoint location, String action) { + List models = Microbot.getRs2TileObjectCache().query() + .withNames("Door", "Gate", "Large door") + .within(location, 0) + .toListOnClientThread(); + for (Rs2TileObjectModel model : models) { + if (model.getTileObjectType() == TileObjectType.WALL && toDoor(model).actions.stream().anyMatch(action::equalsIgnoreCase)) { + return model.click(action); + } + } + return false; + } + + static boolean setDoorState(CheckContext context, String label, WorldPoint anchor, boolean wantClosed, int timeoutMs) { + Optional current = doorAt(anchor, 1); + if (current.isEmpty()) { + context.detail(label, "no door within 1 tile of " + format(anchor)); + return false; + } + if (current.get().isClosed() == wantClosed) { + context.detail(label, "already " + (wantClosed ? "closed" : "open") + ": " + current.get().describe()); + return true; + } + String action = wantClosed ? "Close" : "Open"; + boolean clicked = clickDoor(current.get().location, action); + boolean changed = waitFor(() -> doorAt(anchor, 1).map(door -> door.isClosed() == wantClosed).orElse(false), timeoutMs); + context.detail(label, action + " clicked=" + clicked + ", changed=" + changed + ", now=" + + doorAt(anchor, 1).map(Door::describe).orElse("none")); + return changed; + } + + static Optional wallOrientation(WorldPoint point) { + return wallObjectAt(point).map(WallObject::getOrientationA); + } + + private static Optional wallObjectAt(WorldPoint point) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + WorldView worldView = Microbot.getClient().getTopLevelWorldView(); + LocalPoint localPoint = LocalPoint.fromWorld(worldView, point); + if (localPoint == null) { + return null; + } + Tile[][][] tiles = worldView.getScene().getTiles(); + Tile tile = tiles[point.getPlane()][localPoint.getSceneX()][localPoint.getSceneY()]; + return tile == null ? null : tile.getWallObject(); + }); + } + + static WorldPoint acrossWall(WorldPoint doorTile, int orientation) { + switch (orientation) { + case 1: + return doorTile.dx(-1); + case 2: + return doorTile.dy(1); + case 4: + return doorTile.dx(1); + case 8: + return doorTile.dy(-1); + default: + return null; + } + } + + static boolean reachable(WorldPoint point) { + return Rs2Tile.isTileReachable(point); + } + + static int distance(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return Integer.MAX_VALUE; + } + if (from.getPlane() != to.getPlane()) { + return Integer.MAX_VALUE - 1; + } + return from.distanceTo(to); + } + + static String format(WorldPoint point) { + return point == null ? "null" : point.getX() + "," + point.getY() + "," + point.getPlane(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java new file mode 100644 index 0000000000..98a301b12a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java @@ -0,0 +1,83 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +public final class ReleaseSmokeReport { + + public static final String SUMMARY_FILE = "summary.md"; + private static final int MAX_DETAIL_LENGTH = 240; + + private ReleaseSmokeReport() { + } + + public static String render(ReleaseSmokeResult result) { + StringBuilder out = new StringBuilder(); + out.append("## Microbot release E2E: ").append(result.verdict) + .append(" (").append(result.tier == null ? "?" : result.tier.toLowerCase(Locale.ROOT)).append(" tier)\n\n"); + out.append("- Microbot ").append(nullToDash(result.microbotVersion)) + .append(" (commit ").append(nullToDash(result.microbotCommit)).append("), RuneLite ") + .append(nullToDash(result.runeliteVersion)).append('\n'); + if (result.expectedVersion != null && !result.expectedVersion.isBlank()) { + out.append("- Expected version: ").append(result.expectedVersion).append('\n'); + } + out.append("- Started: ").append(result.timestamp).append(", total ").append(formatDuration(result.durationMs)) + .append(result.failFast ? ", fail-fast" : "").append('\n'); + out.append("- Result: ").append(result.passed).append(" passed, ").append(result.failed).append(" failed, ") + .append(result.skipped).append(" skipped, ").append(result.notRun).append(" not run") + .append(" (exit ").append(result.exitReason).append(", code ").append(result.exitCode).append(")\n"); + for (String error : result.errors) { + out.append("- Harness error: ").append(escape(error)).append('\n'); + } + out.append('\n'); + out.append("| # | Check | Tier | Status | Duration | Detail |\n"); + out.append("|---|---|---|---|---|---|\n"); + for (CheckOutcome outcome : result.results) { + out.append("| ").append(outcome.order) + .append(" | `").append(outcome.id).append("` ") + .append(escape(outcome.description)) + .append(" | ").append(outcome.tier == null ? "" : outcome.tier.name().toLowerCase(Locale.ROOT)) + .append(" | ").append(outcome.status) + .append(" | ").append(outcome.status == CheckStatus.NOT_RUN ? "-" : formatDuration(outcome.durationMs)) + .append(" | ").append(escape(truncate(outcome.detail))) + .append(" |\n"); + } + return out.toString(); + } + + public static Path write(ReleaseSmokeResult result, Path outputDir) throws IOException { + Files.createDirectories(outputDir); + Path file = outputDir.resolve(SUMMARY_FILE); + Files.write(file, render(result).getBytes(StandardCharsets.UTF_8)); + return file; + } + + static String formatDuration(long durationMs) { + long seconds = Math.max(0L, durationMs) / 1000L; + if (seconds < 60) { + return String.format(Locale.ROOT, "%.1fs", Math.max(0L, durationMs) / 1000.0); + } + return String.format(Locale.ROOT, "%dm%02ds", seconds / 60, seconds % 60); + } + + private static String truncate(String value) { + if (value == null) { + return ""; + } + return value.length() <= MAX_DETAIL_LENGTH ? value : value.substring(0, MAX_DETAIL_LENGTH - 3) + "..."; + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("|", "\\|").replace("\r", " ").replace("\n", " "); + } + + private static String nullToDash(String value) { + return value == null || value.isBlank() ? "-" : value; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java new file mode 100644 index 0000000000..147e287ec6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import net.runelite.client.plugins.microbot.testing.TestResult; + +import java.util.ArrayList; +import java.util.List; + +public class ReleaseSmokeResult extends TestResult { + public String tier; + public boolean failFast; + public long totalTimeoutMs; + public List requestedChecks = new ArrayList<>(); + public String microbotVersion; + public String microbotCommit; + public String runeliteVersion; + public String expectedVersion; + public int world; + public long passed; + public long failed; + public long skipped; + public long notRun; + public String verdict; + public List results = new ArrayList<>(); + + public ReleaseSmokeResult(String script) { + super(script); + } + + public void record(CheckOutcome outcome) { + results.add(outcome); + addCheck(outcome.id, outcome.status.isGreen(), + outcome.status == CheckStatus.PASSED ? null : outcome.status + ": " + outcome.detail); + } + + public void finish(String exitReason) { + passed = count(CheckStatus.PASSED); + failed = count(CheckStatus.FAILED); + skipped = count(CheckStatus.SKIPPED); + notRun = count(CheckStatus.NOT_RUN); + complete(exitReason); + if ("completed".equals(exitReason) && results.isEmpty()) { + exitCode = 1; + } + verdict = exitCode == 0 ? "PASS" : "FAIL"; + } + + private long count(CheckStatus status) { + return results.stream().filter(outcome -> outcome.status == status).count(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java new file mode 100644 index 0000000000..3b0e79adab --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java @@ -0,0 +1,23 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.Locale; + +public enum ReleaseTier { + QUICK, + FULL; + + public boolean includes(ReleaseTier checkTier) { + return this == FULL || checkTier == QUICK; + } + + public static ReleaseTier parse(String value) { + if (value == null || value.isBlank()) { + return QUICK; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unknown release E2E tier '" + value + "', expected quick or full", e); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java index e60abe0d50..5d820c1111 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.PluginDescriptor; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -37,4 +38,12 @@ public void localPlayerDoesNotMakeOtherGameStatesPlayable() { assertFalse(TestRunnerPlugin.isClientReady(GameState.LOADING, true, false)); assertFalse(TestRunnerPlugin.isClientReady(GameState.HOPPING, true, false)); } + + @Test + public void testWorldPropertyParsesWorldNumbers() { + assertEquals(383, TestRunnerPlugin.parseWorld(" 383 ")); + assertEquals(-1, TestRunnerPlugin.parseWorld(null)); + assertEquals(-1, TestRunnerPlugin.parseWorld("")); + assertEquals(-1, TestRunnerPlugin.parseWorld("f2p")); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java new file mode 100644 index 0000000000..176a9f19d9 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ClientLogScannerTest { + + private static final String START_OLD = "2026-10-10 18:00:00 CEST [main] INFO net.runelite.client.RuneLite - RuneLite 1.13.1 (launcher version 2.7.6) starting up, args: none"; + private static final String START_NEW = "2026-10-10 19:00:00 CEST [main] INFO net.runelite.client.RuneLite - RuneLite 1.13.1 (launcher version 2.7.6) starting up, args: --disable-telemetry"; + private static final String OLD_ERROR = "2026-10-10 18:10:00 CEST [Client] ERROR n.r.c.p.m.Microbot - previous run"; + private static final String INFO = "2026-10-10 19:00:01 CEST [Client] INFO n.r.c.p.m.Microbot - fine"; + private static final String ERROR = "2026-10-10 19:00:02 CEST [Client] ERROR n.r.c.p.m.Microbot - Failed to hop to world 552"; + private static final String STACK = "java.lang.IllegalStateException: boom"; + private static final String EXAMPLE_INTERRUPT = "2026-10-10 19:00:03 CEST [StateMachineExampleScript-5] ERROR n.r.c.p.m.s.StateMachineExampleScript - [StateMachineExample] Error in state SCAN_OBJECTS: Interrupted waiting for client thread"; + private static final String EXAMPLE_OTHER = "2026-10-10 19:00:04 CEST [StateMachineExampleScript-5] ERROR n.r.c.p.m.s.StateMachineExampleScript - [StateMachineExample] Error in state SCAN_OBJECTS: NullPointerException"; + + @Test + public void currentRunStartsAtTheLastStartupBanner() { + List run = ClientLogScanner.currentRun(List.of(START_OLD, OLD_ERROR, START_NEW, INFO)); + assertEquals(List.of(START_NEW, INFO), run); + } + + @Test + public void currentRunKeepsTheWholeFileWhenTheBannerRolledAway() { + assertEquals(List.of(INFO, ERROR), ClientLogScanner.currentRun(List.of(INFO, ERROR))); + } + + @Test + public void onlyErrorLevelLinesCount() { + assertTrue(ClientLogScanner.isErrorLine(ERROR)); + assertFalse(ClientLogScanner.isErrorLine(INFO)); + assertFalse(ClientLogScanner.isErrorLine("2026-10-10 19:00:02 CEST [Client] WARN n.r.c.Foo - ERROR in message text")); + assertFalse(ClientLogScanner.isErrorLine(STACK)); + } + + @Test + public void errorsCarryTheirFirstContinuationLine() { + List errors = ClientLogScanner.findErrors(List.of(START_NEW, ERROR, STACK, INFO), List.of()); + assertEquals(List.of(ERROR + " | " + STACK), errors); + } + + @Test + public void allowlistedLinesAreIgnored() { + List allow = List.of(new ClientLogScanner.AllowRule("Failed to hop", "test")); + assertTrue(ClientLogScanner.findErrors(List.of(ERROR, INFO), allow).isEmpty()); + } + + @Test + public void defaultAllowlistIsNarrowAndDocumented() { + List errors = ClientLogScanner.findErrors(List.of(EXAMPLE_INTERRUPT, EXAMPLE_OTHER, ERROR), ClientLogScanner.DEFAULT_ALLOWLIST); + assertEquals(List.of(EXAMPLE_OTHER, ERROR), errors); + for (ClientLogScanner.AllowRule rule : ClientLogScanner.DEFAULT_ALLOWLIST) { + assertFalse(rule.getReason().isBlank()); + } + assertTrue(ClientLogScanner.DEFAULT_ALLOWLIST.size() <= 5); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java new file mode 100644 index 0000000000..5fd3afff5e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java @@ -0,0 +1,223 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ReleaseCheckRunnerTest { + + private static final ReleaseCheck.Body PASS = context -> { + }; + + private static List catalog() { + return List.of( + ReleaseCheck.quick("boot", "boot", 1000, PASS), + ReleaseCheck.full("world-hop", "hop", 1000, PASS), + ReleaseCheck.quick("bank", "bank", 1000, PASS), + ReleaseCheck.quick("logout", "logout", 1000, PASS).alwaysRun()); + } + + private static List ids(List checks) { + return checks.stream().map(ReleaseCheck::getId).collect(Collectors.toList()); + } + + private static ReleaseCheckRunner runner(boolean failFast, long totalMs) { + return new ReleaseCheckRunner(new ReleaseCheckRunner.Options() + .failFast(failFast) + .totalTimeoutMs(totalMs) + .cleanupTimeoutMs(2000) + .terminationWaitMs(2000)); + } + + @Test + public void quickTierSkipsFullChecksAndKeepsOrder() { + assertEquals(List.of("boot", "bank", "logout"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of()))); + } + + @Test + public void fullTierIncludesEveryCheckInDeclaredOrder() { + assertEquals(List.of("boot", "world-hop", "bank", "logout"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.FULL, null))); + } + + @Test + public void explicitIdsIgnoreTierAndKeepDeclaredOrder() { + assertEquals(List.of("world-hop", "bank"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of("BANK", " world-hop ")))); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownIdIsRejected() { + ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of("bank", "nope")); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateIdsAreRejected() { + ReleaseCheckRunner.select(List.of(ReleaseCheck.quick("a", "a", 10, PASS), ReleaseCheck.quick("A", "b", 10, PASS)), + ReleaseTier.QUICK, List.of()); + } + + @Test + public void tierParsingDefaultsToQuick() { + assertEquals(ReleaseTier.QUICK, ReleaseTier.parse(null)); + assertEquals(ReleaseTier.FULL, ReleaseTier.parse(" Full ")); + try { + ReleaseTier.parse("medium"); + fail("expected rejection"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("medium")); + } + } + + @Test + public void classifiesPassFailSkipAndUnexpectedExceptions() { + List checks = List.of( + ReleaseCheck.quick("pass", "p", 1000, context -> context.detail("seen", 3)), + ReleaseCheck.quick("fail", "f", 1000, context -> context.require(false, "expected failure")), + ReleaseCheck.quick("skip", "s", 1000, context -> context.skip("no coins")), + ReleaseCheck.quick("boom", "b", 1000, context -> { + throw new IllegalStateException("kaput"); + })); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.PASSED, outcomes.get(0).status); + assertEquals("3", outcomes.get(0).details.get("seen")); + assertNull(outcomes.get(0).detail); + assertEquals(CheckStatus.FAILED, outcomes.get(1).status); + assertEquals("expected failure", outcomes.get(1).detail); + assertEquals(CheckStatus.SKIPPED, outcomes.get(2).status); + assertEquals("no coins", outcomes.get(2).detail); + assertEquals(CheckStatus.FAILED, outcomes.get(3).status); + assertEquals("IllegalStateException: kaput", outcomes.get(3).detail); + assertEquals(List.of(1, 2, 3, 4), outcomes.stream().map(o -> o.order).collect(Collectors.toList())); + } + + @Test + public void continuesAfterFailureByDefault() { + List checks = List.of( + ReleaseCheck.quick("first", "f", 1000, context -> context.require(false, "nope")), + ReleaseCheck.quick("second", "s", 1000, PASS)); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertEquals(CheckStatus.PASSED, outcomes.get(1).status); + } + + @Test + public void failFastMarksRemainingChecksNotRunButStillRunsAlwaysRunChecks() { + AtomicBoolean secondRan = new AtomicBoolean(); + AtomicBoolean logoutRan = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("first", "f", 1000, context -> context.require(false, "nope")), + ReleaseCheck.quick("second", "s", 1000, context -> secondRan.set(true)), + ReleaseCheck.quick("logout", "l", 1000, context -> logoutRan.set(true)).alwaysRun()); + + List outcomes = runner(true, 10_000).run(checks, null); + + assertEquals(CheckStatus.NOT_RUN, outcomes.get(1).status); + assertTrue(outcomes.get(1).detail.contains("fail-fast after first")); + assertFalse(secondRan.get()); + assertEquals(CheckStatus.PASSED, outcomes.get(2).status); + assertTrue(logoutRan.get()); + } + + @Test + public void perCheckTimeoutInterruptsTheBodyAndRunsCleanup() throws Exception { + CountDownLatch interrupted = new CountDownLatch(1); + AtomicBoolean cleaned = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("slow", "s", 200, context -> { + context.onCleanup("restore", () -> cleaned.set(true)); + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + }), + ReleaseCheck.quick("next", "n", 1000, PASS)); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertEquals("Timed out after 200ms", outcomes.get(0).detail); + assertTrue(interrupted.await(2, TimeUnit.SECONDS)); + assertTrue(cleaned.get()); + assertEquals(CheckStatus.PASSED, outcomes.get(1).status); + } + + @Test + public void totalTimeoutCapsTheCheckBudgetAndSkipsTheRest() { + AtomicBoolean logoutRan = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("hog", "h", 5_000, context -> { + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }), + ReleaseCheck.quick("later", "l", 1000, PASS), + ReleaseCheck.quick("logout", "o", 1000, context -> logoutRan.set(true)).alwaysRun()); + + List outcomes = runner(false, 300).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertTrue(outcomes.get(0).detail, outcomes.get(0).detail.contains("remaining total budget")); + assertEquals(CheckStatus.NOT_RUN, outcomes.get(1).status); + assertTrue(outcomes.get(1).detail.contains("total timeout of 300ms exceeded")); + assertEquals(CheckStatus.PASSED, outcomes.get(2).status); + assertTrue(logoutRan.get()); + } + + @Test + public void cleanupsRunInReverseRegistrationOrder() { + List order = Collections.synchronizedList(new ArrayList<>()); + List checks = List.of(ReleaseCheck.quick("c", "c", 1000, context -> { + context.onCleanup("first", () -> order.add("first")); + context.onCleanup("second", () -> order.add("second")); + context.skip("account state"); + })); + + CheckOutcome outcome = runner(false, 10_000).run(checks, null).get(0); + + assertEquals(CheckStatus.SKIPPED, outcome.status); + assertEquals(List.of("second", "first"), order); + } + + @Test + public void failingCleanupTurnsAPassIntoAFailure() { + List checks = List.of(ReleaseCheck.quick("c", "c", 1000, context -> { + context.onCleanup("close bank", () -> { + throw new IllegalStateException("bank still open"); + }); + context.onCleanup("other", () -> { + }); + })); + + CheckOutcome outcome = runner(false, 10_000).run(checks, null).get(0); + + assertEquals(CheckStatus.FAILED, outcome.status); + assertEquals(List.of("close bank: IllegalStateException: bank still open"), outcome.cleanupErrors); + assertTrue(outcome.detail.startsWith("Cleanup failed: close bank")); + } + + @Test + public void listenerSeesEveryOutcomeInOrder() { + List seen = new ArrayList<>(); + runner(true, 10_000).run(ReleaseCheckRunner.select(catalog(), ReleaseTier.FULL, List.of()), outcome -> seen.add(outcome.id + ":" + outcome.status)); + assertEquals(List.of("boot:PASSED", "world-hop:PASSED", "bank:PASSED", "logout:PASSED"), seen); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java new file mode 100644 index 0000000000..7c8c888b64 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java @@ -0,0 +1,155 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.runelite.client.plugins.microbot.testing.TestResultWriter; +import net.runelite.http.api.worlds.World; +import net.runelite.http.api.worlds.WorldType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ReleaseSmokeResultTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private static CheckOutcome outcome(int order, String id, CheckStatus status, String detail) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = id; + outcome.description = id + " description"; + outcome.tier = ReleaseTier.QUICK; + outcome.status = status; + outcome.durationMs = 1500; + outcome.detail = detail; + return outcome; + } + + @Test + public void passedAndSkippedChecksExitZero() { + ReleaseSmokeResult result = new ReleaseSmokeResult("Release Smoke Harness"); + result.record(outcome(1, "boot", CheckStatus.PASSED, null)); + result.record(outcome(2, "bank", CheckStatus.SKIPPED, "no junk item")); + result.finish("completed"); + + assertEquals(0, result.exitCode); + assertEquals("PASS", result.verdict); + assertEquals(1, result.passed); + assertEquals(1, result.skipped); + } + + @Test + public void failedOrNotRunChecksExitNonZero() { + ReleaseSmokeResult failed = new ReleaseSmokeResult("h"); + failed.record(outcome(1, "boot", CheckStatus.FAILED, "bad")); + failed.finish("completed"); + assertEquals(1, failed.exitCode); + assertEquals("FAIL", failed.verdict); + + ReleaseSmokeResult notRun = new ReleaseSmokeResult("h"); + notRun.record(outcome(1, "boot", CheckStatus.PASSED, null)); + notRun.record(outcome(2, "bank", CheckStatus.NOT_RUN, "Not run: total timeout")); + notRun.finish("completed"); + assertEquals(1, notRun.exitCode); + assertEquals(1, notRun.notRun); + } + + @Test + public void emptyRunsAndCrashesAreFailures() { + ReleaseSmokeResult empty = new ReleaseSmokeResult("h"); + empty.finish("completed"); + assertEquals(1, empty.exitCode); + + ReleaseSmokeResult crash = new ReleaseSmokeResult("h"); + crash.finish("crash"); + assertEquals(3, crash.exitCode); + assertEquals("FAIL", crash.verdict); + } + + @Test + public void resultJsonContainsTheStructuredOutcomes() throws Exception { + ReleaseSmokeResult result = new ReleaseSmokeResult("Release Smoke Harness"); + result.tier = "QUICK"; + result.microbotVersion = "2.6.30"; + CheckOutcome bank = outcome(1, "bank", CheckStatus.FAILED, "Inventory not restored"); + bank.details.put("junkItem", "Feather#314"); + result.record(bank); + result.finish("completed"); + + File dir = folder.newFolder("release-e2e"); + TestResultWriter.write(result, dir.getAbsolutePath()); + + JsonObject json = new JsonParser().parse(new String(Files.readAllBytes(dir.toPath().resolve("result.json")), StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("FAIL", json.get("verdict").getAsString()); + assertEquals(1, json.get("exitCode").getAsInt()); + JsonArray results = json.getAsJsonArray("results"); + assertEquals(1, results.size()); + JsonObject first = results.get(0).getAsJsonObject(); + assertEquals("bank", first.get("id").getAsString()); + assertEquals("FAILED", first.get("status").getAsString()); + assertEquals(1500, first.get("durationMs").getAsLong()); + assertEquals("Feather#314", first.getAsJsonObject("details").get("junkItem").getAsString()); + JsonArray legacyChecks = json.getAsJsonArray("checks"); + assertEquals("bank", legacyChecks.get(0).getAsJsonObject().get("name").getAsString()); + } + + @Test + public void summaryIsAMarkdownTableWithEscapedDetails() throws Exception { + ReleaseSmokeResult result = new ReleaseSmokeResult("h"); + result.tier = "FULL"; + result.microbotVersion = "2.6.30"; + result.record(outcome(1, "boot", CheckStatus.PASSED, null)); + result.record(outcome(2, "world-hop", CheckStatus.FAILED, "a | b\nc")); + result.finish("completed"); + + Path file = ReleaseSmokeReport.write(result, folder.newFolder("out").toPath()); + String summary = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + + assertTrue(summary, summary.startsWith("## Microbot release E2E: FAIL (full tier)")); + assertTrue(summary, summary.contains("| 1 | `boot` boot description | quick | PASSED | 1.5s | |")); + assertTrue(summary, summary.contains("| 2 | `world-hop` world-hop description | quick | FAILED | 1.5s | a \\| b c |")); + assertTrue(summary, summary.contains("1 passed, 1 failed, 0 skipped, 0 not run")); + } + + @Test + public void durationsAreHumanReadable() { + assertEquals("0.4s", ReleaseSmokeReport.formatDuration(400)); + assertEquals("59.9s", ReleaseSmokeReport.formatDuration(59_900)); + assertEquals("2m05s", ReleaseSmokeReport.formatDuration(125_000)); + } + + @Test + public void hopTargetIsAPlainF2pWorldPreferringTheSameRegion() { + List worlds = List.of( + world(383, EnumSet.noneOf(WorldType.class), "Castle Wars - Free", 7, 219), + world(571, EnumSet.noneOf(WorldType.class), "PvP Arena (Legacy Duels)", 3, 58), + world(302, EnumSet.of(WorldType.MEMBERS), "-", 7, 20), + world(537, EnumSet.noneOf(WorldType.class), "-", 3, 90), + world(552, EnumSet.noneOf(WorldType.class), "-", 7, 102), + world(553, EnumSet.noneOf(WorldType.class), "-", 7, 152)); + assertEquals(552, ReleaseSmokeChecks.pickF2pWorld(worlds, 383)); + assertEquals(-1, ReleaseSmokeChecks.pickF2pWorld(List.of(worlds.get(0), worlds.get(1)), 383)); + } + + @Test + public void checkIdsParseFromCommasOrWhitespace() { + assertEquals(List.of("bank", "world-hop", "logout"), ReleaseSmokeHarnessPlugin.parseIds(" bank,world-hop logout ")); + assertTrue(ReleaseSmokeHarnessPlugin.parseIds("").isEmpty()); + } + + private static World world(int id, EnumSet types, String activity, int location, int players) { + return World.builder().id(id).types(types).activity(activity).location(location).players(players).address("x").build(); + } +} diff --git a/scripts/run-release-e2e.sh b/scripts/run-release-e2e.sh new file mode 100755 index 0000000000..df03bcc060 --- /dev/null +++ b/scripts/run-release-e2e.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run-release-e2e.sh [quick|full] [CHECK_ID...] + +Runs the live release smoke harness (docs/RELEASE_E2E.md) against a logged-in F2P account. + +Options: + --jar PATH Run an already-built shaded jar (for example the release candidate) instead of building + --fail-fast Stop after the first failing check (logout still runs) + --list Print the check ids and exit + -h, --help Show this help + +Environment: + MICROBOT_E2E_JAR Same as --jar + MICROBOT_E2E_HOME user.home for the client (isolated runtime home with the saved profile) + MICROBOT_E2E_OUTPUT_DIR Result directory (default: /.runelite/test-results/release-e2e) + MICROBOT_E2E_EXPECTED_VERSION Version the client must report (default: microbot.version in gradle.properties, empty disables) + MICROBOT_E2E_TOTAL_TIMEOUT_MS Total budget for the checks (default: 900000 quick, 2100000 full) + MICROBOT_E2E_WORLD Log in to this world (use an F2P world for an F2P account) + MICROBOT_E2E_HOP_WORLD World for the world-hop check (default: least crowded other F2P world) + MICROBOT_E2E_HUB_PLUGIN Plugin Hub plugin for the hub check (default: AttackRangesPlusPlugin) + MICROBOT_E2E_GRADLE Gradle command used to build (default: ./gradlew) + MICROBOT_E2E_JAVA Java executable (default: java) + MICROBOT_E2E_JVM_ARGS Extra JVM arguments +EOF +} + +CHECK_IDS=(boot login state-caches widgets walker-transport bank npc-dialogue object-interaction agent-server + walker-medium grand-exchange world-hop hub-plugins script-lifecycle logout) + +TIER=quick +FAIL_FAST=false +JAR="${MICROBOT_E2E_JAR:-}" +CHECKS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + quick|full) TIER="$1" ;; + --jar) JAR="${2:?--jar needs a path}"; shift ;; + --fail-fast) FAIL_FAST=true ;; + --list) printf '%s\n' "${CHECK_IDS[@]}"; exit 0 ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option $1" >&2; usage >&2; exit 64 ;; + *) CHECKS+=("$1") ;; + esac + shift +done + +for id in ${CHECKS[@]+"${CHECKS[@]}"}; do + if [[ ! " ${CHECK_IDS[*]} " =~ " ${id} " ]]; then + echo "Unknown check id '$id'. Known ids: ${CHECK_IDS[*]}" >&2 + exit 64 + fi +done + +cd "$(dirname "$0")/.." + +RUNTIME_HOME="${MICROBOT_E2E_HOME:-$HOME}" +OUTPUT_DIR="${MICROBOT_E2E_OUTPUT_DIR:-$RUNTIME_HOME/.runelite/test-results/release-e2e}" +GRADLE_CMD="${MICROBOT_E2E_GRADLE:-./gradlew}" +JAVA_BIN="${MICROBOT_E2E_JAVA:-java}" + +if [[ -z "${DISPLAY:-}" && "$(uname)" == "Linux" ]]; then + echo "DISPLAY is not set; the client needs a display" >&2 + exit 65 +fi + +if [[ -z "$JAR" ]]; then + echo "Building shaded client jar..." + $GRADLE_CMD :client:shadowJar --console=plain + JAR="$(ls -t runelite-client/build/libs/client-*-shaded.jar 2>/dev/null | head -n 1 || true)" +fi +if [[ -z "$JAR" || ! -f "$JAR" ]]; then + echo "Shaded jar not found: '${JAR}'" >&2 + exit 66 +fi + +if [[ -n "${MICROBOT_E2E_EXPECTED_VERSION+x}" ]]; then + EXPECTED_VERSION="$MICROBOT_E2E_EXPECTED_VERSION" +else + EXPECTED_VERSION="$(sed -n 's/^microbot\.version=//p' gradle.properties | head -n 1)" +fi + +if [[ -n "${MICROBOT_E2E_TOTAL_TIMEOUT_MS:-}" ]]; then + TOTAL_TIMEOUT_MS="$MICROBOT_E2E_TOTAL_TIMEOUT_MS" +elif [[ "$TIER" == "full" ]]; then + TOTAL_TIMEOUT_MS=2100000 +else + TOTAL_TIMEOUT_MS=900000 +fi +RUNNER_TIMEOUT_MS=$((TOTAL_TIMEOUT_MS + 600000)) + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +IFS=',' CHECK_LIST="${CHECKS[*]:-}" +unset IFS + +CMD=("$JAVA_BIN" -ea -Dfile.encoding=UTF-8) +if [[ -n "${MICROBOT_E2E_HOME:-}" ]]; then + CMD+=("-Duser.home=$RUNTIME_HOME") +fi +if [[ -n "${MICROBOT_E2E_JVM_ARGS:-}" ]]; then + read -r -a EXTRA_JVM_ARGS <<< "$MICROBOT_E2E_JVM_ARGS" + CMD+=("${EXTRA_JVM_ARGS[@]}") +fi +CMD+=( + -Dmicrobot.disableTelemetry=true + -Dmicrobot.test.mode=true + "-Dmicrobot.test.script=Release Smoke Harness" + "-Dmicrobot.test.timeout=$RUNNER_TIMEOUT_MS" + "-Dmicrobot.test.output=$OUTPUT_DIR" + "-Dmicrobot.test.release.tier=$TIER" + "-Dmicrobot.test.release.failFast=$FAIL_FAST" + "-Dmicrobot.test.release.totalTimeoutMs=$TOTAL_TIMEOUT_MS" + "-Dmicrobot.test.release.expectedVersion=$EXPECTED_VERSION" +) +[[ -n "$CHECK_LIST" ]] && CMD+=("-Dmicrobot.test.release.checks=$CHECK_LIST") +[[ -n "${MICROBOT_E2E_WORLD:-}" ]] && CMD+=("-Dmicrobot.test.world=$MICROBOT_E2E_WORLD") +[[ -n "${MICROBOT_E2E_HOP_WORLD:-}" ]] && CMD+=("-Dmicrobot.test.release.hopWorld=$MICROBOT_E2E_HOP_WORLD") +[[ -n "${MICROBOT_E2E_HUB_PLUGIN:-}" ]] && CMD+=("-Dmicrobot.test.release.hubPlugin=$MICROBOT_E2E_HUB_PLUGIN") +CMD+=(-jar "$JAR" --disable-telemetry) + +echo "Running release E2E ($TIER${CHECK_LIST:+: $CHECK_LIST}) with $JAR" +START=$(date +%s) +set +e +"${CMD[@]}" > /dev/null 2> "$OUTPUT_DIR/client-stderr.log" +STATUS=$? +set -e +ELAPSED=$(( $(date +%s) - START )) + +if [[ -f "$OUTPUT_DIR/summary.md" ]]; then + cat "$OUTPUT_DIR/summary.md" +else + echo "No summary written; see $OUTPUT_DIR/client-stderr.log and ~/.runelite/logs/client.log" >&2 + tail -n 40 "$OUTPUT_DIR/client-stderr.log" >&2 || true +fi +if [[ ! -f "$OUTPUT_DIR/result.json" ]]; then + echo "No result.json written in $OUTPUT_DIR" >&2 + [[ "$STATUS" -eq 0 ]] && STATUS=1 +fi + +echo +echo "Wall clock: ${ELAPSED}s, client exit code: $STATUS" +echo "Results: $OUTPUT_DIR/result.json, $OUTPUT_DIR/summary.md" +exit "$STATUS" From c247cb4ab86f0cace78e5b696044889bc16f3e52 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:40:46 +0200 Subject: [PATCH 49/62] fix(release-e2e): exit on login failure and dismiss welcome screen in world login Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/testing/TestRunnerPlugin.java | 24 +++++++++++++++++-- .../release/ReleaseSmokeHarnessPlugin.java | 3 +++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java index 81f7a78c06..8c6067cea3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java @@ -12,7 +12,9 @@ import net.runelite.client.plugins.PluginInstantiationException; import net.runelite.client.plugins.PluginManager; import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; +import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.util.security.LoginManager; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import javax.inject.Inject; import javax.swing.*; @@ -89,15 +91,33 @@ private void prepareTargetPlugin() { private void scheduleWorldLogin(int world) { log.info("[TestRunner] Logging in to world {} with the active profile", world); executor.scheduleWithFixedDelay(() -> { - if (testStarted || client.getGameState() != GameState.LOGIN_SCREEN) { + if (testStarted) { return; } try { + if (client.getGameState() == GameState.LOGGED_IN) { + dismissWelcomeScreen(); + return; + } + if (client.getGameState() != GameState.LOGIN_SCREEN) { + return; + } LoginManager.login(world); } catch (Exception e) { log.warn("[TestRunner] Login attempt to world {} failed: {}", world, e.getMessage()); } - }, 5, 20, TimeUnit.SECONDS); + }, 5, 10, TimeUnit.SECONDS); + } + + private void dismissWelcomeScreen() { + boolean playVisible = Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget play = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + return play != null && !play.isHidden(); + }).orElse(false); + if (playVisible) { + log.info("[TestRunner] Dismissing the welcome screen"); + Rs2Widget.clickWidget(InterfaceID.WelcomeScreen.PLAY); + } } static int parseWorld(String value) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java index 1c8b57c243..42886e7d54 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java @@ -99,6 +99,9 @@ private void runHarness() { if (!sleepUntil(() -> ReleaseSmokeLive.location() != null, 60_000)) { result.addError("Player location never became available after login"); + result.finish("login_failure"); + writeAndExit(result, outputDir); + return; } result.world = Microbot.getClient().getWorld(); From 33df8b279fb3a709e793aca39d529d1eb27596f9 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:25:04 +0200 Subject: [PATCH 50/62] fix(security): stop logging profile credentials to stdout LoginManager.login() printed the whole active ConfigProfile, including login name, encrypted password, bank PIN and Discord webhook URL. Remove that print, exclude the secret fields from ConfigProfile.toString() so existing "{}" profile log lines stay safe, and drop the bank PIN, webhook URL and login name from a few other log messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../runelite/client/config/ConfigProfile.java | 4 +++ .../plugins/microbot/util/bank/Rs2Bank.java | 2 +- .../microbot/util/discord/Rs2Discord.java | 9 ++---- .../microbot/util/security/LoginManager.java | 1 - .../microbot/util/world/Rs2WorldUtil.java | 9 +++--- .../client/config/ConfigProfileTest.java | 29 +++++++++++++++++++ 6 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/config/ConfigProfile.java b/runelite-client/src/main/java/net/runelite/client/config/ConfigProfile.java index 0877f01e3c..7631e4e1e3 100644 --- a/runelite-client/src/main/java/net/runelite/client/config/ConfigProfile.java +++ b/runelite-client/src/main/java/net/runelite/client/config/ConfigProfile.java @@ -37,12 +37,15 @@ public class ConfigProfile private final long id; @Getter @Setter + @ToString.Exclude public String name; @Getter @Setter + @ToString.Exclude public String password; @Getter @Setter + @ToString.Exclude public String bankPin; @Getter @Setter @@ -58,6 +61,7 @@ public class ConfigProfile private Integer selectedWorld; // null = not set, -1 = Random Members, -2 = Random F2P, positive = specific world ID @Getter @Setter + @ToString.Exclude private String discordWebhookUrl; @Getter @Setter diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 5790a890fd..3f3ef5433f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -2652,7 +2652,7 @@ public static boolean handleBankPin(String pin) { final String[] DIGIT_INSTRUCTIONS = {"FIRST digit", "SECOND digit", "THIRD digit", "FOURTH digit"}; if (pin == null || pin.length() != DIGIT_INSTRUCTIONS.length || !pin.matches("\\d+")) { - Microbot.log("Unable to enter bankpin with value " + pin); + Microbot.log("Unable to enter bankpin: configured pin is not a 4-digit number"); return false; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java index b6d61d91b5..feb450f195 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java @@ -86,18 +86,15 @@ public static boolean sendWebhookMessage(String bodyMessage, List try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - Microbot.log("Failed to send Discord notification. Error Code: " + response.code() - + " - URL marked as invalid: " + webHookUrl); + Microbot.log("Failed to send Discord notification. Error Code: " + response.code()); } return response.isSuccessful(); } } catch (IllegalArgumentException e) { - Microbot.log("Invalid Discord webhook URL format - URL marked as invalid: " + webHookUrl - + " - Error: " + e.getMessage()); + Microbot.log("Invalid Discord webhook URL format"); return false; } catch (IOException e) { - Microbot.log("Error while sending Discord notification to URL: " + webHookUrl - + " - Error: " + e.getMessage()); + Microbot.log("Error while sending Discord notification - Error: " + e.getMessage()); return 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 dbf9b1c3c1..a5b13dc8df 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 @@ -125,7 +125,6 @@ public static boolean login() { log.warn("No active profile available for login"); return false; } - System.out.println(getActiveProfile()); Client client = Microbot.getClient(); if (client == null) { log.warn("Cannot login - client is not initialised"); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java index f95e173bc7..e076effb72 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java @@ -133,19 +133,18 @@ private static int getPorfileMemberShipDays() { try { ConfigProfile activeProfile = LoginManager.getActiveProfile(); - String profileName = activeProfile != null ? activeProfile.getName() : null; long memberExpireDays = activeProfile != null ? activeProfile.getMemberExpireDays() : 0; long memberExpireDaysTimeStemp = activeProfile != null ? activeProfile.getMemberExpireDaysTimeStemp() : 0; if( memberExpireDays == 0 && memberExpireDaysTimeStemp == 0){ - log.warn("No membership expiry data set in profile: {} (memberExpireDays: {}, memberExpireDaysTimeStemp: {})", - profileName, memberExpireDays, memberExpireDaysTimeStemp); + log.warn("No membership expiry data set in profile (memberExpireDays: {}, memberExpireDaysTimeStemp: {})", + memberExpireDays, memberExpireDaysTimeStemp); return -1; } // calculate days since cache was saved long currentTime = System.currentTimeMillis(); - log.info("Checking membership expiry for profile: {} (memberExpireDays: {}, memberExpireDaysTimeStemp: {}, currentTime: {})", - profileName, memberExpireDays, memberExpireDaysTimeStemp, currentTime); + log.info("Checking membership expiry for profile (memberExpireDays: {}, memberExpireDaysTimeStemp: {}, currentTime: {})", + memberExpireDays, memberExpireDaysTimeStemp, currentTime); long daysSinceCached = (currentTime - memberExpireDaysTimeStemp) / (24 * 60 * 60 * 1000); if( daysSinceCached > memberExpireDays){ diff --git a/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java b/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java new file mode 100644 index 0000000000..df2951f297 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java @@ -0,0 +1,29 @@ +package net.runelite.client.config; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class ConfigProfileTest +{ + @Test + public void toStringExcludesCredentials() + { + ConfigProfile profile = new ConfigProfile(42L); + profile.setName("login-name-marker"); + profile.setPassword("password-marker"); + profile.setBankPin("9182"); + profile.setDiscordWebhookUrl("https://discord.test/api/webhooks/webhook-marker"); + + String text = profile.toString(); + + assertTrue(text.contains("id=42")); + assertFalse(text.contains("login-name-marker")); + assertFalse(text.contains("password-marker")); + assertFalse(text.contains("9182")); + assertFalse(text.contains("webhook-marker")); + assertFalse(text.contains("password=")); + assertFalse(text.contains("bankPin=")); + assertFalse(text.contains("discordWebhookUrl=")); + } +} From 6dac4a97dae471d6c015eb5a88010d46bb03dede Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:28:47 +0200 Subject: [PATCH 51/62] fix(security): log profile ids instead of login names In Microbot the ConfigProfile name is the account login, and the startup, switch, rename and sync log lines wrote it to stdout and client.log. Log the profile id instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../runelite/client/config/ConfigManager.java | 20 +++++++++---------- .../client/config/ProfileManager.java | 4 ++-- .../agentserver/handler/ProfileHandler.java | 2 +- .../microbot/ui/MicrobotProfilePanel.java | 12 +++++------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java b/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java index 505b44611a..aaffe78ba3 100644 --- a/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java +++ b/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java @@ -156,7 +156,7 @@ public void switchProfile(ConfigProfile newProfile) // Ensure existing config is saved sendConfig(); - log.info("Switching profile to: {} ({})", newProfile.getName(), newProfile.getId()); + log.info("Switching profile to: {}", newProfile.getId()); // sync the latest config revision from the server if (sessionManager.getAccountSession() != null && newProfile.isSync()) @@ -277,7 +277,7 @@ public void onSessionClose(SessionClose sessionClose) if (profile.isSync()) { - log.info("Active remote profile '{}' lost due to session close, converting to a local profile.", profile.getName()); + log.info("Active remote profile {} lost due to session close, converting to a local profile.", profile.getId()); profile.setSync(false); profile.setRev(-1L); } @@ -656,7 +656,7 @@ public void load() continue; } - log.info("Profile '{}' (sync: {}, active: {}, id: {})", p.getName(), p.isSync(), p.isActive(), p.getId()); + log.info("Profile {} (sync: {}, active: {})", p.getId(), p.isSync(), p.isActive()); } if (rsProfile == null) @@ -728,7 +728,7 @@ public void load() if (profile != null) { - log.info("Using profile: {} ({})", profile.getName(), profile.getId()); + log.info("Using profile: {}", profile.getId()); } else { @@ -740,7 +740,7 @@ public void load() profile.setActive(true); } - log.info("Creating profile: {} ({})", profile.getName(), profile.getId()); + log.info("Creating profile: {}", profile.getId()); } // synced profautoogile need to be fetched if outdated @@ -780,7 +780,7 @@ private void mergeRemoteProfiles(List remoteProfiles) if (migrating && remoteProfile.getId() == 0L) { - log.info("Using remote profile {} as the active profile", profile.getName()); + log.info("Using remote profile {} as the active profile", profile.getId()); profile.setActive(true); } } @@ -803,7 +803,7 @@ private void mergeRemoteProfiles(List remoteProfiles) continue; } - log.warn("Lost remote profile for '{}'", localProfile.getName()); + log.warn("Lost remote profile for {}", localProfile.getId()); // convert the profile to a non-synced profile localProfile.setSync(false); localProfile.setRev(-1); @@ -837,11 +837,11 @@ private void syncRemote(ProfileManager.Lock lock, ConfigProfile profile, List { configManager.sendConfig(); @@ -1099,7 +1099,7 @@ private void cloneProfile(ConfigProfile profile) { } while (lock.findProfile(name) != null); - log.info("Cloning profile {} to {}", profile.getName(), name); + log.info("Cloning profile {}", profile.getId()); ConfigProfile clonedProfile = lock.createProfile(name); reload(lock.getProfiles()); @@ -1122,7 +1122,7 @@ private void cloneProfile(ConfigProfile profile) { } private void toggleSync(ActionEvent event, ConfigProfile profile, boolean sync) { - log.info("{} sync for: {}", sync ? "Enabling" : "Disabling", profile.getName()); + log.info("{} sync for: {}", sync ? "Enabling" : "Disabling", profile.getId()); configManager.toggleSync(profile, sync); ((JToggleButton) event.getSource()).setToolTipText(sync ? "Disable cloud sync" : "Enable cloud sync"); } From b1ce8127d1a24763fe58b744368450f865c549b1 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:40:36 +0200 Subject: [PATCH 52/62] fix(security): drop export path and IO message from logs The export path defaults to the profile name, and an IOException message can contain the webhook URL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/ui/MicrobotProfilePanel.java | 2 +- .../client/plugins/microbot/util/discord/Rs2Discord.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java index 2f0af56d26..fdfbb862ed 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java @@ -464,7 +464,7 @@ private void setRsProfileDefaultProfile(long id) { } private void exportProfile(ConfigProfile profile, File file) { - log.info("Exporting profile {} to {}", profile.getId(), file); + log.info("Exporting profile {}", profile.getId()); executor.execute(() -> { configManager.sendConfig(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java index feb450f195..68c578b961 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java @@ -94,7 +94,7 @@ public static boolean sendWebhookMessage(String bodyMessage, List Microbot.log("Invalid Discord webhook URL format"); return false; } catch (IOException e) { - Microbot.log("Error while sending Discord notification - Error: " + e.getMessage()); + Microbot.log("Error while sending Discord notification: " + e.getClass().getSimpleName()); return false; } } From 4e459b4199f89737593d722ab9443900652637e0 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:59:43 +0200 Subject: [PATCH 53/62] fix(world-hop): wait for the world switcher to load before hopping Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 47 +++++++- .../microbot/MicrobotWorldHopTest.java | 113 ++++++++++++++++++ 2 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java 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 52f8cd7eab..c387d8909c 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 @@ -171,6 +171,7 @@ public class Microbot { private static TooltipManager tooltipManager; private static ScheduledFuture xpSchedulorFuture; private static net.runelite.api.World quickHopTargetWorld; + private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -374,7 +375,8 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } - boolean hopIssued = Microbot.getClientThread().runOnClientThreadOptional(() -> { + 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; @@ -400,6 +402,7 @@ public static boolean hopToWorld(int worldNumber) { if (rsWorld == null) return false; quickHopTargetWorld = rsWorld; + target[0] = rsWorld; rsWorld.setActivity(newWorld.getActivity()); rsWorld.setAddress(newWorld.getAddress()); rsWorld.setId(newWorld.getId()); @@ -407,12 +410,21 @@ public static boolean hopToWorld(int worldNumber) { rsWorld.setLocation(newWorld.getLocation()); rsWorld.setTypes(WorldUtil.toWorldTypes(newWorld.getTypes())); - Microbot.getClient().openWorldHopper(); - Microbot.getClient().hopToWorld(rsWorld); - quickHopTargetWorld = null; return true; }).orElse(false); - if (!hopIssued) { + if (target[0] != null) { + boolean hopIssued; + try { + hopIssued = prepared && hopViaWorldSwitcher(target[0], WORLD_SWITCHER_LOAD_TIMEOUT_MS); + } finally { + quickHopTargetWorld = null; + } + if (prepared && !hopIssued) { + log.warn("World switcher did not load in time, could not hop to world {}", worldNumber); + return false; + } + } + if (!prepared) { log.error("Failed to hop to world {}", worldNumber); return false; } @@ -421,7 +433,6 @@ public static boolean hopToWorld(int worldNumber) { // wait inside it is a no-op and the success check fires before the server has // even processed the request. That's the source of "Failed to hop" spam. if (Microbot.getClient().getWorld() != worldNumber) { - sleep(600); sleepUntil(() -> Microbot.isHopping() || Microbot.getClient().getWorld() == worldNumber || Rs2Widget.getWidget(193, 0) != null, 5000); @@ -452,6 +463,30 @@ public static boolean hopToWorld(int worldNumber) { return success; } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { + if (!isWorldSwitcherLoaded()) { + Microbot.getClientThread().runOnClientThreadOptional(() -> { + Microbot.getClient().openWorldHopper(); + return true; + }); + if (!sleepUntil(Microbot::isWorldSwitcherLoaded, loadTimeoutMs)) { + return false; + } + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + if (Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) == null) { + return false; + } + Microbot.getClient().hopToWorld(rsWorld); + return true; + }).orElse(false); + } + + private static boolean isWorldSwitcherLoaded() { + return Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) != null).orElse(false); + } + public static void showMessage(String message) { try { Runnable messageRunnable = () -> 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 new file mode 100644 index 0000000000..2de2c38759 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -0,0 +1,113 @@ +package net.runelite.client.plugins.microbot; + +import net.runelite.api.Client; +import net.runelite.api.World; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MicrobotWorldHopTest { + + private MockedStatic microbot; + private Client client; + private World target; + private final AtomicInteger clientThreadCalls = new AtomicInteger(); + private final AtomicBoolean switcherLoaded = new AtomicBoolean(); + private final AtomicInteger openCall = new AtomicInteger(-1); + private final AtomicInteger hopCall = new AtomicInteger(-1); + + @Before + public void setUp() { + client = mock(Client.class); + target = mock(World.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + clientThreadCalls.incrementAndGet(); + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + Widget buttons = mock(Widget.class); + when(client.getWidget(InterfaceID.Worldswitcher.BUTTONS)).thenAnswer(inv -> switcherLoaded.get() ? buttons : null); + doAnswer(inv -> { + openCall.set(clientThreadCalls.get()); + return null; + }).when(client).openWorldHopper(); + doAnswer(inv -> { + hopCall.set(clientThreadCalls.get()); + return null; + }).when(client).hopToWorld(any()); + + microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void opensSwitcherThenHopsOnceListHasLoaded() { + doAnswer(inv -> { + openCall.set(clientThreadCalls.get()); + new Thread(() -> { + try { + Thread.sleep(200); + } catch (InterruptedException ignored) { + } + switcherLoaded.set(true); + }).start(); + return null; + }).when(client).openWorldHopper(); + + assertTrue(Microbot.hopViaWorldSwitcher(target, 3000)); + + InOrder order = inOrder(client); + order.verify(client).openWorldHopper(); + order.verify(client).hopToWorld(target); + assertNotEquals(openCall.get(), hopCall.get()); + } + + @Test + public void hopsImmediatelyWhenSwitcherAlreadyOpen() { + switcherLoaded.set(true); + + assertTrue(Microbot.hopViaWorldSwitcher(target, 3000)); + + verify(client, never()).openWorldHopper(); + verify(client).hopToWorld(target); + } + + @Test + public void doesNotHopWhenSwitcherNeverLoads() { + assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); + + verify(client).openWorldHopper(); + verify(client, never()).hopToWorld(any()); + assertEquals(-1, hopCall.get()); + } +} From f0d45b3e302dda0f3ef964c318df4d185bfd0aa0 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:04:17 +0200 Subject: [PATCH 54/62] fix(world-hop): reopen the world switcher while waiting for it to load Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/plugins/microbot/Microbot.java | 19 +++++++++++-------- .../microbot/MicrobotWorldHopTest.java | 5 +++-- 2 files changed, 14 insertions(+), 10 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 c387d8909c..d8b98fdfee 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 @@ -172,6 +172,7 @@ public class Microbot { private static ScheduledFuture xpSchedulorFuture; private static net.runelite.api.World quickHopTargetWorld; private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; + private static final int WORLD_SWITCHER_REOPEN_INTERVAL_MS = 600; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -464,14 +465,9 @@ public static boolean hopToWorld(int worldNumber) { } static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { - if (!isWorldSwitcherLoaded()) { - Microbot.getClientThread().runOnClientThreadOptional(() -> { - Microbot.getClient().openWorldHopper(); - return true; - }); - if (!sleepUntil(Microbot::isWorldSwitcherLoaded, loadTimeoutMs)) { - return false; - } + if (!isWorldSwitcherLoaded() + && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { + return false; } return Microbot.getClientThread().runOnClientThreadOptional(() -> { if (Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) == null) { @@ -482,6 +478,13 @@ static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeo }).orElse(false); } + private static void openWorldSwitcher() { + Microbot.getClientThread().runOnClientThreadOptional(() -> { + Microbot.getClient().openWorldHopper(); + return true; + }); + } + private static boolean isWorldSwitcherLoaded() { return Microbot.getClientThread().runOnClientThreadOptional( () -> Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) != null).orElse(false); 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 2de2c38759..924e6d5976 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 @@ -22,6 +22,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -87,7 +88,7 @@ public void opensSwitcherThenHopsOnceListHasLoaded() { assertTrue(Microbot.hopViaWorldSwitcher(target, 3000)); InOrder order = inOrder(client); - order.verify(client).openWorldHopper(); + order.verify(client, atLeastOnce()).openWorldHopper(); order.verify(client).hopToWorld(target); assertNotEquals(openCall.get(), hopCall.get()); } @@ -106,7 +107,7 @@ public void hopsImmediatelyWhenSwitcherAlreadyOpen() { public void doesNotHopWhenSwitcherNeverLoads() { assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); - verify(client).openWorldHopper(); + verify(client, atLeastOnce()).openWorldHopper(); verify(client, never()).hopToWorld(any()); assertEquals(-1, hopCall.get()); } From cc98074739d5e29d6c1449699f8ef3e8c1e213c6 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:32:20 +0200 Subject: [PATCH 55/62] fix(statemachine): re-initialise StateMachineScript on restart A stopped script kept initialized=true, so the next start skipped initialize(): it never re-registered in the registry and resumed from its previous state with stale counters and trace. Stopping now resets the machine and only unregisters the entry it owns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../statemachine/StateMachineScript.java | 12 ++- .../StateMachineScriptRestartTest.java | 87 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java index 1fc93089c4..09fe8d3adc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java @@ -73,7 +73,7 @@ public static Map> getRegistry() { private long loopCount; private long transitionCount; private List> transitions; - private boolean initialized; + private volatile boolean initialized; /** Bounded ring buffer of recent transitions for the trace log. */ private static final int MAX_TRACE_SIZE = 50; @@ -215,6 +215,12 @@ public final boolean step() { private void initialize() { currentState = initialState(); + previousState = null; + lastTransitionReason = null; + lastTransitionAt = null; + loopCount = 0; + transitionCount = 0; + traceBuffer.clear(); stateEnteredAt = Instant.now(); transitions = Collections.unmodifiableList(defineTransitions()); initialized = true; @@ -287,7 +293,9 @@ public void forceState(S newState, String reason) { @Override public void shutdown() { - REGISTRY.remove(getScriptName()); + initialized = false; + REGISTRY.remove(getScriptName(), this); + snapshot.set(null); super.shutdown(); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java new file mode 100644 index 0000000000..b2e17f322e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.statemachine; + +import java.util.ArrayList; +import java.util.List; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class StateMachineScriptRestartTest { + private static final String NAME = Machine.class.getSimpleName(); + + @Test public void restartReRegistersAndBeginsFromInitialState() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.advance = true; + assertTrue(machine.step()); + assertEquals(Machine.State.WORKING, machine.getCurrentState()); + assertSame(machine, StateMachineScript.getRegistry().get(NAME)); + + machine.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + + machine.advance = false; + assertTrue(machine.step()); + assertEquals(Machine.State.IDLE, machine.getCurrentState()); + assertSame(machine, StateMachineScript.getRegistry().get(NAME)); + StateSnapshot snapshot = machine.getSnapshot(); + assertEquals(Machine.State.IDLE, snapshot.currentState()); + assertNull(snapshot.previousState()); + assertEquals(1, snapshot.loopCount()); + assertEquals(0, snapshot.transitionCount()); + assertTrue(snapshot.recentTrace().isEmpty()); + assertEquals(List.of(Machine.State.WORKING, Machine.State.IDLE), machine.visited); + + machine.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + + @Test public void staleInstanceStopDoesNotUnregisterItsReplacement() { + Machine first = new Machine(); + Machine second = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + first.step(); + second.step(); + first.shutdown(); + assertSame(second, StateMachineScript.getRegistry().get(NAME)); + second.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + } finally { + first.shutdown(); + second.shutdown(); + first.dispose(); + second.dispose(); + } + } + + private static final class Machine extends StateMachineScript { + enum State { IDLE, WORKING } + + boolean advance; + final List visited = new ArrayList<>(); + + @Override protected State initialState() { + return State.IDLE; + } + + @Override protected List> defineTransitions() { + return List.of(Transition.from(State.IDLE).when(() -> advance, "advance") + .because("advance").goTo(State.WORKING)); + } + + @Override protected void onState(State state) { + visited.add(state); + } + + void dispose() { + scheduledExecutorService.shutdownNow(); + } + } +} From a193266676604e66ec381deecaea723d9b6db8a0 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:39:38 +0200 Subject: [PATCH 56/62] fix(statemachine): keep an in-flight step from re-registering after stop Stop cancels the loop before resetting, initialisation skips an interrupted (cancelled) step, and snapshot publication and reset share a short lifecycle lock so a step finishing after stop cannot leave a registry entry or stale snapshot behind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../statemachine/StateMachineScript.java | 52 ++++++++++++------- .../StateMachineScriptRestartTest.java | 41 +++++++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java index 09fe8d3adc..c9b9ca5b2e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java @@ -74,6 +74,7 @@ public static Map> getRegistry() { private long transitionCount; private List> transitions; private volatile boolean initialized; + private final Object lifecycleLock = new Object(); /** Bounded ring buffer of recent transitions for the trace log. */ private static final int MAX_TRACE_SIZE = 50; @@ -136,8 +137,8 @@ protected S onError(S state, Exception e) { * @return false if the base guard paused execution, true otherwise */ public final boolean step() { - if (!initialized) { - initialize(); + if (!initialized && !initialize()) { + return false; } // Check the base Script guard (heartbeat, blocking events, pause, etc.) @@ -213,19 +214,25 @@ public final boolean step() { return true; } - private void initialize() { - currentState = initialState(); - previousState = null; - lastTransitionReason = null; - lastTransitionAt = null; - loopCount = 0; - transitionCount = 0; - traceBuffer.clear(); - stateEnteredAt = Instant.now(); - transitions = Collections.unmodifiableList(defineTransitions()); - initialized = true; - REGISTRY.put(getScriptName(), this); + private boolean initialize() { + synchronized (lifecycleLock) { + if (Thread.currentThread().isInterrupted()) { + return false; + } + currentState = initialState(); + previousState = null; + lastTransitionReason = null; + lastTransitionAt = null; + loopCount = 0; + transitionCount = 0; + traceBuffer.clear(); + stateEnteredAt = Instant.now(); + transitions = Collections.unmodifiableList(defineTransitions()); + initialized = true; + REGISTRY.put(getScriptName(), this); + } log.info("[{}] State machine initialized in state {}", getScriptName(), currentState); + return true; } /** @@ -251,13 +258,18 @@ private void publishSnapshot(boolean allGuardsFalse) { } } - snapshot.set(new StateSnapshot<>( + StateSnapshot next = new StateSnapshot<>( currentState, previousState, lastTransitionReason, lastTransitionAt, stateEnteredAt, loopCount, transitionCount, pending, new ArrayList<>(traceBuffer) - )); + ); + synchronized (lifecycleLock) { + if (initialized) { + snapshot.set(next); + } + } } /** @@ -293,10 +305,12 @@ public void forceState(S newState, String reason) { @Override public void shutdown() { - initialized = false; - REGISTRY.remove(getScriptName(), this); - snapshot.set(null); super.shutdown(); + synchronized (lifecycleLock) { + initialized = false; + REGISTRY.remove(getScriptName(), this); + snapshot.set(null); + } } private String getScriptName() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java index b2e17f322e..47c7bf506b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java @@ -61,12 +61,49 @@ public class StateMachineScriptRestartTest { } } + @Test public void cancelledStepAfterStopDoesNotReRegister() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.step(); + machine.shutdown(); + Thread.currentThread().interrupt(); + try { + assertFalse(machine.step()); + } finally { + Thread.interrupted(); + } + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + + @Test public void stopDuringStateActionDoesNotRepublishSnapshot() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.stopInAction = true; + machine.step(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + private static final class Machine extends StateMachineScript { enum State { IDLE, WORKING } boolean advance; + boolean stopInAction; final List visited = new ArrayList<>(); + @Override protected State onError(State state, Exception e) { + return State.WORKING; + } + @Override protected State initialState() { return State.IDLE; } @@ -78,6 +115,10 @@ enum State { IDLE, WORKING } @Override protected void onState(State state) { visited.add(state); + if (stopInAction) { + shutdown(); + throw new IllegalStateException("stopped"); + } } void dispose() { From 51a8f0c6deaf29089f1a506c69571b0fbc81db2f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:12:17 +0200 Subject: [PATCH 57/62] fix(login): handle "Too many login attempts" lockout and stop AutoLogin fighting BreakHandler V2 - Detect login index 33 (Too many login attempts), press Back once, and pause every LoginManager login for an escalating cooldown (5/10/20/30 min, jittered) that resets after a successful login. - AutoLogin and BreakHandler V2 wait out the cooldown instead of retrying. - AutoLogin defers to BreakHandler V2 while a V2 break is active, so the two no longer log in/out against each other during a logout break. - AutoLogin only starts a login from LOGIN_SCREEN, not during HOPPING/LOADING. - Agent server reports the lockout and returns 429 while the cooldown runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginPlugin.java | 2 +- .../accountselector/AutoLoginScript.java | 41 ++++++- .../agentserver/handler/LoginHandler.java | 20 +++- .../breakhandlerv2/BreakHandlerV2Script.java | 33 +++++- .../microbot/util/security/LoginLockout.java | 97 ++++++++++++++++ .../microbot/util/security/LoginManager.java | 38 +++++++ .../accountselector/AutoLoginGateTest.java | 37 ++++++ .../util/security/LoginLockoutTest.java | 90 +++++++++++++++ .../security/LoginManagerLockoutTest.java | 105 ++++++++++++++++++ 9 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index f6cb6476b5..ec0670f94d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -23,7 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", - version = "1.0.4", + version = "1.0.5", tags = {"account", "microbot", "login"}, enabledByDefault = false ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index a746c6987c..fbb1600b74 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -6,10 +6,12 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.Script; import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; +import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2State; import net.runelite.client.plugins.microbot.util.discord.Rs2Discord; import net.runelite.client.plugins.microbot.util.discord.models.DiscordEmbed; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.player.Rs2PlayerModel; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil; @@ -40,6 +42,16 @@ static int computeRetryBackoffSeconds(int attemptsSoFar, int configuredBaseSecon return (int) Math.max(floor, jittered); } + static boolean isBreakHandlerOwningLogin() { + return BreakHandlerScript.isBreakActive() + || BreakHandlerScript.isMicroBreakActive() + || BreakHandlerV2State.isBreakActive(); + } + + static boolean canStartLogin(GameState gameState) { + return gameState == GameState.LOGIN_SCREEN; + } + // ban detection state public static boolean isBanned = false; private static String lastKnownPlayerName = ""; @@ -59,6 +71,7 @@ private enum LoginState { private Instant extendedSleepStartTime = null; private Instant lastLoginAttemptTime = null; private long lastExtendedSleepLoggedMinute = -1; + private boolean lockoutLogged = false; private final AutoLoginSuppression suppression = new AutoLoginSuppression(); void suppressAutoLogin() { @@ -81,7 +94,7 @@ public boolean run(AutoLoginConfig autoLoginConfig) { mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { try { if (!super.run()) return; - if (BreakHandlerScript.isBreakActive() || BreakHandlerScript.isMicroBreakActive()) return; + if (isBreakHandlerOwningLogin()) return; // check for ban detection first checkForBan(); @@ -136,6 +149,14 @@ private void handleWaitingForLoginScreenState(AutoLoginConfig config) { return; } + if (!canStartLogin(LoginManager.getGameState())) { + return; + } + + if (waitForLoginLockout()) { + return; + } + log.info("Login screen detected, initiating login"); initiateLogin(config); transitionToState(LoginState.ATTEMPTING_LOGIN); @@ -153,6 +174,11 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { return; } + if (waitForLoginLockout()) { + lastLoginAttemptTime = Instant.now(); + return; + } + // check login watchdog timeout if enabled if (config.enableLoginWatchdog() && loginWatchdogStartTime != null) { long watchdogTime = Duration.between(loginWatchdogStartTime, Instant.now()).toMinutes(); @@ -226,6 +252,19 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { } } + private boolean waitForLoginLockout() { + if (!LoginManager.checkLoginLockout()) { + lockoutLogged = false; + return false; + } + if (!lockoutLogged) { + log.warn("Too many login attempts - AutoLogin waiting {}s before the next attempt", + TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis())); + lockoutLogged = true; + } + return true; + } + /** * State: LOGIN_EXTENDED_SLEEP * Extended sleep state after login failures. 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 2b0334eb45..7340c61dba 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.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import java.io.IOException; @@ -22,12 +23,14 @@ public class LoginHandler extends AgentHandler { private static final int LOGIN_INDEX_BANNED = 14; private static final int LOGIN_INDEX_DISCONNECTED = 24; private static final int LOGIN_INDEX_NON_MEMBER = 34; + private static final int LOGIN_INDEX_TOO_MANY_ATTEMPTS = LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS; private static final Set FATAL_LOGIN_INDICES = Set.of( LOGIN_INDEX_AUTH_FAILURE, LOGIN_INDEX_INVALID_CREDENTIALS, LOGIN_INDEX_BANNED, - LOGIN_INDEX_NON_MEMBER + LOGIN_INDEX_NON_MEMBER, + LOGIN_INDEX_TOO_MANY_ATTEMPTS ); private static final int DEFAULT_TIMEOUT_SECONDS = 30; @@ -72,6 +75,10 @@ private Map buildStatusMap() { result.put("loggedIn", Microbot.isLoggedIn()); result.put("gameState", gameState.name()); result.put("loginAttemptActive", LoginManager.isLoginAttemptActive()); + long loginCooldownMs = LoginLockout.getInstance().remainingMillis(); + if (loginCooldownMs > 0) { + result.put("loginCooldownMs", loginCooldownMs); + } if (Microbot.isLoggedIn()) { long durationMs = LoginManager.getLoginDuration().toMillis(); @@ -152,6 +159,15 @@ private void handleLogin(HttpExchange exchange) throws IOException { return; } + if (!loginInitiated && LoginLockout.getInstance().isActive()) { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("message", "Too many login attempts - login paused until the cooldown ends"); + result.put("loginCooldownMs", LoginLockout.getInstance().remainingMillis()); + sendJson(exchange, 429, result); + return; + } + if (!loginInitiated) { Map result = new LinkedHashMap<>(); result.put("success", false); @@ -260,6 +276,8 @@ private static String describeLoginIndex(int loginIndex) { return "Disconnected from server"; case LOGIN_INDEX_NON_MEMBER: return "Non-member account cannot login to members world"; + case LOGIN_INDEX_TOO_MANY_ATTEMPTS: + return "Too many login attempts - wait before retrying"; default: return null; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java index f88e8d5fda..0d6dc20560 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java @@ -10,6 +10,7 @@ import net.runelite.client.plugins.microbot.util.discord.Rs2Discord; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil; import net.runelite.client.ui.ClientUI; @@ -58,6 +59,7 @@ public BreakHandlerV2Script() { private int preBreakWorld = -1; private ConfigProfile activeProfile; private boolean unexpectedLogoutDetected = false; + private boolean loginLockoutLogged = false; private String originalWindowTitle = ""; private boolean pluginStopTriggered = false; private boolean pluginRestartPending = false; @@ -89,7 +91,7 @@ public BreakHandlerV2Script() { private static final int MAX_SAFETY_CHECK_ATTEMPTS = 60; private static final int SAFETY_CHECK_DELAY_MS = 5000; // 5 seconds between checks - public static String version = "2.0.7"; + public static String version = "2.0.8"; /** * Run the break handler script @@ -431,6 +433,10 @@ private void handleLoginRequested() { return; } + if (waitForLoginLockout()) { + return; + } + // Check retry limit (max 10 attempts) if (loginRetryCount >= MAX_LOGIN_ATTEMPTS) { log.error("[BreakHandlerV2] Max login attempts ({}) reached", MAX_LOGIN_ATTEMPTS); @@ -458,6 +464,10 @@ private void handleLoginRequested() { sleep(5000); } + if (waitForLoginLockout()) { + return; + } + // Select world based on configuration int targetWorld = selectWorld(); @@ -487,6 +497,22 @@ private void handleLoginRequested() { } } + private boolean waitForLoginLockout() { + if (!LoginManager.checkLoginLockout()) { + loginLockoutLogged = false; + return false; + } + if (!loginLockoutLogged) { + long remainingSeconds = TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis()); + log.warn("[BreakHandlerV2] Too many login attempts - waiting {}s before logging in again", remainingSeconds); + sendDiscordNotification("Login Paused", + "Too many login attempts. Waiting " + (remainingSeconds / 60 + 1) + " minutes before retrying."); + loginLockoutLogged = true; + } + loginRetryCount = Math.min(Math.max(loginRetryCount, INITIAL_FAST_RETRIES), MAX_LOGIN_ATTEMPTS - 1); + return true; + } + /** * Calculate exponential backoff delay for login retries * First 3 attempts: 5s delay @@ -516,6 +542,11 @@ private void handleLoggingIn() { return; } + if (LoginManager.checkLoginLockout()) { + transitionToState(BreakHandlerV2State.LOGIN_REQUESTED); + return; + } + // Check for timeout (10 seconds) if (loginAttemptTime != null && Instant.now().isAfter(loginAttemptTime.plusSeconds(10))) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java new file mode 100644 index 0000000000..de0111775b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java @@ -0,0 +1,97 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; + +public final class LoginLockout { + public static final int LOGIN_INDEX_TOO_MANY_ATTEMPTS = 33; + + static final long[] COOLDOWN_STEPS_MILLIS = { + TimeUnit.MINUTES.toMillis(5), + TimeUnit.MINUTES.toMillis(10), + TimeUnit.MINUTES.toMillis(20), + TimeUnit.MINUTES.toMillis(30) + }; + static final double MIN_JITTER = 0.85; + static final double JITTER_RANGE = 0.3; + static final long DISMISS_INTERVAL_MILLIS = 10_000L; + + private static final LoginLockout INSTANCE = new LoginLockout(); + + private final LongSupplier clock; + private final DoubleSupplier random; + private int consecutiveLockouts; + private boolean active; + private long cooldownUntil; + private boolean dismissedOnce; + private long lastDismissAt; + + LoginLockout() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime()), + () -> ThreadLocalRandom.current().nextDouble()); + } + + LoginLockout(LongSupplier clock, DoubleSupplier random) { + this.clock = clock; + this.random = random; + } + + public static LoginLockout getInstance() { + return INSTANCE; + } + + public static boolean isLockoutLoginIndex(int loginIndex) { + return loginIndex == LOGIN_INDEX_TOO_MANY_ATTEMPTS; + } + + static long baseCooldownMillis(int lockoutNumber) { + int idx = Math.min(Math.max(lockoutNumber - 1, 0), COOLDOWN_STEPS_MILLIS.length - 1); + return COOLDOWN_STEPS_MILLIS[idx]; + } + + public synchronized boolean recordLockout() { + if (isActive()) { + return false; + } + consecutiveLockouts++; + double jitter = MIN_JITTER + random.getAsDouble() * JITTER_RANGE; + long duration = Math.round(baseCooldownMillis(consecutiveLockouts) * jitter); + cooldownUntil = clock.getAsLong() + duration; + active = true; + dismissedOnce = false; + return true; + } + + public synchronized boolean isActive() { + if (active && clock.getAsLong() >= cooldownUntil) { + active = false; + } + return active; + } + + public synchronized long remainingMillis() { + return isActive() ? cooldownUntil - clock.getAsLong() : 0L; + } + + public synchronized int getConsecutiveLockouts() { + return consecutiveLockouts; + } + + public synchronized boolean tryClaimDismiss() { + long now = clock.getAsLong(); + if (dismissedOnce && now - lastDismissAt < DISMISS_INTERVAL_MILLIS) { + return false; + } + dismissedOnce = true; + lastDismissAt = now; + return true; + } + + public synchronized void recordSuccessfulLogin() { + consecutiveLockouts = 0; + active = false; + dismissedOnce = 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 a5b13dc8df..50dc81b904 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 @@ -21,6 +21,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Random; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -97,6 +98,7 @@ public static void markLoggedIn() { if (isLoggedIn()) { LOGIN_ATTEMPT_ACTIVE.set(false); lastLoginTimestamp.set(Instant.now()); + LoginLockout.getInstance().recordSuccessfulLogin(); } } @@ -194,6 +196,11 @@ public static boolean login(String username, String encryptedPassword, int world log.warn("Cannot login - client is not initialised"); return false; } + if (checkLoginLockout()) { + log.debug("Login skipped - login lockout cooldown active ({}s remaining)", + TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis())); + return false; + } synchronized (LOGIN_LOCK) { Instant lastAttempt = LAST_LOGIN_ATTEMPT.get(); Instant now = Instant.now(); @@ -222,6 +229,37 @@ public static boolean login(String username, String encryptedPassword, int world } } + public static boolean isLoginLockoutScreen() { + Client client = Microbot.getClient(); + return client != null + && client.getGameState() == GameState.LOGIN_SCREEN + && LoginLockout.isLockoutLoginIndex(client.getLoginIndex()); + } + + public static boolean checkLoginLockout() { + LoginLockout lockout = LoginLockout.getInstance(); + if (!isLoginLockoutScreen()) { + return lockout.isActive(); + } + if (lockout.recordLockout()) { + log.warn("Too many login attempts - pausing all logins for {}s (lockout #{})", + TimeUnit.MILLISECONDS.toSeconds(lockout.remainingMillis()), lockout.getConsecutiveLockouts()); + } + if (lockout.tryClaimDismiss()) { + dismissLoginLockoutScreen(Microbot.getClient()); + } + return true; + } + + private static void dismissLoginLockoutScreen(Client client) { + if (client == null || Microbot.getMouse() == null) { + return; + } + int loginScreenWidth = 804; + int startingWidth = (client.getCanvasWidth() / 2) - (loginScreenWidth / 2); + Microbot.getMouse().click(365 + startingWidth, 326); + } + private static void handleDisconnectDialogs(Client client) { if (client == null) { return; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java new file mode 100644 index 0000000000..bfc4b5e0eb --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java @@ -0,0 +1,37 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2State; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class AutoLoginGateTest { + @Before @After public void resetBreakHandlers() { + BreakHandlerV2State.setState(BreakHandlerV2State.WAITING_FOR_BREAK); + } + + @Test public void autoLoginRunsWhenNoBreakIsActive() { + assertFalse(AutoLoginScript.isBreakHandlerOwningLogin()); + } + + @Test public void autoLoginDefersToBreakHandlerV2DuringItsBreak() { + for (BreakHandlerV2State state : new BreakHandlerV2State[] { + BreakHandlerV2State.LOGOUT_REQUESTED, BreakHandlerV2State.LOGGED_OUT, + BreakHandlerV2State.LOGIN_REQUESTED, BreakHandlerV2State.LOGGING_IN, + BreakHandlerV2State.LOGIN_EXTENDED_SLEEP}) { + BreakHandlerV2State.setState(state); + assertTrue(state.name(), AutoLoginScript.isBreakHandlerOwningLogin()); + } + } + + @Test public void onlyTheLoginScreenStartsALogin() { + assertTrue(AutoLoginScript.canStartLogin(GameState.LOGIN_SCREEN)); + for (GameState state : new GameState[] {GameState.HOPPING, GameState.LOADING, + GameState.LOGGING_IN, GameState.CONNECTION_LOST, GameState.LOGGED_IN, + GameState.STARTING, GameState.UNKNOWN}) { + assertFalse(state.name(), AutoLoginScript.canStartLogin(state)); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java new file mode 100644 index 0000000000..b9cd6cad37 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java @@ -0,0 +1,90 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Test; +import static org.junit.Assert.*; + +public class LoginLockoutTest { + private final AtomicLong clock = new AtomicLong(1_000L); + + private LoginLockout lockout(double random) { + return new LoginLockout(clock::get, () -> random); + } + + @Test public void recognisesOnlyTheTooManyAttemptsLoginIndex() { + assertTrue(LoginLockout.isLockoutLoginIndex(33)); + for (int index : new int[] {0, 2, 3, 4, 9, 10, 14, 24, 32, 34}) { + assertFalse(LoginLockout.isLockoutLoginIndex(index)); + } + } + + @Test public void lockoutStartsCooldownThatExpiresWithTheClock() { + LoginLockout lockout = lockout(0.5); + assertFalse(lockout.isActive()); + assertTrue(lockout.recordLockout()); + long first = TimeUnit.MINUTES.toMillis(5); + assertTrue(lockout.isActive()); + assertEquals(first, lockout.remainingMillis()); + clock.addAndGet(first - 1); + assertTrue(lockout.isActive()); + clock.incrementAndGet(); + assertFalse(lockout.isActive()); + assertEquals(0L, lockout.remainingMillis()); + } + + @Test public void repeatedDetectionWhileActiveDoesNotExtendOrEscalate() { + LoginLockout lockout = lockout(0.5); + assertTrue(lockout.recordLockout()); + long remaining = lockout.remainingMillis(); + clock.addAndGet(1_000L); + assertFalse(lockout.recordLockout()); + assertEquals(remaining - 1_000L, lockout.remainingMillis()); + assertEquals(1, lockout.getConsecutiveLockouts()); + } + + @Test public void consecutiveLockoutsEscalateAndCap() { + LoginLockout lockout = lockout(0.5); + long[] expectedMinutes = {5, 10, 20, 30, 30}; + for (long minutes : expectedMinutes) { + assertTrue(lockout.recordLockout()); + long duration = TimeUnit.MINUTES.toMillis(minutes); + assertEquals(duration, lockout.remainingMillis()); + clock.addAndGet(duration); + } + } + + @Test public void jitterStaysWithinBounds() { + long base = TimeUnit.MINUTES.toMillis(5); + LoginLockout low = lockout(0.0); + low.recordLockout(); + assertEquals(Math.round(base * 0.85), low.remainingMillis()); + LoginLockout high = lockout(0.999999); + high.recordLockout(); + assertTrue(high.remainingMillis() <= Math.round(base * 1.15)); + assertTrue(high.remainingMillis() > base); + } + + @Test public void successfulLoginClearsCooldownAndEscalation() { + LoginLockout lockout = lockout(0.5); + lockout.recordLockout(); + clock.addAndGet(TimeUnit.MINUTES.toMillis(5)); + lockout.recordLockout(); + lockout.recordSuccessfulLogin(); + assertFalse(lockout.isActive()); + assertEquals(0, lockout.getConsecutiveLockouts()); + assertTrue(lockout.recordLockout()); + assertEquals(TimeUnit.MINUTES.toMillis(5), lockout.remainingMillis()); + } + + @Test public void dismissIsRateLimited() { + LoginLockout lockout = lockout(0.5); + lockout.recordLockout(); + assertTrue(lockout.tryClaimDismiss()); + assertFalse(lockout.tryClaimDismiss()); + clock.addAndGet(LoginLockout.DISMISS_INTERVAL_MILLIS - 1); + assertFalse(lockout.tryClaimDismiss()); + clock.incrementAndGet(); + assertTrue(lockout.tryClaimDismiss()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java new file mode 100644 index 0000000000..62cf7d310d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java @@ -0,0 +1,105 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.atomic.AtomicInteger; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.mouse.Mouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class LoginManagerLockoutTest { + private final AtomicInteger loginIndex = new AtomicInteger(LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS); + private Client client; + private Mouse mouse; + + @Before public void setUp() { + LoginLockout.getInstance().recordSuccessfulLogin(); + client = mock(Client.class); + mouse = mock(Mouse.class); + when(client.getGameState()).thenReturn(GameState.LOGIN_SCREEN); + when(client.getLoginIndex()).thenAnswer(call -> loginIndex.get()); + when(client.getCanvasWidth()).thenReturn(804); + } + + @After public void tearDown() { + LoginLockout.getInstance().recordSuccessfulLogin(); + } + + @Test public void lockoutScreenPressesBackAndNeverSubmits() { + try (var microbot = mockStatic(Microbot.class); + var keyboard = mockStatic(Rs2Keyboard.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.login("user", "", 383)); + + verify(mouse, times(1)).click(365, 326); + verify(client, never()).setUsername(anyString()); + verify(client, never()).setPassword(anyString()); + verify(client, never()).changeWorld(any()); + keyboard.verifyNoInteractions(); + assertTrue(LoginLockout.getInstance().isActive()); + } + } + + @Test public void cooldownKeepsRefusingAfterTheScreenIsDismissed() { + try (var microbot = mockStatic(Microbot.class); + var keyboard = mockStatic(Rs2Keyboard.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.login("user", "", 383)); + loginIndex.set(2); + assertFalse(LoginManager.isLoginLockoutScreen()); + assertTrue(LoginManager.checkLoginLockout()); + assertFalse(LoginManager.login("user", "", 383)); + + verify(mouse, times(1)).click(anyInt(), anyInt()); + verify(client, never()).setUsername(anyString()); + keyboard.verifyNoInteractions(); + } + } + + @Test public void repeatedPollingOnLockoutScreenDoesNotSpamClicks() { + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + for (int i = 0; i < 5; i++) { + assertTrue(LoginManager.checkLoginLockout()); + } + + verify(mouse, times(1)).click(anyInt(), anyInt()); + assertEquals(1, LoginLockout.getInstance().getConsecutiveLockouts()); + } + } + + @Test public void normalLoginScreenIsNotTreatedAsLockout() { + loginIndex.set(2); + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.isLoginLockoutScreen()); + assertFalse(LoginManager.checkLoginLockout()); + verifyNoInteractions(mouse); + } + } + + @Test public void lockoutIndexOutsideLoginScreenIsIgnored() { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.checkLoginLockout()); + verifyNoInteractions(mouse); + } + } +} From 24458dae1f008dcd24e2a9174a8683d45a923fa2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:24:22 +0200 Subject: [PATCH 58/62] fix(login): address review on lockout cooldown - Count a lockout only once per appearance of the lockout screen, so a screen that outlives its cooldown does not escalate on its own. - Reset the AutoLogin watchdog while waiting out a lockout. - Agent server /login returns 429 with loginCooldownMs when polling hits the lockout screen, matching the rejected-initiation path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginScript.java | 1 + .../agentserver/handler/LoginHandler.java | 21 ++++++++++++++----- .../microbot/util/security/LoginLockout.java | 9 +++++++- .../microbot/util/security/LoginManager.java | 1 + .../util/security/LoginLockoutTest.java | 16 ++++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index fbb1600b74..4fde3c6c6f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -176,6 +176,7 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { if (waitForLoginLockout()) { lastLoginAttemptTime = Instant.now(); + loginWatchdogStartTime = null; return; } 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 7340c61dba..eeab31b4a8 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 @@ -160,11 +160,7 @@ private void handleLogin(HttpExchange exchange) throws IOException { } if (!loginInitiated && LoginLockout.getInstance().isActive()) { - Map result = new LinkedHashMap<>(); - result.put("success", false); - result.put("message", "Too many login attempts - login paused until the cooldown ends"); - result.put("loginCooldownMs", LoginLockout.getInstance().remainingMillis()); - sendJson(exchange, 429, result); + sendLoginCooldown(exchange); return; } @@ -189,6 +185,12 @@ private void handleLogin(HttpExchange exchange) throws IOException { LoginResult loginResult = waitForLoginResult(timeoutSeconds); + if (!loginResult.success && LoginLockout.isLockoutLoginIndex(loginResult.loginIndex)) { + LoginManager.checkLoginLockout(); + sendLoginCooldown(exchange); + return; + } + Map result = new LinkedHashMap<>(); result.put("success", loginResult.success); result.put("message", loginResult.message); @@ -205,6 +207,15 @@ private void handleLogin(HttpExchange exchange) throws IOException { sendJson(exchange, loginResult.success ? 200 : 401, result); } + private void sendLoginCooldown(HttpExchange exchange) throws IOException { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("message", "Too many login attempts - login paused until the cooldown ends"); + result.put("loginIndex", LOGIN_INDEX_TOO_MANY_ATTEMPTS); + result.put("loginCooldownMs", LoginLockout.getInstance().remainingMillis()); + sendJson(exchange, 429, result); + } + private LoginResult waitForLoginResult(int timeoutSeconds) { long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java index de0111775b..152a30dd1a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java @@ -26,6 +26,7 @@ public final class LoginLockout { private boolean active; private long cooldownUntil; private boolean dismissedOnce; + private boolean awaitingScreenClear; private long lastDismissAt; LoginLockout() { @@ -52,7 +53,7 @@ static long baseCooldownMillis(int lockoutNumber) { } public synchronized boolean recordLockout() { - if (isActive()) { + if (isActive() || awaitingScreenClear) { return false; } consecutiveLockouts++; @@ -61,9 +62,14 @@ public synchronized boolean recordLockout() { cooldownUntil = clock.getAsLong() + duration; active = true; dismissedOnce = false; + awaitingScreenClear = true; return true; } + public synchronized void recordScreenCleared() { + awaitingScreenClear = false; + } + public synchronized boolean isActive() { if (active && clock.getAsLong() >= cooldownUntil) { active = false; @@ -93,5 +99,6 @@ public synchronized void recordSuccessfulLogin() { consecutiveLockouts = 0; active = false; dismissedOnce = false; + awaitingScreenClear = 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 50dc81b904..da9f6b9ac8 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 @@ -239,6 +239,7 @@ public static boolean isLoginLockoutScreen() { public static boolean checkLoginLockout() { LoginLockout lockout = LoginLockout.getInstance(); if (!isLoginLockoutScreen()) { + lockout.recordScreenCleared(); return lockout.isActive(); } if (lockout.recordLockout()) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java index b9cd6cad37..56adb53e6f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java @@ -50,10 +50,25 @@ private LoginLockout lockout(double random) { assertTrue(lockout.recordLockout()); long duration = TimeUnit.MINUTES.toMillis(minutes); assertEquals(duration, lockout.remainingMillis()); + lockout.recordScreenCleared(); clock.addAndGet(duration); } } + @Test public void lockoutScreenThatOutlivesItsCooldownIsNotCountedAgain() { + LoginLockout lockout = lockout(0.5); + assertTrue(lockout.recordLockout()); + clock.addAndGet(TimeUnit.MINUTES.toMillis(5)); + assertFalse(lockout.isActive()); + assertFalse(lockout.recordLockout()); + assertFalse(lockout.isActive()); + assertEquals(1, lockout.getConsecutiveLockouts()); + lockout.recordScreenCleared(); + assertTrue(lockout.recordLockout()); + assertEquals(2, lockout.getConsecutiveLockouts()); + assertEquals(TimeUnit.MINUTES.toMillis(10), lockout.remainingMillis()); + } + @Test public void jitterStaysWithinBounds() { long base = TimeUnit.MINUTES.toMillis(5); LoginLockout low = lockout(0.0); @@ -68,6 +83,7 @@ private LoginLockout lockout(double random) { @Test public void successfulLoginClearsCooldownAndEscalation() { LoginLockout lockout = lockout(0.5); lockout.recordLockout(); + lockout.recordScreenCleared(); clock.addAndGet(TimeUnit.MINUTES.toMillis(5)); lockout.recordLockout(); lockout.recordSuccessfulLogin(); From c9670c44eb7d72354dfae9410db1166e83c63ea3 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:05:45 +0200 Subject: [PATCH 59/62] fix(autologin): keep retry backoff across failed login attempts AutoLogin reset its retry count and last-attempt time as soon as the client left LOGIN_SCREEN, so every LOGIN_SCREEN -> LOGGING_IN -> LOGIN_SCREEN failure restarted from attempt one and retried within seconds. Stay in ATTEMPTING_LOGIN while the client is logging in or loading, only reset on a confirmed login, and apply the escalating backoff and retry limit when the client falls back to the login screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginPlugin.java | 2 +- .../accountselector/AutoLoginScript.java | 49 +++-- .../AutoLoginRetryStateTest.java | 190 ++++++++++++++++++ 3 files changed, 221 insertions(+), 20 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index ec0670f94d..830b0c83d7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -23,7 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", - version = "1.0.5", + version = "1.0.6", tags = {"account", "microbot", "login"}, enabledByDefault = false ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index 4fde3c6c6f..cb4ef480ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -15,6 +15,7 @@ import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; @@ -73,6 +74,19 @@ private enum LoginState { private long lastExtendedSleepLoggedMinute = -1; private boolean lockoutLogged = false; private final AutoLoginSuppression suppression = new AutoLoginSuppression(); + private Clock clock = Clock.systemUTC(); + + void setClock(Clock clock) { + this.clock = clock; + } + + String getLoginStateName() { + return loginState.name(); + } + + int getRetryCount() { + return retryCount; + } void suppressAutoLogin() { suppression.request(); @@ -116,7 +130,7 @@ public boolean run(AutoLoginConfig autoLoginConfig) { /** * Main state machine for auto login processing. */ - private void processAutoLoginStateMachine(AutoLoginConfig config) { + void processAutoLoginStateMachine(AutoLoginConfig config) { if (suppression.isSuppressed()) { resetLoginState(); return; @@ -175,25 +189,29 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { } if (waitForLoginLockout()) { - lastLoginAttemptTime = Instant.now(); + lastLoginAttemptTime = Instant.now(clock); loginWatchdogStartTime = null; return; } // check login watchdog timeout if enabled if (config.enableLoginWatchdog() && loginWatchdogStartTime != null) { - long watchdogTime = Duration.between(loginWatchdogStartTime, Instant.now()).toMinutes(); + long watchdogTime = Duration.between(loginWatchdogStartTime, Instant.now(clock)).toMinutes(); if (watchdogTime >= config.loginWatchdogTimeout()) { log.warn("Login watchdog timeout reached after {} minutes, entering extended sleep", watchdogTime); - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); transitionToState(LoginState.LOGIN_EXTENDED_SLEEP); return; } } + if (LoginManager.getGameState() != GameState.LOGIN_SCREEN) { + return; + } + // check if enough time has passed for retry — uses exponential backoff if (lastLoginAttemptTime != null) { - long timeSinceLastAttempt = Duration.between(lastLoginAttemptTime, Instant.now()).toSeconds(); + long timeSinceLastAttempt = Duration.between(lastLoginAttemptTime, Instant.now(clock)).toSeconds(); int backoff = computeRetryBackoffSeconds(retryCount, config.loginRetryDelay()); if (timeSinceLastAttempt < backoff) { return; @@ -204,7 +222,7 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { if (retryCount >= config.maxLoginRetries()) { if (config.enableLoginWatchdog()) { log.warn("Max login retries reached, entering extended sleep"); - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); transitionToState(LoginState.LOGIN_EXTENDED_SLEEP); } else { log.warn("Max login retries reached, resetting retry count"); @@ -213,8 +231,7 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { return; } - // check if still on login screen - if (Microbot.getClient() != null && Microbot.getClient().getGameState() == GameState.LOGIN_SCREEN) { + if (Microbot.getClient() != null) { log.info("Retrying login attempt {} of {}", retryCount + 1, config.maxLoginRetries()); int currentLoginIndex = Microbot.getClient().getLoginIndex(); if (Microbot.getClient().getLoginIndex() == 3 || Microbot.getClient().getLoginIndex() == 24) { // you were disconnected from the server. @@ -244,12 +261,6 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { // we have to find out other indexes that mean we cannot login initiateLogin(config); - } else { - - - // not on login screen anymore, return to waiting - resetLoginState(); - transitionToState(LoginState.WAITING_FOR_LOGIN_SCREEN); } } @@ -279,13 +290,13 @@ private void handleLoginExtendedSleepState(AutoLoginConfig config) { } if (extendedSleepStartTime == null) { - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); log.info("Extended sleep started for {} minutes", config.extendedSleepDuration()); return; } // check if extended sleep period is complete - long sleepTime = Duration.between(extendedSleepStartTime, Instant.now()).toMinutes(); + long sleepTime = Duration.between(extendedSleepStartTime, Instant.now(clock)).toMinutes(); if (sleepTime >= config.extendedSleepDuration()) { log.info("Extended sleep period completed after {} minutes, resuming login attempts", sleepTime); resetLoginState(); @@ -308,7 +319,7 @@ private void initiateLogin(AutoLoginConfig config) { if (suppression.isSuppressed()) return; // start login watchdog if enabled and not already started if (config.enableLoginWatchdog() && loginWatchdogStartTime == null) { - loginWatchdogStartTime = Instant.now(); + loginWatchdogStartTime = Instant.now(clock); log.info("Login watchdog started for {} minutes", config.loginWatchdogTimeout()); } @@ -390,7 +401,7 @@ private void initiateLogin(AutoLoginConfig config) { // perform login attempt and track retry state retryCount++; - lastLoginAttemptTime = Instant.now(); + lastLoginAttemptTime = Instant.now(clock); boolean loginInitiated; if (targetWorld != -1) { @@ -410,7 +421,7 @@ private void initiateLogin(AutoLoginConfig config) { } catch (Exception ex) { log.error("Error during intelligent login", ex); retryCount++; - lastLoginAttemptTime = Instant.now(); + lastLoginAttemptTime = Instant.now(clock); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java new file mode 100644 index 0000000000..b653706eda --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java @@ -0,0 +1,190 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; +import net.runelite.client.plugins.microbot.util.security.LoginManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +public class AutoLoginRetryStateTest { + private static final int WORLD = 383; + + private final AtomicReference gameState = new AtomicReference<>(GameState.LOGIN_SCREEN); + private final AtomicInteger loginIndex = new AtomicInteger(2); + private final AtomicInteger loginCalls = new AtomicInteger(); + private final MutableClock clock = new MutableClock(); + private Client client; + private AutoLoginConfig config; + private AutoLoginScript script; + private MockedStatic microbot; + private MockedStatic loginManager; + + @Before public void setUp() { + LoginLockout.getInstance().recordSuccessfulLogin(); + client = mock(Client.class); + when(client.getGameState()).thenAnswer(call -> gameState.get()); + when(client.getLoginIndex()).thenAnswer(call -> loginIndex.get()); + + config = mock(AutoLoginConfig.class); + when(config.usePreferredWorld()).thenReturn(true); + when(config.world()).thenReturn(WORLD); + when(config.loginRetryDelay()).thenReturn(5); + when(config.maxLoginRetries()).thenReturn(5); + when(config.enableLoginWatchdog()).thenReturn(false); + + microbot = mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenAnswer(call -> gameState.get() == GameState.LOGGED_IN); + + loginManager = mockStatic(LoginManager.class, CALLS_REAL_METHODS); + loginManager.when(() -> LoginManager.login(anyInt())).thenAnswer(call -> { + loginCalls.incrementAndGet(); + gameState.set(GameState.LOGGING_IN); + return true; + }); + + script = new AutoLoginScript(); + script.setClock(clock); + } + + @After public void tearDown() { + loginManager.close(); + microbot.close(); + LoginLockout.getInstance().recordSuccessfulLogin(); + } + + @Test public void failedLoginWaitsForBackoffBeforeRetrying() { + tick(); + assertEquals(1, loginCalls()); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + + for (int i = 0; i < 5; i++) { + advance(Duration.ofMillis(600)); + tick(); + } + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + tick(); + advance(Duration.ofSeconds(1)); + tick(); + assertEquals(1, loginCalls()); + assertEquals(1, script.getRetryCount()); + + advance(Duration.ofSeconds(15)); + tick(); + assertEquals(2, loginCalls()); + assertEquals(2, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofSeconds(9)); + tick(); + assertEquals(2, loginCalls()); + + advance(Duration.ofSeconds(20)); + tick(); + assertEquals(3, loginCalls()); + } + + @Test public void maxRetriesStillEntersExtendedSleepWithWatchdog() { + when(config.enableLoginWatchdog()).thenReturn(true); + when(config.loginWatchdogTimeout()).thenReturn(60); + when(config.maxLoginRetries()).thenReturn(2); + + tick(); + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofSeconds(30)); + tick(); + assertEquals(2, loginCalls()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofMinutes(5)); + tick(); + assertEquals(2, loginCalls()); + assertEquals("LOGIN_EXTENDED_SLEEP", script.getLoginStateName()); + } + + @Test public void loggingInThenLoggedInResetsRetryState() { + tick(); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOADING); + advance(Duration.ofMillis(600)); + tick(); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOGGED_IN); + advance(Duration.ofMillis(600)); + tick(); + assertEquals("WAITING_FOR_LOGIN_SCREEN", script.getLoginStateName()); + assertEquals(0, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofMillis(600)); + tick(); + assertEquals(2, loginCalls()); + assertEquals(1, script.getRetryCount()); + } + + @Test public void lockoutScreenUsesCooldownInsteadOfBackoff() { + tick(); + gameState.set(GameState.LOGIN_SCREEN); + loginIndex.set(LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS); + tick(); + assertTrue(LoginLockout.getInstance().isActive()); + + loginIndex.set(2); + for (int i = 0; i < 5; i++) { + advance(Duration.ofMinutes(1)); + tick(); + } + + assertEquals(1, loginCalls()); + assertTrue(LoginLockout.getInstance().isActive()); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + } + + private void tick() { + script.processAutoLoginStateMachine(config); + } + + private void advance(Duration duration) { + clock.now = clock.now.plus(duration); + } + + private int loginCalls() { + return loginCalls.get(); + } + + private static final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-01-01T00:00:00Z"); + + @Override public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override public Clock withZone(ZoneId zone) { + return this; + } + + @Override public Instant instant() { + return now; + } + } +} From c556649749dcfe1d13ef9447eae8d300e2d443e8 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:07:44 +0200 Subject: [PATCH 60/62] fix(telemetry): guard null actor animation, null click bounds, quest race and antiban EDT block - Rs2ActorModel.getAnimation returns -1 when the model wraps no actor (new Rs2PlayerModel() while logged out / loading) - Rs2UiHelper.isRectangleWithinCanvas treats a null rectangle as off-canvas, so doInvoke falls back instead of throwing - QuestScript captures the selected quest once instead of re-reading it inside the client-thread lambda, and no longer unboxes an empty Optional - Antiban panel computes Category.isBusy on the refresh timer thread instead of the Swing EDT, which blocked the UI for up to 10 s per tick Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 2 +- .../plugins/microbot/api/actor/Rs2ActorModel.java | 3 +++ .../plugins/microbot/questhelper/QuestScript.java | 4 ++-- .../microbot/util/antiban/AntibanPlugin.java | 1 + .../microbot/util/antiban/ui/MasterPanel.java | 9 +++++++-- .../plugins/microbot/util/misc/Rs2UiHelper.java | 3 +++ .../player/Rs2PlayerModelNullLocalPlayerTest.java | 6 ++++++ .../util/misc/Rs2UiHelperCanvasBoundsTest.java | 13 +++++++++++++ 8 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 79113de21e..b4a4ec3b82 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -41,7 +41,7 @@ return Microbot.getClientThread().runOnClientThreadOptional(() -> { }).orElse(-1); ``` -**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`), the position accessors of `Rs2ActorModel` (`getWorldView`, `getWorldLocation`, `getLocalLocation`, `projectActorLocationToMainWorld`), which `new Rs2PlayerModel()` reaches with a null actor, and any new helper that reads the local player. +**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`), the position accessors of `Rs2ActorModel` (`getWorldView`, `getWorldLocation`, `getLocalLocation`, `projectActorLocationToMainWorld`) and its `getAnimation` (returns -1), which `new Rs2PlayerModel()` reaches with a null actor, and any new helper that reads the local player. **Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest` and `Rs2PlayerModelNullLocalPlayerTest`. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java index 9b9a7b07bb..400ebc1878 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java @@ -110,6 +110,9 @@ public int getCurrentOrientation() @Override public int getAnimation() { + if (actor == null) { + return -1; + } return actor.getAnimation(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java index 2768b5d8ce..9ffd2aa5c2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java @@ -184,8 +184,8 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (getQuestHelperPlugin().getSelectedQuest() != null && !Microbot.getClientThread().runOnClientThreadOptional(() -> - getQuestHelperPlugin().getSelectedQuest().isCompleted()).orElse(null)) { + QuestHelper selectedQuest = getQuestHelperPlugin().getSelectedQuest(); + if (selectedQuest != null && !Microbot.getClientThread().runOnClientThreadOptional(selectedQuest::isCompleted).orElse(true)) { if (Rs2Widget.isWidgetVisible(ComponentID.DIALOG_OPTION_OPTIONS) && getQuestHelperPlugin().getSelectedQuest().getQuest().getId() != Quest.COOKS_ASSISTANT.getId() && !Rs2Bank.isOpen()) { boolean hasOption = Rs2Dialogue.handleQuestOptionDialogueSelection(); //if there is no quest option in the dialogue, just click player location to remove diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java index 1e9d64832e..3caa1459fd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java @@ -180,6 +180,7 @@ protected void startUp() throws AWTException { panelRefreshTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { + panel.refreshBusyState(); SwingUtilities.invokeLater(panel::loadSettings); } }, 0, 600); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java index 3cb494fb47..4cd6c2cca7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java @@ -52,7 +52,8 @@ public class MasterPanel extends PluginPanel { private final JLabel profileLabel = new JLabel("Category: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().getName() : "null")); private final JLabel activityLabel = new JLabel("Activity: " + (Rs2Antiban.getActivity() != null ? Rs2Antiban.getActivity().getMethod() : "null")); private final JLabel activityIntensityLabel = new JLabel("Activity Intensity: " + (Rs2Antiban.getActivityIntensity() != null ? Rs2Antiban.getActivityIntensity().getName() : "null")); - private final JLabel busyLabel = new JLabel("Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null")); + private final JLabel busyLabel = new JLabel("Busy: null"); + private volatile String busyText = "Busy: null"; private final boolean isFlipped = false; // Track if the image is flipped private final FlippableLabel label; private final JLayeredPane layeredPane; // Use a layered pane for positioning the GIF @@ -203,7 +204,11 @@ public void loadSettings() { profileLabel.setText("Category: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().getName() : "null")); activityLabel.setText("Activity: " + (Rs2Antiban.getActivity() != null ? Rs2Antiban.getActivity().getMethod() : "null")); activityIntensityLabel.setText("Activity Intensity: " + (Rs2Antiban.getActivityIntensity() != null ? Rs2Antiban.getActivityIntensity().getName() : "null")); - busyLabel.setText("Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null")); + busyLabel.setText(busyText); + } + + public void refreshBusyState() { + busyText = "Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null"); } private JPanel createInfoPanel() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java index 157a12a9cd..fc198d3fd3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java @@ -30,6 +30,9 @@ public static boolean isRectangleWithinViewport(Rectangle rectangle) { } public static boolean isRectangleWithinCanvas(Rectangle rectangle) { + if (rectangle == null) { + return false; + } int canvasHeight = Microbot.getClient().getCanvasHeight(); int canvasWidth = Microbot.getClient().getCanvasWidth(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java index 43660953d3..c8e58394d2 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java @@ -10,6 +10,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; @@ -53,4 +54,9 @@ public void positionAccessorsReturnNullWithoutLocalPlayer() { assertNull(player.projectActorLocationToMainWorld()); verifyNoInteractions(clientThread); } + + @Test + public void animationIsIdleWithoutLocalPlayer() { + assertEquals(-1, new Rs2PlayerModel().getAnimation()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java new file mode 100644 index 0000000000..1be06db72e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java @@ -0,0 +1,13 @@ +package net.runelite.client.plugins.microbot.util.misc; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; + +public class Rs2UiHelperCanvasBoundsTest { + + @Test + public void nullRectangleIsNotWithinCanvas() { + assertFalse(Rs2UiHelper.isRectangleWithinCanvas(null)); + } +} From 6a50335dbbdbe4233eeae739ac368eab500c7744 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:18:20 +0200 Subject: [PATCH 61/62] fix(telemetry): address review - reuse captured quest, read antiban category once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/questhelper/QuestScript.java | 36 +++++++++---------- .../microbot/util/antiban/ui/MasterPanel.java | 4 ++- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java index 9ffd2aa5c2..3fd62c956d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java @@ -186,7 +186,7 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { QuestHelper selectedQuest = getQuestHelperPlugin().getSelectedQuest(); if (selectedQuest != null && !Microbot.getClientThread().runOnClientThreadOptional(selectedQuest::isCompleted).orElse(true)) { - if (Rs2Widget.isWidgetVisible(ComponentID.DIALOG_OPTION_OPTIONS) && getQuestHelperPlugin().getSelectedQuest().getQuest().getId() != Quest.COOKS_ASSISTANT.getId() && !Rs2Bank.isOpen()) { + if (Rs2Widget.isWidgetVisible(ComponentID.DIALOG_OPTION_OPTIONS) && selectedQuest.getQuest().getId() != Quest.COOKS_ASSISTANT.getId() && !Rs2Bank.isOpen()) { boolean hasOption = Rs2Dialogue.handleQuestOptionDialogueSelection(); //if there is no quest option in the dialogue, just click player location to remove // the dialogue to avoid getting stuck in an infinite loop of dialogues @@ -194,8 +194,8 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { if (Rs2Dialogue.acceptQuestStartDialogue()) { return; } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.IMP_CATCHER.getId() + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.IMP_CATCHER.getId() && Microbot.getClient().getTopLevelWorldView().getPlane() == 1) { Rs2Dialogue.keyPressForDialogueOption(1); // presses option 1 sleep(1200,1800); @@ -205,14 +205,14 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { return; } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.COOKS_ASSISTANT.getId() && + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.COOKS_ASSISTANT.getId() && Rs2Dialogue.isInDialogue()) { dialogueStartedStep = questStep; // Force this to be true for Cook's Assistant } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.PIRATES_TREASURE.getId() && + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.PIRATES_TREASURE.getId() && Rs2Dialogue.isInDialogue()) { dialogueStartedStep = questStep; } @@ -253,7 +253,7 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { * If we do not prioritize this, the script will think we are missing items */ if (questStep instanceof DetailedQuestStep && !(questStep instanceof NpcStep || questStep instanceof ObjectStep || questStep instanceof DigStep)) { - boolean result = applyDetailedQuestStep((DetailedQuestStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep().getActiveStep()); + boolean result = applyDetailedQuestStep((DetailedQuestStep) selectedQuest.getCurrentStep().getActiveStep()); if (result) { sleepUntil(() -> Rs2Player.isInteracting() || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Dialogue.isInDialogue(), 500); sleepUntil(() -> !Rs2Player.isInteracting() && !Rs2Player.isMoving() && !Rs2Player.isAnimating()); @@ -261,17 +261,17 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof ConditionalStep) { - QuestStep conditionalStep = getQuestHelperPlugin().getSelectedQuest().getCurrentStep().getActiveStep(); + if (selectedQuest.getCurrentStep() instanceof ConditionalStep) { + QuestStep conditionalStep = selectedQuest.getCurrentStep().getActiveStep(); applyStep(conditionalStep); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof NpcStep) { - applyNpcStep((NpcStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof ObjectStep) { - applyObjectStep((ObjectStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof DigStep) { - applyDigStep((DigStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof PuzzleStep) { - applyPuzzleStep((PuzzleStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof NpcStep) { + applyNpcStep((NpcStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof ObjectStep) { + applyObjectStep((ObjectStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof DigStep) { + applyDigStep((DigStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof PuzzleStep) { + applyPuzzleStep((PuzzleStep) selectedQuest.getCurrentStep()); } sleepUntil(() -> Rs2Player.isInteracting() || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Dialogue.isInDialogue(), 500); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java index 4cd6c2cca7..8abd02d40b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.microbot.util.antiban.AntibanPlugin; import net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban; import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; +import net.runelite.client.plugins.microbot.util.antiban.enums.Category; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.PluginPanel; import net.runelite.client.util.ImageUtil; @@ -208,7 +209,8 @@ public void loadSettings() { } public void refreshBusyState() { - busyText = "Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null"); + Category category = Rs2Antiban.getCategory(); + busyText = "Busy: " + (category != null ? category.isBusy() : "null"); } private JPanel createInfoPanel() { From 623c09230d7e2370d6ed99dbb56adf08f589e17e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:21:21 +0200 Subject: [PATCH 62/62] chore(release): bump microbot.version to 2.6.31 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0e2fa03eaf..3a10760e10 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,7 +32,7 @@ project.build.version=1.13.1 runelite.injected-client.version=1.13.1 glslang.path= -microbot.version=2.6.30 +microbot.version=2.6.31 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username=