From f77b03c286e0bc0b5e13081dd54ea42cc991e2e6 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:30:44 +0200 Subject: [PATCH 01/48] fix(walker): report ARRIVED when a pass starts on the final path endpoint processWalk cleared the target on reaching the path endpoint and let the same pass fall through to the after-stuck-check, which read the null currentTarget as an external cancel and returned EXIT even when the player stood on the goal. The endpoint branch now checks for a genuine cancel first, then returns ARRIVED when the player is within the tight finish threshold of the target (EXIT otherwise, with an honest trace). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/walker/Rs2Walker.java | 19 ++- .../Rs2WalkerPathEndpointArrivalTest.java | 139 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerPathEndpointArrivalTest.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 dae623eafd1..1b43f2c4679 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 @@ -457,6 +457,10 @@ static WalkerRouteState routeStateForTesting() { return routeState; } + static WalkerState processWalkForTesting(WorldPoint target, int distance) { + return processWalk(target, distance, 0); + } + private static void clearRecentTransportContext() { routeState.clearRecentTransportContext(); @@ -1757,6 +1761,19 @@ private static WalkerState processWalk(WorldPoint target, int distance) { return processWalk(target, distance, 0); } + private static WalkerState settleReachedPathEndpoint(WorldPoint target, WorldPoint endpoint, WorldPoint playerLoc, + int distance, int processWalkTail) { + if (walkCancelledDiag(target, "processWalk:at-path-endpoint", processWalkTail)) { + return WalkerState.EXIT; + } + boolean arrived = playerLoc.distanceTo(target) <= tightFinishThreshold(target, endpoint, distance); + if (!arrived) { + traceProcessWalkExit("path-endpoint-short-of-target", target, processWalkTail); + } + setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); + return arrived ? WalkerState.ARRIVED : WalkerState.EXIT; + } + private static WalkerState processWalk(WorldPoint target, int distance, int partialRetries) { if (debug) { return WalkerState.EXIT; @@ -1948,7 +1965,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // Partial paths end at an intermediate waypoint (dst still far from {@code target}); // clearing here would drop currentTarget before the partial-path retry/recalc branch. if (!partialPath && isNear(dst, walkLoop.playerLoc) && routeState.interimTargetWp == null) { - setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); + return settleReachedPathEndpoint(target, dst, walkLoop.playerLoc, distance, processWalkTail); } boolean shouldIssueActiveRouteIdleNudge = shouldIssueActiveRouteIdleNudge(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerPathEndpointArrivalTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerPathEndpointArrivalTest.java new file mode 100644 index 00000000000..8a868fb3f7c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerPathEndpointArrivalTest.java @@ -0,0 +1,139 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Client; +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.ShortestPathConfig; +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.state.WalkerRouteState; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WalkerPathEndpointArrivalTest +{ + private static final WorldPoint TARGET = new WorldPoint(3227, 3214, 0); + private static final List PATH = List.of( + new WorldPoint(3224, 3214, 0), + new WorldPoint(3225, 3214, 0), + new WorldPoint(3226, 3214, 0), + TARGET); + private static final String REACHED_ENDPOINT = "rs2walker:processWalk:reached-path-endpoint"; + + private MockedStatic microbot; + private MockedStatic player; + private MockedStatic tile; + private MockedStatic pathApi; + private ShortestPathConfig originalConfig; + private WorldPoint originalTarget; + private Rs2ActiveRouteStatus status; + + @Before + public void setUp() + { + originalConfig = Rs2Walker.config; + originalTarget = Rs2Walker.currentTarget; + Rs2Walker.config = mock(ShortestPathConfig.class); + Rs2Walker.clearWalkerDedupeForTesting(); + WalkerRouteState routeState = Rs2Walker.routeStateForTesting(); + routeState.lastRouteClearReason = ""; + routeState.interimTargetWp = null; + routeState.interimTargetIdx = -1; + routeState.stuckCount = 0; + routeState.lastMovedTimeMs = System.currentTimeMillis(); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(mock(Client.class)); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + player = Mockito.mockStatic(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenReturn(TARGET); + + tile = Mockito.mockStatic(Rs2Tile.class); + tile.when(() -> Rs2Tile.getReachableTilesFromTile(any(WorldPoint.class), anyInt())).thenReturn(new HashMap<>()); + + status = mock(Rs2ActiveRouteStatus.class); + when(status.isPresent()).thenReturn(true); + when(status.isReady()).thenReturn(true); + when(status.getRawPath()).thenReturn(PATH); + when(status.getWalkablePath()).thenReturn(PATH); + when(status.getMetrics()).thenReturn(Optional.empty()); + pathApi = Mockito.mockStatic(Rs2PathApi.class); + pathApi.when(Rs2PathApi::getActiveRouteStatus).thenReturn(status); + pathApi.when(() -> Rs2PathApi.hasWalkableTileWithin(any(WorldPoint.class), anyInt())).thenReturn(true); + + Rs2Walker.currentTarget = TARGET; + } + + @After + public void tearDown() + { + pathApi.close(); + tile.close(); + player.close(); + microbot.close(); + Rs2Walker.currentTarget = originalTarget; + Rs2Walker.config = originalConfig; + Rs2Walker.clearWalkerDedupeForTesting(); + } + + @Test + public void standingOnTheEndpointThatIsTheTargetArrives() + { + WalkerState state = Rs2Walker.processWalkForTesting(TARGET, 0); + + assertEquals(WalkerState.ARRIVED, state); + assertEquals(REACHED_ENDPOINT, Rs2Walker.getLastRouteClearReason()); + assertNull(Rs2Walker.currentTarget); + } + + @Test + public void externalCancelBeforeTheEndpointCheckStillExits() + { + player.when(Rs2Player::isInteracting).thenAnswer(invocation -> { + Rs2Walker.currentTarget = null; + return false; + }); + + WalkerState state = Rs2Walker.processWalkForTesting(TARGET, 0); + + assertEquals(WalkerState.EXIT, state); + assertNotEquals(REACHED_ENDPOINT, Rs2Walker.getLastRouteClearReason()); + } + + @Test + public void endpointShortOfTheTightFinishThresholdDoesNotArrive() + { + WorldPoint farTarget = new WorldPoint(3230, 3214, 0); + Rs2Walker.currentTarget = farTarget; + + WalkerState state = Rs2Walker.processWalkForTesting(farTarget, 5); + + assertEquals(WalkerState.EXIT, state); + assertEquals(REACHED_ENDPOINT, Rs2Walker.getLastRouteClearReason()); + assertNull(Rs2Walker.currentTarget); + } +} From 7df38338d01534b4324205a50157a0d9b42ca8ab Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:47:21 +0200 Subject: [PATCH 02/48] fix(questhelper): initialise requirements before checking client meets them QuestManager.updateQuestList applies the SHOW_MEETS_REQS quest filter, which calls QuestHelper.clientMeetsRequirements, before every helper has had its requirements set up. CurrentAffairs.getGeneralRequirements then passed a null twoFreeInvSlots into List.of and the NPE aborted the quest list refresh on the client thread. Initialise requirements first, as SkillFiltering.questPassesSkillFilter already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../questhelper/questhelpers/QuestHelper.java | 1 + ...uestHelperClientMeetsRequirementsTest.java | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelperClientMeetsRequirementsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelper.java index 18a590d939a..f4a01dc6846 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelper.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelper.java @@ -194,6 +194,7 @@ public QuestState getState(Client client) public boolean clientMeetsRequirements() { + initializeRequirements(); if (getGeneralRequirements() == null) { return true; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelperClientMeetsRequirementsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelperClientMeetsRequirementsTest.java new file mode 100644 index 00000000000..d373f288160 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/questhelpers/QuestHelperClientMeetsRequirementsTest.java @@ -0,0 +1,40 @@ +package net.runelite.client.plugins.microbot.questhelper.questhelpers; + +import java.lang.reflect.Field; +import net.runelite.api.Client; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.questhelper.helpers.quests.currentaffairs.CurrentAffairs; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class QuestHelperClientMeetsRequirementsTest +{ + private CurrentAffairs helper; + + @Before + public void setUp() throws Exception + { + helper = new CurrentAffairs(); + Client client = mock(Client.class); + when(client.getIntStack()).thenReturn(new int[]{1}); + inject("client", client); + inject("configManager", mock(ConfigManager.class)); + } + + private void inject(String name, Object value) throws Exception + { + Field field = QuestHelper.class.getDeclaredField(name); + field.setAccessible(true); + field.set(helper, value); + } + + @Test + public void initializesRequirementsBeforeReadingThem() + { + assertFalse(helper.clientMeetsRequirements()); + } +} From 9a25c78e3fd19b2d23bae2104840ae37087a3c88 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:31:51 +0200 Subject: [PATCH 03/48] fix(gameobject): bound scene object scan by the worldview tile array getSceneObjects reads tiles from the local player's worldview and looped to Constants.SCENE_SIZE. On a sailing boat that worldview is only 8 or 16 tiles wide, so every scene-object query threw ArrayIndexOutOfBoundsException and aborted the walker. Take the loop bounds from the tile array instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/README.md | 2 + docs/entity-guides/movement.md | 19 +++++ .../util/gameobject/Rs2GameObject.java | 7 +- .../Rs2GameObjectSmallWorldViewTest.java | 77 +++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectSmallWorldViewTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index cbf145245c0..be3872fb2e5 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -23,6 +23,8 @@ Route camera turns only for unreachable targets: [movement gotcha 23](movement.m Camera turns wait for the yaw to settle before returning: [movement gotcha 24](movement.md#24-a-camera-turn-is-finished-only-when-the-yaw-stops-changing). +Scene tile loops on a boat worldview must use the tile array bounds: [movement gotcha 25](movement.md#25-bound-scene-tile-loops-by-the-tile-array-not-constantsscene_size). + ## Format Each entity guide is a numbered list of gotchas. Each entry follows this structure: diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 1c2cf04ed37..f975925286f 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -481,3 +481,22 @@ north and a pitch of 0 sends `adjustPitch` holding UP, so a fake 0 looked like a **Defensive check:** `Rs2CameraSettleTest` simulates a camera that coasts after release. It also covers the timeout bound and the client-thread no-op. + +## 25. Bound scene tile loops by the tile array, not `Constants.SCENE_SIZE` + +The local player's worldview is not always the 104x104 top-level scene. On a sailing boat +the player stands in a sub-worldview whose `Scene.getTiles()` is only 8 or 16 tiles wide. +A loop that runs `x, y < Constants.SCENE_SIZE` over that array throws +`ArrayIndexOutOfBoundsException` at the boat's width. + +**Why this matters:** `Rs2GameObject.getSceneObjects` read tiles from +`player.getWorldView().getScene()` and looped to `SCENE_SIZE`. Every scene-object query made +from a boat threw `Index 8 out of bounds for length 8`, which aborted the walker inside +`handleNearbyRawPathSceneObjects` and crashed banking in scripts that called `findBank`. + +**Pattern to follow:** take the loop bounds from the array you index (`tiles[z].length`, +`tiles[z][0].length`), or from `worldView.getSizeX()` / `getSizeY()`, whenever the tiles come +from a worldview that is not guaranteed to be the top-level one. + +**Defensive check:** `Rs2GameObjectSmallWorldViewTest` queries wall objects on an 8x8 +worldview and expects the object on the last tile. 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 47b8e891598..b3f82f20f66 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 @@ -1562,10 +1562,11 @@ private static Stream getSceneObjects(Function 0 ? tiles[z][0].length : 0; - for (int x = 0; x < sceneSize; x++) { - for (int y = 0; y < sceneSize; y++) { + for (int x = 0; x < sizeX; x++) { + for (int y = 0; y < sizeY; y++) { for (int h = 0; h <= z; h++) { Tile tile = tiles[h][x][y]; if (tile == null) continue; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectSmallWorldViewTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectSmallWorldViewTest.java new file mode 100644 index 00000000000..264d9fe405a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectSmallWorldViewTest.java @@ -0,0 +1,77 @@ +package net.runelite.client.plugins.microbot.util.gameobject; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.Scene; +import net.runelite.api.Tile; +import net.runelite.api.WallObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +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.List; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +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 Rs2GameObjectSmallWorldViewTest { + + private static final int BOAT_SIZE = 8; + + private MockedStatic microbot; + private Tile[][][] tiles; + + @Before + public void setUp() { + tiles = new Tile[4][BOAT_SIZE][BOAT_SIZE]; + + Scene scene = mock(Scene.class); + when(scene.getTiles()).thenReturn(tiles); + WorldView worldView = mock(WorldView.class); + when(worldView.getScene()).thenReturn(scene); + when(worldView.getPlane()).thenReturn(0); + Player player = mock(Player.class); + when(player.getWorldView()).thenReturn(worldView); + Client client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(player); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.invoke(any(Supplier.class))).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + + 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 wallObjectQueryOnBoatWorldViewDoesNotOverflowTileArray() { + int last = BOAT_SIZE - 1; + LocalPoint location = new LocalPoint(last * 128 + 64, last * 128 + 64); + WallObject wall = mock(WallObject.class); + when(wall.getLocalLocation()).thenReturn(location); + Tile tile = mock(Tile.class); + when(tile.getWallObject()).thenReturn(wall); + when(tile.getLocalLocation()).thenReturn(location); + tiles[0][last][last] = tile; + + List walls = Rs2GameObject.getWallObjects(o -> true, location, 10 * 128); + + assertEquals(1, walls.size()); + assertSame(wall, walls.get(0)); + } +} From f3419fbc585ea4e5809d3d74407375e0250bef73 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:41:18 +0200 Subject: [PATCH 04/48] fix(agentserver): treat failed response body writes as client disconnects AgentHandler.isClientDisconnect only matched English Linux/macOS socket error texts, so Windows WSAECONNABORTED/WSAECONNRESET and localized messages were logged at ERROR and a 500 was written to the dead socket, which then rethrew. Response body writes now go through writeBody, which wraps any IOException in ClientDisconnectException, and the Windows English texts are matched as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../agentserver/handler/AgentHandler.java | 21 ++++- .../handler/ScreenshotHandler.java | 5 +- .../AgentHandlerClientDisconnectTest.java | 90 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerClientDisconnectTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java index a698341e40a..72100e47060 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java @@ -108,6 +108,9 @@ private static boolean constantTimeEquals(String a, String b) { public static boolean isClientDisconnect(Throwable t) { while (t != null) { + if (t instanceof ClientDisconnectException) { + return true; + } if (t instanceof java.io.IOException) { String msg = t.getMessage(); if (msg != null) { @@ -115,6 +118,8 @@ public static boolean isClientDisconnect(Throwable t) { if (lower.contains("broken pipe") || lower.contains("connection reset") || lower.contains("connection closed") + || lower.contains("connection was aborted") + || lower.contains("forcibly closed") || lower.contains("insufficient bytes written")) { return true; } @@ -142,8 +147,14 @@ protected void sendJson(HttpExchange exchange, int statusCode, Object body) thro byte[] bytes = json.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(statusCode, bytes.length); + writeBody(exchange, bytes); + } + + protected static void writeBody(HttpExchange exchange, byte[] bytes) throws IOException { try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); + } catch (IOException e) { + throw new ClientDisconnectException(e); } } @@ -154,9 +165,7 @@ protected void sendOpaqueNotFound(HttpExchange exchange) throws IOException { byte[] bytes = "Not Found".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); exchange.sendResponseHeaders(404, bytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } + writeBody(exchange, bytes); } protected Map errorResponse(String message) { @@ -231,6 +240,12 @@ protected String getSubPath(HttpExchange exchange, String basePath) { return full.substring(basePath.length()); } + public static final class ClientDisconnectException extends IOException { + public ClientDisconnectException(IOException cause) { + super(cause.getMessage(), cause); + } + } + public static class HttpMethodException extends Exception { public HttpMethodException(String message) { super(message); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScreenshotHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScreenshotHandler.java index 712662fbac8..9751c85da5a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScreenshotHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScreenshotHandler.java @@ -12,7 +12,6 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.io.OutputStream; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.CountDownLatch; @@ -76,9 +75,7 @@ protected void handleRequest(HttpExchange exchange) throws IOException { byte[] bytes = baos.toByteArray(); exchange.getResponseHeaders().set("Content-Type", "image/png"); exchange.sendResponseHeaders(200, bytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } + writeBody(exchange, bytes); } private BufferedImage captureFrame() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerClientDisconnectTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerClientDisconnectTest.java new file mode 100644 index 00000000000..106f449580b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerClientDisconnectTest.java @@ -0,0 +1,90 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import org.junit.After; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.util.Collections; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AgentHandlerClientDisconnectTest { + + private static final String LOCALIZED_ABORT = "De software op uw hostcomputer heeft een verbinding verbroken"; + + private static final class PingHandler extends AgentHandler { + PingHandler() { + super(new Gson()); + } + + @Override + public String getPath() { + return "/ping"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException { + sendJson(exchange, 200, Collections.singletonMap("ok", true)); + } + } + + @After + public void tearDown() { + AgentHandler.setTokenSupplier(null); + } + + @Test + public void bodyWriteFailureIsTreatedAsDisconnectWithoutErrorResponse() throws Exception { + AgentHandler.setTokenSupplier(null); + HttpExchange exchange = mock(HttpExchange.class); + Headers requestHeaders = new Headers(); + requestHeaders.add("Host", "127.0.0.1:8081"); + when(exchange.getRequestHeaders()).thenReturn(requestHeaders); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + when(exchange.getRequestURI()).thenReturn(URI.create("/ping")); + when(exchange.getResponseBody()).thenReturn(new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException(LOCALIZED_ABORT); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + throw new IOException(LOCALIZED_ABORT); + } + }); + + new PingHandler().handle(exchange); + + verify(exchange, times(1)).sendResponseHeaders(eq(200), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + } + + @Test + public void recognisesWindowsDisconnectMessages() { + assertTrue(AgentHandler.isClientDisconnect(new IOException("An established connection was aborted by the software in your host machine"))); + assertTrue(AgentHandler.isClientDisconnect(new IOException("An existing connection was forcibly closed by the remote host"))); + assertTrue(AgentHandler.isClientDisconnect(new AgentHandler.ClientDisconnectException(new IOException(LOCALIZED_ABORT)))); + assertTrue(AgentHandler.isClientDisconnect(new RuntimeException(new AgentHandler.ClientDisconnectException(new IOException(LOCALIZED_ABORT))))); + } + + @Test + public void unrelatedIoFailureIsNotADisconnect() { + assertFalse(AgentHandler.isClientDisconnect(new IOException(LOCALIZED_ABORT))); + assertFalse(AgentHandler.isClientDisconnect(new IOException("No space left on device"))); + assertFalse(AgentHandler.isClientDisconnect(new IllegalStateException("broken pipe"))); + } +} From 737e4868df35cd888a5969917a2105d5936c30f3 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:59:28 +0200 Subject: [PATCH 05/48] fix(config-panel): stop inventory setups combo box crashing on the EDT Treat a null inventory setups list (MInventorySetupsPlugin not started) as an empty combo box without clearing the stored selection, and drop the synchronous sendConfig() calls that took the profile file lock on the EDT and threw OverlappingFileLockException when the scheduled save held it. setConfiguration already queues the change for the scheduled save. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/ui/MicrobotConfigPanel.java | 19 +++-- ...icrobotConfigPanelInventorySetupsTest.java | 76 +++++++++++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanelInventorySetupsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java index 335e1190b63..2d877f44064 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java @@ -665,12 +665,16 @@ private JPanel createDimension(ConfigDescriptor cd, ConfigItemDescriptor cid) { // in config as a JSON string. // --------------------------------------------------------------------------- private JComboBox createInventorySetupsComboBox(ConfigDescriptor cd, ConfigItemDescriptor cid) { - // Suppose MInventorySetupsPlugin.getInventorySetups() returns a List - List setups = MInventorySetupsPlugin.getInventorySetups(); + return createInventorySetupsComboBox(configManager, cd.getGroup().value(), cid.getItem().keyName(), MInventorySetupsPlugin.getInventorySetups()); + } + + static JComboBox createInventorySetupsComboBox(ConfigManager configManager, String group, String key, List setups) { + if (setups == null) { + return new JComboBox<>(); + } if (setups.isEmpty()) { // If there are no setups, return an empty combo box - configManager.setConfiguration(cd.getGroup().value(), cid.getItem().keyName(), ""); - configManager.sendConfig(); + configManager.setConfiguration(group, key, ""); return new JComboBox<>(); } JComboBox box = new JComboBox<>(new DefaultComboBoxModel<>(setups.toArray(new InventorySetup[0]))); @@ -689,11 +693,10 @@ public Component getListCellRendererComponent(JList list, Object value, int i }); // 1) Retrieve dezerialized InventorySetup from config - InventorySetup dezerialized = configManager.getConfiguration(cd.getGroup().value(), cid.getItem().keyName(), InventorySetup.class); + InventorySetup dezerialized = configManager.getConfiguration(group, key, InventorySetup.class); if (dezerialized == null) { dezerialized = setups.get(0); - configManager.setConfiguration(cd.getGroup().value(), cid.getItem().keyName(), dezerialized); - configManager.sendConfig(); + configManager.setConfiguration(group, key, dezerialized); } for (InventorySetup setup : setups) { if (setup.getName().equals(dezerialized.getName())) { @@ -708,7 +711,7 @@ public Component getListCellRendererComponent(JList list, Object value, int i if (e.getStateChange() == ItemEvent.SELECTED) { InventorySetup chosen = (InventorySetup) box.getSelectedItem(); if (chosen != null) { - configManager.setConfiguration(cd.getGroup().value(), cid.getItem().keyName(), chosen); + configManager.setConfiguration(group, key, chosen); } } }); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanelInventorySetupsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanelInventorySetupsTest.java new file mode 100644 index 00000000000..ee90c049335 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanelInventorySetupsTest.java @@ -0,0 +1,76 @@ +package net.runelite.client.plugins.microbot.ui; + +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; +import org.junit.Test; + +import javax.swing.JComboBox; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +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 MicrobotConfigPanelInventorySetupsTest { + private static final String GROUP = "group"; + private static final String KEY = "setup"; + + private final ConfigManager configManager = mock(ConfigManager.class); + + @Test + public void nullSetupsReturnsEmptyBoxWithoutTouchingConfig() { + JComboBox box = MicrobotConfigPanel.createInventorySetupsComboBox(configManager, GROUP, KEY, null); + + assertEquals(0, box.getItemCount()); + verify(configManager, never()).setConfiguration(anyString(), anyString(), anyString()); + verify(configManager, never()).setConfiguration(anyString(), anyString(), any(Object.class)); + verify(configManager, never()).sendConfig(); + } + + @Test + public void emptySetupsClearsConfigWithoutSyncSave() { + JComboBox box = MicrobotConfigPanel.createInventorySetupsComboBox(configManager, GROUP, KEY, Collections.emptyList()); + + assertEquals(0, box.getItemCount()); + verify(configManager).setConfiguration(GROUP, KEY, ""); + verify(configManager, never()).sendConfig(); + } + + @Test + public void missingStoredSetupDefaultsToFirstWithoutSyncSave() { + InventorySetup first = setup("first"); + InventorySetup second = setup("second"); + + JComboBox box = MicrobotConfigPanel.createInventorySetupsComboBox(configManager, GROUP, KEY, Arrays.asList(first, second)); + + assertEquals(2, box.getItemCount()); + assertSame(first, box.getSelectedItem()); + verify(configManager).setConfiguration(GROUP, KEY, first); + verify(configManager, never()).sendConfig(); + } + + @Test + public void storedSetupIsSelectedByName() { + InventorySetup first = setup("first"); + InventorySetup second = setup("second"); + InventorySetup stored = setup("second"); + when(configManager.getConfiguration(GROUP, KEY, InventorySetup.class)).thenReturn(stored); + + JComboBox box = MicrobotConfigPanel.createInventorySetupsComboBox(configManager, GROUP, KEY, Arrays.asList(first, second)); + + assertSame(second, box.getSelectedItem()); + verify(configManager, never()).sendConfig(); + } + + private static InventorySetup setup(String name) { + InventorySetup setup = mock(InventorySetup.class); + when(setup.getName()).thenReturn(name); + return setup; + } +} From 81c4a51bad482e420818cf32713478aa231b71ae Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:18:31 +0200 Subject: [PATCH 06/48] fix(config): detect InventorySetup config items by return type on reset setDefaultConfiguration treated any key whose name contained "inventorysetup" as an InventorySetup and parsed its value as JSON. Boolean toggles such as Hub plugins' useInventorySetup store "true"/"false", so clicking Reset threw IllegalStateException on the EDT. Use the item's return type instead, and tolerate unparseable stored setups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../runelite/client/config/ConfigManager.java | 14 ++++++-- .../client/config/ConfigManagerTest.java | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 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 aaffe78ba3d..603b7111fa1 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 @@ -29,6 +29,7 @@ import com.google.common.base.Strings; import com.google.common.collect.ComparisonChain; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import lombok.Getter; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -1259,7 +1260,7 @@ public void setDefaultConfiguration(T proxy, boolean override // null and the empty string are treated identically in sendConfig and treated as an unset // If a config value defaults to "" and the current value is null, it will cause an extra // unset to be sent, so treat them as equal - boolean isInventorySetup = item.keyName().toLowerCase().contains("inventorysetup"); + boolean isInventorySetup = method.getReturnType() == InventorySetup.class; if (Objects.equals(current, valueString) || (Strings.isNullOrEmpty(current) && Strings.isNullOrEmpty(valueString))) { if (isInventorySetup) { @@ -1274,9 +1275,16 @@ public void setDefaultConfiguration(T proxy, boolean override if (current != null && !current.isBlank()) { if (isInventorySetup) { var setups = configInventorySetupDataManager.loadV3Setups(this); - InventorySetup currentObj = gson.fromJson(current, InventorySetup.class); + InventorySetup currentObj; + try { + currentObj = gson.fromJson(current, InventorySetup.class); + } catch (JsonParseException | IllegalStateException e) { + log.debug("Unable to parse inventory setup for {}.{}", group.value(), item.keyName(), e); + currentObj = null; + } + String currentName = currentObj == null ? null : currentObj.getName(); if (!setups.isEmpty() && setups.stream() - .noneMatch(x -> x.getName().equalsIgnoreCase(currentObj.getName()))) { + .noneMatch(x -> x.getName().equalsIgnoreCase(currentName))) { setConfiguration(group.value(), item.keyName(), ""); } } diff --git a/runelite-client/src/test/java/net/runelite/client/config/ConfigManagerTest.java b/runelite-client/src/test/java/net/runelite/client/config/ConfigManagerTest.java index 61abc932ca9..7198b6ef0ff 100644 --- a/runelite-client/src/test/java/net/runelite/client/config/ConfigManagerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/config/ConfigManagerTest.java @@ -24,6 +24,7 @@ */ package net.runelite.client.config; +import com.google.gson.Gson; import com.google.inject.Guice; import com.google.inject.testing.fieldbinder.Bind; import com.google.inject.testing.fieldbinder.BoundFieldModule; @@ -37,6 +38,7 @@ import net.runelite.client.RuneLite; import net.runelite.client.account.SessionManager; import net.runelite.client.eventbus.EventBus; +import net.runelite.http.api.RuneLiteAPI; import org.junit.Assert; import static org.junit.Assert.assertNotNull; import org.junit.Before; @@ -64,6 +66,9 @@ public class ConfigManagerTest @Bind RuneLiteConfig runeliteConfig; + @Bind + Gson gson = RuneLiteAPI.GSON; + @Bind @Named("sessionfile") File sessionfile = RuneLite.DEFAULT_SESSION_FILE; @@ -165,6 +170,33 @@ public void testResetNullDefaultConfig() Assert.assertNull(conf.nullDefaultKey()); } + @ConfigGroup("inventorySetupKeyTest") + public interface InventorySetupKeyConfig extends Config + { + @ConfigItem( + keyName = "useInventorySetup", + name = "Use Inventory Setup", + description = "value" + ) + default boolean useInventorySetup() + { + return false; + } + } + + @Test + public void testResetBooleanKeyNamedInventorySetup() + { + InventorySetupKeyConfig conf = manager.getConfig(InventorySetupKeyConfig.class); + manager.setConfiguration("inventorySetupKeyTest", "useInventorySetup", true); + Assert.assertTrue(conf.useInventorySetup()); + + manager.setDefaultConfiguration(conf, true); + + Assert.assertFalse(conf.useInventorySetup()); + Assert.assertEquals("false", manager.getConfiguration("inventorySetupKeyTest", "useInventorySetup")); + } + @Test public void testKeySplitter() { From b100688f1a5cdcf211ac247ee98596c0524b4d30 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:08:47 +0200 Subject: [PATCH 07/48] fix(ge): return null from GrandExchangeWidget.getSlot for a null slot Rs2GrandExchange.processOffer BUY passes getAvailableSlot() into getOfferBuyButton, which is null when every GE slot is occupied. getSlot then switched on the null enum and threw an NPE instead of letting the existing buyOffer null check abort the buy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../grandexchange/GrandExchangeWidget.java | 4 ++++ .../GrandExchangeWidgetNullSlotTest.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java index 1bea65ff752..8350e0386a5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java @@ -276,6 +276,10 @@ static boolean hasOfferPriceChanged(long basePrice) */ static Widget getSlot(GrandExchangeSlots slot) { + if (slot == null) + { + return null; + } switch (slot) { case ONE: diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java new file mode 100644 index 00000000000..9e54da31622 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java @@ -0,0 +1,22 @@ +package net.runelite.client.plugins.microbot.util.grandexchange; + +import org.junit.Test; + +import static org.junit.Assert.assertNull; + +public class GrandExchangeWidgetNullSlotTest { + @Test + public void getSlotReturnsNullForNullSlot() { + assertNull(GrandExchangeWidget.getSlot(null)); + } + + @Test + public void getOfferBuyButtonReturnsNullForNullSlot() { + assertNull(GrandExchangeWidget.getOfferBuyButton(null)); + } + + @Test + public void getOfferSellButtonReturnsNullForNullSlot() { + assertNull(GrandExchangeWidget.getOfferSellButton(null)); + } +} From 5263c7286df0e2da10e9634ea73a3fee741080dd Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:14:08 +0200 Subject: [PATCH 08/48] fix(player): guard null LocalPoint in instanced getWorldLocation_Internal LocalPoint.fromWorld returns null in an instance when the player's world point is on another plane or outside the top-level scene (boat worldview, scene loading). Fall back to the player's local location, then to the raw world location, instead of passing null into WorldPoint.fromLocalInstance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/README.md | 2 + docs/entity-guides/movement.md | 24 ++++++ .../microbot/util/player/Rs2Player.java | 2 + .../Rs2PlayerInstanceWorldLocationTest.java | 79 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerInstanceWorldLocationTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index be3872fb2e5..d88396d5360 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -25,6 +25,8 @@ Camera turns wait for the yaw to settle before returning: [movement gotcha 24](m Scene tile loops on a boat worldview must use the tile array bounds: [movement gotcha 25](movement.md#25-bound-scene-tile-loops-by-the-tile-array-not-constantsscene_size). +Instance location conversions must null-check `LocalPoint.fromWorld`: [movement gotcha 26](movement.md#26-null-check-localpointfromworld-before-converting-instance-coordinates). + ## Format Each entity guide is a numbered list of gotchas. Each entry follows this structure: diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index f975925286f..d6887bb9b59 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -500,3 +500,27 @@ from a worldview that is not guaranteed to be the top-level one. **Defensive check:** `Rs2GameObjectSmallWorldViewTest` queries wall objects on an 8x8 worldview and expects the object on the last tile. + +## 26. Null-check `LocalPoint.fromWorld` before converting instance coordinates + +`LocalPoint.fromWorld(worldView, worldPoint)` returns `null` when the plane differs from the world view's plane or the tile is outside that scene, which happens in instances while the player is on a boat sub-worldview or the scene is still loading. `WorldPoint.fromLocalInstance` dereferences its `LocalPoint` immediately, so the result must be checked first. + +**Why this matters:** `Rs2Player.getWorldLocation_Internal()` passed the `fromWorld` result straight into `fromLocalInstance`. Telemetry for 2.6.30 and 2.6.31 recorded about 585 NullPointerExceptions, most from `KourendLibraryOverlay.render` calling `Rs2Player.getWorldLocation()` every frame. + +**Pattern to follow:** + +```java +// Wrong: NPE when the world point does not resolve in the top-level scene. +LocalPoint l = LocalPoint.fromWorld(client.getTopLevelWorldView(), player.getWorldLocation()); +return WorldPoint.fromLocalInstance(client, l); + +// Right: fall back to the player's own local point, then to the raw world point. +LocalPoint l = LocalPoint.fromWorld(client.getTopLevelWorldView(), player.getWorldLocation()); +if (l == null) l = player.getLocalLocation(); +if (l == null) return player.getWorldLocation(); +return WorldPoint.fromLocalInstance(client, l); +``` + +**Where this applies:** `Rs2Player.getWorldLocation_Internal()` and any helper that converts an actor's world point to an instance template point. + +**Defensive check:** Mock a world view whose plane differs from the player's world point and assert the helper returns a fallback point, as in `Rs2PlayerInstanceWorldLocationTest`. 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 19360a72493..7806e6559e3 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 @@ -1042,6 +1042,8 @@ public static WorldPoint getWorldLocation_Internal(){ if (player == null) return null; if (Microbot.getClient().getTopLevelWorldView().getScene().isInstance()) { LocalPoint l = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), player.getWorldLocation()); + if (l == null) l = player.getLocalLocation(); + if (l == null) return player.getWorldLocation(); return WorldPoint.fromLocalInstance(Microbot.getClient(), l); } return player.getWorldLocation(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerInstanceWorldLocationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerInstanceWorldLocationTest.java new file mode 100644 index 00000000000..f0c83c7a1ac --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerInstanceWorldLocationTest.java @@ -0,0 +1,79 @@ +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.Player; +import net.runelite.api.Scene; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +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 Rs2PlayerInstanceWorldLocationTest { + private final Map original = new HashMap<>(); + private Client client; + private WorldView worldView; + private Player player; + + @Before + public void setUp() throws Exception { + client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + worldView = mock(WorldView.class); + Scene scene = mock(Scene.class); + player = mock(Player.class); + replace("client", client); + replace("clientThread", clientThread); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + when(client.getLocalPlayer()).thenReturn(player); + when(client.getTopLevelWorldView()).thenReturn(worldView); + when(client.getWorldView(anyInt())).thenReturn(worldView); + when(worldView.getScene()).thenReturn(scene); + when(scene.isInstance()).thenReturn(true); + when(worldView.getPlane()).thenReturn(1); + when(player.getWorldLocation()).thenReturn(new WorldPoint(3200, 3200, 0)); + } + + 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 fallsBackToWorldLocationWhenNoLocalPointResolves() { + when(player.getLocalLocation()).thenReturn(null); + assertEquals(new WorldPoint(3200, 3200, 0), Rs2Player.getWorldLocation_Internal()); + } + + @Test + public void fallsBackToPlayerLocalLocationWhenWorldPointIsNotInTopLevelScene() { + when(worldView.isInstance()).thenReturn(false); + when(worldView.getBaseX()).thenReturn(3000); + when(worldView.getBaseY()).thenReturn(3100); + when(player.getLocalLocation()).thenReturn(LocalPoint.fromScene(10, 20)); + assertEquals(new WorldPoint(3010, 3120, 1), Rs2Player.getWorldLocation_Internal()); + } +} From 6770c7d5f37d4e4900f367c4b5fe1e41e49dea10 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:16:05 +0200 Subject: [PATCH 09/48] docs(movement): do not claim gotcha 26 fallback handles boat worldviews Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/movement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index d6887bb9b59..cdeb7e56cd5 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -503,7 +503,7 @@ worldview and expects the object on the last tile. ## 26. Null-check `LocalPoint.fromWorld` before converting instance coordinates -`LocalPoint.fromWorld(worldView, worldPoint)` returns `null` when the plane differs from the world view's plane or the tile is outside that scene, which happens in instances while the player is on a boat sub-worldview or the scene is still loading. `WorldPoint.fromLocalInstance` dereferences its `LocalPoint` immediately, so the result must be checked first. +`LocalPoint.fromWorld(worldView, worldPoint)` returns `null` when the plane differs from the world view's plane or the tile is outside that scene, for example in an instance while the scene is still loading. The fallback below only prevents the crash: if the player is on a boat sub-worldview, `player.getLocalLocation()` is in boat space and does not give the real sea position. `WorldPoint.fromLocalInstance` dereferences its `LocalPoint` immediately, so the result must be checked first. **Why this matters:** `Rs2Player.getWorldLocation_Internal()` passed the `fromWorld` result straight into `fromLocalInstance`. Telemetry for 2.6.30 and 2.6.31 recorded about 585 NullPointerExceptions, most from `KourendLibraryOverlay.render` calling `Rs2Player.getWorldLocation()` every frame. From a59bdd5e85c1bec7c9b716aac362e4c7745ba37f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:29:51 +0200 Subject: [PATCH 10/48] fix(agentserver): stop quest helper on the client thread POST /quest-helper/stop called QuestManager.shutDownQuest(true) on the AgentServer HTTP thread. With true it refreshes the quest list, which reads quest state via client.runScript and throws "must be called on client thread". Run it through runOnClientThreadOptional and return 500 when it does not complete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../handler/QuestHelperHandler.java | 13 +- .../handler/QuestHelperHandlerStopTest.java | 111 ++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStopTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java index c13da797e08..ac931cd7cfc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java @@ -173,7 +173,18 @@ private void handleStop(HttpExchange exchange) throws IOException return; } - plugin.getQuestManager().shutDownQuest(true); + boolean stopped = Microbot.getClientThread() + .runOnClientThreadOptional(() -> + { + plugin.getQuestManager().shutDownQuest(true); + return true; + }) + .orElse(false); + if (!stopped) + { + sendJson(exchange, 500, errorResponse("Failed to stop quest helper")); + return; + } Map result = new LinkedHashMap<>(); result.put("success", true); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStopTest.java new file mode 100644 index 00000000000..9474424f269 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStopTest.java @@ -0,0 +1,111 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; +import net.runelite.client.plugins.microbot.questhelper.managers.QuestManager; +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.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +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 QuestHelperHandlerStopTest +{ + private static final Gson GSON = new GsonBuilder().create(); + + private MockedStatic microbot; + private ClientThread clientThread; + private QuestManager questManager; + private HttpExchange exchange; + private ByteArrayOutputStream responseBody; + private final AtomicBoolean onClientThread = new AtomicBoolean(); + + @Before + public void setUp() + { + questManager = mock(QuestManager.class); + QuestHelperPlugin plugin = mock(QuestHelperPlugin.class); + when(plugin.getQuestManager()).thenReturn(questManager); + clientThread = mock(ClientThread.class); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(() -> Microbot.getPlugin(QuestHelperPlugin.class)).thenReturn(plugin); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + responseBody = new ByteArrayOutputStream(); + exchange = mock(HttpExchange.class); + when(exchange.getRequestMethod()).thenReturn("POST"); + when(exchange.getRequestURI()).thenReturn(URI.create("/quest-helper/stop")); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + when(exchange.getResponseBody()).thenReturn(responseBody); + } + + @After + public void tearDown() + { + microbot.close(); + } + + @Test + public void stopShutsDownQuestOnClientThread() throws Exception + { + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> + { + Callable callable = inv.getArgument(0); + onClientThread.set(true); + try + { + return Optional.ofNullable(callable.call()); + } + finally + { + onClientThread.set(false); + } + }); + doAnswer(inv -> + { + assertTrue("shutDownQuest must run on the client thread", onClientThread.get()); + return null; + }).when(questManager).shutDownQuest(true); + + new QuestHelperHandler(GSON).handleRequest(exchange); + + verify(questManager).shutDownQuest(true); + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertTrue(responseBody.toString(StandardCharsets.UTF_8).contains("\"success\":true")); + } + + @Test + public void stopReturns500WhenClientThreadCallFails() throws Exception + { + when(clientThread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + + new QuestHelperHandler(GSON).handleRequest(exchange); + + verify(questManager, never()).shutDownQuest(true); + verify(exchange).sendResponseHeaders(eq(500), anyLong()); + assertEquals("{\"error\":\"Failed to stop quest helper\"}", responseBody.toString(StandardCharsets.UTF_8)); + } +} From 4f1d8edac1faa56a7436bc24b056517723e4c050 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:44:35 +0200 Subject: [PATCH 11/48] fix(shop): guard Rs2Shop.itemBounds against missing or unpopulated shop widget itemBounds dereferenced the shop items widget and indexed its dynamic children without checks, throwing NPE when the shop was closing and AIOOBE before the children were populated. Return null in those cases so invokeMenu falls back to its default rectangle, compute the bounds once, and have buyItem return false when an exception is caught. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../plugins/microbot/util/shop/Rs2Shop.java | 13 ++- .../util/shop/Rs2ShopItemBoundsTest.java | 94 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/shop/Rs2ShopItemBoundsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/shop/Rs2Shop.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/shop/Rs2Shop.java index bff1c462481..f1234a9721e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/shop/Rs2Shop.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/shop/Rs2Shop.java @@ -5,6 +5,7 @@ import net.runelite.api.NPCComposition; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.widgets.ComponentID; +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.util.Global; @@ -153,6 +154,7 @@ public static boolean buyItem(String itemName, String quantity) { invokeMenu(rs2Item, actionAndQuantity); } catch (Exception ex) { Microbot.logStackTrace("Rs2Shop", ex); + return false; } return true; } @@ -177,6 +179,7 @@ public static boolean buyItem(int itemId, String quantity) { invokeMenu(rs2Item, actionAndQuantity); } catch (Exception ex) { Microbot.logStackTrace("Rs2Shop", ex); + return false; } return true; } @@ -389,6 +392,7 @@ private static void invokeMenu(Rs2ItemModel rs2Item, String action) { } + Rectangle bounds = itemBounds(rs2Item); Microbot.doInvoke(new NewMenuEntry() .param0(param0) .param1(param1) @@ -397,7 +401,7 @@ private static void invokeMenu(Rs2ItemModel rs2Item, String action) { .itemId(rs2Item.getId()) .target(rs2Item.getName()) , - (itemBounds(rs2Item) == null) ? new Rectangle(1, 1) : itemBounds(rs2Item)); + bounds == null ? new Rectangle(1, 1) : bounds); //Rs2Reflection.invokeMenu(param0, param1, menuAction.getId(), identifier, rs2Item.id, action, target, -1, -1); } @@ -430,6 +434,11 @@ private static boolean hasShopChanged(List initialShopItems) { * @return Rectangle of the item */ private static Rectangle itemBounds(Rs2ItemModel rs2Item) { - return Rs2Widget.getWidget(19660816).getDynamicChildren()[rs2Item.getSlot()+1].getBounds(); + Widget items = Rs2Widget.getWidget(19660816); + if (items == null) return null; + Widget[] children = items.getDynamicChildren(); + int index = rs2Item.getSlot() + 1; + if (children == null || index < 0 || index >= children.length || children[index] == null) return null; + return children[index].getBounds(); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/shop/Rs2ShopItemBoundsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/shop/Rs2ShopItemBoundsTest.java new file mode 100644 index 00000000000..83d0ae6099b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/shop/Rs2ShopItemBoundsTest.java @@ -0,0 +1,94 @@ +package net.runelite.client.plugins.microbot.util.shop; + +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +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.awt.Rectangle; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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 Rs2ShopItemBoundsTest { + + private static final int SHOP_ITEMS_WIDGET = 19660816; + private static final int ITEM_ID = 1931; + + private MockedStatic widgets; + private MockedStatic microbot; + private List previousShopItems; + + @Before + public void setUp() { + widgets = Mockito.mockStatic(Rs2Widget.class); + microbot = Mockito.mockStatic(Microbot.class); + previousShopItems = Rs2Shop.shopItems; + Rs2ItemModel item = mock(Rs2ItemModel.class); + when(item.getId()).thenReturn(ITEM_ID); + when(item.getName()).thenReturn("Pot"); + when(item.getQuantity()).thenReturn(5); + when(item.getSlot()).thenReturn(11); + Rs2Shop.shopItems = new ArrayList<>(Collections.singletonList(item)); + } + + @After + public void tearDown() { + Rs2Shop.shopItems = previousShopItems; + microbot.close(); + widgets.close(); + } + + private void assertInvokedWith(Rectangle expected) { + assertTrue(Rs2Shop.buyItem(ITEM_ID, "1")); + microbot.verify(() -> Microbot.doInvoke(any(), eq(expected))); + microbot.verify(() -> Microbot.logStackTrace(anyString(), any(Exception.class)), Mockito.never()); + } + + @Test + public void buyFallsBackToDefaultBoundsWhenShopWidgetMissing() { + widgets.when(() -> Rs2Widget.getWidget(SHOP_ITEMS_WIDGET)).thenReturn(null); + assertInvokedWith(new Rectangle(1, 1)); + } + + @Test + public void buyFallsBackToDefaultBoundsWhenShopChildrenNotPopulated() { + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(new Widget[0]); + widgets.when(() -> Rs2Widget.getWidget(SHOP_ITEMS_WIDGET)).thenReturn(container); + assertInvokedWith(new Rectangle(1, 1)); + } + + @Test + public void buyFallsBackToDefaultBoundsWhenShopChildrenNull() { + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(null); + widgets.when(() -> Rs2Widget.getWidget(SHOP_ITEMS_WIDGET)).thenReturn(container); + assertInvokedWith(new Rectangle(1, 1)); + } + + @Test + public void buyUsesSlotBoundsWhenShopChildrenPopulated() { + Rectangle slotBounds = new Rectangle(40, 60, 32, 32); + Widget slot = mock(Widget.class); + when(slot.getBounds()).thenReturn(slotBounds); + Widget[] children = new Widget[13]; + children[12] = slot; + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(children); + widgets.when(() -> Rs2Widget.getWidget(SHOP_ITEMS_WIDGET)).thenReturn(container); + assertInvokedWith(slotBounds); + } +} From cdd2073860f98767f6731f6c52c6084ddcb156c2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:00:35 +0200 Subject: [PATCH 12/48] fix(npc): read Rs2NpcModel id on the client thread NPC.getId resolves the transformed composition using varbits and the definition cache, which NPEs inside the client when called from script threads such as Forestry blocking-event validate() via withId/nearest. Marshal it through runOnClientThreadOptional like Rs2ActorModel.getName. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/api/npc/models/Rs2NpcModel.java | 2 +- .../models/Rs2NpcModelGetIdThreadTest.java | 69 +++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 1 - 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelGetIdThreadTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java index 6d5183f4abe..0523e9a8c1f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java @@ -36,7 +36,7 @@ public Rs2NpcModel(final NPC npc) @Override public int getId() { - return npc.getId(); + return Microbot.getClientThread().runOnClientThreadOptional(npc::getId).orElse(-1); } public int getIndex() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelGetIdThreadTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelGetIdThreadTest.java new file mode 100644 index 00000000000..f7910c454cf --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelGetIdThreadTest.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.api.npc.models; + +import net.runelite.api.NPC; +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.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2NpcModelGetIdThreadTest { + + private MockedStatic microbot; + 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); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void readsNpcIdOnClientThread() { + NPC npc = mock(NPC.class); + when(npc.getId()).thenAnswer(inv -> { + if (!onClientThread.get()) { + throw new NullPointerException("NPC.getId called off the client thread"); + } + return 12345; + }); + + assertEquals(12345, new Rs2NpcModel(npc).getId()); + } + + @Test + public void returnsMinusOneWhenClientThreadReturnsNothing() { + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + NPC npc = mock(NPC.class); + + assertEquals(-1, new Rs2NpcModel(npc).getId()); + } +} 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 3054b91a799..29ce9886e0c 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 @@ -30,7 +30,6 @@ net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.IndexedObjectSet#stream(): Stream net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.WorldView#npcs(): IndexedObjectSet net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#click(String): boolean -> net.runelite.api.NPCComposition#getActions(): String[] -net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#getId(): int -> net.runelite.api.NPC#getId(): int net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#getIndex(): int -> net.runelite.api.NPC#getIndex(): int net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#getMenuAction(int): MenuAction -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#hasLineOfSight(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView From eca9e7b8d05f803d44788dec873e3e448d8f3651 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:56:44 +0200 Subject: [PATCH 13/48] fix(grounditems): hold table lock while copying collected ground items getCollectedGroundItems iterated the synchronized table outside its lock, so script threads hit ConcurrentModificationException when the client thread spawned or despawned items during the copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../grounditems/GroundItemsPlugin.java | 5 +- .../GroundItemsPluginCollectedItemsTest.java | 81 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/grounditems/GroundItemsPluginCollectedItemsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/grounditems/GroundItemsPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/grounditems/GroundItemsPlugin.java index ac5fa38dd95..c98208a2b6b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/grounditems/GroundItemsPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/grounditems/GroundItemsPlugin.java @@ -190,7 +190,10 @@ static class PriceHighlight private final Map lootbeams = new HashMap<>(); public static Table getCollectedGroundItems() { - return ImmutableTable.copyOf(collectedGroundItems); + synchronized (collectedGroundItems) + { + return ImmutableTable.copyOf(collectedGroundItems); + } } @Provides diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/grounditems/GroundItemsPluginCollectedItemsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/grounditems/GroundItemsPluginCollectedItemsTest.java new file mode 100644 index 00000000000..11e7b97bdb7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/grounditems/GroundItemsPluginCollectedItemsTest.java @@ -0,0 +1,81 @@ +package net.runelite.client.plugins.grounditems; + +import com.google.common.collect.Table; +import java.lang.reflect.Field; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.api.coords.WorldPoint; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertTrue; + +public class GroundItemsPluginCollectedItemsTest +{ + private static final int ITEMS = 200; + + private Table collected; + private ExecutorService executor; + + @Before + @SuppressWarnings("unchecked") + public void setUp() throws Exception + { + Field field = GroundItemsPlugin.class.getDeclaredField("collectedGroundItems"); + field.setAccessible(true); + collected = (Table) field.get(null); + collected.clear(); + executor = Executors.newSingleThreadExecutor(); + } + + @After + public void tearDown() + { + executor.shutdownNow(); + collected.clear(); + } + + @Test + public void copyIsSafeWhileItemsSpawnAndDespawn() throws Exception + { + for (int i = 0; i < ITEMS; i++) + { + put(i); + } + + AtomicBoolean running = new AtomicBoolean(true); + Future writer = executor.submit(() -> + { + int i = ITEMS; + while (running.get()) + { + put(i); + collected.remove(new WorldPoint(3200 + (i - ITEMS) % ITEMS, 3200, 0), i - ITEMS); + i++; + } + }); + + try + { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) + { + assertTrue(GroundItemsPlugin.getCollectedGroundItems().size() >= ITEMS); + } + } + finally + { + running.set(false); + } + writer.get(5, TimeUnit.SECONDS); + } + + private void put(int id) + { + WorldPoint point = new WorldPoint(3200 + id % ITEMS, 3200, 0); + collected.put(point, id, GroundItem.builder().id(id).location(point).build()); + } +} From 08982cec761ea25c2f777227ead610bcf463b895 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:12:29 +0200 Subject: [PATCH 14/48] fix(tileobject): fall back to base composition when impostor is unresolved getObjectComposition() replaced the composition with getImpostor() whenever impostor ids existed, returning null when the varbit/varp could not be resolved, so Rs2TileObjectModel.click NPE'd on objComp.getImpostorIds(). Keep the base composition in that case, drop click's redundant impostor re-check, and return false when no composition is available. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../tileobject/models/Rs2TileObjectModel.java | 18 +++-- .../Rs2TileObjectModelCompositionTest.java | 76 +++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 2 - 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java index 65245c83800..d84c3e6572b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java @@ -112,7 +112,7 @@ public int getId() { public String getName() { return Microbot.getClientThread().invoke(() -> { ObjectComposition composition = Microbot.getClient().getObjectDefinition(tileObject.getId()); - if (composition.getImpostorIds() != null) { + if (composition != null && composition.getImpostorIds() != null) { composition = composition.getImpostor(); } if (composition == null) @@ -169,8 +169,11 @@ public boolean isOpShown(int index) { public ObjectComposition getObjectComposition() { return Microbot.getClientThread().invoke(() -> { ObjectComposition composition = Microbot.getClient().getObjectDefinition(tileObject.getId()); - if (composition.getImpostorIds() != null) { - composition = composition.getImpostor(); + if (composition != null && composition.getImpostorIds() != null) { + ObjectComposition impostor = composition.getImpostor(); + if (impostor != null) { + composition = impostor; + } } return composition; }); @@ -257,12 +260,11 @@ public boolean click(String action) { if (action != null) { //performance improvement to only get compoisiton if action has been specified var objComp = getObjectComposition(); - String[] actions; - if (objComp.getImpostorIds() != null && objComp.getImpostor() != null) { - actions = objComp.getImpostor().getActions(); - } else { - actions = objComp.getActions(); + if (objComp == null) { + log.warn("Failed to interact with object {} - no object composition", getId()); + return false; } + String[] actions = objComp.getActions(); for (int i = 0; i < actions.length; i++) { if (actions[i] == null) continue; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java new file mode 100644 index 00000000000..c113946477c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java @@ -0,0 +1,76 @@ +package net.runelite.client.plugins.microbot.api.tileobject.models; + +import java.util.function.Supplier; +import net.runelite.api.Client; +import net.runelite.api.ObjectComposition; +import net.runelite.api.TileObject; +import net.runelite.api.coords.LocalPoint; +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 static org.junit.Assert.assertFalse; +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 Rs2TileObjectModelCompositionTest { + + private static final int OBJECT_ID = 43700; + + private MockedStatic microbot; + private Client client; + private TileObject tileObject; + + @Before + public void setUp() { + client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.invoke(any(Supplier.class))).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + microbot.when(Microbot::getClient).thenReturn(client); + + tileObject = mock(TileObject.class); + when(tileObject.getId()).thenReturn(OBJECT_ID); + when(tileObject.getLocalLocation()).thenReturn(new LocalPoint(6400, 6400, -1)); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void unresolvedImpostorFallsBackToBaseComposition() { + ObjectComposition base = mock(ObjectComposition.class); + when(base.getImpostorIds()).thenReturn(new int[]{1, 2}); + when(base.getImpostor()).thenReturn(null); + when(client.getObjectDefinition(OBJECT_ID)).thenReturn(base); + + assertSame(base, new Rs2TileObjectModel(tileObject).getObjectComposition()); + } + + @Test + public void resolvedImpostorIsReturned() { + ObjectComposition base = mock(ObjectComposition.class); + ObjectComposition impostor = mock(ObjectComposition.class); + when(base.getImpostorIds()).thenReturn(new int[]{1, 2}); + when(base.getImpostor()).thenReturn(impostor); + when(client.getObjectDefinition(OBJECT_ID)).thenReturn(base); + + assertSame(impostor, new Rs2TileObjectModel(tileObject).getObjectComposition()); + } + + @Test + public void clickReturnsFalseWhenCompositionIsMissing() { + when(client.getObjectDefinition(OBJECT_ID)).thenReturn(null); + + assertFalse(new Rs2TileObjectModel(tileObject).click("Enter")); + } +} 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 29ce9886e0c..21859681a74 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 @@ -59,8 +59,6 @@ net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#cl net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeY(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition -net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.WorldView#getId(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#getClickbox(): Shape -> net.runelite.api.TileObject#getClickbox(): Shape From e7a9f3b18c9d2b087832343251b4f0baa9ae3b71 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:20:12 +0200 Subject: [PATCH 15/48] fix(tileobject): refuse to click multiloc objects whose impostor is hidden getImpostor() returns null when the varbit/varp selects transform -1, meaning the object is currently hidden. click() previously fell back to the base composition and dispatched GAME_OBJECT_FIRST_OPTION on an invisible object, returning true. click() now resolves the impostor itself on the client thread and returns false before turning the camera or invoking the menu action, matching Rs2GameObject.clickObject. getObjectComposition() keeps its base-composition fallback for read callers, and getName() keeps returning null for hidden objects on purpose. The legacy util.tileobject.Rs2TileObjectModel shares the pattern but has no callers in the client or Microbot-Hub, so it is left unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../tileobject/models/Rs2TileObjectModel.java | 14 ++++++++++++-- .../Rs2TileObjectModelCompositionTest.java | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java index d84c3e6572b..b9aaadf5ded 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModel.java @@ -179,6 +179,16 @@ public ObjectComposition getObjectComposition() { }); } + private ObjectComposition getVisibleObjectComposition() { + return Microbot.getClientThread().invoke(() -> { + ObjectComposition composition = Microbot.getClient().getObjectDefinition(tileObject.getId()); + if (composition != null && composition.getImpostorIds() != null) { + return composition.getImpostor(); + } + return composition; + }); + } + @Override public boolean isReachable() { WorldView objectWorldView = getWorldView(); @@ -259,9 +269,9 @@ public boolean click(String action) { String objName = ""; if (action != null) { //performance improvement to only get compoisiton if action has been specified - var objComp = getObjectComposition(); + var objComp = getVisibleObjectComposition(); if (objComp == null) { - log.warn("Failed to interact with object {} - no object composition", getId()); + log.warn("Failed to interact with object {} - object not currently visible", getId()); return false; } String[] actions = objComp.getActions(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java index c113946477c..8deb58f06e6 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileobject/models/Rs2TileObjectModelCompositionTest.java @@ -1,5 +1,6 @@ package net.runelite.client.plugins.microbot.api.tileobject.models; +import java.awt.Rectangle; import java.util.function.Supplier; import net.runelite.api.Client; import net.runelite.api.ObjectComposition; @@ -7,6 +8,7 @@ import net.runelite.api.coords.LocalPoint; import net.runelite.client.callback.ClientThread; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -17,6 +19,7 @@ import static org.junit.Assert.assertSame; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class Rs2TileObjectModelCompositionTest { @@ -67,6 +70,19 @@ public void resolvedImpostorIsReturned() { assertSame(impostor, new Rs2TileObjectModel(tileObject).getObjectComposition()); } + @Test + public void clickReturnsFalseWithoutInvokingWhenImpostorIsUnresolved() { + ObjectComposition base = mock(ObjectComposition.class); + when(base.getImpostorIds()).thenReturn(new int[]{1, 2}); + when(base.getImpostor()).thenReturn(null); + when(base.getActions()).thenReturn(new String[5]); + when(base.getName()).thenReturn("null"); + when(client.getObjectDefinition(OBJECT_ID)).thenReturn(base); + + assertFalse(new Rs2TileObjectModel(tileObject).click("Enter")); + microbot.verify(() -> Microbot.doInvoke(any(NewMenuEntry.class), any(Rectangle.class)), never()); + } + @Test public void clickReturnsFalseWhenCompositionIsMissing() { when(client.getObjectDefinition(OBJECT_ID)).thenReturn(null); From c0f02ac2f0d878760cd3f9f1f56816af2d85a7b7 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:30:41 +0200 Subject: [PATCH 16/48] fix(ge): return null from getConfirm when the offer container is closed GrandExchangeWidget.getConfirm dereferenced getOfferContainer() without a null check, throwing an NPE from Rs2GrandExchange.confirm when the offer setup screen was closed or never opened. Return null instead, and skip the warning-prompt wait in confirm() when there is no button to click. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../grandexchange/GrandExchangeWidget.java | 4 ++ .../util/grandexchange/Rs2GrandExchange.java | 4 +- .../GrandExchangeWidgetNullSlotTest.java | 41 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java index 8350e0386a5..fcae4e1a17a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidget.java @@ -251,6 +251,10 @@ static Widget getChooseItem() static Widget getConfirm() { var parent = getOfferContainer(); + if (parent == null || parent.getDynamicChildren() == null) + { + return null; + } return Rs2Widget.findWidget("Confirm", Arrays.stream(parent.getDynamicChildren()).collect(Collectors.toList()), true); } 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 a233384adb6..7a164c9d2e5 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 @@ -581,7 +581,9 @@ public static boolean buyItem(String itemName, int price, int quantity) { * click "Yes" to proceed with the offer. */ private static void confirm() { - Rs2Widget.clickWidget(GrandExchangeWidget.getConfirm()); + if (!Rs2Widget.clickWidget(GrandExchangeWidget.getConfirm())) { + return; + } sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much"), 2000); if (Rs2Widget.hasWidget("Your offer is much")) { Rs2Widget.clickWidget("Yes"); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java index 9e54da31622..73e9faaad38 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/GrandExchangeWidgetNullSlotTest.java @@ -1,8 +1,19 @@ package net.runelite.client.plugins.microbot.util.grandexchange; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class GrandExchangeWidgetNullSlotTest { @Test @@ -19,4 +30,34 @@ public void getOfferBuyButtonReturnsNullForNullSlot() { public void getOfferSellButtonReturnsNullForNullSlot() { assertNull(GrandExchangeWidget.getOfferSellButton(null)); } + + @Test + public void getConfirmReturnsNullWhenOfferContainerMissing() { + try (MockedStatic widgets = Mockito.mockStatic(Rs2Widget.class)) { + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.GE_OFFERS, 26)).thenReturn(null); + assertNull(GrandExchangeWidget.getConfirm()); + } + } + + @Test + public void getConfirmReturnsNullWhenOfferContainerHasNoDynamicChildren() { + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(null); + try (MockedStatic widgets = Mockito.mockStatic(Rs2Widget.class)) { + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.GE_OFFERS, 26)).thenReturn(container); + assertNull(GrandExchangeWidget.getConfirm()); + } + } + + @Test + public void getConfirmFindsButtonInOfferContainer() { + Widget container = mock(Widget.class); + Widget button = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(new Widget[]{button}); + try (MockedStatic widgets = Mockito.mockStatic(Rs2Widget.class)) { + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.GE_OFFERS, 26)).thenReturn(container); + widgets.when(() -> Rs2Widget.findWidget(eq("Confirm"), anyList(), anyBoolean())).thenReturn(button); + assertSame(button, GrandExchangeWidget.getConfirm()); + } + } } From e4a2604338f87e7b844e46c10274e184cf79ab94 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:36:14 +0200 Subject: [PATCH 17/48] fix(ge): fail processOffer when the confirm button cannot be clicked confirm() now returns false when getConfirm() is missing, and BUY/SELL break out instead of reporting a placed offer because the setup screen is already closed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../util/grandexchange/Rs2GrandExchange.java | 9 +++-- .../Rs2GrandExchangeConfirmTest.java | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchangeConfirmTest.java 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 7a164c9d2e5..3b6aac530f8 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 @@ -241,7 +241,7 @@ public static boolean processOffer(GrandExchangeRequest request) { //failed to set quantity return false; } - confirm(); + if (!confirm()) break; success = sleepUntil(() -> !isOfferSetupOpen()); break; @@ -267,7 +267,7 @@ public static boolean processOffer(GrandExchangeRequest request) { } } - confirm(); + if (!confirm()) break; success = sleepUntil(() -> !isOfferSetupOpen()); break; } @@ -580,14 +580,15 @@ public static boolean buyItem(String itemName, int price, int quantity) { * such as "Your offer is much higher". If such a prompt is detected, it will automatically * click "Yes" to proceed with the offer. */ - private static void confirm() { + private static boolean confirm() { if (!Rs2Widget.clickWidget(GrandExchangeWidget.getConfirm())) { - return; + return false; } sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much"), 2000); if (Rs2Widget.hasWidget("Your offer is much")) { Rs2Widget.clickWidget("Yes"); } + return true; } /** diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchangeConfirmTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchangeConfirmTest.java new file mode 100644 index 00000000000..f9941baccce --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchangeConfirmTest.java @@ -0,0 +1,37 @@ +package net.runelite.client.plugins.microbot.util.grandexchange; + +import net.runelite.api.gameval.InterfaceID; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.Test; +import org.mockito.Answers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; + +public class Rs2GrandExchangeConfirmTest { + @Test + public void sellItemReportsFailureWhenOfferContainerClosesBeforeConfirm() { + try (MockedStatic ge = Mockito.mockStatic(Rs2GrandExchange.class, Answers.CALLS_REAL_METHODS); + MockedStatic inventory = Mockito.mockStatic(Rs2Inventory.class); + MockedStatic widgets = Mockito.mockStatic(Rs2Widget.class); + MockedStatic global = Mockito.mockStatic(Global.class, invocation -> + invocation.getMethod().getReturnType() == boolean.class ? true : Answers.RETURNS_DEFAULTS.answer(invocation))) + { + ge.when(Rs2GrandExchange::isOpen).thenReturn(true); + ge.when(Rs2GrandExchange::getAvailableSlots).thenReturn(new GrandExchangeSlots[]{GrandExchangeSlots.ONE}); + ge.when(Rs2GrandExchange::getOfferPrice).thenReturn(100L); + ge.when(Rs2GrandExchange::getOfferQuantity).thenReturn(5); + ge.when(Rs2GrandExchange::isOfferSetupOpen).thenReturn(false); + inventory.when(() -> Rs2Inventory.hasItem("Oak plank", false)).thenReturn(true); + inventory.when(() -> Rs2Inventory.interact("Oak plank", "Offer", false)).thenReturn(true); + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.GE_OFFERS, 26)).thenReturn(null); + + assertFalse(Rs2GrandExchange.sellItem("Oak plank", 5, 100)); + widgets.verify(() -> Rs2Widget.clickWidget(anyString()), Mockito.never()); + } + } +} From c9276915e2764e8dbe8c2182b1c5f352a82e2772 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:25:07 +0200 Subject: [PATCH 18/48] fix(config-panel): guard null config descriptor and plugin in Microbot config panel Opening Configure from the Microbot Plugin Hub for a loaded plugin without a config interface reached MicrobotConfigPanel.rebuild with a null ConfigDescriptor and threw on the EDT. The shared openConfigurationPanel entry now ignores descriptors without config, and the Hub Configure button falls back to filtering the plugin list when the plugin has no config proxy, matching upstream PluginHubPanel. The Reset button dereferenced pluginConfig.getPlugin() before its null check, so resetting the Microbot client settings (fake descriptor, no plugin) threw. resetProfilePluginProperties now runs inside the existing plugin != null block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/ui/MicrobotConfigPanel.java | 2 +- .../microbot/ui/MicrobotPluginHubPanel.java | 21 ++++++------ .../microbot/ui/MicrobotPluginListPanel.java | 4 +++ ...MicrobotPluginListPanelOpenConfigTest.java | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanelOpenConfigTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java index 2d877f44064..80d6a69f58b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotConfigPanel.java @@ -420,10 +420,10 @@ public void mouseClicked(MouseEvent e) { if (result == JOptionPane.YES_OPTION) { configManager.setDefaultConfiguration(pluginConfig.getConfig(), true); - MicrobotConfigManager.resetProfilePluginProperties(pluginConfig.getPlugin().getClass()); // Reset non-config panel keys Plugin plugin = pluginConfig.getPlugin(); if (plugin != null) { + MicrobotConfigManager.resetProfilePluginProperties(plugin.getClass()); plugin.resetConfiguration(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginHubPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginHubPanel.java index 252ea6bdcb4..52777ea3b33 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginHubPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginHubPanel.java @@ -318,18 +318,17 @@ private class PluginItem extends JPanel implements SearchablePlugin { configure.setToolTipText("Configure"); configure.setBorder(null); if (!loadedPlugins.isEmpty()) { - String search = null; - if (loadedPlugins.size() > 1) { - search = manifest.getInternalName(); - } else { - Plugin plugin = loadedPlugins.iterator().next(); - configure.addActionListener(l -> topLevelConfigPanel.openConfigurationPanel(plugin)); - } + configure.addActionListener(l -> { + if (loadedPlugins.size() == 1) { + Plugin plugin = loadedPlugins.iterator().next(); + if (pluginManager.getPluginConfigProxy(plugin) != null) { + topLevelConfigPanel.openConfigurationPanel(plugin); + return; + } + } - if (search != null) { - final String _search = search; - configure.addActionListener(l -> topLevelConfigPanel.openWithFilter(_search)); - } + topLevelConfigPanel.openWithFilter(manifest.getInternalName()); + }); } else { configure.setVisible(false); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanel.java index 335e13e25fe..2be0fafb341 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanel.java @@ -286,6 +286,10 @@ void openConfigurationPanel(Plugin plugin) { } void openConfigurationPanel(MicrobotPluginConfigurationDescriptor plugin) { + if (plugin.getConfigDescriptor() == null) { + return; + } + MicrobotConfigPanel panel = configPanelProvider.get(); panel.init(plugin); muxer.pushState(this); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanelOpenConfigTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanelOpenConfigTest.java new file mode 100644 index 00000000000..95892b73212 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ui/MicrobotPluginListPanelOpenConfigTest.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.ui; + +import javax.inject.Provider; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.externalplugins.ExternalPluginManager; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class MicrobotPluginListPanelOpenConfigTest { + @Test + @SuppressWarnings("unchecked") + public void openingPluginWithoutConfigDescriptorDoesNotBuildConfigPanel() { + Provider configPanelProvider = mock(Provider.class); + MicrobotPluginListPanel panel = new MicrobotPluginListPanel( + mock(ConfigManager.class), + mock(PluginManager.class), + mock(MicrobotPluginManager.class), + mock(ExternalPluginManager.class), + mock(EventBus.class), + configPanelProvider); + + panel.openConfigurationPanel(new MicrobotPluginConfigurationDescriptor( + "No Config", "Plugin without config", new String[0], null, null)); + + verifyNoInteractions(configPanelProvider); + } +} From 1d08c6387ec33341c6ded0ad873805c4e9e37ccc Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:37:26 +0200 Subject: [PATCH 19/48] fix(shortestpath): ignore null walk targets from the panel Start buttons The Slayer Master Start button passed SlayerMasters.NONE's null world point, and the Custom Location Start button passed null on unparsable coordinates, so startWalking threw an NPE on the EDT. startWalking now logs and returns on a null target, and the Slayer Master button uses the null-safe getSlayerMasterTarget(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shortestpath/ShortestPathPanel.java | 7 +- .../ShortestPathPanelStartWalkingTest.java | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelStartWalkingTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java index e700f13925a..861a53d7556 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java @@ -362,7 +362,7 @@ private JPanel createSlayerMasterPanel() JButton startButton = new JButton("Start"); JButton stopButton = new JButton("Stop"); - startButton.addActionListener(e -> startWalking(getSelectedSlayerMaster().getWorldPoint())); + startButton.addActionListener(e -> startWalking(getSlayerMasterTarget())); stopButton.addActionListener(e -> stopWalking()); JPanel turaelSkipPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); @@ -720,6 +720,11 @@ private JPanel createHotkeyRow(String keyName, Keybind initial, String tooltip) void startWalking(WorldPoint point) { + if (point == null) + { + Microbot.log("WebWalker: no valid destination to walk to."); + return; + } Microbot.log("Web walking starting. Traveling to Custom Location (" + point.getX() + ", " + point.getY() + ", " + point.getPlane() + ")."); plugin.getShortestPathScript().setTriggerWalker(point); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelStartWalkingTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelStartWalkingTest.java new file mode 100644 index 00000000000..29afa9d730c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelStartWalkingTest.java @@ -0,0 +1,84 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import java.lang.reflect.Field; +import javax.swing.JComboBox; +import javax.swing.JTextField; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.enums.SlayerMasters; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import static org.junit.Assert.assertNull; +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; + +public class ShortestPathPanelStartWalkingTest +{ + private ShortestPathPanel panel; + private ShortestPathScript script; + + @Before + public void setUp() throws Exception + { + panel = mock(ShortestPathPanel.class, Mockito.CALLS_REAL_METHODS); + ShortestPathPlugin plugin = mock(ShortestPathPlugin.class); + script = mock(ShortestPathScript.class); + when(plugin.getShortestPathScript()).thenReturn(script); + setField("plugin", plugin); + } + + @Test + public void startWalkingIgnoresNullTarget() + { + panel.startWalking(null); + + verify(script, never()).setTriggerWalker(any()); + } + + @Test + public void startWalkingTriggersWalkerForTarget() + { + WorldPoint target = new WorldPoint(3200, 3200, 0); + + panel.startWalking(target); + + verify(script).setTriggerWalker(target); + } + + @Test + public void slayerMasterWithoutLocationDoesNotTriggerWalker() throws Exception + { + JComboBox comboBox = new JComboBox<>(SlayerMasters.values()); + comboBox.setSelectedItem(SlayerMasters.NONE); + setField("slayerMasterComboBox", comboBox); + + assertNull(panel.getSlayerMasterTarget()); + panel.startWalking(panel.getSlayerMasterTarget()); + + verify(script, never()).setTriggerWalker(any()); + } + + @Test + public void invalidCustomLocationDoesNotTriggerWalker() throws Exception + { + setField("xField", new JTextField("abc")); + setField("yField", new JTextField("3200")); + setField("zField", new JTextField("0")); + + assertNull(panel.getCustomLocation()); + panel.startWalking(panel.getCustomLocation()); + + verify(script, never()).setTriggerWalker(any()); + } + + private void setField(String name, Object value) throws Exception + { + Field field = ShortestPathPanel.class.getDeclaredField(name); + field.setAccessible(true); + field.set(panel, value); + } +} From a7f6f7eafbc79f782535b1fd7ec800e6ebe234f2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:07:44 +0200 Subject: [PATCH 20/48] fix(bank): guard Rs2Bank scroll helpers against unloaded bank widgets getBankItemCount now returns 0 when the bank-size text is blank or not a number instead of throwing NumberFormatException. scrollBankToSlot checks for a missing BANK_ITEM_CONTAINER before computing the scroll, and calculateScrollYFromSlotId replaces a runtime no-op assert with a real null check, so the helpers no longer NPE while the bank is not loaded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/items.md | 7 ++ .../plugins/microbot/util/bank/Rs2Bank.java | 12 ++- .../util/bank/Rs2BankUnloadedWidgetsTest.java | 88 +++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankUnloadedWidgetsTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index a68163fd997..630626609fb 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -274,6 +274,13 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin **Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. +## 16. Bank widgets can be missing or blank right after the bank opens + +`BANK_ITEM_CONTAINER` can be null and the `BANK_ITEM_COUNT_TOP` text can be `""` while the bank interface is opening, closing or rebuilding. Never `assert` a widget non-null (asserts are disabled at runtime) and never `Integer.parseInt` widget text without handling blank or non-numeric input. `Rs2Bank.getBankItemCount()` returns 0 for unpopulated text, and `scrollBankToSlot` returns false before computing a scroll when the container is missing. + +**Defensive check:** Run `Rs2BankUnloadedWidgetsTest`. + + ## Terminal supply stops need observed logout An owning script can set `withMissingSupplyHandler` on its Inventory Setup loader to keep its scheduled loop alive while `TerminalLogoutPrompt` closes the bank and requests logout. Do not show a blocking shortage notification or cancel that loop until `LOGGED_OUT` is observed. The default loader policy stays unchanged when no handler is supplied; ordinary breaks and world hops do not arm terminal Auto Login suppression. 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 3f3ef5433f3..f1c84a7d1e6 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 @@ -3005,7 +3005,13 @@ private static Widget getBankSizeWidget() { public static int getBankItemCount() { Widget bank = getBankSizeWidget(); if (bank == null) return 0; - return Integer.parseInt(bank.getText()); + String text = bank.getText(); + if (text == null) return 0; + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return 0; + } } /** @@ -3328,7 +3334,7 @@ private static int calculateScrollYFromSlotId(int slotId) { Widget w = Microbot.getClient().getWidget(BANK_ITEM_CONTAINER); // Check the height of the bank window to adjust scrolling if necessary - assert w != null; + if (w == null) return 0; int bankHeight = w.getHeight() / (BANK_ITEM_HEIGHT + BANK_ITEM_Y_PADDING); // Calculate the minimum scrollY to ensure the item is visible at the top of the window @@ -3359,9 +3365,9 @@ private static int calculateScrollYFromSlotId(int slotId) { * @param slotId the slot ID of the item to scroll to */ public static boolean scrollBankToSlot(int slotId) { - int scrollY = calculateScrollYFromSlotId(slotId); Widget w = Microbot.getClient().getWidget(BANK_ITEM_CONTAINER); if (w == null) return false; + int scrollY = calculateScrollYFromSlotId(slotId); Microbot.getClientThread().invokeLater(() -> { Microbot.getClient().setVarcIntValue(VarClientInt.BANK_SCROLL, scrollY); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankUnloadedWidgetsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankUnloadedWidgetsTest.java new file mode 100644 index 00000000000..f1c91772717 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankUnloadedWidgetsTest.java @@ -0,0 +1,88 @@ +package net.runelite.client.plugins.microbot.util.bank; + +import net.runelite.api.Client; +import net.runelite.api.widgets.ComponentID; +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.Optional; +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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2BankUnloadedWidgetsTest { + + private MockedStatic microbot; + private Client client; + private ClientThread clientThread; + + @Before + public void setUp() { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + private void bankSizeText(String text) { + Widget size = mock(Widget.class); + when(size.getText()).thenReturn(text); + when(client.getWidget(ComponentID.BANK_ITEM_COUNT_TOP)).thenReturn(size); + } + + @Test + public void bankItemCountIsZeroWhenSizeTextIsBlank() { + bankSizeText(""); + assertEquals(0, Rs2Bank.getBankItemCount()); + bankSizeText(null); + assertEquals(0, Rs2Bank.getBankItemCount()); + bankSizeText("abc"); + assertEquals(0, Rs2Bank.getBankItemCount()); + } + + @Test + public void bankItemCountParsesPopulatedText() { + bankSizeText(" 412 "); + assertEquals(412, Rs2Bank.getBankItemCount()); + } + + @Test + public void scrollBankToSlotIsFalseWithoutItemContainer() { + when(client.getWidget(Rs2Bank.BANK_ITEM_CONTAINER)).thenReturn(null); + assertFalse(Rs2Bank.scrollBankToSlot(10)); + verify(clientThread, never()).invokeLater(any(Runnable.class)); + } + + @Test + public void scrollBankToSlotSurvivesContainerDisappearingMidCalculation() { + Widget container = mock(Widget.class); + when(client.getWidget(Rs2Bank.BANK_ITEM_CONTAINER)).thenReturn(container, (Widget) null); + assertTrue(Rs2Bank.scrollBankToSlot(10)); + verify(container).setScrollY(0); + verify(container, never()).getHeight(); + verify(clientThread).invokeLater(any(Runnable.class)); + } +} From 30ffaf1f260037c5b58194f931acc9a623952c85 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:23:02 +0200 Subject: [PATCH 21/48] fix(api): build NPC and player cache snapshots on the client thread Rs2NpcCache.getStream and Rs2PlayerCache.getStream iterated worldView.npcs()/players() on the calling script thread. The injected client's IndexedObjectSet is not thread-safe and its iterator NPEs when the client thread mutates the array mid-iteration (AIOFighter AttackNpcScript, client 2.6.31). Run the refresh inside runOnClientThreadOptional like Rs2TileObjectCache/Rs2TileItemCache, keep the per-tick snapshot, and return an empty stream on failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../plugins/microbot/api/npc/Rs2NpcCache.java | 44 +++--- .../microbot/api/player/Rs2PlayerCache.java | 42 ++--- .../microbot/api/npc/Rs2NpcCacheTest.java | 146 ++++++++++++++++++ .../api/player/Rs2PlayerCacheTest.java | 146 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 8 +- 5 files changed, 340 insertions(+), 46 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCacheTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCacheTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCache.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCache.java index d5e6cff2f69..8154ff20f37 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCache.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCache.java @@ -9,6 +9,7 @@ import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -20,8 +21,8 @@ public final class Rs2NpcCache { private final Client client; private final ClientThread clientThread; - private int lastUpdateNpcs = 0; - private List npcs = new ArrayList<>(); + private volatile int lastUpdateNpcs = 0; + private volatile List npcs = new ArrayList<>(); @Inject public Rs2NpcCache(Client client, ClientThread clientThread) { @@ -39,28 +40,31 @@ public Rs2NpcQueryable query() { * @return Stream of Rs2NpcModel */ public Stream getStream() { - if (lastUpdateNpcs >= client.getTickCount()) { - return npcs.stream(); - } + return clientThread.runOnClientThreadOptional(() -> { + int tick = client.getTickCount(); + if (lastUpdateNpcs >= tick) { + return npcs; + } - List result = new ArrayList<>(); + List result = new ArrayList<>(); - for (var id : Microbot.getWorldViewIds()) { - WorldView worldView = client.getWorldView(id); - if (worldView == null) { - continue; - } + for (var id : Microbot.getWorldViewIds()) { + WorldView worldView = client.getWorldView(id); + if (worldView == null) { + continue; + } - result.addAll(worldView.npcs() - .stream() - .filter(Objects::nonNull) - .map(Rs2NpcModel::new) - .collect(Collectors.toList())); - } + result.addAll(worldView.npcs() + .stream() + .filter(Objects::nonNull) + .map(Rs2NpcModel::new) + .collect(Collectors.toList())); + } - npcs = result; - lastUpdateNpcs = client.getTickCount(); - return result.stream(); + npcs = result; + lastUpdateNpcs = tick; + return result; + }).orElse(Collections.emptyList()).stream(); } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCache.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCache.java index e1d2043dbc1..53864dec016 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCache.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCache.java @@ -9,6 +9,7 @@ import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -20,8 +21,8 @@ public final class Rs2PlayerCache { private final Client client; private final ClientThread clientThread; - private int lastUpdatePlayers = 0; - private List players = new ArrayList<>(); + private volatile int lastUpdatePlayers = 0; + private volatile List players = new ArrayList<>(); @Inject public Rs2PlayerCache(Client client, ClientThread clientThread) { @@ -38,27 +39,30 @@ public Rs2PlayerQueryable query() { * @return Stream of Rs2PlayerModel */ public Stream getStream() { - if (lastUpdatePlayers >= client.getTickCount()) { - return players.stream(); - } + return clientThread.runOnClientThreadOptional(() -> { + int tick = client.getTickCount(); + if (lastUpdatePlayers >= tick) { + return players; + } - List result = new ArrayList<>(); + List result = new ArrayList<>(); - for (var id : Microbot.getWorldViewIds()) { - WorldView worldView = client.getWorldView(id); - if (worldView == null) { - continue; + for (var id : Microbot.getWorldViewIds()) { + WorldView worldView = client.getWorldView(id); + if (worldView == null) { + continue; + } + result.addAll(worldView.players() + .stream() + .filter(Objects::nonNull) + .map(Rs2PlayerModel::new) + .collect(Collectors.toList())); } - result.addAll(worldView.players() - .stream() - .filter(Objects::nonNull) - .map(Rs2PlayerModel::new) - .collect(Collectors.toList())); - } - players = result; - lastUpdatePlayers = client.getTickCount(); - return players.stream(); + players = result; + lastUpdatePlayers = tick; + return result; + }).orElse(Collections.emptyList()).stream(); } /** diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCacheTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCacheTest.java new file mode 100644 index 00000000000..3bbecbd881f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/Rs2NpcCacheTest.java @@ -0,0 +1,146 @@ +package net.runelite.client.plugins.microbot.api.npc; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import net.runelite.api.Client; +import net.runelite.api.IndexedObjectSet; +import net.runelite.api.NPC; +import net.runelite.api.WorldView; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class Rs2NpcCacheTest +{ + private static final int WORLD_VIEW_ID = 4244; + + private final AtomicBoolean onClientThread = new AtomicBoolean(); + private final AtomicInteger tick = new AtomicInteger(5); + private final AtomicInteger iterations = new AtomicInteger(); + + private Client client; + private ClientThread clientThread; + private NPC first; + private NPC second; + private Rs2NpcCache cache; + + @Before + public void setUp() + { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + { + Callable callable = invocation.getArgument(0); + onClientThread.set(true); + try + { + return Optional.ofNullable(callable.call()); + } + finally + { + onClientThread.set(false); + } + }); + + first = mock(NPC.class); + second = mock(NPC.class); + IndexedObjectSet npcs = new IndexedObjectSet<>() + { + @Override + public NPC byIndex(int index) + { + return null; + } + + @Override + public Iterator iterator() + { + iterations.incrementAndGet(); + Iterator delegate = Arrays.asList(first, null, second).iterator(); + return new Iterator<>() + { + @Override + public boolean hasNext() + { + return guarded(delegate.hasNext()); + } + + @Override + public NPC next() + { + return guarded(delegate.next()); + } + }; + } + }; + + WorldView worldView = mock(WorldView.class); + doReturn(npcs).when(worldView).npcs(); + when(client.getTickCount()).thenAnswer(invocation -> guarded(tick.get())); + when(client.getWorldView(WORLD_VIEW_ID)).thenAnswer(invocation -> guarded(worldView)); + + Microbot.getWorldViewIds().add(WORLD_VIEW_ID); + cache = new Rs2NpcCache(client, clientThread); + } + + @After + public void tearDown() + { + Microbot.getWorldViewIds().remove(WORLD_VIEW_ID); + } + + private T guarded(T value) + { + assertTrue("client state must only be read inside the client-thread task", onClientThread.get()); + return value; + } + + @Test + public void offClientThreadRefreshIteratesNpcsOnlyInsideClientThreadTask() + { + List npcs = cache.getStream().collect(Collectors.toList()); + + assertEquals(2, npcs.size()); + verify(clientThread, times(1)).runOnClientThreadOptional(any()); + } + + @Test + public void sameTickReusesSnapshotAndNextTickRescans() + { + assertEquals(2, cache.getStream().count()); + assertEquals(2, cache.getStream().count()); + assertEquals(1, iterations.get()); + + tick.incrementAndGet(); + assertEquals(2, cache.getStream().count()); + assertEquals(2, iterations.get()); + } + + @Test + public void clientThreadFailureReturnsEmptyStreamWithoutReadingClient() + { + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + + assertEquals(0, cache.getStream().count()); + verifyNoInteractions(client); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCacheTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCacheTest.java new file mode 100644 index 00000000000..6aff154cca3 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerCacheTest.java @@ -0,0 +1,146 @@ +package net.runelite.client.plugins.microbot.api.player; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import net.runelite.api.Client; +import net.runelite.api.IndexedObjectSet; +import net.runelite.api.Player; +import net.runelite.api.WorldView; +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.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class Rs2PlayerCacheTest +{ + private static final int WORLD_VIEW_ID = 4245; + + private final AtomicBoolean onClientThread = new AtomicBoolean(); + private final AtomicInteger tick = new AtomicInteger(5); + private final AtomicInteger iterations = new AtomicInteger(); + + private Client client; + private ClientThread clientThread; + private Player first; + private Player second; + private Rs2PlayerCache cache; + + @Before + public void setUp() + { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + { + Callable callable = invocation.getArgument(0); + onClientThread.set(true); + try + { + return Optional.ofNullable(callable.call()); + } + finally + { + onClientThread.set(false); + } + }); + + first = mock(Player.class); + second = mock(Player.class); + IndexedObjectSet players = new IndexedObjectSet<>() + { + @Override + public Player byIndex(int index) + { + return null; + } + + @Override + public Iterator iterator() + { + iterations.incrementAndGet(); + Iterator delegate = Arrays.asList(first, null, second).iterator(); + return new Iterator<>() + { + @Override + public boolean hasNext() + { + return guarded(delegate.hasNext()); + } + + @Override + public Player next() + { + return guarded(delegate.next()); + } + }; + } + }; + + WorldView worldView = mock(WorldView.class); + doReturn(players).when(worldView).players(); + when(client.getTickCount()).thenAnswer(invocation -> guarded(tick.get())); + when(client.getWorldView(WORLD_VIEW_ID)).thenAnswer(invocation -> guarded(worldView)); + + Microbot.getWorldViewIds().add(WORLD_VIEW_ID); + cache = new Rs2PlayerCache(client, clientThread); + } + + @After + public void tearDown() + { + Microbot.getWorldViewIds().remove(WORLD_VIEW_ID); + } + + private T guarded(T value) + { + assertTrue("client state must only be read inside the client-thread task", onClientThread.get()); + return value; + } + + @Test + public void offClientThreadRefreshIteratesPlayersOnlyInsideClientThreadTask() + { + List players = cache.getStream().collect(Collectors.toList()); + + assertEquals(2, players.size()); + verify(clientThread, times(1)).runOnClientThreadOptional(any()); + } + + @Test + public void sameTickReusesSnapshotAndNextTickRescans() + { + assertEquals(2, cache.getStream().count()); + assertEquals(2, cache.getStream().count()); + assertEquals(1, iterations.get()); + + tick.incrementAndGet(); + assertEquals(2, cache.getStream().count()); + assertEquals(2, iterations.get()); + } + + @Test + public void clientThreadFailureReturnsEmptyStreamWithoutReadingClient() + { + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + + assertEquals(0, cache.getStream().count()); + verifyNoInteractions(client); + } +} 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 21859681a74..9039ec94141 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 @@ -25,18 +25,11 @@ net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache#getLocalBoat(): Rs2Bo net.runelite.client.plugins.microbot.api.boat.models.Rs2BoatModel#clickSailButton(): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.api.boat.models.Rs2BoatModel#setHeading(Heading): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.api.boat.models.Rs2BoatModel#setHeading(Heading): void -> net.runelite.api.WorldView#getId(): int -net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.Client#getTickCount(): int -net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.Client#getWorldView(int): WorldView -net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.IndexedObjectSet#stream(): Stream -net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache#getStream(): Stream -> net.runelite.api.WorldView#npcs(): IndexedObjectSet net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#click(String): boolean -> net.runelite.api.NPCComposition#getActions(): String[] net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#getIndex(): int -> net.runelite.api.NPC#getIndex(): int net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#getMenuAction(int): MenuAction -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#hasLineOfSight(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel#hasLineOfSight(): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldPoint): boolean -net.runelite.client.plugins.microbot.api.player.Rs2PlayerCache#getStream(): Stream -> net.runelite.api.Client#getTickCount(): int -net.runelite.client.plugins.microbot.api.player.Rs2PlayerCache#getStream(): Stream -> net.runelite.api.Client#getWorldView(int): WorldView -net.runelite.client.plugins.microbot.api.player.Rs2PlayerCache#getStream(): Stream -> net.runelite.api.IndexedObjectSet#stream(): Stream net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel#(): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel#getId(): int -> net.runelite.api.Player#getId(): int net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache#getQuestState(Quest): QuestState -> net.runelite.api.Quest#getId(): int @@ -556,6 +549,7 @@ net.runelite.client.plugins.microbot.util.player.Rs2Player#getPlayerEquipmentIds net.runelite.client.plugins.microbot.util.player.Rs2Player#getPlayerEquipmentIds(Rs2PlayerModel): Map -> net.runelite.api.kit.KitType#values(): KitType[] net.runelite.client.plugins.microbot.util.player.Rs2Player#getPlayers(Predicate, boolean): Stream -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#getPlayers(Predicate, boolean): Stream -> net.runelite.api.IndexedObjectSet#stream(): Stream +net.runelite.client.plugins.microbot.util.player.Rs2Player#getPlayers(Predicate, boolean): Stream -> net.runelite.api.WorldView#players(): IndexedObjectSet net.runelite.client.plugins.microbot.util.player.Rs2Player#getWorld(): int -> net.runelite.api.Client#getWorld(): int net.runelite.client.plugins.microbot.util.player.Rs2Player#handleAnimationChanged(AnimationChanged): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#handleAnimationChanged(AnimationChanged): void -> net.runelite.api.Player#getAnimation(): int From 9ccbac70d3b0960b71b7a96ce8c8985ad3378455 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:41:22 +0200 Subject: [PATCH 22/48] fix(camera): skip projection for LocalPoints outside their worldview scene Rs2Camera.isTileOnScreen passed NPC and tile-object local points straight to Perspective.getCanvasTilePoly, which only rejects points outside the extended scene. Scene X/Y beyond the normal scene reached WorldView.getTileHeight and threw ArrayIndexOutOfBoundsException, aborting Rs2NpcModel.click and Rs2TileObjectModel.click. Both overloads now resolve the point's worldview and return false when it is missing or the scene coordinates are outside it, so callers turn the camera instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/README.md | 2 + docs/entity-guides/movement.md | 23 ++++ .../microbot/util/camera/Rs2Camera.java | 23 +++- .../camera/Rs2CameraTileOnScreenTest.java | 114 ++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraTileOnScreenTest.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index d88396d5360..d36ac59febd 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -27,6 +27,8 @@ Scene tile loops on a boat worldview must use the tile array bounds: [movement g Instance location conversions must null-check `LocalPoint.fromWorld`: [movement gotcha 26](movement.md#26-null-check-localpointfromworld-before-converting-instance-coordinates). +Bounds-check a `LocalPoint` against its worldview before projecting it to the canvas: [movement gotcha 27](movement.md#27-bounds-check-a-localpoint-against-its-own-worldview-before-projecting-it). + ## Format Each entity guide is a numbered list of gotchas. Each entry follows this structure: diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index cdeb7e56cd5..f452174e483 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -524,3 +524,26 @@ return WorldPoint.fromLocalInstance(client, l); **Where this applies:** `Rs2Player.getWorldLocation_Internal()` and any helper that converts an actor's world point to an instance template point. **Defensive check:** Mock a world view whose plane differs from the player's world point and assert the helper returns a fallback point, as in `Rs2PlayerInstanceWorldLocationTest`. + +## 27. Bounds-check a `LocalPoint` against its own worldview before projecting it + +`Perspective.getCanvasTilePoly` only rejects points outside the extended scene (`getSizeX() + 2 * ESCENE_OFFSET` on the top-level view). A tile with scene X/Y in 104..143, or a negative one, still reaches `WorldView.getTileHeight`, which indexes the normal scene height array and throws `ArrayIndexOutOfBoundsException`. Resolve the worldview from `localPoint.getWorldView()` and check the scene X/Y against `0..getSizeX()-1` / `0..getSizeY()-1` before projecting. + +**Why this matters:** `Rs2Camera.isTileOnScreen` passed NPC and tile-object local points straight to `getCanvasTilePoly`. Telemetry for 2.6.30 to 2.6.32 recorded `Index 109/111/126 out of bounds for length 105` from `Rs2NpcModel.click` and `Rs2TileObjectModel.click`, which aborted the interaction instead of turning the camera. + +**Pattern to follow:** + +```java +// Wrong: out-of-scene points throw inside Perspective. +Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); + +// Right: treat a point outside its worldview's scene as off screen. +WorldView wv = client.getWorldView(localPoint.getWorldView()); +if (wv == null || localPoint.getSceneX() < 0 || localPoint.getSceneY() < 0 + || localPoint.getSceneX() >= wv.getSizeX() || localPoint.getSceneY() >= wv.getSizeY()) return false; +Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); +``` + +**Where this applies:** `Rs2Camera.isTileOnScreen(LocalPoint)`, `Rs2Camera.isTileOnScreen(TileObject)`, and any helper that projects an entity's local point to the canvas. + +**Defensive check:** Mock a worldview whose `getTileHeight` throws and assert out-of-scene points return false without calling it, as in `Rs2CameraTileOnScreenTest`. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java index 103f72d4385..13de2f268f0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java @@ -272,11 +272,14 @@ public static boolean isTrackingNpc() { } public static boolean isTileOnScreen(TileObject tileObject) { - int viewportHeight = Microbot.getClient().getViewportHeight(); - int viewportWidth = Microbot.getClient().getViewportWidth(); + Client client = Microbot.getClient(); + LocalPoint localPoint = tileObject.getLocalLocation(); + if (!isInWorldViewScene(client, localPoint)) return false; + int viewportHeight = client.getViewportHeight(); + int viewportWidth = client.getViewportWidth(); - Polygon poly = Perspective.getCanvasTilePoly(Microbot.getClient(), tileObject.getLocalLocation()); + Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); if (poly == null) return false; @@ -285,6 +288,8 @@ public static boolean isTileOnScreen(TileObject tileObject) { public static boolean isTileOnScreen(LocalPoint localPoint) { Client client = Microbot.getClient(); + if (!isInWorldViewScene(client, localPoint)) return false; + int viewportHeight = client.getViewportHeight(); int viewportWidth = client.getViewportWidth(); @@ -300,6 +305,18 @@ public static boolean isTileOnScreen(LocalPoint localPoint) { return canvasPoint != null; } + private static boolean isInWorldViewScene(Client client, LocalPoint localPoint) { + if (localPoint == null) return false; + int sceneX = localPoint.getSceneX(); + int sceneY = localPoint.getSceneY(); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + WorldView worldView = client.getWorldView(localPoint.getWorldView()); + return worldView != null + && sceneX >= 0 && sceneY >= 0 + && sceneX < worldView.getSizeX() && sceneY < worldView.getSizeY(); + }).orElse(false); + } + // get the camera zoom public static int getZoom() { return Microbot.getClient().getVarcIntValue(VarClientInt.CAMERA_ZOOM_RESIZABLE_VIEWPORT); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraTileOnScreenTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraTileOnScreenTest.java new file mode 100644 index 00000000000..cd3d49cf9c0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraTileOnScreenTest.java @@ -0,0 +1,114 @@ +package net.runelite.client.plugins.microbot.util.camera; + +import net.runelite.api.Client; +import net.runelite.api.Scene; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +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.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +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 Rs2CameraTileOnScreenTest { + + private static final int WORLD_VIEW_ID = WorldView.TOPLEVEL; + private static final int SCENE_SIZE = 104; + + private MockedStatic microbot; + private Client client; + private WorldView worldView; + + @Before + public void setUp() { + client = mock(Client.class); + worldView = mock(WorldView.class); + when(worldView.getId()).thenReturn(WORLD_VIEW_ID); + when(worldView.isTopLevel()).thenReturn(true); + when(worldView.getSizeX()).thenReturn(SCENE_SIZE); + when(worldView.getSizeY()).thenReturn(SCENE_SIZE); + when(worldView.getTileHeight(anyInt(), anyInt(), anyInt())).thenThrow(new ArrayIndexOutOfBoundsException("Index 111 out of bounds for length 105")); + when(client.getWorldView(WORLD_VIEW_ID)).thenReturn(worldView); + when(client.getViewportWidth()).thenReturn(765); + when(client.getViewportHeight()).thenReturn(503); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.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 localPointPastSceneEdgeIsNotOnScreen() { + assertFalse(Rs2Camera.isTileOnScreen(sceneTile(111, 50))); + assertFalse(Rs2Camera.isTileOnScreen(sceneTile(50, 126))); + verify(worldView, never()).getTileHeight(anyInt(), anyInt(), anyInt()); + } + + @Test + public void localPointBeforeSceneOriginIsNotOnScreen() { + assertFalse(Rs2Camera.isTileOnScreen(sceneTile(-5, 50))); + assertFalse(Rs2Camera.isTileOnScreen(sceneTile(50, -5))); + verify(worldView, never()).getTileHeight(anyInt(), anyInt(), anyInt()); + } + + @Test + public void localPointInUnloadedWorldViewIsNotOnScreen() { + LocalPoint localPoint = new LocalPoint(50 * 128 + 64, 50 * 128 + 64, 7); + assertFalse(Rs2Camera.isTileOnScreen(localPoint)); + verify(worldView, never()).getTileHeight(anyInt(), anyInt(), anyInt()); + } + + @Test + public void nullLocalPointIsNotOnScreen() { + assertFalse(Rs2Camera.isTileOnScreen((LocalPoint) null)); + } + + @Test + public void tileObjectPastSceneEdgeIsNotOnScreen() { + TileObject tileObject = mock(TileObject.class); + when(tileObject.getLocalLocation()).thenReturn(sceneTile(109, 50)); + assertFalse(Rs2Camera.isTileOnScreen(tileObject)); + verify(worldView, never()).getTileHeight(anyInt(), anyInt(), anyInt()); + } + + @Test + public void localPointInsideSceneStillReachesPerspective() { + Scene scene = mock(Scene.class); + when(scene.getExtendedTileSettings()).thenReturn(new byte[4][184][184]); + when(worldView.getScene()).thenReturn(scene); + doReturn(0).when(worldView).getTileHeight(anyInt(), anyInt(), anyInt()); + Rs2Camera.isTileOnScreen(sceneTile(103, 103)); + verify(worldView, atLeastOnce()).getTileHeight(anyInt(), anyInt(), anyInt()); + } + + private static LocalPoint sceneTile(int sceneX, int sceneY) { + return new LocalPoint(sceneX * 128 + 64, sceneY * 128 + 64, WORLD_VIEW_ID); + } +} From 75953785fe5e3661a60e7acb569c384d1752ad2c Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:50:29 +0200 Subject: [PATCH 23/48] docs(movement): null-check LocalPoint in gotcha 27 example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/movement.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index f452174e483..d07e1bc4a6e 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -538,6 +538,7 @@ return WorldPoint.fromLocalInstance(client, l); Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); // Right: treat a point outside its worldview's scene as off screen. +if (localPoint == null) return false; WorldView wv = client.getWorldView(localPoint.getWorldView()); if (wv == null || localPoint.getSceneX() < 0 || localPoint.getSceneY() < 0 || localPoint.getSceneX() >= wv.getSizeX() || localPoint.getSceneY() >= wv.getSizeY()) return false; From c9cc693b5ae97290c6d7b9fe18af0e00b71f885b Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:35:47 +0200 Subject: [PATCH 24/48] fix(agentserver): treat empty JSON body as {} and malformed JSON as 400 readJsonBody returned null for an empty body, so handlers such as BankHandler.handleDeposit and ScriptHandler.resolvePlugin threw NPEs, and let JsonSyntaxException escape from handlers without a local catch (DynamicScriptDeployHandler.handleDeploy). Both surfaced as HTTP 500 with error telemetry. readJsonBody now returns an empty map for empty/null bodies and throws BadRequestException for unparseable or oversized bodies, which AgentHandler.handle maps to a 400 logged at debug. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../agentserver/handler/AgentHandler.java | 43 ++++-- .../handler/AgentHandlerJsonBodyTest.java | 140 ++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerJsonBodyTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java index 72100e47060..0157fbc053b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.agentserver.handler; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import lombok.extern.slf4j.Slf4j; @@ -65,20 +66,27 @@ public void handle(HttpExchange exchange) throws IOException { try { handleRequest(exchange); + } catch (BadRequestException e) { + log.debug("Bad request for {}: {}", exchange.getRequestURI(), e.getMessage()); + sendErrorResponse(exchange, 400, e.getMessage()); } catch (Exception e) { if (isClientDisconnect(e)) { log.debug("Client disconnected before {} response could be sent", exchange.getRequestURI()); return; } log.error("Error handling {}", exchange.getRequestURI(), e); - try { - sendJson(exchange, 500, errorResponse("Internal server error: " + e.getMessage())); - } catch (IOException ioe) { - if (!isClientDisconnect(ioe)) { - throw ioe; - } - log.debug("Client disconnected before error response could be sent for {}", exchange.getRequestURI()); + sendErrorResponse(exchange, 500, "Internal server error: " + e.getMessage()); + } + } + + private void sendErrorResponse(HttpExchange exchange, int statusCode, String message) throws IOException { + try { + sendJson(exchange, statusCode, errorResponse(message)); + } catch (IOException ioe) { + if (!isClientDisconnect(ioe)) { + throw ioe; } + log.debug("Client disconnected before error response could be sent for {}", exchange.getRequestURI()); } } @@ -189,25 +197,30 @@ protected Map parseQuery(URI uri) { return params; } + @SuppressWarnings("unchecked") protected Map readJsonBody(HttpExchange exchange) throws IOException { String cl = exchange.getRequestHeaders().getFirst("Content-Length"); if (cl != null) { try { long advertised = Long.parseLong(cl.trim()); if (advertised > MAX_BODY_SIZE) { - throw new IllegalArgumentException("Request body too large"); + throw new BadRequestException("Request body too large"); } } catch (NumberFormatException ignored) { } } byte[] bodyBytes = exchange.getRequestBody().readNBytes(MAX_BODY_SIZE + 1); if (bodyBytes.length > MAX_BODY_SIZE) { - throw new IllegalArgumentException("Request body too large"); + throw new BadRequestException("Request body too large"); } String body = new String(bodyBytes, StandardCharsets.UTF_8); - @SuppressWarnings("unchecked") - Map parsed = gson.fromJson(body, LinkedHashMap.class); - return parsed; + Map parsed; + try { + parsed = gson.fromJson(body, LinkedHashMap.class); + } catch (JsonParseException e) { + throw new BadRequestException("Invalid JSON body"); + } + return parsed != null ? parsed : new LinkedHashMap<>(); } protected int getIntParam(Map params, String key, int defaultValue) { @@ -246,6 +259,12 @@ public ClientDisconnectException(IOException cause) { } } + public static class BadRequestException extends IllegalArgumentException { + public BadRequestException(String message) { + super(message); + } + } + public static class HttpMethodException extends Exception { public HttpMethodException(String message) { super(message); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerJsonBodyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerJsonBodyTest.java new file mode 100644 index 00000000000..804da5fda25 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerJsonBodyTest.java @@ -0,0 +1,140 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +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 AgentHandlerJsonBodyTest { + + private static final class EchoHandler extends AgentHandler { + EchoHandler() { + super(new Gson()); + } + + @Override + public String getPath() { + return "/echo"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException { + Map body = readJsonBody(exchange); + Map result = new LinkedHashMap<>(); + result.put("name", body.get("name")); + result.put("size", body.size()); + sendJson(exchange, 200, result); + } + } + + private ByteArrayOutputStream responseBody; + + @Before + public void setUp() { + AgentHandler.setTokenSupplier(null); + responseBody = new ByteArrayOutputStream(); + } + + @After + public void tearDown() { + AgentHandler.setTokenSupplier(null); + } + + private HttpExchange exchangeWithBody(String body) { + HttpExchange exchange = mock(HttpExchange.class); + Headers requestHeaders = new Headers(); + requestHeaders.add("Host", "127.0.0.1:8081"); + when(exchange.getRequestHeaders()).thenReturn(requestHeaders); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + when(exchange.getRequestURI()).thenReturn(URI.create("/echo")); + when(exchange.getRequestMethod()).thenReturn("POST"); + when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + when(exchange.getResponseBody()).thenReturn(responseBody); + return exchange; + } + + private String response() { + return responseBody.toString(StandardCharsets.UTF_8); + } + + @Test + public void emptyBodyIsTreatedAsEmptyObject() throws Exception { + HttpExchange exchange = exchangeWithBody(""); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertTrue(response().contains("\"size\":0")); + } + + @Test + public void jsonNullBodyIsTreatedAsEmptyObject() throws Exception { + HttpExchange exchange = exchangeWithBody("null"); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertTrue(response().contains("\"size\":0")); + } + + @Test + public void validBodyIsParsed() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"name\":\"Lobster\"}"); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertTrue(response().contains("\"name\":\"Lobster\"")); + } + + @Test + public void malformedJsonReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"path\":\"C:\\Users\\bot\\script.java\"}"); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(400), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + assertEquals("{\"error\":\"Invalid JSON body\"}", response()); + } + + @Test + public void nonObjectJsonReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("[1,2,3]"); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(400), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + } + + @Test + public void oversizedBodyReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("{}"); + exchange.getRequestHeaders().add("Content-Length", String.valueOf(1024 * 1024)); + + new EchoHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(400), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + } +} From d2826eba7f87ba4e2111b42998ca4fec452c2719 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:18:07 +0200 Subject: [PATCH 25/48] fix(questhelper): initialise requirements before collecting item requirements QuestManager.getAllItemRequirements runs on login and reads getItemRequirements()/getItemRecommended() from every not-completed helper, but setupRequirements is only scheduled for tick end, so helpers building List.of over requirement fields (AlfredGrimhandsBarcrawl, CurrentAffairs) threw NPE on the client thread. Initialise each helper's requirements first, as SkillFiltering and clientMeetsRequirements do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../questhelper/managers/QuestManager.java | 27 +++++++----- .../QuestManagerItemRequirementsTest.java | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManagerItemRequirementsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManager.java index 85864b9f0f7..455c06c1a6d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManager.java @@ -481,16 +481,7 @@ private void getAllItemRequirements() SortedMap> newReqs = new TreeMap<>(); SortedMap> newRecommended = new TreeMap<>(); - filteredQuests.forEach((QuestHelper questHelper) -> { - if (questHelper.getItemRequirements() != null) - { - newReqs.put(questHelper.getQuest(), questHelper.getItemRequirements()); - } - if (questHelper.getItemRecommended() != null) - { - newRecommended.put(questHelper.getQuest(), questHelper.getItemRecommended()); - } - }); + collectItemRequirements(filteredQuests, newReqs, newRecommended); itemRequirements = newReqs; itemRecommended = newRecommended; @@ -504,6 +495,22 @@ private void getAllItemRequirements() }); } + static void collectItemRequirements(List quests, SortedMap> reqs, + SortedMap> recommended) + { + quests.forEach((QuestHelper questHelper) -> { + questHelper.initializeRequirements(); + if (questHelper.getItemRequirements() != null) + { + reqs.put(questHelper.getQuest(), questHelper.getItemRequirements()); + } + if (questHelper.getItemRecommended() != null) + { + recommended.put(questHelper.getQuest(), questHelper.getItemRecommended()); + } + }); + } + /** * Updates all items for the All Items helper. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManagerItemRequirementsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManagerItemRequirementsTest.java new file mode 100644 index 00000000000..5d5d0ae0ffb --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/managers/QuestManagerItemRequirementsTest.java @@ -0,0 +1,44 @@ +package net.runelite.client.plugins.microbot.questhelper.managers; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.SortedMap; +import java.util.TreeMap; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.questhelper.helpers.miniquests.alfredgrimhandsbarcrawl.AlfredGrimhandsBarcrawl; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import net.runelite.client.plugins.microbot.questhelper.questinfo.QuestHelperQuest; +import net.runelite.client.plugins.microbot.questhelper.requirements.item.ItemRequirement; +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; + +public class QuestManagerItemRequirementsTest +{ + private AlfredGrimhandsBarcrawl helper; + + @Before + public void setUp() throws Exception + { + helper = new AlfredGrimhandsBarcrawl(); + helper.setQuest(QuestHelperQuest.ALFRED_GRIMHANDS_BARCRAWL); + Field field = QuestHelper.class.getDeclaredField("configManager"); + field.setAccessible(true); + field.set(helper, mock(ConfigManager.class)); + } + + @Test + public void initializesRequirementsBeforeReadingThem() + { + SortedMap> reqs = new TreeMap<>(); + SortedMap> recommended = new TreeMap<>(); + + QuestManager.collectItemRequirements(List.of(helper), reqs, recommended); + + assertEquals(1, reqs.get(QuestHelperQuest.ALFRED_GRIMHANDS_BARCRAWL).size()); + assertNotNull(recommended.get(QuestHelperQuest.ALFRED_GRIMHANDS_BARCRAWL)); + } +} From 0d9fd5239d7015aab389fcb9f9885bcfba48a61d Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:31:08 +0200 Subject: [PATCH 26/48] fix(questhelper): guard QuestOverviewPanel.addQuest against a null current step QuestManager.initializeNewQuest checks getCurrentStep() on the client thread and then defers panel.addQuest to the EDT; the step can be cleared in between when the quest is stopped or switched, or when updateQuest finds no step. Read the step once and fall back to the fake step when it is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../questhelper/panel/QuestOverviewPanel.java | 7 +++-- .../panel/QuestOverviewPanelTest.java | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanelTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanel.java index d2d63233d3b..3f392e4dc20 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanel.java @@ -253,17 +253,18 @@ public void addQuest(QuestHelper quest, boolean isActive) allQuestStepPanelList.clear(); List steps = quest.getPanels(); + QuestStep questStep = quest.getCurrentStep(); QuestStep currentStep; - if (isActive) + if (isActive && questStep != null) { - currentStep = quest.getCurrentStep().getActiveStep(); + currentStep = questStep.getActiveStep(); } else { currentStep = new DetailedQuestStep(quest, "Fake step"); } - if (quest.getCurrentStep() != null) + if (questStep != null) { questNameLabel.setText(quest.getQuest().getName()); actionsContainer.setVisible(true); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanelTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanelTest.java new file mode 100644 index 00000000000..e80fe6da27d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/panel/QuestOverviewPanelTest.java @@ -0,0 +1,30 @@ +package net.runelite.client.plugins.microbot.questhelper.panel; + +import java.util.Collections; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; +import net.runelite.client.plugins.microbot.questhelper.managers.QuestManager; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +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 QuestOverviewPanelTest +{ + @Test + public void addQuestToleratesCurrentStepClearedBeforeEdtRuns() + { + QuestOverviewPanel panel = new QuestOverviewPanel(mock(QuestHelperPlugin.class), mock(QuestManager.class)); + QuestHelper quest = mock(QuestHelper.class); + when(quest.getPanels()).thenReturn(Collections.emptyList()); + when(quest.getCurrentStep()).thenReturn(null); + + panel.addQuest(quest, true); + + assertSame(quest, panel.currentQuest); + verify(quest, never()).getQuest(); + } +} From 9837c9774d747f58a89a257f463cf0566ce34e4a Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:21:35 +0200 Subject: [PATCH 27/48] fix(config): serialize in-JVM profile locks to avoid OverlappingFileLockException FileChannel locks are held per JVM, so a second thread calling ProfileManager.lock() while another thread held profiles.lck got an OverlappingFileLockException (seen on the EDT in MicrobotProfilePanel.switchToProfile). Guard Lock with a static ReentrantLock taken before the lock file is opened, release it on close and on constructor failure, and always close the lock stream even if saving profiles fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../client/config/ProfileManager.java | 43 ++++++++-- .../client/config/ProfileManagerLockTest.java | 80 +++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/config/ProfileManagerLockTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/config/ProfileManager.java b/runelite-client/src/main/java/net/runelite/client/config/ProfileManager.java index 91895b054e1..3c5e8852d76 100644 --- a/runelite-client/src/main/java/net/runelite/client/config/ProfileManager.java +++ b/runelite-client/src/main/java/net/runelite/client/config/ProfileManager.java @@ -43,6 +43,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; import javax.inject.Inject; import javax.inject.Singleton; @@ -57,6 +58,7 @@ public class ProfileManager { private static final File PROFILES_DIR = new File(RuneLite.RUNELITE_DIR, "microbot-profiles"); private static final File PROFILES = new File(PROFILES_DIR, "profiles.json"); + private static final ReentrantLock PROCESS_LOCK = new ReentrantLock(); private final Gson gson; @@ -73,11 +75,26 @@ public class Lock implements AutoCloseable { @SneakyThrows public Lock() { - lockFile = new File(PROFILES_DIR, "profiles.lck"); - lockOut = new FileOutputStream(lockFile); - lockChannel = lockOut.getChannel(); - lockChannel.lock(); - profiles = new ArrayList<>(load()); + PROCESS_LOCK.lock(); + FileOutputStream out = null; + try { + lockFile = new File(PROFILES_DIR, "profiles.lck"); + out = new FileOutputStream(lockFile); + out.getChannel().lock(); + lockOut = out; + lockChannel = out.getChannel(); + profiles = new ArrayList<>(load()); + } catch (Throwable t) { + if (out != null) { + try { + out.close(); + } catch (IOException e) { + t.addSuppressed(e); + } + } + PROCESS_LOCK.unlock(); + throw t; + } } private List load() { @@ -97,6 +114,19 @@ private List load() { @Override @SneakyThrows public void close() { + try { + save(); + } finally { + try { + lockOut.close(); + lockFile.delete(); + } finally { + PROCESS_LOCK.unlock(); + } + } + } + + private void save() throws IOException { if (modified) { log.debug("saving {} profiles", profiles.size()); @@ -119,9 +149,6 @@ public void close() { Files.move(tempFile.toPath(), PROFILES.toPath(), StandardCopyOption.REPLACE_EXISTING); } } - - lockOut.close(); - lockFile.delete(); } public List getProfiles() { diff --git a/runelite-client/src/test/java/net/runelite/client/config/ProfileManagerLockTest.java b/runelite-client/src/test/java/net/runelite/client/config/ProfileManagerLockTest.java new file mode 100644 index 00000000000..5af6772efba --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/config/ProfileManagerLockTest.java @@ -0,0 +1,80 @@ +package net.runelite.client.config; + +import com.google.gson.Gson; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ProfileManagerLockTest +{ + private ProfileManager profileManager; + private ExecutorService executor; + + @Before + public void setUp() + { + profileManager = new ProfileManager(new Gson()); + executor = Executors.newSingleThreadExecutor(); + } + + @After + public void tearDown() + { + executor.shutdownNow(); + } + + @Test + public void secondThreadWaitsForHeldLockInsteadOfThrowing() throws Exception + { + Future second; + try (ProfileManager.Lock ignored = profileManager.lock()) + { + second = executor.submit(() -> + { + try (ProfileManager.Lock lock = profileManager.lock()) + { + return lock.getProfiles() != null; + } + }); + + try + { + second.get(300, TimeUnit.MILLISECONDS); + fail("second lock acquired while the first was still held"); + } + catch (TimeoutException expected) + { + } + catch (ExecutionException e) + { + throw new AssertionError("second lock threw instead of waiting", e.getCause()); + } + assertFalse(second.isDone()); + } + + assertTrue(second.get(10, TimeUnit.SECONDS)); + } + + @Test + public void lockIsReacquirableAfterClose() + { + try (ProfileManager.Lock ignored = profileManager.lock()) + { + assertNotNull(ignored.getProfiles()); + } + try (ProfileManager.Lock lock = profileManager.lock()) + { + assertNotNull(lock.getProfiles()); + } + } +} From ad50ebe1037efb2e235cb8af063079a7e4a1748b Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:44:52 +0200 Subject: [PATCH 28/48] fix(bank): guard depositLootingBag wait against unloaded looting bag widget depositLootingBag waited with Rs2Widget.getWidget(15, 11).getChildren()[0] inside sleepUntil, which threw a NullPointerException when the looting bag view was not loaded and could throw on null or empty children. The wait now uses Rs2Bank.isLootingBagViewEmpty, which reads the widget on the client thread and treats a missing widget or missing children as emptied. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/items.md | 4 +- .../plugins/microbot/util/bank/Rs2Bank.java | 11 ++- .../util/bank/Rs2BankLootingBagViewTest.java | 72 +++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 1 - 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankLootingBagViewTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 630626609fb..5085953e032 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -276,9 +276,9 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin ## 16. Bank widgets can be missing or blank right after the bank opens -`BANK_ITEM_CONTAINER` can be null and the `BANK_ITEM_COUNT_TOP` text can be `""` while the bank interface is opening, closing or rebuilding. Never `assert` a widget non-null (asserts are disabled at runtime) and never `Integer.parseInt` widget text without handling blank or non-numeric input. `Rs2Bank.getBankItemCount()` returns 0 for unpopulated text, and `scrollBankToSlot` returns false before computing a scroll when the container is missing. +`BANK_ITEM_CONTAINER` can be null and the `BANK_ITEM_COUNT_TOP` text can be `""` while the bank interface is opening, closing or rebuilding. Never `assert` a widget non-null (asserts are disabled at runtime) and never `Integer.parseInt` widget text without handling blank or non-numeric input. `Rs2Bank.getBankItemCount()` returns 0 for unpopulated text, and `scrollBankToSlot` returns false before computing a scroll when the container is missing. The looting bag view (`15:11`) can be unloaded or have null/empty children once the bag closes, so `depositLootingBag` waits on `Rs2Bank.isLootingBagViewEmpty()`, which reads the widget on the client thread and treats a missing widget or children as emptied. -**Defensive check:** Run `Rs2BankUnloadedWidgetsTest`. +**Defensive check:** Run `Rs2BankUnloadedWidgetsTest` and `Rs2BankLootingBagViewTest`. ## Terminal supply stops need observed logout 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 f1c84a7d1e6..395580794cd 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 @@ -2925,7 +2925,7 @@ public static boolean depositLootingBag(){ sleepUntil(()-> Rs2Widget.getWidget(983046) != null, Rs2Random.between(2000,5000)); if(Rs2Widget.getWidget(983046) != null){ if(Rs2Widget.clickWidget(983046)){ - sleepUntil(()-> Rs2Widget.getWidget(15,11).getChildren()[0] == null, Rs2Random.between(500,1000)); + sleepUntil(Rs2Bank::isLootingBagViewEmpty, Rs2Random.between(500,1000)); if(Rs2Widget.clickWidget(983048)){ // close the bag return true; } @@ -2936,6 +2936,15 @@ public static boolean depositLootingBag(){ return false; } + static boolean isLootingBagViewEmpty() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget items = Microbot.getClient().getWidget(15, 11); + if (items == null) return true; + Widget[] children = items.getChildren(); + return children == null || children.length == 0 || children[0] == null; + }).orElse(true); + } + /** * Withdraw items from the lootTrackerPlugin diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankLootingBagViewTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankLootingBagViewTest.java new file mode 100644 index 00000000000..e86e1aaf7c3 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankLootingBagViewTest.java @@ -0,0 +1,72 @@ +package net.runelite.client.plugins.microbot.util.bank; + +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.Optional; +import java.util.concurrent.Callable; + +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 Rs2BankLootingBagViewTest { + + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() { + client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + private void lootingBagItems(Widget[] children) { + Widget items = mock(Widget.class); + when(items.getChildren()).thenReturn(children); + when(client.getWidget(15, 11)).thenReturn(items); + } + + @Test + public void emptyWhenWidgetIsNotLoaded() { + when(client.getWidget(15, 11)).thenReturn(null); + assertTrue(Rs2Bank.isLootingBagViewEmpty()); + } + + @Test + public void emptyWhenChildrenAreNullOrEmpty() { + lootingBagItems(null); + assertTrue(Rs2Bank.isLootingBagViewEmpty()); + lootingBagItems(new Widget[0]); + assertTrue(Rs2Bank.isLootingBagViewEmpty()); + lootingBagItems(new Widget[]{null}); + assertTrue(Rs2Bank.isLootingBagViewEmpty()); + } + + @Test + public void notEmptyWhileFirstChildIsPresent() { + lootingBagItems(new Widget[]{mock(Widget.class)}); + assertFalse(Rs2Bank.isLootingBagViewEmpty()); + } +} 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 9039ec94141..675d7c8458d 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 @@ -89,7 +89,6 @@ net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isLockedSlot(int): boolea net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isLockedSlot(int): boolean -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isWidgetLocked(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#itemBounds(Rs2ItemModel): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$depositLootingBag$49(): boolean -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$39(TileObject, BankLocation): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$40(Set, TileObject): AbstractMap$SimpleEntry -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$handleAmount$17(): boolean -> net.runelite.api.widgets.Widget#getText(): String From 618184ad6dd2b3018e80c8dbbafb8beef88f7077 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:23:50 +0200 Subject: [PATCH 29/48] fix(questhelper): reset Shadow of the Storm incantation on chant IncantationStep compared the packed widget component id against the bare inventory group id, so the "Chant" click never reset incantationPosition. A second chant then indexed past incantationOrder and threw ArrayIndexOutOfBoundsException on the client thread. Compare the group id instead and guard updateChoiceIfRequired against a missing order or an exhausted position. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shadowofthestorm/IncantationStep.java | 10 +- .../shadowofthestorm/IncantationStepTest.java | 112 ++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStepTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStep.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStep.java index d1813681982..f30c7cedc2b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStep.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStep.java @@ -34,6 +34,7 @@ import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetUtil; import net.runelite.client.eventbus.Subscribe; import java.util.Collections; @@ -127,7 +128,7 @@ public void onMenuOptionClicked(MenuOptionClicked event) return; } - if (widget.getId() != InterfaceID.INVENTORY) + if (WidgetUtil.componentToInterface(widget.getId()) != InterfaceID.INVENTORY) { return; } @@ -143,11 +144,16 @@ public void onMenuOptionClicked(MenuOptionClicked event) */ private void updateChoiceIfRequired() { - if (!shouldUpdateChoice()) + if (incantationOrder == null || !shouldUpdateChoice()) { return; } + if (incantationPosition >= incantationOrder.length) + { + incantationPosition = 0; + } + // As the incantations have all the same dialogs we want to reset the choices after each dialog // as we want only the correct one to be highlighted choices.resetChoices(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStepTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStepTest.java new file mode 100644 index 00000000000..50d6789a468 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/shadowofthestorm/IncantationStepTest.java @@ -0,0 +1,112 @@ +package net.runelite.client.plugins.microbot.questhelper.helpers.quests.shadowofthestorm; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import net.runelite.api.Client; +import net.runelite.api.MenuEntry; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import net.runelite.client.plugins.microbot.questhelper.steps.QuestStep; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IncantationStepTest +{ + private Client client; + private IncantationStep step; + + @Before + public void setUp() throws Exception + { + client = mock(Client.class); + step = new IncantationStep(mock(QuestHelper.class), false); + Field clientField = QuestStep.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(step, client); + } + + private void givenIncantationVarbits() + { + when(client.getVarbitValue(VarbitID.AGRITH_INCANTATION_1)).thenReturn(0); + when(client.getVarbitValue(VarbitID.AGRITH_INCANTATION_2)).thenReturn(1); + when(client.getVarbitValue(VarbitID.AGRITH_INCANTATION_3)).thenReturn(2); + when(client.getVarbitValue(VarbitID.AGRITH_INCANTATION_4)).thenReturn(3); + when(client.getVarbitValue(VarbitID.AGRITH_INCANTATION_5)).thenReturn(4); + } + + private void givenIncantationChatMenu() + { + Widget option = mock(Widget.class); + when(option.getText()).thenReturn("Nahudu"); + Widget menu = mock(Widget.class); + when(menu.getChildren()).thenReturn(new Widget[]{mock(Widget.class), mock(Widget.class), option}); + when(menu.getChild(2)).thenReturn(option); + when(client.getWidget(InterfaceID.CHATMENU, 1)).thenReturn(menu); + } + + private void updateChoice() throws Exception + { + Method method = IncantationStep.class.getDeclaredMethod("updateChoiceIfRequired"); + method.setAccessible(true); + method.invoke(step); + } + + private int position() throws Exception + { + Field field = IncantationStep.class.getDeclaredField("incantationPosition"); + field.setAccessible(true); + return field.getInt(step); + } + + @Test + public void chantOnInventorySigilResetsPosition() throws Exception + { + givenIncantationVarbits(); + givenIncantationChatMenu(); + step.updateHints(); + updateChoice(); + updateChoice(); + + Widget sigil = mock(Widget.class); + when(sigil.getId()).thenReturn(InterfaceID.Inventory.ITEMS); + MenuEntry entry = mock(MenuEntry.class); + when(entry.getWidget()).thenReturn(sigil); + when(entry.getOption()).thenReturn("Chant"); + step.onMenuOptionClicked(new MenuOptionClicked(entry)); + + assertEquals(0, position()); + } + + @Test + public void repeatedChantMenusDoNotRunPastIncantation() throws Exception + { + givenIncantationVarbits(); + givenIncantationChatMenu(); + step.updateHints(); + + for (int i = 0; i < 6; i++) + { + updateChoice(); + } + + assertEquals(1, position()); + } + + @Test + public void chantMenuBeforeOrderIsKnownIsIgnored() throws Exception + { + givenIncantationChatMenu(); + step.updateHints(); + + updateChoice(); + + assertEquals(0, position()); + } +} From b73d8c49e9c304d5320db8d55e70e76077a2d40e Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:59:07 +0200 Subject: [PATCH 30/48] fix(questhelper): refuse agent quest start before helpers are loaded /quest-helper/start could run before QuestHelperPlugin.startUp had instantiated its helpers, so QuestManager selected a helper with null config and quest. loadSteps then threw an NPE in PuzzleWrapperStep, and every later /quest-helper/status threw an NPE on getQuest(). The handler now returns 409 while the plugin is not active, and startQuestHelper refuses a helper whose quest or config has not been injected yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../handler/QuestHelperHandler.java | 9 +- .../questhelper/QuestHelperPlugin.java | 2 +- .../handler/QuestHelperHandlerStartTest.java | 108 ++++++++++++++++++ ...QuestHelperPluginStartQuestHelperTest.java | 82 +++++++++++++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStartTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPluginStartQuestHelperTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java index ac931cd7cfc..9dec06c412a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java @@ -137,9 +137,16 @@ private void handleStart(HttpExchange exchange) throws IOException return; } + PluginManager pluginManager = Microbot.getPluginManager(); + if (pluginManager == null || !pluginManager.isActive(plugin)) + { + sendJson(exchange, 409, errorResponse("Quest Helper plugin is not active")); + return; + } + if (!plugin.startQuestHelper(name)) { - sendJson(exchange, 404, errorResponse("Quest helper not found: " + name)); + sendJson(exchange, 404, errorResponse("Quest helper not found or not loaded: " + name)); return; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index 1a0d00be2d2..9b121ee49e1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -481,7 +481,7 @@ public List getPluginBankTagItemsForSections() public boolean startQuestHelper(String questName) { QuestHelper questHelper = QuestHelperQuest.getByName(questName); - if (questHelper == null) + if (questHelper == null || questHelper.getQuest() == null || questHelper.getConfig() == null) { return false; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStartTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStartTest.java new file mode 100644 index 00000000000..c5b4da83308 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandlerStartTest.java @@ -0,0 +1,108 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperConfig; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; +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.assertEquals; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class QuestHelperHandlerStartTest +{ + private static final Gson GSON = new GsonBuilder().create(); + + private MockedStatic microbot; + private QuestHelperPlugin plugin; + private PluginManager pluginManager; + private ConfigManager configManager; + private HttpExchange exchange; + private ByteArrayOutputStream responseBody; + + @Before + public void setUp() + { + plugin = mock(QuestHelperPlugin.class); + pluginManager = mock(PluginManager.class); + configManager = mock(ConfigManager.class); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(() -> Microbot.getPlugin(QuestHelperPlugin.class)).thenReturn(plugin); + microbot.when(Microbot::getPluginManager).thenReturn(pluginManager); + microbot.when(Microbot::getConfigManager).thenReturn(configManager); + + responseBody = new ByteArrayOutputStream(); + exchange = mock(HttpExchange.class); + when(exchange.getRequestMethod()).thenReturn("POST"); + when(exchange.getRequestURI()).thenReturn(URI.create("/quest-helper/start")); + when(exchange.getRequestHeaders()).thenReturn(new Headers()); + when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream( + "{\"name\":\"A Taste of Hope\"}".getBytes(StandardCharsets.UTF_8))); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + when(exchange.getResponseBody()).thenReturn(responseBody); + } + + @After + public void tearDown() + { + microbot.close(); + } + + @Test + public void startReturns409WhenPluginIsNotActive() throws Exception + { + when(pluginManager.isActive(plugin)).thenReturn(false); + + new QuestHelperHandler(GSON).handleRequest(exchange); + + verify(plugin, never()).startQuestHelper(anyString()); + verifyNoInteractions(configManager); + verify(exchange).sendResponseHeaders(eq(409), anyLong()); + assertEquals("{\"error\":\"Quest Helper plugin is not active\"}", responseBody.toString(StandardCharsets.UTF_8)); + } + + @Test + public void startReturns404WhenHelperIsNotLoaded() throws Exception + { + when(pluginManager.isActive(plugin)).thenReturn(true); + when(plugin.startQuestHelper("A Taste of Hope")).thenReturn(false); + + new QuestHelperHandler(GSON).handleRequest(exchange); + + verifyNoInteractions(configManager); + verify(exchange).sendResponseHeaders(eq(404), anyLong()); + } + + @Test + public void startTurnsHelperOnWhenPluginIsActive() throws Exception + { + when(pluginManager.isActive(plugin)).thenReturn(true); + when(plugin.startQuestHelper("A Taste of Hope")).thenReturn(true); + + new QuestHelperHandler(GSON).handleRequest(exchange); + + verify(configManager).setRSProfileConfiguration(QuestHelperConfig.QUEST_HELPER_GROUP, "TurnOn", true); + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPluginStartQuestHelperTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPluginStartQuestHelperTest.java new file mode 100644 index 00000000000..a8dbe9769a2 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPluginStartQuestHelperTest.java @@ -0,0 +1,82 @@ +package net.runelite.client.plugins.microbot.questhelper; + +import net.runelite.client.plugins.microbot.questhelper.managers.QuestManager; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import net.runelite.client.plugins.microbot.questhelper.questinfo.QuestHelperQuest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class QuestHelperPluginStartQuestHelperTest +{ + @Mock private QuestHelperConfig config; + @Mock private QuestManager questManager; + @InjectMocks private QuestHelperPlugin plugin; + + private QuestHelper helper; + private QuestHelperQuest originalQuest; + private QuestHelperConfig originalConfig; + + @Before + public void setUp() + { + MockitoAnnotations.initMocks(this); + helper = QuestHelperQuest.COOKS_ASSISTANT.getQuestHelper(); + originalQuest = helper.getQuest(); + originalConfig = helper.getConfig(); + } + + @After + public void tearDown() + { + helper.setQuest(originalQuest); + helper.setConfig(originalConfig); + } + + @Test + public void refusesHelperThatWasNeverInstantiated() + { + helper.setQuest(null); + helper.setConfig(null); + + assertFalse(plugin.startQuestHelper(QuestHelperQuest.COOKS_ASSISTANT.getName())); + verify(questManager, never()).startUpQuest(any(), anyBoolean()); + } + + @Test + public void refusesHelperWithoutConfig() + { + helper.setQuest(QuestHelperQuest.COOKS_ASSISTANT); + helper.setConfig(null); + + assertFalse(plugin.startQuestHelper(QuestHelperQuest.COOKS_ASSISTANT.getName())); + verify(questManager, never()).startUpQuest(any(), anyBoolean()); + } + + @Test + public void startsInstantiatedHelper() + { + helper.setQuest(QuestHelperQuest.COOKS_ASSISTANT); + helper.setConfig(config); + + assertTrue(plugin.startQuestHelper(QuestHelperQuest.COOKS_ASSISTANT.getName())); + verify(questManager).startUpQuest(helper, true); + } + + @Test + public void refusesUnknownQuestName() + { + assertFalse(plugin.startQuestHelper("Not A Real Quest")); + verify(questManager, never()).startUpQuest(any(), anyBoolean()); + } +} From cccc1c666c042d7736f3266913bf11eaa801c675 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:21:43 +0200 Subject: [PATCH 31/48] fix(discord): handle null player name in ban/break notifications Rs2Discord.createPlayerField and sendCustomNotification called playerName.isEmpty() without a null check. AutoLoginScript and BreakHandlerScript cached the player name by calling getName() twice, and the second client-thread lookup could return null. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../accountselector/AutoLoginScript.java | 5 ++-- .../breakhandler/BreakHandlerScript.java | 5 ++-- .../microbot/util/discord/Rs2Discord.java | 4 +-- .../discord/Rs2DiscordPlayerFieldTest.java | 26 +++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/discord/Rs2DiscordPlayerFieldTest.java 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 97c53740b27..f205d5f476d 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 @@ -480,8 +480,9 @@ private void updatePlayerNameCache() { // detect fresh login - update player name cache if (currentlyLoggedIn && !wasLoggedIn) { Rs2PlayerModel localPlayer = Rs2Player.getLocalPlayer(); - if (localPlayer != null && localPlayer.getName() != null) { - lastKnownPlayerName = localPlayer.getName(); + String playerName = localPlayer != null ? localPlayer.getName() : null; + if (playerName != null) { + lastKnownPlayerName = playerName; log.info("Updated cached player name: {}", lastKnownPlayerName); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java index 0068091d98c..97507950883 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java @@ -933,8 +933,9 @@ private void updatePlayerNameCache() { // detect fresh login - update player name cache if (currentlyLoggedIn && !wasLoggedIn) { Rs2PlayerModel localPlayer = Rs2Player.getLocalPlayer(); - if (localPlayer != null && localPlayer.getName() != null) { - lastKnownPlayerName = localPlayer.getName(); + String playerName = localPlayer != null ? localPlayer.getName() : null; + if (playerName != null) { + lastKnownPlayerName = playerName; log.info("Updated cached player name: {}", lastKnownPlayerName); } } 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 68c578b9618..87c4bc28040 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 @@ -181,7 +181,7 @@ public static boolean sendCustomNotification(String title, String description, i embed.setDescription(description); embed.setColor(color); embed.addField(new DiscordEmbed.Field("Player", - playerName.isEmpty() ? "Unknown" : playerName, true)); + playerName == null || playerName.isEmpty() ? "Unknown" : playerName, true)); embed.addField(new DiscordEmbed.Field("Time", timestamp, true)); embed.addField(new DiscordEmbed.Field("Source", source, true)); embed.setFooter(new DiscordEmbed.Footer("Microbot", null)); @@ -281,6 +281,6 @@ public static DiscordEmbed.Field createTimestampField() { * creates a player field */ public static DiscordEmbed.Field createPlayerField(String playerName) { - return createField("Player", playerName.isEmpty() ? "Unknown" : playerName, true); + return createField("Player", playerName == null || playerName.isEmpty() ? "Unknown" : playerName, true); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/discord/Rs2DiscordPlayerFieldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/discord/Rs2DiscordPlayerFieldTest.java new file mode 100644 index 00000000000..fb6d67fcb93 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/discord/Rs2DiscordPlayerFieldTest.java @@ -0,0 +1,26 @@ +package net.runelite.client.plugins.microbot.util.discord; + +import net.runelite.client.plugins.microbot.util.discord.models.DiscordEmbed; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class Rs2DiscordPlayerFieldTest { + + @Test + public void nullPlayerNameFallsBackToUnknown() { + DiscordEmbed.Field field = Rs2Discord.createPlayerField(null); + assertEquals("Player", field.getName()); + assertEquals("Unknown", field.getValue()); + } + + @Test + public void emptyPlayerNameFallsBackToUnknown() { + assertEquals("Unknown", Rs2Discord.createPlayerField("").getValue()); + } + + @Test + public void playerNameIsUsedWhenPresent() { + assertEquals("Zezima", Rs2Discord.createPlayerField("Zezima").getValue()); + } +} From 04e40d9ef495ec07e245cafc4d214a5df8d14276 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:41:53 +0200 Subject: [PATCH 32/48] fix(combat): guard null style label widget in setAttackStyle Rs2Combat.setAttackStyle dereferenced the label widget at id+3 to log its text without a null check, so AIOFighter's AttackStyleScript threw an NPE before clicking when the combat tab label was not loaded. Fetch the label once, fall back to the style name when it is missing, and still click. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/combat/Rs2Combat.java | 3 +- .../combat/Rs2CombatSetAttackStyleTest.java | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/combat/Rs2CombatSetAttackStyleTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/combat/Rs2Combat.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/combat/Rs2Combat.java index c833aeeaff3..d3422416cbe 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/combat/Rs2Combat.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/combat/Rs2Combat.java @@ -41,7 +41,8 @@ public static boolean setAttackStyle(WidgetInfo style) { // if (isSelected(widget.getId() + 1)) { // return true; // } - log("Setting attack style to " + Rs2Widget.getWidget(widget.getId() + 3).getText()); + Widget label = Rs2Widget.getWidget(widget.getId() + 3); + log("Setting attack style to " + (label != null ? label.getText() : style.name())); Rs2Widget.clickWidget(widget); return true; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/combat/Rs2CombatSetAttackStyleTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/combat/Rs2CombatSetAttackStyleTest.java new file mode 100644 index 00000000000..d33b4e4e114 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/combat/Rs2CombatSetAttackStyleTest.java @@ -0,0 +1,79 @@ +package net.runelite.client.plugins.microbot.util.combat; + +import net.runelite.api.Client; +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.util.widget.Rs2Widget; +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.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2CombatSetAttackStyleTest { + + private static final int STYLE_WIDGET_ID = 38862852; + + private MockedStatic microbot; + private MockedStatic widgets; + private Client client; + private Widget styleWidget; + + @Before + public void setUp() { + client = mock(Client.class); + styleWidget = mock(Widget.class); + when(styleWidget.getId()).thenReturn(STYLE_WIDGET_ID); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + widgets = Mockito.mockStatic(Rs2Widget.class); + } + + @After + public void tearDown() { + widgets.close(); + microbot.close(); + } + + @Test + public void clicksStyleWhenLabelWidgetIsMissing() { + when(client.getWidget(WidgetInfo.COMBAT_STYLE_ONE)).thenReturn(styleWidget); + widgets.when(() -> Rs2Widget.getWidget(STYLE_WIDGET_ID + 3)).thenReturn(null); + + assertTrue(Rs2Combat.setAttackStyle(WidgetInfo.COMBAT_STYLE_ONE)); + + microbot.verify(() -> Microbot.log("Setting attack style to COMBAT_STYLE_ONE")); + widgets.verify(() -> Rs2Widget.clickWidget(styleWidget)); + } + + @Test + public void logsLabelTextWhenLabelWidgetIsPresent() { + Widget label = mock(Widget.class); + when(label.getText()).thenReturn("Accurate"); + when(client.getWidget(WidgetInfo.COMBAT_STYLE_ONE)).thenReturn(styleWidget); + widgets.when(() -> Rs2Widget.getWidget(STYLE_WIDGET_ID + 3)).thenReturn(label); + + assertTrue(Rs2Combat.setAttackStyle(WidgetInfo.COMBAT_STYLE_ONE)); + + microbot.verify(() -> Microbot.log("Setting attack style to Accurate")); + widgets.verify(() -> Rs2Widget.clickWidget(styleWidget)); + } + + @Test + public void returnsFalseWithoutClickingWhenStyleWidgetIsMissing() { + when(client.getWidget(WidgetInfo.COMBAT_STYLE_ONE)).thenReturn(null); + + assertFalse(Rs2Combat.setAttackStyle(WidgetInfo.COMBAT_STYLE_ONE)); + + microbot.verify(() -> Microbot.log(anyString()), Mockito.never()); + widgets.verify(() -> Rs2Widget.clickWidget(styleWidget), Mockito.never()); + } +} From 9498fb13a943273d57b6d798267b136665d9dba9 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:57:51 +0200 Subject: [PATCH 33/48] fix(cannon): guard Rs2Cannon refill and repair against unknown cannon position CannonPlugin.getCannonPosition() is null until the cannon plugin records a placement and after it shuts down, so refill() and repair() threw an NPE on every AIOFighter tick with cannon enabled. Return false when it is null. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/gameobject/Rs2Cannon.java | 6 +- .../gameobject/Rs2CannonNullPositionTest.java | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java index fcaab7f99c7..6309eda832e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java @@ -27,7 +27,8 @@ public static boolean repair() { 3, 3, brokenCannon.getWorldLocation().getPlane() ); - if (!cannonLocation.toWorldPoint().equals(CannonPlugin.getCannonPosition().toWorldPoint())) return false; + WorldArea cannonPosition = CannonPlugin.getCannonPosition(); + if (cannonPosition == null || !cannonLocation.toWorldPoint().equals(cannonPosition.toWorldPoint())) return false; Microbot.status = "Repairing Cannon"; @@ -61,7 +62,8 @@ public static boolean refill(int cannonRefillAmount) { 3, 3, cannon.getWorldLocation().getPlane() ); - if (!cannonLocation.toWorldPoint().equals(CannonPlugin.getCannonPosition().toWorldPoint())) return false; + WorldArea cannonPosition = CannonPlugin.getCannonPosition(); + if (cannonPosition == null || !cannonLocation.toWorldPoint().equals(cannonPosition.toWorldPoint())) return false; Microbot.pauseAllScripts.compareAndSet(false, true); Rs2GameObject.interact(cannon, "Fire"); Rs2Player.waitForWalking(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java new file mode 100644 index 00000000000..2b0d76dec03 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java @@ -0,0 +1,74 @@ +package net.runelite.client.plugins.microbot.util.gameobject; + +import net.runelite.api.GameObject; +import net.runelite.api.TileObject; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.cannon.CannonPlugin; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +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.never; +import static org.mockito.Mockito.when; + +public class Rs2CannonNullPositionTest { + + private MockedStatic microbot; + private MockedStatic inventory; + private MockedStatic gameObject; + + @Before + public void setUp() { + assertNull(CannonPlugin.getCannonPosition()); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any(Callable.class))).thenReturn(Optional.of(0)); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + inventory = Mockito.mockStatic(Rs2Inventory.class); + inventory.when(() -> Rs2Inventory.hasItemAmount(eq("cannonball"), anyInt(), anyBoolean())).thenReturn(true); + + GameObject cannon = mock(GameObject.class); + when(cannon.getWorldLocation()).thenReturn(new WorldPoint(3200, 3200, 0)); + gameObject = Mockito.mockStatic(Rs2GameObject.class); + gameObject.when(() -> Rs2GameObject.findObject(any(Integer[].class))).thenReturn(cannon); + } + + @After + public void tearDown() { + gameObject.close(); + inventory.close(); + microbot.close(); + Microbot.pauseAllScripts.set(false); + } + + @Test + public void refillReturnsFalseWhenCannonPositionUnknown() { + assertFalse(Rs2Cannon.refill(15)); + assertFalse(Microbot.pauseAllScripts.get()); + gameObject.verify(() -> Rs2GameObject.interact(any(TileObject.class), anyString()), never()); + } + + @Test + public void repairReturnsFalseWhenCannonPositionUnknown() { + assertFalse(Rs2Cannon.repair()); + gameObject.verify(() -> Rs2GameObject.interact(any(TileObject.class), anyString()), never()); + } +} From ed5b058a58766685bccebedcb0036a7d1894d736 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:09:56 +0200 Subject: [PATCH 34/48] fix(cannon): resolve owned cannon from OWNEDMCANNON varp in Rs2Cannon Rs2Cannon.refill and repair read CannonPlugin's static cannonPosition, which is only set by the Cannon plugin's VarbitChanged handler and is null when that plugin is disabled or was enabled after login. Read the OWNEDMCANNON varp through the player state cache instead so refills and repairs work regardless of the Cannon plugin state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/gameobject/Rs2Cannon.java | 32 +++++--------- ...nTest.java => Rs2CannonOwnershipTest.java} | 44 +++++++++++++++---- .../client-thread-guardrail-baseline.txt | 3 +- 3 files changed, 48 insertions(+), 31 deletions(-) rename runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/{Rs2CannonNullPositionTest.java => Rs2CannonOwnershipTest.java} (62%) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java index 6309eda832e..6c31fdff4c0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2Cannon.java @@ -3,8 +3,8 @@ import net.runelite.api.ObjectID; import net.runelite.api.TileObject; import net.runelite.api.VarPlayer; -import net.runelite.api.coords.WorldArea; -import net.runelite.client.plugins.cannon.CannonPlugin; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarPlayerID; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.math.Rs2Random; @@ -20,15 +20,7 @@ public static boolean repair() { if (brokenCannon == null) return false; - // Create centered WorldArea (3x3 area with cannon at center) - WorldArea cannonLocation = new WorldArea( - brokenCannon.getWorldLocation().getX() - 1, - brokenCannon.getWorldLocation().getY() - 1, - 3, 3, - brokenCannon.getWorldLocation().getPlane() - ); - WorldArea cannonPosition = CannonPlugin.getCannonPosition(); - if (cannonPosition == null || !cannonLocation.toWorldPoint().equals(cannonPosition.toWorldPoint())) return false; + if (!isOwnedCannon(brokenCannon)) return false; Microbot.status = "Repairing Cannon"; @@ -55,15 +47,7 @@ public static boolean refill(int cannonRefillAmount) { TileObject cannon = Rs2GameObject.findObject(new Integer[]{ObjectID.DWARF_MULTICANNON, ObjectID.DWARF_MULTICANNON_43027}); if (cannon == null) return false; - // Create centered WorldArea (3x3 area with cannon at center) - WorldArea cannonLocation = new WorldArea( - cannon.getWorldLocation().getX() - 1, - cannon.getWorldLocation().getY() - 1, - 3, 3, - cannon.getWorldLocation().getPlane() - ); - WorldArea cannonPosition = CannonPlugin.getCannonPosition(); - if (cannonPosition == null || !cannonLocation.toWorldPoint().equals(cannonPosition.toWorldPoint())) return false; + if (!isOwnedCannon(cannon)) return false; Microbot.pauseAllScripts.compareAndSet(false, true); Rs2GameObject.interact(cannon, "Fire"); Rs2Player.waitForWalking(); @@ -74,4 +58,12 @@ public static boolean refill(int cannonRefillAmount) { return true; } + private static boolean isOwnedCannon(TileObject cannon) { + int ownedCoord = Microbot.getVarbitPlayerValue(VarPlayerID.OWNEDMCANNON); + if (ownedCoord <= 0) return false; + WorldPoint location = cannon.getWorldLocation(); + WorldPoint southWest = new WorldPoint(location.getX() - 1, location.getY() - 1, location.getPlane()); + return southWest.equals(WorldPoint.fromCoord(ownedCoord)); + } + } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonOwnershipTest.java similarity index 62% rename from runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java rename to runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonOwnershipTest.java index 2b0d76dec03..ca42bdd8133 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonNullPositionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2CannonOwnershipTest.java @@ -3,8 +3,8 @@ import net.runelite.api.GameObject; import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarPlayerID; import net.runelite.client.callback.ClientThread; -import net.runelite.client.plugins.cannon.CannonPlugin; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import org.junit.After; @@ -17,7 +17,7 @@ import java.util.concurrent.Callable; 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.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; @@ -27,16 +27,17 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; -public class Rs2CannonNullPositionTest { +public class Rs2CannonOwnershipTest { + + private static final WorldPoint CANNON_CENTER = new WorldPoint(3200, 3200, 0); private MockedStatic microbot; private MockedStatic inventory; private MockedStatic gameObject; + private GameObject cannon; @Before public void setUp() { - assertNull(CannonPlugin.getCannonPosition()); - ClientThread clientThread = mock(ClientThread.class); when(clientThread.runOnClientThreadOptional(any(Callable.class))).thenReturn(Optional.of(0)); microbot = Mockito.mockStatic(Microbot.class); @@ -45,8 +46,8 @@ public void setUp() { inventory = Mockito.mockStatic(Rs2Inventory.class); inventory.when(() -> Rs2Inventory.hasItemAmount(eq("cannonball"), anyInt(), anyBoolean())).thenReturn(true); - GameObject cannon = mock(GameObject.class); - when(cannon.getWorldLocation()).thenReturn(new WorldPoint(3200, 3200, 0)); + cannon = mock(GameObject.class); + when(cannon.getWorldLocation()).thenReturn(CANNON_CENTER); gameObject = Mockito.mockStatic(Rs2GameObject.class); gameObject.when(() -> Rs2GameObject.findObject(any(Integer[].class))).thenReturn(cannon); } @@ -59,16 +60,41 @@ public void tearDown() { Microbot.pauseAllScripts.set(false); } + private void ownCannonAt(int coord) { + microbot.when(() -> Microbot.getVarbitPlayerValue(VarPlayerID.OWNEDMCANNON)).thenReturn(coord); + } + + private static int coordOf(int x, int y, int plane) { + return (plane << 28) | (x << 14) | y; + } + @Test - public void refillReturnsFalseWhenCannonPositionUnknown() { + public void refillReturnsFalseWhenNoCannonOwned() { + ownCannonAt(0); assertFalse(Rs2Cannon.refill(15)); assertFalse(Microbot.pauseAllScripts.get()); gameObject.verify(() -> Rs2GameObject.interact(any(TileObject.class), anyString()), never()); } @Test - public void repairReturnsFalseWhenCannonPositionUnknown() { + public void refillReturnsFalseWhenOwnedCannonIsElsewhere() { + ownCannonAt(coordOf(3300, 3300, 0)); + assertFalse(Rs2Cannon.refill(15)); + assertFalse(Microbot.pauseAllScripts.get()); + gameObject.verify(() -> Rs2GameObject.interact(any(TileObject.class), anyString()), never()); + } + + @Test + public void repairReturnsFalseWhenNoCannonOwned() { + ownCannonAt(-1); assertFalse(Rs2Cannon.repair()); gameObject.verify(() -> Rs2GameObject.interact(any(TileObject.class), anyString()), never()); } + + @Test + public void repairInteractsWithOwnedCannonWithoutCannonPlugin() { + ownCannonAt(coordOf(CANNON_CENTER.getX() - 1, CANNON_CENTER.getY() - 1, CANNON_CENTER.getPlane())); + assertTrue(Rs2Cannon.repair()); + gameObject.verify(() -> Rs2GameObject.interact((TileObject) cannon, "Repair")); + } } 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 675d7c8458d..80f1e2a97d4 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 @@ -156,8 +156,7 @@ net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#storeEquipmentI net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#storeEquipmentItemsInMemory(ItemContainerChanged): void -> net.runelite.api.ItemContainer#getItems(): Item[] net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#storeEquipmentItemsInMemory(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getContainerId(): int net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#storeEquipmentItemsInMemory(ItemContainerChanged): void -> net.runelite.api.events.ItemContainerChanged#getItemContainer(): ItemContainer -net.runelite.client.plugins.microbot.util.gameobject.Rs2Cannon#refill(int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.gameobject.Rs2Cannon#repair(): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.gameobject.Rs2Cannon#isOwnedCannon(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#canWalkTo(TileObject, int): boolean -> net.runelite.api.Client#isInInstancedRegion(): boolean net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#canWalkTo(TileObject, int): boolean -> net.runelite.api.GameObject#getSceneMinLocation(): Point net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#canWalkTo(TileObject, int): boolean -> net.runelite.api.GameObject#sizeX(): int From 51df0b48940059e4bf6715852617f031ba49a77f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:31:56 +0200 Subject: [PATCH 35/48] fix(slayer): return null from getSlayerTask when DB field lookup is empty Microbot.getDBTableField returns an empty array when the client-thread call times out, is interrupted or fails, or when the row has no value for the column. getSlayerTask indexed [0] on that result in both the boss and regular branches, throwing ArrayIndexOutOfBoundsException from SlayerScript. Check the array length and return null, the documented no-task value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../util/skills/slayer/Rs2Slayer.java | 18 +++- .../slayer/Rs2SlayerGetSlayerTaskTest.java | 95 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2SlayerGetSlayerTaskTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java index 0f154f58070..ca2dfdb0755 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java @@ -81,9 +81,14 @@ public static String getSlayerTask() { { return null; } - taskDBRow = (Integer) Microbot.getDBTableField( + Object[] bossTask = Microbot.getDBTableField( bossRows.get(0), - DBTableID.SlayerTaskSublist.COL_TASK, 0)[0]; + DBTableID.SlayerTaskSublist.COL_TASK, 0); + if (bossTask == null || bossTask.length == 0) + { + return null; + } + taskDBRow = (Integer) bossTask[0]; } else { @@ -99,10 +104,15 @@ public static String getSlayerTask() { taskDBRow = taskRows.get(0); } - return (String) Microbot.getDBTableField( + Object[] taskName = Microbot.getDBTableField( taskDBRow, DBTableID.SlayerTask.COL_NAME_UPPERCASE, - 0)[0]; + 0); + if (taskName == null || taskName.length == 0) + { + return null; + } + return (String) taskName[0]; } /** diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2SlayerGetSlayerTaskTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2SlayerGetSlayerTaskTest.java new file mode 100644 index 00000000000..071d686423c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2SlayerGetSlayerTaskTest.java @@ -0,0 +1,95 @@ +package net.runelite.client.plugins.microbot.util.skills.slayer; + +import java.util.Collections; +import net.runelite.api.gameval.DBTableID; +import net.runelite.api.gameval.VarPlayerID; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class Rs2SlayerGetSlayerTaskTest { + private static final int BOSS_TASK_ID = 98; + private static final int REGULAR_TASK_ID = 7; + private static final int SUBLIST_ROW = 4000; + private static final int TASK_ROW = 5000; + + private MockedStatic microbot; + + @Before + public void setUp() { + microbot = Mockito.mockStatic(Microbot.class); + } + + @After + public void tearDown() { + microbot.close(); + } + + private void taskId(int id) { + microbot.when(() -> Microbot.getVarbitPlayerValue(VarPlayerID.SLAYER_TARGET)).thenReturn(id); + } + + @Test + public void regularTaskWithEmptyNameFieldReturnsNull() { + taskId(REGULAR_TASK_ID); + microbot.when(() -> Microbot.getDBRowsByValue(DBTableID.SlayerTask.ID, DBTableID.SlayerTask.COL_ID, 0, REGULAR_TASK_ID)) + .thenReturn(Collections.singletonList(TASK_ROW)); + microbot.when(() -> Microbot.getDBTableField(TASK_ROW, DBTableID.SlayerTask.COL_NAME_UPPERCASE, 0)) + .thenReturn(new Object[]{}); + + assertNull(Rs2Slayer.getSlayerTask()); + } + + @Test + public void bossTaskWithEmptySublistFieldReturnsNull() { + taskId(BOSS_TASK_ID); + microbot.when(() -> Microbot.getDBRowsByValue(DBTableID.SlayerTaskSublist.ID, DBTableID.SlayerTaskSublist.COL_SUBTABLE_ID, 0, BOSS_TASK_ID)) + .thenReturn(Collections.singletonList(SUBLIST_ROW)); + microbot.when(() -> Microbot.getDBTableField(SUBLIST_ROW, DBTableID.SlayerTaskSublist.COL_TASK, 0)) + .thenReturn(new Object[]{}); + + assertNull(Rs2Slayer.getSlayerTask()); + } + + @Test + public void bossTaskWithEmptyNameFieldReturnsNull() { + taskId(BOSS_TASK_ID); + microbot.when(() -> Microbot.getDBRowsByValue(DBTableID.SlayerTaskSublist.ID, DBTableID.SlayerTaskSublist.COL_SUBTABLE_ID, 0, BOSS_TASK_ID)) + .thenReturn(Collections.singletonList(SUBLIST_ROW)); + microbot.when(() -> Microbot.getDBTableField(SUBLIST_ROW, DBTableID.SlayerTaskSublist.COL_TASK, 0)) + .thenReturn(new Object[]{TASK_ROW}); + microbot.when(() -> Microbot.getDBTableField(TASK_ROW, DBTableID.SlayerTask.COL_NAME_UPPERCASE, 0)) + .thenReturn(new Object[]{}); + + assertNull(Rs2Slayer.getSlayerTask()); + } + + @Test + public void regularTaskReturnsName() { + taskId(REGULAR_TASK_ID); + microbot.when(() -> Microbot.getDBRowsByValue(DBTableID.SlayerTask.ID, DBTableID.SlayerTask.COL_ID, 0, REGULAR_TASK_ID)) + .thenReturn(Collections.singletonList(TASK_ROW)); + microbot.when(() -> Microbot.getDBTableField(TASK_ROW, DBTableID.SlayerTask.COL_NAME_UPPERCASE, 0)) + .thenReturn(new Object[]{"Goblins"}); + + assertEquals("Goblins", Rs2Slayer.getSlayerTask()); + } + + @Test + public void bossTaskReturnsName() { + taskId(BOSS_TASK_ID); + microbot.when(() -> Microbot.getDBRowsByValue(DBTableID.SlayerTaskSublist.ID, DBTableID.SlayerTaskSublist.COL_SUBTABLE_ID, 0, BOSS_TASK_ID)) + .thenReturn(Collections.singletonList(SUBLIST_ROW)); + microbot.when(() -> Microbot.getDBTableField(SUBLIST_ROW, DBTableID.SlayerTaskSublist.COL_TASK, 0)) + .thenReturn(new Object[]{TASK_ROW}); + microbot.when(() -> Microbot.getDBTableField(TASK_ROW, DBTableID.SlayerTask.COL_NAME_UPPERCASE, 0)) + .thenReturn(new Object[]{"Cerberus"}); + + assertEquals("Cerberus", Rs2Slayer.getSlayerTask()); + } +} From 19b5c3387cffdff6f9484adfce3aed1e16c634f1 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:46:01 +0200 Subject: [PATCH 36/48] fix(coords): treat empty path as unreachable in Rs2WorldPoint.distanceToPath pathTo returns an empty list when Rs2Tile.pathTo yields no checkpoints or its first tile is null, so distanceToPath called get(-1) and threw IndexOutOfBoundsException, aborting AIOFighter's target sort. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/util/coords/Rs2WorldPoint.java | 2 +- .../coords/Rs2WorldPointEmptyPathTest.java | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointEmptyPathTest.java 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 625742cc67b..756af0f6c08 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 @@ -103,7 +103,7 @@ public int distanceToPath(WorldPoint other) return Integer.MAX_VALUE; } List checkpointWPs = this.pathTo(other); - if (checkpointWPs == null) + if (checkpointWPs == null || checkpointWPs.isEmpty()) { // No path found return Integer.MAX_VALUE; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointEmptyPathTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointEmptyPathTest.java new file mode 100644 index 00000000000..15e5cdc755b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointEmptyPathTest.java @@ -0,0 +1,44 @@ +package net.runelite.client.plugins.microbot.util.coords; + +import java.util.Collections; +import java.util.List; +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class Rs2WorldPointEmptyPathTest +{ + private static final WorldPoint SOURCE = new WorldPoint(3200, 3200, 0); + private static final WorldPoint TARGET = new WorldPoint(3205, 3200, 0); + + @Test + public void distanceToPathIsUnreachableWhenPathIsEmpty() + { + Rs2WorldPoint point = new Rs2WorldPoint(SOURCE) + { + @Override + public List pathTo(WorldPoint other) + { + return Collections.emptyList(); + } + }; + + assertEquals(Integer.MAX_VALUE, point.distanceToPath(TARGET)); + } + + @Test + public void distanceToPathSumsCheckpointsWhenPathReachesTarget() + { + Rs2WorldPoint point = new Rs2WorldPoint(SOURCE) + { + @Override + public List pathTo(WorldPoint other) + { + return List.of(new WorldPoint(3203, 3200, 0), TARGET); + } + }; + + assertEquals(5, point.distanceToPath(TARGET)); + } +} From 7a9922773d00c2aef8a356ab47a9e7e6357f5a0f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:58:47 +0200 Subject: [PATCH 37/48] fix(camera): keep angleToTile null-safe without a local player Route every Rs2Camera.angleToTile overload through one helper that returns the current camera facing when the local player world location is null (login, loading, hop), so turnTo and centerTileOnScreen become no-ops instead of throwing a NullPointerException on the client thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 2 +- .../microbot/util/camera/Rs2Camera.java | 38 +++------ .../util/camera/Rs2CameraAngleToTileTest.java | 85 +++++++++++++++++++ 3 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraAngleToTileTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index b4a4ec3b824..98ec3f5688d 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`) and its `getAnimation` (returns -1), 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, the callers of those accessors (`Rs2Camera.angleToTile` returns the current facing so a turn is a no-op), 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/util/camera/Rs2Camera.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java index 13de2f268f0..bba45e62a2e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java @@ -26,39 +26,29 @@ public class Rs2Camera { private static final int LEGACY_MAX_PITCH = 383; public static int angleToTile(Actor t) { - return Microbot.getClientThread().invoke(() -> { - var playerLocation = new Rs2PlayerModel().getWorldLocation(); - int angle = (int) Math.toDegrees(Math.atan2(t.getWorldLocation().getY() - playerLocation.getY(), - t.getWorldLocation().getX() - playerLocation.getX())); - return angle >= 0 ? angle : 360 + angle; - }); + return Microbot.getClientThread().invoke(() -> angleFromPlayer(t.getWorldLocation().getX(), t.getWorldLocation().getY())); } public static int angleToTile(TileObject t) { - return Microbot.getClientThread().invoke(() -> { - var playerLocation = new Rs2PlayerModel().getWorldLocation(); - int angle = (int) Math.toDegrees(Math.atan2(t.getWorldLocation().getY() - playerLocation.getY(), - t.getWorldLocation().getX() - playerLocation.getX())); - return angle >= 0 ? angle : 360 + angle; - }); + return Microbot.getClientThread().invoke(() -> angleFromPlayer(t.getWorldLocation().getX(), t.getWorldLocation().getY())); } public static int angleToTile(LocalPoint localPoint) { - return Microbot.getClientThread().invoke(() -> { - var playerLocation = new Rs2PlayerModel().getWorldLocation(); - int angle = (int) Math.toDegrees(Math.atan2(localPoint.getY() - playerLocation.getY(), - localPoint.getX() - playerLocation.getX())); - return angle >= 0 ? angle : 360 + angle; - }); + return Microbot.getClientThread().invoke(() -> angleFromPlayer(localPoint.getX(), localPoint.getY())); } public static int angleToTile(WorldPoint worldPoint) { - return Microbot.getClientThread().invoke(() -> { - var playerLocation = new Rs2PlayerModel().getWorldLocation(); - int angle = (int) Math.toDegrees(Math.atan2(worldPoint.getY() - playerLocation.getY(), - worldPoint.getX() - playerLocation.getX())); - return angle >= 0 ? angle : 360 + angle; - }); + return Microbot.getClientThread().invoke(() -> angleFromPlayer(worldPoint.getX(), worldPoint.getY())); + } + + private static int angleFromPlayer(int targetX, int targetY) { + var playerLocation = new Rs2PlayerModel().getWorldLocation(); + if (playerLocation == null) { + int facing = (int) (fromClientAngleUnits(readYawDirectly()) * (360.0 / LEGACY_ANGLE_UNITS)); + return (facing + 90) % 360; + } + int angle = (int) Math.toDegrees(Math.atan2(targetY - playerLocation.getY(), targetX - playerLocation.getX())); + return angle >= 0 ? angle : 360 + angle; } public static void turnTo(final Actor actor) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraAngleToTileTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraAngleToTileTest.java new file mode 100644 index 00000000000..bc0ca4e2dbe --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraAngleToTileTest.java @@ -0,0 +1,85 @@ +package net.runelite.client.plugins.microbot.util.camera; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +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.Optional; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +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 Rs2CameraAngleToTileTest { + + private static final int WEST_CLIENT_YAW = 512 * 8; + + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() { + client = mock(Client.class); + when(client.isClientThread()).thenReturn(true); + when(client.getCameraYaw()).thenReturn(WEST_CLIENT_YAW); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.invoke(any(Supplier.class))).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.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 worldPointWithoutLocalPlayerKeepsCurrentFacing() { + when(client.getLocalPlayer()).thenReturn(null); + + int angle = Rs2Camera.angleToTile(new WorldPoint(3200, 3210, 0)); + + assertEquals(180, angle); + assertEquals(0, Rs2Camera.getAngleTo(angle - 90)); + } + + @Test + public void localPointWithoutLocalPlayerKeepsCurrentFacing() { + when(client.getLocalPlayer()).thenReturn(null); + + assertEquals(180, Rs2Camera.angleToTile(new LocalPoint(6400, 6400))); + } + + @Test + public void worldPointWithLocalPlayerUsesBearingFromPlayer() { + WorldView worldView = mock(WorldView.class); + when(worldView.isTopLevel()).thenReturn(true); + Player player = mock(Player.class); + when(player.getWorldView()).thenReturn(worldView); + when(player.getWorldLocation()).thenReturn(new WorldPoint(3200, 3200, 0)); + when(client.getLocalPlayer()).thenReturn(player); + + assertEquals(90, Rs2Camera.angleToTile(new WorldPoint(3200, 3210, 0))); + assertEquals(0, Rs2Camera.angleToTile(new WorldPoint(3210, 3200, 0))); + assertEquals(270, Rs2Camera.angleToTile(new WorldPoint(3200, 3190, 0))); + } +} From fb26b19e90be0fa8108dc603c9ae6309d32216d2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:42:04 +0200 Subject: [PATCH 38/48] fix(blocking-events): attach throwable when logging BlockingEvent failures The loop, validate and execute catch blocks in BlockingEventManager logged through the format overload of Microbot.log, which stringified the exception into the message and called log.error without a throwable. Error telemetry therefore received these failures (e.g. KittenAttentionEvent RuntimeException) with no exception type or stack. Validate/execute now use the Exception overload, which attaches the throwable and demotes interrupt chains to DEBUG; the Throwable loop catch logs through slf4j with the throwable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../microbot/BlockingEventManager.java | 12 +- .../BlockingEventManagerLoggingTest.java | 135 ++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/BlockingEventManagerLoggingTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java index d7e19d3e1ad..8e8b7a2b305 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java @@ -111,7 +111,7 @@ private void loopOnce() { try { validateAndEnqueueWithBackoff(); } catch (Throwable t) { - Microbot.log(Level.ERROR, "BlockingEvent loop error: %s", t); + log.error("BlockingEvent loop error", t); } finally { // re-schedule using the latest currentDelay (volatile) loopFuture = scheduler.schedule(this::loopOnce, currentDelay, TimeUnit.MILLISECONDS); @@ -138,10 +138,7 @@ private void validateAndEnqueueWithBackoff() { } } } catch (Exception ex) { - Microbot.log(Level.ERROR, - "Error validating BlockingEvent (%s): %s", - event.getName(), - ex); + Microbot.log("Error validating BlockingEvent (" + event.getName() + ")", Level.ERROR, ex); } } } @@ -194,10 +191,7 @@ public boolean shouldBlockAndProcess() } catch (Exception ex) { - Microbot.log(Level.ERROR, - "Error executing BlockingEvent (%s): %s", - event.getName(), - ex); + Microbot.log("Error executing BlockingEvent (" + event.getName() + ")", Level.ERROR, ex); } finally { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/BlockingEventManagerLoggingTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/BlockingEventManagerLoggingTest.java new file mode 100644 index 00000000000..191411b4345 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/BlockingEventManagerLoggingTest.java @@ -0,0 +1,135 @@ +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.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +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.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class BlockingEventManagerLoggingTest +{ + private Logger logger; + private Level previousLevel; + private CountDownLatch logged; + private ListAppender appender; + private BlockingEventManager manager; + + @Before + public void setUp() + { + logger = (Logger) LoggerFactory.getLogger(Microbot.class); + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + logged = new CountDownLatch(1); + appender = new ListAppender() + { + @Override + protected void append(ILoggingEvent event) + { + super.append(event); + logged.countDown(); + } + }; + appender.start(); + logger.addAppender(appender); + + manager = new BlockingEventManager(); + for (BlockingEvent event : new ArrayList<>(manager.getEvents())) + { + manager.remove(event); + } + } + + @After + public void tearDown() + { + manager.shutdown(); + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + + @Test + public void executeFailureIsLoggedWithThrowable() throws Exception + { + manager.add(new FailingEvent(false, new RuntimeException("Timed out waiting for client thread"))); + + validateAndEnqueue(); + assertTrue(manager.shouldBlockAndProcess()); + assertTrue(logged.await(5, TimeUnit.SECONDS)); + + ILoggingEvent event = appender.list.get(0); + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("Error executing BlockingEvent (FailingEvent)", event.getFormattedMessage()); + IThrowableProxy throwable = event.getThrowableProxy(); + assertNotNull(throwable); + assertEquals(RuntimeException.class.getName(), throwable.getClassName()); + assertTrue(throwable.getStackTraceElementProxyArray().length > 0); + } + + @Test + public void validateFailureIsLoggedWithThrowable() throws Exception + { + manager.add(new FailingEvent(true, new IllegalStateException("boom"))); + + validateAndEnqueue(); + assertTrue(logged.await(5, TimeUnit.SECONDS)); + + ILoggingEvent event = appender.list.get(0); + assertEquals(Level.ERROR, event.getLevel()); + assertEquals("Error validating BlockingEvent (FailingEvent)", event.getFormattedMessage()); + assertNotNull(event.getThrowableProxy()); + assertEquals(IllegalStateException.class.getName(), event.getThrowableProxy().getClassName()); + } + + private void validateAndEnqueue() throws Exception + { + Method method = BlockingEventManager.class.getDeclaredMethod("validateAndEnqueueWithBackoff"); + method.setAccessible(true); + method.invoke(manager); + } + + private static final class FailingEvent implements BlockingEvent + { + private final boolean failValidate; + private final RuntimeException failure; + + private FailingEvent(boolean failValidate, RuntimeException failure) + { + this.failValidate = failValidate; + this.failure = failure; + } + + @Override + public boolean validate() + { + if (failValidate) + { + throw failure; + } + return true; + } + + @Override + public boolean execute() + { + throw failure; + } + + @Override + public BlockingEventPriority priority() + { + return BlockingEventPriority.NORMAL; + } + } +} From 1f92114a96928e960ca07af008a5a7d3d56001e9 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:05:11 +0200 Subject: [PATCH 39/48] fix(npc): keep Rs2NpcModel distance helpers null-safe without a local player getDistanceFromPlayer and isWithinDistanceFromPlayer dereferenced Client.getLocalPlayer() and the NPC LocalPoint inside the client-thread lambda, so calls made on the login screen, while hopping or during loading threw an NPE that ClientThread logged at ERROR. Return Integer.MAX_VALUE / false when the player, its location or the NPC location is missing, in both the api and legacy util Rs2NpcModel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 4 +- .../microbot/api/npc/models/Rs2NpcModel.java | 18 ++- .../microbot/util/npc/Rs2NpcModel.java | 20 ++- .../Rs2NpcModelDistanceFromPlayerTest.java | 120 ++++++++++++++++++ 4 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelDistanceFromPlayerTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 98ec3f5688d..ac3b683f486 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -41,9 +41,9 @@ 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`) and its `getAnimation` (returns -1), which `new Rs2PlayerModel()` reaches with a null actor, the callers of those accessors (`Rs2Camera.angleToTile` returns the current facing so a turn is a no-op), 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, the callers of those accessors (`Rs2Camera.angleToTile` returns the current facing so a turn is a no-op), both `Rs2NpcModel` classes' `getDistanceFromPlayer` (returns `Integer.MAX_VALUE`) and `isWithinDistanceFromPlayer` (returns `false`), 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`. +**Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest`, `Rs2PlayerModelNullLocalPlayerTest` and `Rs2NpcModelDistanceFromPlayerTest`. **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/api/npc/models/Rs2NpcModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java index 0523e9a8c1f..a16d457e186 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModel.java @@ -56,8 +56,13 @@ public int getIndex() */ public boolean isWithinDistanceFromPlayer(int maxDistance) { return Microbot.getClientThread().runOnClientThreadOptional(() -> { - return this.getLocalLocation().distanceTo( - Microbot.getClient().getLocalPlayer().getLocalLocation()) <= maxDistance; + Player localPlayer = Microbot.getClient().getLocalPlayer(); + LocalPoint playerLocation = localPlayer == null ? null : localPlayer.getLocalLocation(); + LocalPoint npcLocation = this.getLocalLocation(); + if (playerLocation == null || npcLocation == null) { + return false; + } + return npcLocation.distanceTo(playerLocation) <= maxDistance; }).orElse(false); } @@ -69,8 +74,13 @@ public boolean isWithinDistanceFromPlayer(int maxDistance) { */ public int getDistanceFromPlayer() { return Microbot.getClientThread().runOnClientThreadOptional(() -> { - return this.getLocalLocation().distanceTo( - Microbot.getClient().getLocalPlayer().getLocalLocation()); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + LocalPoint playerLocation = localPlayer == null ? null : localPlayer.getLocalLocation(); + LocalPoint npcLocation = this.getLocalLocation(); + if (playerLocation == null || npcLocation == null) { + return Integer.MAX_VALUE; + } + return npcLocation.distanceTo(playerLocation); }).orElse(Integer.MAX_VALUE); } 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 9f3a1cf7b9e..250371037ae 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 @@ -6,6 +6,8 @@ import net.runelite.api.NPC; import net.runelite.api.NPCComposition; import net.runelite.api.NpcOverrides; +import net.runelite.api.Player; +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.IEntity; @@ -88,8 +90,13 @@ public NPCComposition getComposition() */ public boolean isWithinDistanceFromPlayer(int maxDistance) { return Microbot.getClientThread().runOnClientThreadOptional(() -> { - return this.getLocalLocation().distanceTo( - Microbot.getClient().getLocalPlayer().getLocalLocation()) <= maxDistance; + Player localPlayer = Microbot.getClient().getLocalPlayer(); + LocalPoint playerLocation = localPlayer == null ? null : localPlayer.getLocalLocation(); + LocalPoint npcLocation = this.getLocalLocation(); + if (playerLocation == null || npcLocation == null) { + return false; + } + return npcLocation.distanceTo(playerLocation) <= maxDistance; }).orElse(false); } @@ -101,8 +108,13 @@ public boolean isWithinDistanceFromPlayer(int maxDistance) { */ public int getDistanceFromPlayer() { return Microbot.getClientThread().runOnClientThreadOptional(() -> { - return this.getLocalLocation().distanceTo( - Microbot.getClient().getLocalPlayer().getLocalLocation()); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + LocalPoint playerLocation = localPlayer == null ? null : localPlayer.getLocalLocation(); + LocalPoint npcLocation = this.getLocalLocation(); + if (playerLocation == null || npcLocation == null) { + return Integer.MAX_VALUE; + } + return npcLocation.distanceTo(playerLocation); }).orElse(Integer.MAX_VALUE); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelDistanceFromPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelDistanceFromPlayerTest.java new file mode 100644 index 00000000000..51497265143 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/npc/models/Rs2NpcModelDistanceFromPlayerTest.java @@ -0,0 +1,120 @@ +package net.runelite.client.plugins.microbot.api.npc.models; + +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +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.Optional; +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2NpcModelDistanceFromPlayerTest { + + private MockedStatic microbot; + private Client client; + private NPC npc; + + @Before + public void setUp() { + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + when(clientThread.invoke(any(Supplier.class))).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + client = mock(Client.class); + npc = mock(NPC.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + microbot.when(Microbot::getClient).thenReturn(client); + } + + @After + public void tearDown() { + microbot.close(); + } + + private void givenLocalPlayerAt(LocalPoint location) { + Player player = mock(Player.class); + when(player.getLocalLocation()).thenReturn(location); + when(client.getLocalPlayer()).thenReturn(player); + } + + @Test + public void nullLocalPlayerIsUnreachable() { + when(client.getLocalPlayer()).thenReturn(null); + when(npc.getLocalLocation()).thenReturn(new LocalPoint(128, 128, -1)); + + Rs2NpcModel model = new Rs2NpcModel(npc); + assertEquals(Integer.MAX_VALUE, model.getDistanceFromPlayer()); + assertFalse(model.isWithinDistanceFromPlayer(Integer.MAX_VALUE)); + } + + @Test + public void nullLocalPlayerLocationIsUnreachable() { + givenLocalPlayerAt(null); + when(npc.getLocalLocation()).thenReturn(new LocalPoint(128, 128, -1)); + + Rs2NpcModel model = new Rs2NpcModel(npc); + assertEquals(Integer.MAX_VALUE, model.getDistanceFromPlayer()); + assertFalse(model.isWithinDistanceFromPlayer(Integer.MAX_VALUE)); + } + + @Test + public void nullNpcLocationIsUnreachable() { + givenLocalPlayerAt(new LocalPoint(128, 128, -1)); + when(npc.getLocalLocation()).thenReturn(null); + + Rs2NpcModel model = new Rs2NpcModel(npc); + assertEquals(Integer.MAX_VALUE, model.getDistanceFromPlayer()); + assertFalse(model.isWithinDistanceFromPlayer(Integer.MAX_VALUE)); + } + + @Test + public void measuresDistanceWhenBothLocationsPresent() { + givenLocalPlayerAt(new LocalPoint(128, 128, -1)); + when(npc.getLocalLocation()).thenReturn(new LocalPoint(128, 128 + 3 * 128, -1)); + + Rs2NpcModel model = new Rs2NpcModel(npc); + assertEquals(3 * 128, model.getDistanceFromPlayer()); + assertTrue(model.isWithinDistanceFromPlayer(3 * 128)); + assertFalse(model.isWithinDistanceFromPlayer(3 * 128 - 1)); + } + + @Test + public void legacyModelNullLocalPlayerIsUnreachable() { + when(client.getLocalPlayer()).thenReturn(null); + when(npc.getLocalLocation()).thenReturn(new LocalPoint(128, 128, -1)); + + net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel model = + new net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel(npc); + assertEquals(Integer.MAX_VALUE, model.getDistanceFromPlayer()); + assertFalse(model.isWithinDistanceFromPlayer(Integer.MAX_VALUE)); + } + + @Test + public void legacyModelNullNpcLocationIsUnreachable() { + givenLocalPlayerAt(new LocalPoint(128, 128, -1)); + when(npc.getLocalLocation()).thenReturn(null); + + net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel model = + new net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel(npc); + assertEquals(Integer.MAX_VALUE, model.getDistanceFromPlayer()); + assertFalse(model.isWithinDistanceFromPlayer(Integer.MAX_VALUE)); + } +} From 024a1dac6b2990cdff2de68f0734261dca11b577 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:14:45 +0200 Subject: [PATCH 40/48] fix(gameobject): return false from clickObject when player or object location is unknown The world-point distance fallback in Rs2GameObject.clickObject dereferenced Rs2Player.getWorldLocation() without a null check, so calls during login, world hop or loading (e.g. via Rs2Bank.openBank) threw an NPE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 2 +- .../util/gameobject/Rs2GameObject.java | 5 +- ...GameObjectClickObjectNullLocationTest.java | 66 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectClickObjectNullLocationTest.java diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index ac3b683f486..1d322af621a 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`) and its `getAnimation` (returns -1), which `new Rs2PlayerModel()` reaches with a null actor, the callers of those accessors (`Rs2Camera.angleToTile` returns the current facing so a turn is a no-op), both `Rs2NpcModel` classes' `getDistanceFromPlayer` (returns `Integer.MAX_VALUE`) and `isWithinDistanceFromPlayer` (returns `false`), 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, the callers of those accessors (`Rs2Camera.angleToTile` returns the current facing so a turn is a no-op), both `Rs2NpcModel` classes' `getDistanceFromPlayer` (returns `Integer.MAX_VALUE`) and `isWithinDistanceFromPlayer` (returns `false`), `Rs2GameObject.clickObject` (returns `false` when it falls back to world-point distance), 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`, `Rs2PlayerModelNullLocalPlayerTest` and `Rs2NpcModelDistanceFromPlayerTest`. 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 b3f82f20f66..fb5768b9e06 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 @@ -1829,7 +1829,10 @@ public static boolean clickObject(TileObject object, String action) { int dy = playerLocal.getSceneY() - objectLocal.getSceneY(); tooFar = Math.max(Math.abs(dx), Math.abs(dy)) > 51; } else { - tooFar = Rs2Player.getWorldLocation().distanceTo(object.getWorldLocation()) > 51; + WorldPoint playerWorld = Rs2Player.getWorldLocation(); + WorldPoint objectWorld = object.getWorldLocation(); + if (playerWorld == null || objectWorld == null) return false; + tooFar = playerWorld.distanceTo(objectWorld) > 51; } if (tooFar) { Microbot.log("Object with id " + object.getId() + " is not close enough to interact with. Walking to the object...."); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectClickObjectNullLocationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectClickObjectNullLocationTest.java new file mode 100644 index 00000000000..aaea7b50956 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectClickObjectNullLocationTest.java @@ -0,0 +1,66 @@ +package net.runelite.client.plugins.microbot.util.gameobject; + +import net.runelite.api.Client; +import net.runelite.api.TileObject; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +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.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2GameObjectClickObjectNullLocationTest { + + private MockedStatic microbot; + private MockedStatic player; + private MockedStatic walker; + private boolean savedCantReachTarget; + + @Before + public void setUp() { + savedCantReachTarget = Microbot.cantReachTarget; + Microbot.cantReachTarget = false; + Client client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(null); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + player = Mockito.mockStatic(Rs2Player.class); + walker = Mockito.mockStatic(Rs2Walker.class); + } + + @After + public void tearDown() { + walker.close(); + player.close(); + microbot.close(); + Microbot.cantReachTarget = savedCantReachTarget; + } + + @Test + public void returnsFalseWhenPlayerLocationUnknown() { + player.when(Rs2Player::getWorldLocation).thenReturn(null); + TileObject object = mock(TileObject.class); + when(object.getWorldLocation()).thenReturn(new WorldPoint(3200, 3200, 0)); + + assertFalse(Rs2GameObject.clickObject(object, "Bank")); + walker.verify(() -> Rs2Walker.walkTo(any(WorldPoint.class)), Mockito.never()); + } + + @Test + public void returnsFalseWhenObjectLocationUnknown() { + player.when(Rs2Player::getWorldLocation).thenReturn(new WorldPoint(3200, 3200, 0)); + TileObject object = mock(TileObject.class); + when(object.getWorldLocation()).thenReturn(null); + + assertFalse(Rs2GameObject.clickObject(object, "Bank")); + walker.verify(() -> Rs2Walker.walkTo(any(WorldPoint.class)), Mockito.never()); + } +} From 4e5a032721165f7958ad530e008b422fcb1087e6 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:42:44 +0200 Subject: [PATCH 41/48] fix(bank): return false from walkToBank when no bank location is known Rs2Bank.walkToBank() passes getNearestBank() straight into walkToBank(BankLocation, boolean), which dereferenced the location even though getNearestBank() returns null without a player location or a reachable bank. Guard it the same way walkToBankAndUseBank already does. BankLocation.hasRequirements for MINING_GUILD read Rs2Player.getWorldLocation() twice without a null check, so a location lost mid-search (loading, hop, logout) threw during the nearest-bank filter. Read it once and treat a missing location as unmet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/worlds.md | 2 +- .../client/plugins/microbot/util/bank/Rs2Bank.java | 4 ++++ .../microbot/util/bank/enums/BankLocation.java | 5 ++++- .../util/bank/Rs2BankNullPlayerLocationTest.java | 14 ++++++++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 1d322af621a..b1a7eb6efc2 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -47,4 +47,4 @@ return Microbot.getClientThread().runOnClientThreadOptional(() -> { **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`. +**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` and `walkToBank` return `false`, `BankLocation.MINING_GUILD.hasRequirements` 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 395580794cd..77700b23d5a 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 @@ -2558,6 +2558,10 @@ public static boolean walkToBank(BankLocation bankLocation) { * @return true if the Rs2Player's location is within 4 tiles of the specified bank location. */ public static boolean walkToBank(BankLocation bankLocation, boolean toggleRun) { + if (bankLocation == null) { + log.warn("Bank location is null, cannot walk to bank."); + return false; + } if (Rs2Bank.isOpen()) return true; Rs2Player.toggleRunEnergy(toggleRun); Microbot.status = "Walking to nearest bank " + bankLocation.toString(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java index 38c353ce5ac..79982cf25c1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java @@ -154,7 +154,10 @@ public boolean hasRequirements() { case FARMING_GUILD: return Rs2Player.getSkillRequirement(Skill.FARMING, 45, true); case MINING_GUILD: - boolean inRegion = Rs2Player.getWorldLocation().getRegionID() == 12183 || Rs2Player.getWorldLocation().getRegionID() == 12184; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return false; + int regionId = playerLocation.getRegionID(); + boolean inRegion = regionId == 12183 || regionId == 12184; return inRegion && Rs2Player.getSkillRequirement(Skill.MINING, 60, true); case FISHING_GUILD: return Rs2Player.getSkillRequirement(Skill.FISHING, 68, true); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java index a3fabadabf1..b070019b75d 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java @@ -45,6 +45,20 @@ public void isNearBankIsFalseWithoutPlayerLocation() { assertFalse(Rs2Bank.isNearBank(BankLocation.GRAND_EXCHANGE, 10)); } + @Test + public void walkToBankIsFalseWithoutBankLocation() { + assertFalse(Rs2Bank.walkToBank()); + assertFalse(Rs2Bank.walkToBank(null)); + assertFalse(Rs2Bank.walkToBank(null, false)); + } + + @Test + public void miningGuildRequirementsAreUnmetWithoutPlayerLocation() { + player.when(Rs2Player::isMember).thenReturn(true); + player.when(Rs2Player::isInMemberWorld).thenReturn(true); + assertFalse(BankLocation.MINING_GUILD.hasRequirements()); + } + @Test public void distanceBetweenIsUnreachableForNullEndpoint() { WorldPoint point = new WorldPoint(3164, 3487, 0); From e58b1815fe3a1cc759ebfda72c01c1f85ea11890 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:51:22 +0200 Subject: [PATCH 42/48] docs(bank): note null bankLocation precedence in walkToBank javadoc Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 77700b23d5a..21a50973521 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 @@ -2551,7 +2551,7 @@ public static boolean walkToBank(BankLocation bankLocation) { /** * Walks to a specified bank location with an option to toggle run energy. - * If the bank is already open, the method exits immediately. + * Returns false if bankLocation is null; otherwise exits immediately if the bank is already open. * * @param bankLocation the target bank location to walk to. * @param toggleRun whether to toggle run energy during the walk. From 71e47ac504523c27a46c478ddf4f966544a9ffa9 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:10:43 +0200 Subject: [PATCH 43/48] fix(bank): drop Client of Kourend gate from Kourend banks and deposit boxes Kourend banks and deposit boxes have no quest requirement in game, but hasRequirements excluded them until Client of Kourend was started. On such accounts Rs2Bank.getNearestBank skipped every Kourend bank, so scripts like Stall Thieving at the Hosidius fruit stall home-teleported to a bank outside Kourend instead of walking to the Vinery bank. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../util/bank/enums/BankLocation.java | 16 ----- .../util/depositbox/DepositBoxLocation.java | 4 +- .../BankLocationKourendRequirementsTest.java | 67 +++++++++++++++++ ...sitBoxLocationKourendRequirementsTest.java | 71 +++++++++++++++++++ 4 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/BankLocationKourendRequirementsTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocationKourendRequirementsTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java index 79982cf25c1..a4f05fa641a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/enums/BankLocation.java @@ -222,22 +222,6 @@ public boolean hasRequirements() { case VARLAMORE_WEST: // Requires Children of the Sun return Rs2Player.getQuestState(Quest.CHILDREN_OF_THE_SUN) == QuestState.FINISHED; - case LOVAKENGJ: - case HOSIDIUS: - case PISCARILIUS: - case ARCEUUS: - case WINTERTODT: - case SULPHUR_MINE: - case ZEAH_SAND_BANK: - case BLAST_MINE: - case SHAYZIEN_BANK: - case SHAYZIEN_CHEST: - case GREAT_KOUREND_CASTLE: - case HOSIDIUS_KITCHEN: - case VINERY_BANK: - case MOUNT_KARUULM: - // Requires Client of Kourend - return Rs2Player.getQuestState(Quest.CLIENT_OF_KOUREND) != QuestState.NOT_STARTED; case DWARF_MINE_BANK: case BLAST_FURNACE_BANK: // Requires The Giant Dwarf diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocation.java index 4dfa8543c0d..7380a5b05ce 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocation.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocation.java @@ -208,9 +208,7 @@ public boolean hasRequirements() { case MOUNT_QUIDAMORTEM: case HOSIDIUS: case MOUNT_KARUULM: - if (!isMember()) return false; - // Requires Client of Kourend - return Rs2Player.getQuestState(Quest.CLIENT_OF_KOUREND) != QuestState.NOT_STARTED; + return isMember(); case SHILO_VILLAGE: if (!isMember()) return false; // Requires Shilo Village to enter the village & use the bank diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/BankLocationKourendRequirementsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/BankLocationKourendRequirementsTest.java new file mode 100644 index 00000000000..03b8b83a34c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/BankLocationKourendRequirementsTest.java @@ -0,0 +1,67 @@ +package net.runelite.client.plugins.microbot.util.bank; + +import net.runelite.api.Quest; +import net.runelite.api.QuestState; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +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.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; + +public class BankLocationKourendRequirementsTest { + + private static final BankLocation[] KOUREND_BANKS = { + BankLocation.LOVAKENGJ, + BankLocation.HOSIDIUS, + BankLocation.PISCARILIUS, + BankLocation.ARCEUUS, + BankLocation.WINTERTODT, + BankLocation.SULPHUR_MINE, + BankLocation.ZEAH_SAND_BANK, + BankLocation.BLAST_MINE, + BankLocation.SHAYZIEN_BANK, + BankLocation.SHAYZIEN_CHEST, + BankLocation.GREAT_KOUREND_CASTLE, + BankLocation.HOSIDIUS_KITCHEN, + BankLocation.VINERY_BANK, + BankLocation.MOUNT_KARUULM + }; + + private MockedStatic player; + + @Before + public void setUp() { + player = Mockito.mockStatic(Rs2Player.class); + player.when(() -> Rs2Player.getQuestState(any(Quest.class))).thenReturn(QuestState.NOT_STARTED); + } + + @After + public void tearDown() { + player.close(); + } + + @Test + public void kourendBanksAreUsableWithoutClientOfKourend() { + player.when(Rs2Player::isMember).thenReturn(true); + player.when(Rs2Player::isInMemberWorld).thenReturn(true); + for (BankLocation bank : KOUREND_BANKS) { + assertTrue(bank.name(), bank.hasRequirements()); + } + assertFalse(BankLocation.CANIFIS.hasRequirements()); + } + + @Test + public void kourendBanksStillRequireMembership() { + player.when(Rs2Player::isMember).thenReturn(false); + player.when(Rs2Player::isInMemberWorld).thenReturn(false); + for (BankLocation bank : KOUREND_BANKS) { + assertFalse(bank.name(), bank.hasRequirements()); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocationKourendRequirementsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocationKourendRequirementsTest.java new file mode 100644 index 00000000000..fd8377e58f1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/depositbox/DepositBoxLocationKourendRequirementsTest.java @@ -0,0 +1,71 @@ +package net.runelite.client.plugins.microbot.util.depositbox; + +import java.util.Optional; +import net.runelite.api.Quest; +import net.runelite.api.QuestState; +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 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 DepositBoxLocationKourendRequirementsTest { + + private static final DepositBoxLocation[] KOUREND_DEPOSIT_BOXES = { + DepositBoxLocation.ARCEUUS, + DepositBoxLocation.LOVAKENGJ, + DepositBoxLocation.SHAYZIEN, + DepositBoxLocation.KOUREND_CASTLE, + DepositBoxLocation.PORT_PISCARILIUS, + DepositBoxLocation.WINTERTODT_CAMP, + DepositBoxLocation.MOUNT_QUIDAMORTEM, + DepositBoxLocation.HOSIDIUS, + DepositBoxLocation.MOUNT_KARUULM + }; + + private MockedStatic microbot; + private MockedStatic player; + + @Before + public void setUp() { + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenReturn(Optional.of(false)); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + player = Mockito.mockStatic(Rs2Player.class); + player.when(() -> Rs2Player.getQuestState(any(Quest.class))).thenReturn(QuestState.NOT_STARTED); + } + + @After + public void tearDown() { + player.close(); + microbot.close(); + } + + @Test + public void kourendDepositBoxesAreUsableWithoutClientOfKourend() { + player.when(Rs2Player::isMember).thenReturn(true); + player.when(Rs2Player::isInMemberWorld).thenReturn(true); + for (DepositBoxLocation box : KOUREND_DEPOSIT_BOXES) { + assertTrue(box.name(), box.hasRequirements()); + } + } + + @Test + public void kourendDepositBoxesStillRequireMembership() { + player.when(Rs2Player::isMember).thenReturn(false); + player.when(Rs2Player::isInMemberWorld).thenReturn(false); + for (DepositBoxLocation box : KOUREND_DEPOSIT_BOXES) { + assertFalse(box.name(), box.hasRequirements()); + } + } +} From 9503aec3b228ef3fd85bc314cd8116c10567389f Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:14:59 +0200 Subject: [PATCH 44/48] fix(agentserver): reject wrong-typed JSON body fields with 400 instead of 500 Handlers cast JSON body fields directly, so a quoted value such as "quantity": "28" threw ClassCastException, returned 500 and was logged at error level. Add typed body accessors on AgentHandler that accept numeric and boolean strings and otherwise throw BadRequestException, use them in place of the raw casts, and map any remaining ClassCastException to a 400 logged at debug level. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../agentserver/handler/AgentHandler.java | 35 ++++ .../agentserver/handler/BankHandler.java | 10 +- .../agentserver/handler/DialogueHandler.java | 4 +- .../handler/GroundItemHandler.java | 4 +- .../agentserver/handler/InventoryHandler.java | 10 +- .../agentserver/handler/LoginHandler.java | 10 +- .../agentserver/handler/NpcHandler.java | 6 +- .../agentserver/handler/ObjectHandler.java | 6 +- .../handler/QuestHelperHandler.java | 2 +- .../agentserver/handler/ScriptHandler.java | 6 +- .../agentserver/handler/WalkHandler.java | 6 +- .../handler/AgentHandlerBodyFieldTest.java | 169 ++++++++++++++++++ 12 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java index 0157fbc053b..2880a714102 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java @@ -69,6 +69,9 @@ public void handle(HttpExchange exchange) throws IOException { } catch (BadRequestException e) { log.debug("Bad request for {}: {}", exchange.getRequestURI(), e.getMessage()); sendErrorResponse(exchange, 400, e.getMessage()); + } catch (ClassCastException e) { + log.debug("Invalid field type for {}: {}", exchange.getRequestURI(), e.getMessage()); + sendErrorResponse(exchange, 400, "Invalid field type in request body"); } catch (Exception e) { if (isClientDisconnect(e)) { log.debug("Client disconnected before {} response could be sent", exchange.getRequestURI()); @@ -223,6 +226,38 @@ protected Map readJsonBody(HttpExchange exchange) throws IOExcep return parsed != null ? parsed : new LinkedHashMap<>(); } + protected static Integer getIntField(Map body, String key) { + Object val = body.get(key); + if (val == null) return null; + if (val instanceof Number) return ((Number) val).intValue(); + if (val instanceof String) { + try { + return Integer.parseInt(((String) val).trim()); + } catch (NumberFormatException ignored) { + } + } + throw new BadRequestException("Field '" + key + "' must be a number"); + } + + protected static String getStringField(Map body, String key) { + Object val = body.get(key); + if (val == null) return null; + if (val instanceof String) return (String) val; + throw new BadRequestException("Field '" + key + "' must be a string"); + } + + protected static Boolean getBooleanField(Map body, String key) { + Object val = body.get(key); + if (val == null) return null; + if (val instanceof Boolean) return (Boolean) val; + if (val instanceof String) { + String s = ((String) val).trim(); + if ("true".equalsIgnoreCase(s)) return Boolean.TRUE; + if ("false".equalsIgnoreCase(s)) return Boolean.FALSE; + } + throw new BadRequestException("Field '" + key + "' must be a boolean"); + } + protected int getIntParam(Map params, String key, int defaultValue) { String val = params.get(key); if (val == null) return defaultValue; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/BankHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/BankHandler.java index a53b18764b4..45939a75815 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/BankHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/BankHandler.java @@ -122,8 +122,8 @@ private void handleDeposit(HttpExchange exchange) throws IOException { return; } - Boolean all = (Boolean) body.get("all"); - String name = (String) body.get("name"); + Boolean all = getBooleanField(body, "all"); + String name = getStringField(body, "name"); boolean success; if (Boolean.TRUE.equals(all) && (name == null || name.isEmpty())) { @@ -157,9 +157,9 @@ private void handleWithdraw(HttpExchange exchange) throws IOException { return; } - String name = (String) body.get("name"); - Number quantityNum = (Number) body.get("quantity"); - Number idNum = (Number) body.get("id"); + String name = getStringField(body, "name"); + Integer quantityNum = getIntField(body, "quantity"); + Integer idNum = getIntField(body, "id"); if (name == null && idNum == null) { sendJson(exchange, 400, errorResponse("Provide either name or id")); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/DialogueHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/DialogueHandler.java index f5b02ac2b58..c02f09c3df2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/DialogueHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/DialogueHandler.java @@ -119,8 +119,8 @@ private void handleSelect(HttpExchange exchange) throws IOException { return; } - String option = (String) body.get("option"); - Number indexNum = (Number) body.get("index"); + String option = getStringField(body, "option"); + Integer indexNum = getIntField(body, "index"); Map response = new LinkedHashMap<>(); boolean success; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/GroundItemHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/GroundItemHandler.java index cfa930a2413..e6ce5fba011 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/GroundItemHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/GroundItemHandler.java @@ -97,8 +97,8 @@ private void handlePickup(HttpExchange exchange) throws IOException { return; } - String name = (String) body.get("name"); - Number idNum = (Number) body.get("id"); + String name = getStringField(body, "name"); + Integer idNum = getIntField(body, "id"); Rs2TileItemModel item = null; if (name != null && !name.isEmpty()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/InventoryHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/InventoryHandler.java index 123d067e28d..dd1876fd160 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/InventoryHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/InventoryHandler.java @@ -77,14 +77,14 @@ private void handleInteract(HttpExchange exchange) throws IOException { return; } - String action = (String) body.get("action"); + String action = getStringField(body, "action"); if (action == null || action.isEmpty()) { sendJson(exchange, 400, errorResponse("Missing required field: action")); return; } - String name = (String) body.get("name"); - Number idNum = (Number) body.get("id"); + String name = getStringField(body, "name"); + Integer idNum = getIntField(body, "id"); boolean success; if (name != null && !name.isEmpty()) { @@ -120,13 +120,13 @@ private void handleDrop(HttpExchange exchange) throws IOException { return; } - String name = (String) body.get("name"); + String name = getStringField(body, "name"); if (name == null || name.isEmpty()) { sendJson(exchange, 400, errorResponse("Missing required field: name")); return; } - Boolean all = (Boolean) body.get("all"); + Boolean all = getBooleanField(body, "all"); boolean success; if (Boolean.TRUE.equals(all)) { success = Rs2Inventory.dropAll(name); 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 252b1c10cec..d33c3aa5e7b 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 @@ -140,14 +140,16 @@ private void handleLogin(HttpExchange exchange) throws IOException { int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; if (body != null) { - if (body.containsKey("world")) { - targetWorld = ((Number) body.get("world")).intValue(); + Integer world = getIntField(body, "world"); + if (world != null) { + targetWorld = world; } if (body.containsKey("wait")) { wait = Boolean.TRUE.equals(body.get("wait")); } - if (body.containsKey("timeout")) { - timeoutSeconds = Math.min(((Number) body.get("timeout")).intValue(), MAX_TIMEOUT_SECONDS); + Integer timeout = getIntField(body, "timeout"); + if (timeout != null) { + timeoutSeconds = Math.min(timeout, MAX_TIMEOUT_SECONDS); if (timeoutSeconds <= 0) timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/NpcHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/NpcHandler.java index c3171a2100f..b8d3c537c7b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/NpcHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/NpcHandler.java @@ -97,14 +97,14 @@ private void handleInteract(HttpExchange exchange) throws IOException { return; } - String action = (String) body.get("action"); + String action = getStringField(body, "action"); if (action == null || action.isEmpty()) { sendJson(exchange, 400, errorResponse("Missing required field: action")); return; } - String name = (String) body.get("name"); - Number idNum = (Number) body.get("id"); + String name = getStringField(body, "name"); + Integer idNum = getIntField(body, "id"); Rs2NpcModel npc = null; if (name != null && !name.isEmpty()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java index e19fb0b08c5..be267834e5e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java @@ -97,14 +97,14 @@ private void handleInteract(HttpExchange exchange) throws IOException { return; } - String action = (String) body.get("action"); + String action = getStringField(body, "action"); if (action == null || action.isEmpty()) { sendJson(exchange, 400, errorResponse("Missing required field: action")); return; } - String name = (String) body.get("name"); - Number idNum = (Number) body.get("id"); + String name = getStringField(body, "name"); + Integer idNum = getIntField(body, "id"); Rs2TileObjectModel obj = null; if (name != null && !name.isEmpty()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java index 9dec06c412a..2f7114eb9db 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java @@ -123,7 +123,7 @@ private void handleStart(HttpExchange exchange) throws IOException } Map body = readJsonBody(exchange); - String name = (String) body.get("name"); + String name = getStringField(body, "name"); if (name == null || name.isEmpty()) { sendJson(exchange, 400, errorResponse("name is required")); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScriptHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScriptHandler.java index 5e81e48e226..9be192cd237 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScriptHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ScriptHandler.java @@ -288,7 +288,7 @@ private void handleResults(HttpExchange exchange) throws IOException { } else if ("POST".equals(method)) { Map body = readJsonBody(exchange); - String className = (String) body.get("className"); + String className = getStringField(body, "className"); if (className == null || className.isEmpty()) { sendJson(exchange, 400, errorResponse("className is required")); return; @@ -363,12 +363,12 @@ private Plugin resolvePlugin(Map body) { PluginManager pm = getPluginManager(); if (pm == null) return null; - String className = (String) body.get("className"); + String className = getStringField(body, "className"); if (className != null && !className.isEmpty()) { return Microbot.getPlugin(className); } - String name = (String) body.get("name"); + String name = getStringField(body, "name"); if (name != null && !name.isEmpty()) { return findPluginByName(name); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java index 063ab2d043a..7205eb1a154 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java @@ -62,14 +62,14 @@ protected void handleRequest(HttpExchange exchange) throws IOException { return; } - Number xNum = (Number) body.get("x"); - Number yNum = (Number) body.get("y"); + Integer xNum = getIntField(body, "x"); + Integer yNum = getIntField(body, "y"); if (xNum == null || yNum == null) { sendJson(exchange, 400, errorResponse("Missing required fields: x, y")); return; } - Number planeNum = (Number) body.get("plane"); + Integer planeNum = getIntField(body, "plane"); int plane = planeNum != null ? planeNum.intValue() : 0; boolean wait = Boolean.TRUE.equals(body.get("wait")); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java new file mode 100644 index 00000000000..3617dd92339 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +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 AgentHandlerBodyFieldTest { + + private static final class QuantityHandler extends AgentHandler { + QuantityHandler() { + super(new Gson()); + } + + @Override + public String getPath() { + return "/quantity"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException { + Map body = readJsonBody(exchange); + Integer quantity = getIntField(body, "quantity"); + Map result = new LinkedHashMap<>(); + result.put("quantity", quantity); + sendJson(exchange, 200, result); + } + } + + private static final class RawCastHandler extends AgentHandler { + RawCastHandler() { + super(new Gson()); + } + + @Override + public String getPath() { + return "/raw"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException { + Map body = readJsonBody(exchange); + Number quantity = (Number) body.get("quantity"); + sendJson(exchange, 200, Map.of("quantity", quantity.intValue())); + } + } + + private ByteArrayOutputStream responseBody; + + @Before + public void setUp() { + AgentHandler.setTokenSupplier(null); + responseBody = new ByteArrayOutputStream(); + } + + @After + public void tearDown() { + AgentHandler.setTokenSupplier(null); + } + + private HttpExchange exchangeWithBody(String body) { + HttpExchange exchange = mock(HttpExchange.class); + Headers requestHeaders = new Headers(); + requestHeaders.add("Host", "127.0.0.1:8081"); + when(exchange.getRequestHeaders()).thenReturn(requestHeaders); + when(exchange.getResponseHeaders()).thenReturn(new Headers()); + when(exchange.getRequestURI()).thenReturn(URI.create("/quantity")); + when(exchange.getRequestMethod()).thenReturn("POST"); + when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + when(exchange.getResponseBody()).thenReturn(responseBody); + return exchange; + } + + private String response() { + return responseBody.toString(StandardCharsets.UTF_8); + } + + @Test + public void numericQuantityIsAccepted() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"quantity\":28}"); + + new QuantityHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertEquals("{\"quantity\":28}", response()); + } + + @Test + public void numericStringQuantityIsAccepted() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"quantity\":\"28\"}"); + + new QuantityHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(200), anyLong()); + assertEquals("{\"quantity\":28}", response()); + } + + @Test + public void nonNumericQuantityReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"quantity\":\"all\"}"); + + new QuantityHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(400), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + assertEquals("Field 'quantity' must be a number", new Gson().fromJson(response(), Map.class).get("error")); + } + + @Test + public void rawCastMismatchReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"quantity\":\"28\"}"); + + new RawCastHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(400), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + } + + @Test + public void accessorsReturnNullForMissingFields() { + Map body = new LinkedHashMap<>(); + + assertNull(AgentHandler.getIntField(body, "id")); + assertNull(AgentHandler.getStringField(body, "name")); + assertNull(AgentHandler.getBooleanField(body, "all")); + } + + @Test + public void stringAndBooleanAccessorsValidateType() { + Map body = new LinkedHashMap<>(); + body.put("name", 995.0); + body.put("all", "yes"); + body.put("wait", "true"); + + try { + AgentHandler.getStringField(body, "name"); + fail("expected BadRequestException"); + } catch (AgentHandler.BadRequestException e) { + assertEquals("Field 'name' must be a string", e.getMessage()); + } + try { + AgentHandler.getBooleanField(body, "all"); + fail("expected BadRequestException"); + } catch (AgentHandler.BadRequestException e) { + assertEquals("Field 'all' must be a boolean", e.getMessage()); + } + assertEquals(Boolean.TRUE, AgentHandler.getBooleanField(body, "wait")); + } +} From 1d9044940e7a57656ff0f39dc730601455161e51 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:17:45 +0200 Subject: [PATCH 45/48] fix(agentserver): use typed body accessors in remaining handlers and drop ClassCastException backstop Keyboard, Settings, WidgetClick and WidgetInvoke handlers now read body fields through getIntField/getStringField/getBooleanField, and wait, timeout and distance in Login/Logout/Walk handlers go through the same helpers. The blanket ClassCastException catch in AgentHandler.handle is removed so genuine internal cast bugs still report as 500 and reach error telemetry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../agentserver/handler/AgentHandler.java | 3 --- .../agentserver/handler/KeyboardHandler.java | 4 ++-- .../agentserver/handler/LoginHandler.java | 2 +- .../agentserver/handler/LogoutHandler.java | 2 +- .../agentserver/handler/SettingsHandler.java | 8 +++---- .../agentserver/handler/WalkHandler.java | 12 ++++++---- .../handler/WidgetClickHandler.java | 6 ++--- .../handler/WidgetInvokeHandler.java | 10 ++++---- .../handler/AgentHandlerBodyFieldTest.java | 23 +++++++++++++++---- 9 files changed, 42 insertions(+), 28 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java index 2880a714102..0b5f0c2adb5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandler.java @@ -69,9 +69,6 @@ public void handle(HttpExchange exchange) throws IOException { } catch (BadRequestException e) { log.debug("Bad request for {}: {}", exchange.getRequestURI(), e.getMessage()); sendErrorResponse(exchange, 400, e.getMessage()); - } catch (ClassCastException e) { - log.debug("Invalid field type for {}: {}", exchange.getRequestURI(), e.getMessage()); - sendErrorResponse(exchange, 400, "Invalid field type in request body"); } catch (Exception e) { if (isClientDisconnect(e)) { log.debug("Client disconnected before {} response could be sent", exchange.getRequestURI()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/KeyboardHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/KeyboardHandler.java index cdcc371de08..33a98a9d0c7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/KeyboardHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/KeyboardHandler.java @@ -37,8 +37,8 @@ protected void handleRequest(HttpExchange exchange) throws IOException { return; } - String text = (String) request.get("text"); - String key = (String) request.get("key"); + String text = getStringField(request, "text"); + String key = getStringField(request, "key"); if (text == null && key == null) { sendJson(exchange, 400, errorResponse("Required: text (string to type) or key (enter/escape/backspace)")); 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 d33c3aa5e7b..a22936694ad 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 @@ -145,7 +145,7 @@ private void handleLogin(HttpExchange exchange) throws IOException { targetWorld = world; } if (body.containsKey("wait")) { - wait = Boolean.TRUE.equals(body.get("wait")); + wait = Boolean.TRUE.equals(getBooleanField(body, "wait")); } Integer timeout = getIntField(body, "timeout"); if (timeout != null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LogoutHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LogoutHandler.java index e487e5260ff..e628ac6b634 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LogoutHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LogoutHandler.java @@ -68,7 +68,7 @@ private void handleLogout(HttpExchange exchange) throws IOException { if (body != null) { if (body.containsKey("wait")) { - wait = Boolean.TRUE.equals(body.get("wait")); + wait = Boolean.TRUE.equals(getBooleanField(body, "wait")); } if (body.containsKey("timeout")) { Object raw = body.get("timeout"); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/SettingsHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/SettingsHandler.java index 7a86cae4a24..55cea623cd3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/SettingsHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/SettingsHandler.java @@ -66,7 +66,7 @@ private void handleLevelUp(HttpExchange exchange) throws IOException { return; } - Boolean enable = (Boolean) request.get("enable"); + Boolean enable = getBooleanField(request, "enable"); if (enable == null) { sendJson(exchange, 400, errorResponse("Required: enable (true/false)")); return; @@ -123,7 +123,7 @@ private void handleConfig(HttpExchange exchange) throws IOException { return; } - String key = (String) request.get("key"); + String key = getStringField(request, "key"); Object value = request.get("value"); if (key == null || value == null) { sendJson(exchange, 400, errorResponse("Required: key, value")); @@ -192,8 +192,8 @@ private void handlePluginConfig(HttpExchange exchange) throws IOException { return; } - String group = (String) request.get("group"); - String key = (String) request.get("key"); + String group = getStringField(request, "group"); + String key = getStringField(request, "key"); Object value = request.get("value"); if (group == null || group.isEmpty() || key == null || key.isEmpty()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java index 7205eb1a154..e55bcb95358 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java @@ -72,17 +72,19 @@ protected void handleRequest(HttpExchange exchange) throws IOException { Integer planeNum = getIntField(body, "plane"); int plane = planeNum != null ? planeNum.intValue() : 0; - boolean wait = Boolean.TRUE.equals(body.get("wait")); + boolean wait = Boolean.TRUE.equals(getBooleanField(body, "wait")); int timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; - if (body.get("timeout") instanceof Number) { - timeoutSeconds = ((Number) body.get("timeout")).intValue(); + Integer timeoutNum = getIntField(body, "timeout"); + if (timeoutNum != null) { + timeoutSeconds = timeoutNum; if (timeoutSeconds <= 0) timeoutSeconds = DEFAULT_TIMEOUT_SECONDS; if (timeoutSeconds > MAX_TIMEOUT_SECONDS) timeoutSeconds = MAX_TIMEOUT_SECONDS; } int reachedDistance = DEFAULT_REACHED_DISTANCE; - if (body.get("distance") instanceof Number) { - reachedDistance = ((Number) body.get("distance")).intValue(); + Integer distanceNum = getIntField(body, "distance"); + if (distanceNum != null) { + reachedDistance = distanceNum; if (reachedDistance < 0) reachedDistance = DEFAULT_REACHED_DISTANCE; if (reachedDistance > MAX_REACHED_DISTANCE) reachedDistance = MAX_REACHED_DISTANCE; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetClickHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetClickHandler.java index bcfc8c873e2..362bcc60ef0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetClickHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetClickHandler.java @@ -36,9 +36,9 @@ protected void handleRequest(HttpExchange exchange) throws IOException { return; } - Number groupNum = (Number) request.get("groupId"); - Number childNum = (Number) request.get("childId"); - String text = (String) request.get("text"); + Integer groupNum = getIntField(request, "groupId"); + Integer childNum = getIntField(request, "childId"); + String text = getStringField(request, "text"); Map response = new LinkedHashMap<>(); boolean clicked; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetInvokeHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetInvokeHandler.java index a42423e32e8..f5295202df1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetInvokeHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WidgetInvokeHandler.java @@ -41,11 +41,11 @@ protected void handleRequest(HttpExchange exchange) throws IOException { return; } - Number groupNum = (Number) request.get("groupId"); - Number childNum = (Number) request.get("childId"); - Number param0Num = (Number) request.get("param0"); - String action = (String) request.get("action"); - Number identifierNum = (Number) request.get("identifier"); + Integer groupNum = getIntField(request, "groupId"); + Integer childNum = getIntField(request, "childId"); + Integer param0Num = getIntField(request, "param0"); + String action = getStringField(request, "action"); + Integer identifierNum = getIntField(request, "identifier"); if (groupNum == null || childNum == null) { sendJson(exchange, 400, errorResponse("Required: groupId, childId")); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java index 3617dd92339..db02c7736c4 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/AgentHandlerBodyFieldTest.java @@ -79,12 +79,16 @@ public void tearDown() { } private HttpExchange exchangeWithBody(String body) { + return exchangeWithBody("/quantity", body); + } + + private HttpExchange exchangeWithBody(String path, String body) { HttpExchange exchange = mock(HttpExchange.class); Headers requestHeaders = new Headers(); requestHeaders.add("Host", "127.0.0.1:8081"); when(exchange.getRequestHeaders()).thenReturn(requestHeaders); when(exchange.getResponseHeaders()).thenReturn(new Headers()); - when(exchange.getRequestURI()).thenReturn(URI.create("/quantity")); + when(exchange.getRequestURI()).thenReturn(URI.create(path)); when(exchange.getRequestMethod()).thenReturn("POST"); when(exchange.getRequestBody()).thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); when(exchange.getResponseBody()).thenReturn(responseBody); @@ -127,13 +131,24 @@ public void nonNumericQuantityReturnsBadRequest() throws Exception { } @Test - public void rawCastMismatchReturnsBadRequest() throws Exception { - HttpExchange exchange = exchangeWithBody("{\"quantity\":\"28\"}"); + public void bankWithdrawWithNonNumericQuantityReturnsBadRequest() throws Exception { + HttpExchange exchange = exchangeWithBody("/bank/withdraw", "{\"quantity\":\"x\",\"name\":\"y\"}"); - new RawCastHandler().handle(exchange); + new BankHandler(new Gson()).handle(exchange); verify(exchange).sendResponseHeaders(eq(400), anyLong()); verify(exchange, never()).sendResponseHeaders(eq(500), anyLong()); + assertEquals("Field 'quantity' must be a number", new Gson().fromJson(response(), Map.class).get("error")); + } + + @Test + public void internalClassCastExceptionStillReturnsServerError() throws Exception { + HttpExchange exchange = exchangeWithBody("{\"quantity\":\"28\"}"); + + new RawCastHandler().handle(exchange); + + verify(exchange).sendResponseHeaders(eq(500), anyLong()); + verify(exchange, never()).sendResponseHeaders(eq(400), anyLong()); } @Test From 874bf7feb7f79036f109243d114bb8fd23341ab9 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:46:23 +0200 Subject: [PATCH 46/48] fix(shortestpath): resolve clue location on the client thread The Web Walker clue panel timer, Start button and clue hotkey all call getCurrentClueLocation from the EDT. Clue location providers such as AnagramClue read varbits, which throws "must be called on client thread". Resolve the location through runOnClientThreadOptional, falling back to null. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- .../shortestpath/ShortestPathPanel.java | 7 + .../ShortestPathPanelClueLocationTest.java | 122 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelClueLocationTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java index 861a53d7556..1faefeb3392 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java @@ -1006,6 +1006,13 @@ WorldPoint getCurrentClueLocation() return null; } + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> resolveClueLocation(clue, cluePlugin)) + .orElse(null); + } + + private static WorldPoint resolveClueLocation(ClueScroll clue, ClueScrollPlugin cluePlugin) + { // Check if clue implements LocationClueScroll (single location) if (clue instanceof LocationClueScroll) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelClueLocationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelClueLocationTest.java new file mode 100644 index 00000000000..d0830e521fe --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanelClueLocationTest.java @@ -0,0 +1,122 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.cluescrolls.ClueScrollPlugin; +import net.runelite.client.plugins.cluescrolls.clues.ClueScroll; +import net.runelite.client.plugins.cluescrolls.clues.LocationClueScroll; +import net.runelite.client.plugins.cluescrolls.clues.LocationsClueScroll; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class ShortestPathPanelClueLocationTest +{ + private static final WorldPoint LOCATION = new WorldPoint(2440, 5170, 0); + + private final AtomicBoolean onClientThread = new AtomicBoolean(false); + private MockedStatic microbot; + private ClientThread clientThread; + private ClueScrollPlugin cluePlugin; + private ShortestPathPanel panel; + + @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); + } + }); + + cluePlugin = mock(ClueScrollPlugin.class); + PluginManager pluginManager = mock(PluginManager.class); + when(pluginManager.getPlugins()).thenReturn(Collections.singletonList(cluePlugin)); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + microbot.when(Microbot::getPluginManager).thenReturn(pluginManager); + + panel = mock(ShortestPathPanel.class, Mockito.CALLS_REAL_METHODS); + } + + @After + public void tearDown() + { + microbot.close(); + } + + @Test + public void resolvesSingleLocationOnClientThread() + { + ClueScroll clue = mock(ClueScroll.class, withSettings().extraInterfaces(LocationClueScroll.class)); + when(((LocationClueScroll) clue).getLocation(cluePlugin)).thenAnswer(inv -> { + if (!onClientThread.get()) + { + throw new IllegalStateException("must be called on client thread"); + } + return LOCATION; + }); + when(cluePlugin.getClue()).thenReturn(clue); + + assertEquals(LOCATION, panel.getCurrentClueLocation()); + } + + @Test + public void resolvesMultipleLocationsOnClientThread() + { + ClueScroll clue = mock(ClueScroll.class, withSettings().extraInterfaces(LocationsClueScroll.class)); + when(((LocationsClueScroll) clue).getLocations(cluePlugin)).thenAnswer(inv -> { + if (!onClientThread.get()) + { + throw new IllegalStateException("must be called on client thread"); + } + return new WorldPoint[]{LOCATION}; + }); + when(cluePlugin.getClue()).thenReturn(clue); + + assertEquals(LOCATION, panel.getCurrentClueLocation()); + } + + @Test + public void returnsNullWhenClientThreadReturnsNothing() + { + ClueScroll clue = mock(ClueScroll.class, withSettings().extraInterfaces(LocationClueScroll.class)); + when(cluePlugin.getClue()).thenReturn(clue); + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + + assertNull(panel.getCurrentClueLocation()); + } + + @Test + public void returnsNullWithoutActiveClue() + { + when(cluePlugin.getClue()).thenReturn(null); + + assertNull(panel.getCurrentClueLocation()); + } +} From 2348cc739c2dfbfce03f38f035f21dd6988492b2 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:20:52 +0200 Subject: [PATCH 47/48] fix(inventory): return null instead of NPE when no widget matches item slot Rs2Inventory.invokeMenu called Optional.orElseGet(null), which throws an NPE whenever the active inventory container has no child for the item's slot (seen while selling to a shop). Use orElse(null) so the existing fallback to the item's inventory actions is reached. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LjtEMzQHGuq4M1Mpqk9pPV --- docs/entity-guides/items.md | 7 ++ .../microbot/util/inventory/Rs2Inventory.java | 2 +- .../inventory/Rs2InventoryInvokeMenuTest.java | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/inventory/Rs2InventoryInvokeMenuTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 5085953e032..bdecc1dae1b 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -281,6 +281,13 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin **Defensive check:** Run `Rs2BankUnloadedWidgetsTest` and `Rs2BankLootingBagViewTest`. +## 17. An inventory container may have no child for the item's slot + +While a shop, bank, deposit box or Grand Exchange inventory is open or rebuilding, the active container's children may not include a widget whose index matches `Rs2ItemModel.getSlot()`. Look the child up with `findFirst().orElse(null)` and fall back to `rs2Item.getInventoryActions()`; never pass `null` to `Optional.orElseGet`, which throws an NPE instead of returning null. + +**Defensive check:** Run `Rs2InventoryInvokeMenuTest`. + + ## Terminal supply stops need observed logout An owning script can set `withMissingSupplyHandler` on its Inventory Setup loader to keep its scheduled loop alive while `TerminalLogoutPrompt` closes the bank and requests logout. Do not show a blocking shortage notification or cancel that loop until `LOGGED_OUT` is observed. The default loader policy stays unchanged when no handler is supplied; ordinary breaks and world hops do not arm terminal Auto Login suppression. 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 fde57b87b45..fa911af7ab2 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 @@ -1928,7 +1928,7 @@ private static void invokeMenu(Rs2ItemModel rs2Item, String action) { if (inventoryWidgets == null) return; if (!action.isEmpty()) { - var itemWidget = Arrays.stream(inventoryWidgets).filter(x -> x != null && x.getIndex() == rs2Item.getSlot()).findFirst().orElseGet(null); + var itemWidget = Arrays.stream(inventoryWidgets).filter(x -> x != null && x.getIndex() == rs2Item.getSlot()).findFirst().orElse(null); String[] actions = itemWidget != null && itemWidget.getActions() != null ? itemWidget.getActions() : diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/inventory/Rs2InventoryInvokeMenuTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/inventory/Rs2InventoryInvokeMenuTest.java new file mode 100644 index 00000000000..dd7ab082136 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/inventory/Rs2InventoryInvokeMenuTest.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.util.inventory; + +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.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; +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.junit.Test; +import org.mockito.Answers; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2InventoryInvokeMenuTest { + + private static final int SHOP_INVENTORY_ITEM_CONTAINER = 19726336; + + @Test + public void sellItemFallsBackToItemActionsWhenNoContainerChildMatchesSlot() { + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + + Widget otherSlot = mock(Widget.class); + when(otherSlot.getIndex()).thenReturn(0); + Widget container = mock(Widget.class); + when(container.getChildren()).thenReturn(new Widget[]{otherSlot, null}); + + Rs2ItemModel item = mock(Rs2ItemModel.class); + when(item.getName()).thenReturn("Pot"); + when(item.getId()).thenReturn(1931); + when(item.getSlot()).thenReturn(5); + when(item.getInventoryActions()).thenReturn(new String[]{"Value", "Sell 1", "Sell 5", "Sell 10", "Sell 50"}); + + try (MockedStatic inventory = Mockito.mockStatic(Rs2Inventory.class, Answers.CALLS_REAL_METHODS); + MockedStatic microbot = Mockito.mockStatic(Microbot.class); + MockedStatic widgets = Mockito.mockStatic(Rs2Widget.class); + MockedStatic tabs = Mockito.mockStatic(Rs2Tab.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic ge = Mockito.mockStatic(Rs2GrandExchange.class); + MockedStatic shop = Mockito.mockStatic(Rs2Shop.class)) + { + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + shop.when(Rs2Shop::isOpen).thenReturn(true); + widgets.when(() -> Rs2Widget.getWidget(SHOP_INVENTORY_ITEM_CONTAINER)).thenReturn(container); + inventory.when(() -> Rs2Inventory.get("Pot", true)).thenReturn(item); + inventory.when(Rs2Inventory::isItemSelected).thenReturn(false); + inventory.when(() -> Rs2Inventory.itemBounds(item)).thenReturn(null); + + assertTrue(Rs2Inventory.sellItem("Pot", "1")); + + microbot.verify(() -> Microbot.doInvoke(argThat(entry -> + entry.getIdentifier() == 2 + && entry.getParam0() == 5 + && entry.getParam1() == SHOP_INVENTORY_ITEM_CONTAINER), any())); + } + } +} From 0510c9f8f87e01bbd834fcc919ef8c4b047b7b37 Mon Sep 17 00:00:00 2001 From: chsami <23292806+chsami@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:33:43 +0200 Subject: [PATCH 48/48] chore: bump Microbot to 2.6.34 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6448772f354..5856373f7c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,7 +32,7 @@ project.build.version=1.13.2 runelite.injected-client.version=1.13.2 glslang.path= -microbot.version=2.6.33 +microbot.version=2.6.34 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username=