From affb6b22c1de0fed79155ab7f44dd18ffd8a984b Mon Sep 17 00:00:00 2001 From: oversized93 <203591866+oversized93@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:58:22 -0500 Subject: [PATCH] fix(webwalker): traverse far gates/doors and recover from smoothed-path dead-ends Two related Rs2Walker navigation-robustness fixes: 1. Walking-to-door state. Clicking a gate/door several tiles away makes the OSRS server walk the player there and open it in one action. The walker was cancelling that approach with recovery/minimap clicks and then falsely marking the door "opened" (10s suppression via markStationaryDoorOpened on a non-traversal), stranding the player against a closed gate with no retry. A distance-scaled "walking to door" state now keeps the door interactable and yields recovery clicks until the player arrives (or a deadline/no-progress timeout elapses); the door is only suppressed on actual traversal. 2. Raw-path recovery for unreachable tiles. The smoothed walkable path can dead-end against a barrier that must be crossed from the far side (e.g. a closed gate). The unreachable-tile recovery only scanned 5 tiles for a Euclidean-closer-to-target tile, which cannot follow the winding route to the crossing, so the walker livelocked (tile-unreachable-near-player + stall_recalc indefinitely) until the character idle-logged-out. It now falls back to the raw path (findFurthestReachableRawPathPoint) to advance to the actual crossing tile, where existing door handling opens it. Adds unit tests for the new pure helpers (walkToDoorDeadlineMs, walkToDoorWindowOpen, doorTileNearSegment, doorApproachDistance). Verified live in-game on a fenced gate route; 65 walker unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../microbot/util/walker/Rs2Walker.java | 212 +++++++++++++++++- .../util/walker/Rs2WalkerUnitTest.java | 89 ++++++++ 2 files changed, 298 insertions(+), 3 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 3fc838f644..bf8d77afad 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 @@ -141,6 +141,16 @@ public static WorldPoint getCurrentTarget() { private static final long DOOR_EDGE_SKIP_COOLDOWN_MS = 700L; private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 1400L; private static final long DOOR_TRAVERSAL_RECOVERY_BLOCK_MS = 2_200L; + // "Walking to door" state. Clicking a gate/door several tiles away makes the OSRS server walk + // the player all the way to it and open it in one action. While that approach is in flight the + // walker must NOT fire recovery/minimap clicks (they cancel the walk-to-open and strand the + // player against a closed gate) and must keep the door interactable instead of suppressing it + // as "opened". The deadline scales with the click distance so a far gate gets time to arrive. + private static final int WALK_TO_DOOR_ADJACENT_TILES = 2; + private static final long WALK_TO_DOOR_BASE_MS = 2_000L; + private static final long WALK_TO_DOOR_PER_TILE_MS = 700L; + private static final long WALK_TO_DOOR_MAX_MS = 12_000L; + private static final long WALK_TO_DOOR_NO_PROGRESS_MS = 3_000L; private static final int PATHFINDER_DONE_POLL_WAIT_MS = 1200; private static final int PATHFINDER_DONE_RETRY_SLEEP_MIN_MS = 120; private static final int PATHFINDER_DONE_RETRY_SLEEP_MAX_MS = 220; @@ -164,6 +174,12 @@ public static WorldPoint getCurrentTarget() { private static volatile long rawScanFocusedDoorSetAtMs = 0L; private static volatile int rawScanFocusedDoorAttempts = 0; private static volatile long doorInteractionSettleUntilMs = 0L; + private static volatile WorldPoint walkingToDoorTile = null; + private static volatile WorldPoint walkingToDoorFrom = null; + private static volatile WorldPoint walkingToDoorTo = null; + private static volatile long walkingToDoorUntilMs = 0L; + private static volatile int walkingToDoorClosestDist = Integer.MAX_VALUE; + private static volatile long walkingToDoorLastProgressMs = 0L; private static volatile long lastDoorEdgePassSkipAtMs = 0L; private static volatile long lastUnreachableRecoveryClickAtMs = 0L; private static volatile long walkSessionStartedAtMs = 0L; @@ -1580,6 +1596,65 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } + if (isWalkingToDoorActive()) { + // We recently clicked a gate/door several tiles away; the server + // walk-to-open is still carrying us. A recovery/minimap click here + // would cancel that approach and strand us against the closed gate + // (the reproduced bug). Yield until we arrive (or stop) and let the + // walk-to-door finish; the state self-clears on traversal/timeout. + final WorldPoint walkToDoorTarget = walkingToDoorTile; + sleepUntil(() -> isWalkCancelled(target) + || isDoorEdgeResolved(walkingToDoorFrom, walkingToDoorTo) + || !Rs2Player.isMoving(), + 1500); + if (walkCancelledDiag(target, "processWalk:walking-to-door-yield", processWalkTail)) { + return WalkerState.EXIT; + } + walkerDiag("walking-to-door yield door=%s remMs=%d player=%s", + walkToDoorTarget, + Math.max(0L, walkingToDoorUntilMs - System.currentTimeMillis()), + Rs2Player.getWorldLocation()); + exitReason = "walking-to-door-yield"; + break; + } + + // Raw-path recovery: the smoothed walkable path can dead-end against a + // barrier (e.g. a gate that must be crossed from the far side) — the path + // tile reads unreachable and the narrow Euclidean scan below can't follow + // the winding detour to the crossing, so the walker livelocks. The RAW path + // keeps the true tile-by-tile route, so walk toward the furthest reachable + // raw tile; that advances us to the actual crossing point where door + // handling can then open it. (Reach 12 == MINIMAP_REACH_EUCLIDEAN-1, which + // is a local declared later in the loop.) + if (rawPath != null && !rawPath.isEmpty()) { + WorldPoint rawReach = findFurthestReachableRawPathPoint(rawPath, playerLoc, 12); + if (rawReach != null && !rawReach.equals(playerLoc)) { + WorldPoint rawNav = getPointWithWallDistance(rawReach); + if (!Rs2Tile.isTileReachable(rawNav)) { + rawNav = rawReach; + } + boolean rawClicked = Rs2Walker.walkMiniMap(rawNav); + if (!rawClicked) { + rawClicked = walkMiniMapToward(rawNav, playerLoc, 12); + } + log.info("[Walker] unreachable raw-path recovery: clicked={} to={} (smoothed tile {} unreachable)", + rawClicked, compactWorldPoint(rawReach), compactWorldPoint(currentWorldPoint)); + if (rawClicked) { + markFirstMovementClick("raw_path_recovery_click", target, playerLoc, + "to=" + compactWorldPoint(rawNav)); + lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); + WorldPoint pathLastRaw = path.get(path.size() - 1); + int finishThRaw = tightFinishThreshold(target, pathLastRaw, distance); + waitUntilIdleAfterSceneWalk(target, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, target, + finishThRaw); + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + exitReason = "unreachable-raw-path-recovery"; + break; + } + } + } + // If we still can't resolve a blocker by interaction, do not stall. // Click a reachable "progress" tile that advances toward the target/path. // This keeps the walker responsive and usually moves us into the door's @@ -2002,7 +2077,8 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { } // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. - if ("interim-in-flight".equals(exitReason) || "off-path-but-moving".equals(exitReason)) { + if ("interim-in-flight".equals(exitReason) || "off-path-but-moving".equals(exitReason) + || "walking-to-door-yield".equals(exitReason)) { walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); processWalkTail--; } @@ -3703,6 +3779,10 @@ && isNullOrPlaceholderObjectName(comp.getName())) { return false; } markDoorInteractionSettling(); + int walkToDoorDist = doorApproachDistance(posBefore, probe, fromWp); + if (walkToDoorDist > WALK_TO_DOOR_ADJACENT_TILES) { + markWalkingToDoor(probe, fromWp, toWp, walkToDoorDist); + } waitForDoorInteractionProgress(fromWp, toWp); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); @@ -3841,6 +3921,10 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } markDoorInteractionSettling(); + int walkToDoorDist = doorApproachDistance(posBefore, probe, fromWp); + if (walkToDoorDist > WALK_TO_DOOR_ADJACENT_TILES) { + markWalkingToDoor(probe, fromWp, toWp, walkToDoorDist); + } waitForDoorInteractionProgress(fromWp, toWp); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); @@ -4065,6 +4149,116 @@ private static void markDoorInteractionSettling() { doorInteractionSettleUntilMs = System.currentTimeMillis() + DOOR_POST_INTERACT_SETTLE_MS; } + /** + * Record that we just clicked a gate/door {@code distanceToDoor} tiles away and are now walking + * to it. The deadline scales with distance (capped) so a far gate gets time to be reached and + * opened by the single server walk-to-open action. While this state is active the walker keeps + * the door interactable and refuses to fire recovery/minimap clicks that would cancel the + * approach. See {@link #isWalkingToDoorActive()}. + */ + private static void markWalkingToDoor(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp, int distanceToDoor) { + if (doorTile == null) { + return; + } + long now = System.currentTimeMillis(); + walkingToDoorTile = doorTile; + walkingToDoorFrom = fromWp; + walkingToDoorTo = toWp; + walkingToDoorUntilMs = walkToDoorDeadlineMs(now, distanceToDoor); + walkingToDoorClosestDist = distanceToDoor; + walkingToDoorLastProgressMs = now; + } + + /** Pure: absolute deadline for a walk-to-door click, scaling with distance and capped. */ + static long walkToDoorDeadlineMs(long now, int distanceToDoor) { + long scaled = WALK_TO_DOOR_BASE_MS + (long) Math.max(0, distanceToDoor) * WALK_TO_DOOR_PER_TILE_MS; + return now + Math.min(WALK_TO_DOOR_MAX_MS, scaled); + } + + /** Pure: the walk-to-door window is open while before the deadline and still making progress. */ + static boolean walkToDoorWindowOpen(long now, long untilMs, long lastProgressMs, long noProgressMs) { + return now <= untilMs && (now - lastProgressMs) <= noProgressMs; + } + + /** Pure: is {@code doorTile} within {@code segmentDist} of either endpoint, same plane. */ + static boolean doorTileNearSegment(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp, int segmentDist) { + if (doorTile == null) { + return false; + } + return (fromWp != null && fromWp.getPlane() == doorTile.getPlane() && doorTile.distanceTo2D(fromWp) <= segmentDist) + || (toWp != null && toWp.getPlane() == doorTile.getPlane() && doorTile.distanceTo2D(toWp) <= segmentDist); + } + + private static void clearWalkingToDoor() { + walkingToDoorTile = null; + walkingToDoorFrom = null; + walkingToDoorTo = null; + walkingToDoorUntilMs = 0L; + walkingToDoorClosestDist = Integer.MAX_VALUE; + walkingToDoorLastProgressMs = 0L; + } + + /** + * True while a recent far-door click is still being walked to: the distance-scaled deadline has + * not elapsed, the door edge has not resolved, and the player is still making progress toward the + * door. Self-clears the state when the edge resolves so callers see one source of truth, and + * releases (returns false) if the approach stalls so normal stuck-recovery can take over. + */ + private static boolean isWalkingToDoorActive() { + WorldPoint doorTile = walkingToDoorTile; + if (doorTile == null) { + return false; + } + long now = System.currentTimeMillis(); + if (now > walkingToDoorUntilMs) { + return false; + } + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || player.getPlane() != doorTile.getPlane()) { + return false; + } + if (isDoorEdgeResolved(walkingToDoorFrom, walkingToDoorTo)) { + clearWalkingToDoor(); + return false; + } + int dist = player.distanceTo2D(doorTile); + if (dist < walkingToDoorClosestDist) { + walkingToDoorClosestDist = dist; + walkingToDoorLastProgressMs = now; + } + // Approach has stalled (blocked by something other than this door) — release so normal + // recovery / stall handling resumes. + return walkToDoorWindowOpen(now, walkingToDoorUntilMs, walkingToDoorLastProgressMs, WALK_TO_DOOR_NO_PROGRESS_MS); + } + + private static boolean isWalkingToDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + if (walkingToDoorTile == null || !isWalkingToDoorActive()) { + return false; + } + return doorTileNearSegment(walkingToDoorTile, fromWp, toWp, 2); + } + + private static boolean isWalkingToDoorTile(WorldPoint doorTile) { + WorldPoint active = walkingToDoorTile; + return doorTile != null && active != null && isWalkingToDoorActive() + && active.getPlane() == doorTile.getPlane() && active.distanceTo2D(doorTile) <= 2; + } + + /** Smaller of player->doorTile and player->fromWp (same plane only); MAX_VALUE if unknown. */ + static int doorApproachDistance(WorldPoint player, WorldPoint doorTile, WorldPoint fromWp) { + if (player == null) { + return Integer.MAX_VALUE; + } + int d = Integer.MAX_VALUE; + if (doorTile != null && doorTile.getPlane() == player.getPlane()) { + d = Math.min(d, player.distanceTo2D(doorTile)); + } + if (fromWp != null && fromWp.getPlane() == player.getPlane()) { + d = Math.min(d, player.distanceTo2D(fromWp)); + } + return d; + } + private static void markGlobalDoorInteractionCooldown() { nextDoorInteractionAllowedAtMs = Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS); } @@ -4095,11 +4289,17 @@ private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoin } private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( + boolean suppressed = Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( recentlyOpenedStationaryDoors, STATIONARY_DOOR_SUPPRESS_MS, fromWp, toWp); + if (!suppressed) { + return false; + } + // Keep the gate/door we are actively walking to interactable — a prior throttle or + // not-yet-traversed interaction must not suppress the very door we still need to open. + return !isWalkingToDoorOnSegment(fromWp, toWp); } private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { @@ -4115,7 +4315,8 @@ private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { recentlyOpenedStationaryDoors.remove(doorTile); return false; } - return true; + // Do not hide the door we are actively walking to from candidate scans. + return !isWalkingToDoorTile(doorTile); } /** @@ -4898,6 +5099,10 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< return false; } markDoorInteractionSettling(); + int walkToDoorDist = doorApproachDistance(posBefore, bestLoc, bestFrom); + if (walkToDoorDist > WALK_TO_DOOR_ADJACENT_TILES) { + markWalkingToDoor(bestLoc, bestFrom, bestTo, walkToDoorDist); + } waitForDoorInteractionProgress(bestFrom, bestTo); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, bestLoc, bestFrom, bestTo); @@ -5462,6 +5667,7 @@ public static void setTarget(WorldPoint target, String clearReasonWhenNull) { if (target == null) { logRouteClear(clearReasonWhenNull); + clearWalkingToDoor(); synchronized (ShortestPathPlugin.getPathfinderMutex()) { final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder != null) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index 10363aed35..79430bcaa0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java @@ -659,4 +659,93 @@ public void telemetry_totalRecalcs_doesNotIncludeUnreachable() { assertEquals(1, Rs2Walker.Telemetry.totalRecalcs()); } + + // --------------------------------------------------------------------------- + // Walking-to-door state — far gate/door fix + // + // Root cause: clicking a gate/door several tiles away makes the server walk the + // player there and open it in one action; the walker was cancelling that with + // recovery clicks and then falsely suppressing the door for 10s. These pin the + // pure decision logic of the fix (deadline scaling, progress window, segment match). + // --------------------------------------------------------------------------- + + @Test + public void walkToDoorDeadline_scalesWithDistance() { + long now = 1_000_000L; + assertEquals("distance 0 floors at base", now + 2_000L, Rs2Walker.walkToDoorDeadlineMs(now, 0)); + assertEquals("base 2000 + 5*700", now + 2_000L + 5 * 700L, Rs2Walker.walkToDoorDeadlineMs(now, 5)); + } + + @Test + public void walkToDoorDeadline_capsAtMax() { + long now = 1_000_000L; + // 2000 + 100*700 = 72000, must cap at 12000 so a bad/unknown distance can't pin the walker. + assertEquals(now + 12_000L, Rs2Walker.walkToDoorDeadlineMs(now, 100)); + } + + @Test + public void walkToDoorDeadline_negativeDistanceFloorsAtBase() { + long now = 1_000_000L; + assertEquals("negative/unknown distance must not underflow the deadline", + now + 2_000L, Rs2Walker.walkToDoorDeadlineMs(now, -5)); + } + + @Test + public void walkToDoorWindow_openWhileBeforeDeadlineAndProgressing() { + long now = 10_000L; + assertTrue(Rs2Walker.walkToDoorWindowOpen(now, now + 1, now, 3_000L)); + assertTrue("recent progress (2s ago, < 3s budget) keeps the window open", + Rs2Walker.walkToDoorWindowOpen(now, now + 5_000L, now - 2_000L, 3_000L)); + } + + @Test + public void walkToDoorWindow_closedAfterDeadline() { + long now = 10_000L; + assertFalse("past the hard deadline the window must close so normal recovery resumes", + Rs2Walker.walkToDoorWindowOpen(now, now - 1, now, 3_000L)); + } + + @Test + public void walkToDoorWindow_closedAfterNoProgressTimeout() { + long now = 10_000L; + assertFalse("no progress for 3.5s (> 3s budget) must close the window even before the deadline", + Rs2Walker.walkToDoorWindowOpen(now, now + 10_000L, now - 3_500L, 3_000L)); + } + + @Test + public void doorTileNearSegment_withinTwoOfEitherEndpoint() { + WorldPoint door = new WorldPoint(3189, 3275, 0); + assertTrue("door 2 tiles from 'from' endpoint is on the segment", + Rs2Walker.doorTileNearSegment(door, + new WorldPoint(3189, 3277, 0), new WorldPoint(3189, 3274, 0), 2)); + } + + @Test + public void doorTileNearSegment_rejectsFarSegmentWrongPlaneAndNull() { + WorldPoint door = new WorldPoint(3189, 3275, 0); + assertFalse("segment ~10 tiles away is not this door's segment", + Rs2Walker.doorTileNearSegment(door, + new WorldPoint(3189, 3285, 0), new WorldPoint(3189, 3284, 0), 2)); + assertFalse("same coords on another plane must not match", + Rs2Walker.doorTileNearSegment(door, + new WorldPoint(3189, 3276, 1), new WorldPoint(3189, 3274, 1), 2)); + assertFalse("null door tile must not NPE", + Rs2Walker.doorTileNearSegment(null, + new WorldPoint(3189, 3276, 0), new WorldPoint(3189, 3274, 0), 2)); + } + + @Test + public void doorApproachDistance_takesNearerEndpointAndGuardsPlaneAndNull() { + WorldPoint player = new WorldPoint(3187, 3285, 0); + WorldPoint door = new WorldPoint(3189, 3275, 0); // ~10 away + WorldPoint from = new WorldPoint(3188, 3281, 0); // ~4 away (nearer) + assertEquals("uses the nearer of door/from", + player.distanceTo2D(from), Rs2Walker.doorApproachDistance(player, door, from)); + assertEquals("null player must yield MAX_VALUE, not NPE", + Integer.MAX_VALUE, Rs2Walker.doorApproachDistance(null, door, from)); + assertEquals("different-plane door+from must be ignored -> MAX_VALUE (no false 'adjacent')", + Integer.MAX_VALUE, + Rs2Walker.doorApproachDistance(player, + new WorldPoint(3189, 3275, 1), new WorldPoint(3188, 3281, 1))); + } }