From ad57674da7e61e0bb5c1a294bf6d7954ac017a33 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 6 Jul 2026 17:06:41 +0100 Subject: [PATCH 1/9] =?UTF-8?q?Improve=20Rs2Walker=20route-object=20handli?= =?UTF-8?q?ng,=20gate=20traversal,=20and=20stale=20interim=20recovery=20Th?= =?UTF-8?q?is=20PR=20hardens=20Rs2Walker=20against=20several=20movement=20?= =?UTF-8?q?stalls=20and=20route-object=20edge=20cases=20found=20during=20l?= =?UTF-8?q?ive=20walking=20tests.=20Changes=20=E2=80=A2=20Improved=20small?= =?UTF-8?q?=20gate=20/=20door=20detection=20on=20raw=20path=20segments.=20?= =?UTF-8?q?=E2=80=A2=20Added=20generic=20detection=20for=20diagonal=20gate?= =?UTF-8?q?=20crossings,=20including=20corner=20cases=20where=20the=20smoo?= =?UTF-8?q?thed=20path=20crosses=20a=20gate=20edge=20diagonally.=20?= =?UTF-8?q?=E2=80=A2=20Prevented=20door-like=20catalog=20transports,=20suc?= =?UTF-8?q?h=20as=20Open=20Gate,=20from=20being=20incorrectly=20treated=20?= =?UTF-8?q?as=20generic=20transports.=20=E2=80=A2=20Added=20pending=20door?= =?UTF-8?q?/gate=20scans=20before=20direct=20minimap=20clicks,=20route=20c?= =?UTF-8?q?heckpoint=20clicks,=20interim=20movement=20waits,=20and=20unrea?= =?UTF-8?q?chable=20recovery.=20=E2=80=A2=20Expanded=20raw-path=20door=20s?= =?UTF-8?q?canning=20to=20include=20nearby=20previous=20raw=20edges=20when?= =?UTF-8?q?=20the=20player=20starts=20slightly=20off-path=20near=20a=20gat?= =?UTF-8?q?e.=20=E2=80=A2=20Added=20range=20gating=20before=20interacting?= =?UTF-8?q?=20with=20future=20door/gate=20objects=20so=20the=20walker=20do?= =?UTF-8?q?es=20not=20mark=20a=20far-away=20gate=20as=20handled.=20?= =?UTF-8?q?=E2=80=A2=20Fixed=20false=20=E2=80=9Cgate=20opened=E2=80=9D=20h?= =?UTF-8?q?andling=20where=20the=20walker=20clicked=20near=20a=20gate,=20f?= =?UTF-8?q?ailed=20to=20open=20it,=20but=20still=20treated=20it=20as=20suc?= =?UTF-8?q?cessful.=20=E2=80=A2=20Reworked=20post-door=20edge=20nudging=20?= =?UTF-8?q?so=20it=20only=20counts=20as=20successful=20if=20the=20player?= =?UTF-8?q?=20actually=20reaches=20or=20crosses=20the=20target=20door=20ed?= =?UTF-8?q?ge.=20=E2=80=A2=20Added=20unresolved=20nudge=20logging=20with?= =?UTF-8?q?=20door=5Fedge=5Fnudge=5Funresolved.=20=E2=80=A2=20Improved=20d?= =?UTF-8?q?oorStillHasAction=20to=20rescan=20nearby/segment=20objects=20in?= =?UTF-8?q?stead=20of=20relying=20only=20on=20the=20exact=20probe=20tile.?= =?UTF-8?q?=20=E2=80=A2=20Added=20stale=20sticky=20interim=20cleanup:=20?= =?UTF-8?q?=E2=97=A6=20clears=20interim=20checkpoint=20when=20the=20player?= =?UTF-8?q?=20is=20already=20close=20enough=20=E2=97=A6=20clears=20expired?= =?UTF-8?q?=20interim=20checkpoints=20=E2=97=A6=20clears=20invalid=20inter?= =?UTF-8?q?im=20checkpoints=20=E2=97=A6=20clears=20interim=20state=20on=20?= =?UTF-8?q?stall=20recalculation=20=E2=80=A2=20Added=20interim=5Fclear=20d?= =?UTF-8?q?iagnostic=20logging.=20=E2=80=A2=20Documented=20movement=20gotc?= =?UTF-8?q?has=20for=20gate=20edge=20handling=20and=20sticky=20interim=20c?= =?UTF-8?q?leanup=20in=20docs/entity-guides/movement.md.=20=E2=80=A2=20Add?= =?UTF-8?q?ed=20unit=20tests=20for:=20=E2=97=A6=20diagonal=20gate=20segmen?= =?UTF-8?q?t=20detection=20=E2=97=A6=20wrong-neighbor=20door-edge=20nudge?= =?UTF-8?q?=20rejection=20=E2=97=A6=20successful=20door-edge=20nudge=20res?= =?UTF-8?q?olution=20=E2=97=A6=20stale=20interim=20checkpoint=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit currently Testing for regression --- docs/entity-guides/movement.md | 53 ++ .../microbot/util/walker/Rs2Walker.java | 617 ++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 59 ++ 3 files changed, 683 insertions(+), 46 deletions(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 470dba4efd..63986f7471 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -178,3 +178,56 @@ while (!Thread.currentThread().isInterrupted() && !condition.getAsBoolean()) { **Where this applies:** `Global.sleep*`, `Global.sleepUntil*`, `Rs2Walker.setTarget(null)`, and any walker helper that waits after clicking a door, shortcut, transport, or minimap tile. **Defensive check:** Start a long webwalk, press Ctrl+X during movement or a route-object wait, and verify no additional path recalculations or route-object interactions occur after the cancel log. + +## 10. Do not treat reachable endpoint tiles as proof that a gate edge is open + +Local reachability answers whether individual tiles can be reached within the sampled area; it does not prove that the computed path edge between two reachable tiles can be crossed without opening a gate, door, stile, or similar route object. Before skipping door handling, issuing a direct short minimap/checkpoint click, or yielding to an in-flight interim minimap target, scan the nearby remaining route for door-like scene objects that sit on the route segment. Include one or two raw edges before the closest path index; when the player is slightly off-path near a gate, the closest raw tile can already be on the far side of the gate edge. For diagonal hops beside small gates, also check the two cardinal sub-steps of the diagonal; the gate edge may sit on one of those sub-steps even when the direct diagonal segment does not equal the wall edge. Do not skip door probing just because the edge or object is catalogued as a transport when it is an `Open Gate` / door-like object transport. A raw-route scan may notice a future gate early, but the actual object interaction must still be range-gated against that gate edge before treating it as handled. + +**Why this matters:** A short route near the Lumbridge farm allotment can correctly choose the gate as the shortest path, but the walker may see both sides as valid reachable tiles and click the minimap endpoint. The game then routes around the fence instead of opening the gate. + +**Pattern to follow:** + +```java +int scanStart = Math.max(0, closestRawIndex - 2); +if (bothEndpointTilesReachable + && !hasDoorLikeSceneObjectOnSegment(from, to, playerLoc, HANDLER_RANGE)) { + continue; +} +if (doorOpenedButPlayerDidNotTraverse) { + // Only count this as success if the nudge actually reaches/crosses the door edge. + tryDoorEdgeCrossNudge(from, to, currentTarget); +} +if (hasPendingDoorLikeSceneObjectBeforeDirectClick(rawPath, path, playerLoc, DIRECT_CLICK_MAX_DISTANCE) + || handlePendingDoorBeforeRouteClick(rawPath, path, i, targetIdx, smoothedToRaw, timeoutMs, + attemptedDoorEdgesThisPass, playerLoc) + || handlePendingDoorNearRawPath(rawPath, timeoutMs, attemptedDoorEdgesThisPass, playerLoc, 2, 14) + || handlePendingDoorDuringInterim(rawPath, timeoutMs, attemptedDoorEdgesThisPass, playerLoc)) { + return WalkerState.MOVING; +} +``` + +**Where this applies:** `Rs2Walker.tryDirectShortWalk`, route checkpoint/minimap click selection, unreachable-smoothed-tile recovery, interim minimap movement waits, post-open door-edge nudges, `Rs2Walker.handleDoorsInRawSegment`, and any future optimization that skips route-object probing because tiles look locally reachable. + +**Defensive check:** Reproduce from the Lumbridge farm road toward a target southwest of the allotment with both gates closed; the route should open each gate as it enters handler range, not stay in `interim-in-flight` until the server path has already routed around the field. + +## 11. Clear sticky minimap interim targets outside the click branch + +Sticky interim targets prevent click thrash while the player is moving toward a minimap checkpoint, but they are only useful while the checkpoint is still ahead. Clear them at the start of each walk pass when the player is already within the close threshold, the target is on another plane, or the checkpoint has aged out. Also clear them when stall-recalc fires, otherwise the replan can inherit the same stale checkpoint and spin without issuing a new movement command. + +**Why this matters:** Long post-transport routes through cluttered areas can stop one tile from the sticky interim target. If the next pass does not enter the checkpoint-click branch, the stale interim remains in diagnostics and each stall-recalc repeats the same state until the tail iteration limit exits. + +**Pattern to follow:** + +```java +if (shouldClearInterimTarget(interimTargetWp, Rs2Player.getWorldLocation(), interimSetAtMs, nowMs)) { + clearInterimTarget("close-or-expired"); +} +if (isStuckTooLong()) { + clearInterimTarget("stall-recalc"); + recalculatePath(); +} +``` + +**Where this applies:** `Rs2Walker.processWalk`, recovery minimap clicks, post-transport walking, and any future logic that stores a sticky route checkpoint across loop iterations. + +**Defensive check:** Reproduce a long route after the Falador crumbling-wall shortcut toward Ardougne through the dead-tree field; if the player reaches one tile from the interim checkpoint, the next pass should log `interim_clear` and select a fresh movement target instead of repeating `STALL_RECALC` until `tail_max`. 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 f58762a457..18891a7b43 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 @@ -147,6 +147,9 @@ public static WorldPoint getCurrentTarget() { private static final int PATHFINDER_DONE_RETRY_SLEEP_MAX_MS = 220; private static final long STARTUP_FIRST_CLICK_BUDGET_MS = 2200L; private static final int POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN = 13; + private static final int POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER = 3; + private static final int POST_DOOR_EDGE_NUDGE_WAIT_MS = 1200; + private static final int HANDLER_RANGE = 13; private static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; private static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; private static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; @@ -1250,6 +1253,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; + clearInterimTarget("stall-recalc"); setTarget(target); continue; } @@ -1282,6 +1286,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part primeExpectedTransportDestinations(path, indexOfStartPoint); lastPosition = Rs2Player.getWorldLocation(); + clearInterimTargetIfReachedOrExpired(lastPosition, System.currentTimeMillis()); WorldPoint plImmediate = lastPosition; WorldPoint pathLastForImmediate = path.isEmpty() ? null : path.get(path.size() - 1); @@ -1337,7 +1342,6 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part boolean doorOrTransportResult = false; boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); String exitReason = "end-of-path"; - final int HANDLER_RANGE = 13; Map doorEdgesAttemptedThisTail = new HashMap<>(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); @@ -1591,16 +1595,6 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part break; } } - if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, - null)) { - exitReason = "door-handled-unreachable"; - break; - } - if (isRecoveryMovementInFlight()) { - exitReason = "recovery-move-in-flight"; - break; - } boolean gateDoorInteraction = isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown(); long recentDoorAgeMs = recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart); boolean pendingDoorTraversal = recentDoorAgeMs >= 0 @@ -1618,6 +1612,21 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part exitReason = "door-traversal-pending-yield"; break; } + if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), + doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { + exitReason = "door-handled-unreachable-raw-scan"; + break; + } + if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, + obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + null)) { + exitReason = "door-handled-unreachable"; + break; + } + if (isRecoveryMovementInFlight()) { + exitReason = "recovery-move-in-flight"; + break; + } // Fallback: only interact with objects on/adjacent to blocked path edges // within ~15 tiles. Prevents clicking already-open / unrelated doors. final long nowMs = System.currentTimeMillis(); @@ -1749,6 +1758,19 @@ && walkFastCanvas(recoverTarget)) { // we get close. If we've stopped (no movement), re-click the same interim // rather than spinning without issuing movement commands. if (Rs2Player.isMoving()) { + if (!inInstance && handlePendingDoorDuringInterim(rawPath, + obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + playerLoc)) { + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + doorOrTransportResult = true; + exitReason = "door-handled-during-interim"; + break; + } final WorldPoint posBeforeWait = playerLoc; sleepUntil(() -> interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= INTERIM_CLOSE_TILES @@ -1867,6 +1889,13 @@ && walkFastCanvas(recoverTarget)) { clickTarget = rawReachableTarget; } } + if (!inInstance && handlePendingDoorBeforeRouteClick(rawPath, path, i, targetIdx, + smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + playerLoc)) { + doorOrTransportResult = true; + exitReason = "door-handled-before-minimap-click"; + break; + } long nowBeforeClick = System.currentTimeMillis(); if (lastTransportHandledAtMs > 0 && nowBeforeClick - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { @@ -3019,6 +3048,11 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, || hasPendingExplicitTransportStepBeforeArrival(path, target, distance)) { return WalkerState.MOVING; } + if (!inInstance && hasPendingDoorLikeSceneObjectBeforeDirectClick(rawPath, path, playerLoc, + directClickMaxDistance)) { + log.debug("[Walker] defer tryDirectShortWalk minimap: route has pending door/gate scene object"); + return WalkerState.MOVING; + } if (!inInstance && !Rs2Tile.isWalkable(end)) { return WalkerState.MOVING; @@ -3154,6 +3188,158 @@ private static boolean localRouteDetoursFromComputedRoute(List rawPa return localSteps == null || localSteps > computedSteps + detourSlackTiles; } + private static boolean hasPendingDoorLikeSceneObjectBeforeDirectClick(List rawPath, + List path, + WorldPoint playerLoc, + int directClickMaxDistance) { + List route = rawPath != null && rawPath.size() >= 2 ? rawPath : path; + if (route == null || route.size() < 2 || playerLoc == null) { + return false; + } + + int closest = getClosestTileIndex(route); + if (closest < 0 || closest >= route.size()) { + return false; + } + + int maxEdges = 12; + int radius = Math.max(3, directClickMaxDistance + 2); + int start = Math.max(0, closest - 2); + int endExclusive = Math.min(route.size() - 1, start + maxEdges); + for (int i = start; i < endExclusive; i++) { + WorldPoint from = route.get(i); + WorldPoint to = route.get(i + 1); + if (from == null || to == null) { + continue; + } + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { + break; + } + if (from.distanceTo2D(playerLoc) > radius && to.distanceTo2D(playerLoc) > radius) { + break; + } + if (isCatalogBackedTransportSegment(route, i) && !isDoorLikeCatalogTransportSegment(route, i)) { + continue; + } + if (hasDoorLikeSceneObjectOnSegment(from, to, playerLoc, radius)) { + return true; + } + } + return false; + } + + private static boolean handlePendingDoorBeforeRouteClick(List rawPath, + List path, + int fromPathIdx, + int targetPathIdx, + int[] smoothedToRaw, + long timeoutMs, + Map attempted, + WorldPoint playerLoc) { + if (rawPath == null || rawPath.size() < 2 || path == null || path.isEmpty() + || playerLoc == null || targetPathIdx < fromPathIdx) { + return false; + } + + int rawStart = rawIndexForSmoothedIndex(fromPathIdx, smoothedToRaw, rawPath); + int rawTarget = rawIndexForSmoothedIndex(targetPathIdx, smoothedToRaw, rawPath); + if (rawStart < 0 || rawTarget < 0) { + return false; + } + + int from = Math.max(0, Math.min(rawStart, rawTarget) - 2); + int toExclusive = Math.min(rawPath.size() - 1, Math.max(rawStart, rawTarget) + 1); + for (int ri = from; ri < toExclusive && ri < rawPath.size() - 1; ri++) { + WorldPoint a = rawPath.get(ri); + WorldPoint b = rawPath.get(ri + 1); + if (a == null || b == null) { + continue; + } + if (a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { + break; + } + if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { + continue; + } + if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + continue; + } + if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { + continue; + } + if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + return true; + } + } + return false; + } + + private static boolean handlePendingDoorDuringInterim(List rawPath, + long timeoutMs, + Map attempted, + WorldPoint playerLoc) { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null + || isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown() + || isRecoveryMovementInFlight()) { + return false; + } + + return handlePendingDoorNearRawPath(rawPath, timeoutMs, attempted, playerLoc, 2, 14); + } + + private static boolean handlePendingDoorNearRawPath(List rawPath, + long timeoutMs, + Map attempted, + WorldPoint playerLoc, + int backtrackEdges, + int lookaheadEdges) { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { + return false; + } + + int rawStart = getClosestTileIndex(rawPath); + if (rawStart < 0) { + return false; + } + + int start = Math.max(0, rawStart - Math.max(0, backtrackEdges)); + int endExclusive = Math.min(rawPath.size() - 1, rawStart + Math.max(1, lookaheadEdges)); + for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { + WorldPoint a = rawPath.get(ri); + WorldPoint b = rawPath.get(ri + 1); + if (a == null || b == null) { + continue; + } + if (a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { + break; + } + if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { + continue; + } + if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + continue; + } + if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { + continue; + } + if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + return true; + } + } + return false; + } + + private static int rawIndexForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, List rawPath) { + if (rawPath == null || rawPath.isEmpty()) { + return -1; + } + if (smoothedToRaw != null && smoothedIdx >= 0 && smoothedIdx < smoothedToRaw.length) { + return Math.max(0, Math.min(smoothedToRaw[smoothedIdx], rawPath.size() - 1)); + } + int closest = getClosestTileIndex(rawPath); + return closest >= 0 ? closest : -1; + } + private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange, WorldPoint target) { if (rawPath == null || rawPath.size() < 2) { return false; @@ -3194,7 +3380,7 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } clearRawScanDoorFocus("focus-invalid"); - int start = Math.max(0, rawStart - 1); + int start = Math.max(0, rawStart - 2); int endExclusive = Math.min(rawPath.size() - 1, rawStart + 12); for (int i = start; i < endExclusive; i++) { WorldPoint currentWorldPoint = rawPath.get(i); @@ -3241,7 +3427,7 @@ private static boolean hasDoorCandidateOnRawSegment(List rawPath, in if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { return false; } - if (isCatalogBackedTransportSegment(rawPath, index)) { + if (isCatalogBackedTransportSegment(rawPath, index) && !isDoorLikeCatalogTransportSegment(rawPath, index)) { return false; } boolean isInstance = Microbot.getClient() @@ -3680,7 +3866,7 @@ private static boolean handleDoors(List path, int index, boolean all return false; } - if (isCatalogBackedTransportSegment(path, index)) { + if (isCatalogBackedTransportSegment(path, index) && !isDoorLikeCatalogTransportSegment(path, index)) { return false; } @@ -3722,7 +3908,11 @@ private static boolean handleDoors(List path, int index, boolean all object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); } if (object == null) continue; - if (isCatalogTransportObject(object)) { + if (!isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { + Telemetry.recordDoorReject("door-out-of-range"); + continue; + } + if (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) { Telemetry.recordDoorReject("catalog-transport-object"); continue; } @@ -3789,7 +3979,6 @@ && isNullOrPlaceholderObjectName(comp.getName())) { if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { WebWalkLog.spInfo("door_attempt_throttled | mode=segment-door probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - markStationaryDoorOpened(probe); return false; } if (shouldThrottleGlobalDoorInteraction()) { @@ -3806,13 +3995,11 @@ && isNullOrPlaceholderObjectName(comp.getName())) { } catch (Exception ex) { WebWalkLog.spInfo("door_interact_exception | mode=segment-door probe={} from={} to={} ex={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); - markStationaryDoorOpened(probe); return false; } if (!interacted) { WebWalkLog.spInfo("door_interact_failed | mode=segment-door probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - markStationaryDoorOpened(probe); return false; } markDoorInteractionSettling(); @@ -3836,11 +4023,16 @@ && isNullOrPlaceholderObjectName(comp.getName())) { log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", probe, fromWp, toWp, posBefore, posAfter); } - if (doorStillHasAction(probe, doorActions, action)) { + if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); + } else { + markStationaryDoorOpened(probe); + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + return true; + } } - markStationaryDoorOpened(probe); return false; } markStationaryDoorOpened(probe); @@ -3876,7 +4068,7 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp if (loc.distanceTo2D(playerLoc) > searchDistance) return false; if (sessionBlacklistedDoors.contains(loc)) return false; if (!(o instanceof WallObject) && !(o instanceof GameObject)) return false; - if (isCatalogTransportObject(o)) return false; + if (isCatalogTransportObject(o) && !isDoorLikeSceneObject(o)) return false; if (!isDoorOnSegment(o, fromWp, toWp)) return false; ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); if (!isDoorComposition(comp, doorActions)) return false; @@ -3889,7 +4081,11 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, boolean allowSegmentProbe) { if (object == null || probe == null) return false; - if (isCatalogTransportObject(object)) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (!isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { + return false; + } + if (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) { return false; } @@ -3927,7 +4123,6 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { WebWalkLog.spInfo("door_attempt_throttled | mode=segment-probe probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - markStationaryDoorOpened(probe); return false; } if (shouldThrottleGlobalDoorInteraction()) { @@ -3944,13 +4139,11 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, } catch (Exception ex) { WebWalkLog.spInfo("door_interact_exception | mode=segment-probe probe={} from={} to={} ex={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); - markStationaryDoorOpened(probe); return false; } if (!interacted) { WebWalkLog.spInfo("door_interact_failed | mode=segment-probe probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - markStationaryDoorOpened(probe); return false; } markDoorInteractionSettling(); @@ -3980,24 +4173,56 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return true; } - if (doorStillHasAction(probe, doorActions, action)) { + if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); + } else { + markStationaryDoorOpened(probe); + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + return true; + } } - markStationaryDoorOpened(probe); return false; } - private static boolean doorStillHasAction(WorldPoint probe, List doorActions, String action) { + private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { if (probe == null || action == null) { return false; } - WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); - TileObject object = wall != null - ? wall - : Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); - if (object == null) { + WorldPoint anchor = Rs2Player.getWorldLocation(); + if (anchor == null || anchor.getPlane() != probe.getPlane()) { + anchor = probe; + } + + TileObject object = Rs2GameObject.getAll(o -> doorObjectStillHasAction(o, probe, fromWp, toWp, doorActions, action), + anchor, Math.max(3, HANDLER_RANGE)) + .stream() + .findFirst() + .orElse(null); + return object != null; + } + + private static boolean doorObjectStillHasAction(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + if (object == null || object.getWorldLocation() == null || action == null) { + return false; + } + if (!(object instanceof WallObject) && !(object instanceof GameObject)) { + return false; + } + WorldPoint loc = object.getWorldLocation(); + if (probe != null && loc.getPlane() != probe.getPlane()) { + return false; + } + if (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) { + return false; + } + boolean nearProbe = probe != null && loc.distanceTo2D(probe) <= 2; + boolean onSegment = fromWp != null && toWp != null && isDoorOnSegment(object, fromWp, toWp); + if (!nearProbe && !onSegment) { return false; } ObjectComposition composition = Rs2GameObject.convertToObjectComposition(object); @@ -4139,6 +4364,118 @@ private static boolean tryPostDoorFastMinimapClick(List path, int ed return clicked; } + private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target) { + if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { + return false; + } + WorldPoint before = Rs2Player.getWorldLocation(); + if (before == null || before.getPlane() != toWp.getPlane()) { + return false; + } + if (before.equals(toWp)) { + return true; + } + if (before.distanceTo2D(toWp) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { + return false; + } + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + + boolean clicked = walkFastCanvas(toWp); + if (!clicked) { + clicked = walkMiniMapToward(toWp, before, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + } + if (!clicked) { + return false; + } + + markFirstMovementClick("first_door_edge_nudge", target, before, "to=" + compactWorldPoint(toWp)); + sleepUntil(() -> { + if (isWalkCancelled(target)) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + return isDoorEdgeNudgeResolved(before, now, fromWp, toWp); + }, POST_DOOR_EDGE_NUDGE_WAIT_MS); + + WorldPoint after = Rs2Player.getWorldLocation(); + boolean progressed = isDoorEdgeNudgeResolved(before, after, fromWp, toWp); + if (progressed) { + WebWalkLog.tmark("door_edge_nudge", System.currentTimeMillis() - walkSessionStartedAtMs, + target, before, "from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + } else { + WebWalkLog.spInfo("door_edge_nudge_unresolved | from={} to={} before={} after={}", + compactWorldPoint(fromWp), compactWorldPoint(toWp), compactWorldPoint(before), compactWorldPoint(after)); + } + return progressed; + } + + static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, WorldPoint fromWp, WorldPoint toWp) { + if (before == null || after == null || fromWp == null || toWp == null) { + return false; + } + if (before.equals(after)) { + return false; + } + if (before.getPlane() != after.getPlane() + || after.getPlane() != fromWp.getPlane() + || after.getPlane() != toWp.getPlane()) { + return false; + } + int beforeTo = before.distanceTo2D(toWp); + int afterTo = after.distanceTo2D(toWp); + if (after.equals(toWp) || afterTo == 0) { + return true; + } + return afterTo <= 1 && afterTo < beforeTo; + } + + static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc, long setAtMs, long nowMs) { + if (interim == null) { + return false; + } + if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { + return true; + } + if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + return true; + } + return setAtMs > 0L && nowMs - setAtMs > INTERIM_MAX_AGE_MS; + } + + private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc, long nowMs) { + WorldPoint interim = interimTargetWp; + if (!shouldClearInterimTarget(interim, playerLoc, interimSetAtMs, nowMs)) { + return false; + } + String reason; + if (playerLoc == null || interim == null || playerLoc.getPlane() != interim.getPlane()) { + reason = "invalid"; + } else if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + reason = "close"; + } else { + reason = "expired"; + } + clearInterimTarget(reason); + return true; + } + + private static void clearInterimTarget(String reason) { + WorldPoint old = interimTargetWp; + if (old != null) { + WebWalkLog.spInfo("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + } + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + } + private static boolean shouldThrottleGlobalDoorInteraction() { return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(nextDoorInteractionAllowedAtMs); } @@ -4276,6 +4613,23 @@ private static boolean isCatalogBackedTransportSegment(WorldPoint from, WorldPoi return false; } + private static boolean isDoorLikeCatalogTransportSegment(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return false; + } + return isDoorLikeCatalogTransportSegment(path.get(index), path.get(index + 1)); + } + + private static boolean isDoorLikeCatalogTransportSegment(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return false; + } + return hasDoorLikeDirectedCatalogTransport(from, to) + || hasDoorLikeDirectedCatalogTransport(to, from) + || hasDoorLikeAdjacentOriginShortTransportHop(from, to) + || hasDoorLikeAdjacentOriginShortTransportHop(to, from); + } + private static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, WorldPoint dest) { if (origin == null || dest == null) { return false; @@ -4287,6 +4641,18 @@ private static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, Wo return transports.stream().anyMatch(t -> Objects.equals(t.getDestination(), dest)); } + private static boolean hasDoorLikeDirectedCatalogTransport(WorldPoint origin, WorldPoint dest) { + if (origin == null || dest == null) { + return false; + } + Set transports = ShortestPathPlugin.getTransports().get(origin); + if (transports == null || transports.isEmpty()) { + return false; + } + return transports.stream() + .anyMatch(t -> Objects.equals(t.getDestination(), dest) && isDoorLikeCatalogTransport(t)); + } + /** * True when some catalog origin one step from {@code from} has a same-plane adjacent transport to {@code to}. * Restricted to {@link #isAdjacentSamePlaneTransport} rows so long-distance transports do not suppress doors. @@ -4315,6 +4681,52 @@ private static boolean matchesAdjacentOriginShortTransportHop(WorldPoint from, W return false; } + private static boolean hasDoorLikeAdjacentOriginShortTransportHop(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + return false; + } + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) { + continue; + } + WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); + Set transports = ShortestPathPlugin.getTransports().get(catalogOrigin); + if (transports == null || transports.isEmpty()) { + continue; + } + for (Transport t : transports) { + if (Objects.equals(t.getDestination(), to) + && isAdjacentSamePlaneTransport(t) + && isDoorLikeCatalogTransport(t)) { + return true; + } + } + } + } + return false; + } + + private static boolean isDoorLikeCatalogTransport(Transport transport) { + if (transport == null || transport.getType() != TransportType.TRANSPORT) { + return false; + } + return isDoorLikeGameObjectName(transport.getName()) + || isDoorLikeGameObjectName(transport.getDisplayInfo()) + || isDoorLikeTransportAction(transport.getAction()); + } + + private static boolean isDoorLikeTransportAction(String action) { + if (action == null) { + return false; + } + String al = action.toLowerCase(Locale.ROOT); + return al.startsWith("open") + || al.startsWith("pass") + || al.startsWith("go-through") + || al.startsWith("walk-through"); + } + /** * True when this scene object is the interactable listed on a transport catalog row (same * coordinates and object ids as TSV loaded into {@link ShortestPathPlugin#getTransports()}). @@ -4344,7 +4756,7 @@ private static boolean isCatalogTransportObject(TileObject object) { continue; } for (Transport t : transports) { - if (t != null && t.getObjectId() == id) { + if (t != null && t.getObjectId() == id && !isDoorLikeCatalogTransport(t)) { return true; } } @@ -4450,6 +4862,71 @@ private static String getDoorAction(ObjectComposition comp, List doorAct .orElse(null); } + private static boolean hasDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { + return false; + } + if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { + return false; + } + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } + + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (isPendingRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (isPendingRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + WorldPoint location = object.getWorldLocation(); + if (location.getPlane() != playerLoc.getPlane() + || location.distanceTo2D(playerLoc) > radiusTiles + || sessionBlacklistedDoors.contains(location) + || (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) + || !isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + + ObjectComposition comp = resolveCompositionForDoorProbe(object); + if (comp == null + || isNullOrPlaceholderObjectName(comp.getName()) + || doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + return false; + } + String action = pickWalkDoorAction(comp); + return isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + } + + private static boolean isDoorLikeSceneObject(TileObject object) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + ObjectComposition comp = resolveCompositionForDoorProbe(object); + if (comp == null + || isNullOrPlaceholderObjectName(comp.getName()) + || doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + return false; + } + String action = pickWalkDoorAction(comp); + return isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + } + private static boolean isDoorOnSegment(TileObject object, WorldPoint fromWp, WorldPoint toWp) { if (object == null || object.getWorldLocation() == null) return false; if (object instanceof WallObject) { @@ -4458,6 +4935,29 @@ private static boolean isDoorOnSegment(TileObject object, WorldPoint fromWp, Wor return isPointNearSegment(object.getWorldLocation(), fromWp, toWp, 1); } + private static boolean isDoorInteractionWithinRange(TileObject object, WorldPoint probe, + WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int rangeTiles) { + if (playerLoc == null || rangeTiles <= 0) { + return false; + } + int best = Integer.MAX_VALUE; + WorldPoint objectLoc = object != null ? object.getWorldLocation() : null; + if (objectLoc != null && objectLoc.getPlane() == playerLoc.getPlane()) { + best = Math.min(best, objectLoc.distanceTo2D(playerLoc)); + } + if (probe != null && probe.getPlane() == playerLoc.getPlane()) { + best = Math.min(best, probe.distanceTo2D(playerLoc)); + } + if (fromWp != null && fromWp.getPlane() == playerLoc.getPlane()) { + best = Math.min(best, fromWp.distanceTo2D(playerLoc)); + } + if (toWp != null && toWp.getPlane() == playerLoc.getPlane()) { + best = Math.min(best, toWp.distanceTo2D(playerLoc)); + } + return best <= rangeTiles; + } + static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldPoint toWp) { if (wall == null || wall.getWorldLocation() == null || fromWp == null || toWp == null) return false; if (wall.getWorldLocation().getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) return false; @@ -4469,6 +4969,17 @@ static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldP WorldPoint blockedNeighborB = getWallDoorNeighborPoint(wall.getOrientationB(), doorTile); if (blockedNeighborA == null && blockedNeighborB == null) return false; + if (fromWp.getX() != toWp.getX() && fromWp.getY() != toWp.getY()) { + WorldPoint xThenY = new WorldPoint(toWp.getX(), fromWp.getY(), fromWp.getPlane()); + WorldPoint yThenX = new WorldPoint(fromWp.getX(), toWp.getY(), fromWp.getPlane()); + if (isDoorEdgeTransitionAny(fromWp, xThenY, doorTile, blockedNeighborA, blockedNeighborB) + || isDoorEdgeTransitionAny(xThenY, toWp, doorTile, blockedNeighborA, blockedNeighborB) + || isDoorEdgeTransitionAny(fromWp, yThenX, doorTile, blockedNeighborA, blockedNeighborB) + || isDoorEdgeTransitionAny(yThenX, toWp, doorTile, blockedNeighborA, blockedNeighborB)) { + return true; + } + } + int x = fromWp.getX(); int y = fromWp.getY(); int steps = 0; @@ -4480,8 +4991,7 @@ static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldP x += Integer.signum(toWp.getX() - x); y += Integer.signum(toWp.getY() - y); WorldPoint next = new WorldPoint(x, y, fromWp.getPlane()); - if ((blockedNeighborA != null && isDoorEdgeTransition(previous, next, doorTile, blockedNeighborA)) - || (blockedNeighborB != null && isDoorEdgeTransition(previous, next, doorTile, blockedNeighborB))) { + if (isDoorEdgeTransitionAny(previous, next, doorTile, blockedNeighborA, blockedNeighborB)) { return true; } previous = next; @@ -4517,6 +5027,12 @@ private static boolean isDoorEdgeTransition(WorldPoint a, WorldPoint b, WorldPoi || (a.equals(blockedNeighbor) && b.equals(doorTile)); } + private static boolean isDoorEdgeTransitionAny(WorldPoint a, WorldPoint b, WorldPoint doorTile, + WorldPoint blockedNeighborA, WorldPoint blockedNeighborB) { + return (blockedNeighborA != null && isDoorEdgeTransition(a, b, doorTile, blockedNeighborA)) + || (blockedNeighborB != null && isDoorEdgeTransition(a, b, doorTile, blockedNeighborB)); + } + private static boolean isPointNearSegment(WorldPoint point, WorldPoint fromWp, WorldPoint toWp, int distance) { if (point == null || fromWp == null || toWp == null || point.getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) { return false; @@ -4549,6 +5065,12 @@ private static boolean handleDoorsWithTimeout(List path, int index, private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, Map attemptedDoorEdgesThisPass) { + return handleDoorsWithTimeout(path, index, timeoutMs, attemptedDoorEdgesThisPass, false); + } + + private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, + Map attemptedDoorEdgesThisPass, + boolean allowSegmentProbe) { long start = System.currentTimeMillis(); WorldPoint[] segment = resolveDoorSegment(path, index); String edgeKey = segment != null && segment.length >= 2 && segment[0] != null && segment[1] != null @@ -4560,7 +5082,7 @@ private static boolean handleDoorsWithTimeout(List path, int index, WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); return false; } - boolean handled = handleDoors(path, index); + boolean handled = handleDoors(path, index, allowSegmentProbe); if (!handled) { // Do not consume one-shot budget when no interaction happened; allow // a later resolver in the same pass to attempt this edge. @@ -4663,7 +5185,7 @@ private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int rad boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(w)) continue; + if (isCatalogTransportObject(w) && !isDoorLikeSceneObject(w)) continue; candidates++; // Allow empty-action doors: use default interact. @@ -4689,7 +5211,7 @@ private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int rad boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(g)) continue; + if (isCatalogTransportObject(g) && !isDoorLikeSceneObject(g)) continue; candidates++; String actionFinal = action == null ? "" : action; @@ -4755,7 +5277,7 @@ private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, Li boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(w)) continue; + if (isCatalogTransportObject(w) && !isDoorLikeSceneObject(w)) continue; String actionFinal = action == null ? "" : action; @@ -4792,7 +5314,7 @@ private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, Li boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(g)) continue; + if (isCatalogTransportObject(g) && !isDoorLikeSceneObject(g)) continue; String actionFinal = action == null ? "" : action; @@ -4887,7 +5409,7 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(w)) continue; + if (isCatalogTransportObject(w) && !isDoorLikeSceneObject(w)) continue; String actionFinal = action == null ? "" : action; @@ -4926,7 +5448,7 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); if (!doorLike) continue; - if (isCatalogTransportObject(g)) continue; + if (isCatalogTransportObject(g) && !isDoorLikeSceneObject(g)) continue; String actionFinal = action == null ? "" : action; @@ -5056,7 +5578,7 @@ private static void mergePathAdjCandidate( if (object == null || location == null) { return; } - if (isCatalogTransportObject(object)) { + if (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) { return; } String identity = object.getClass().getSimpleName() + "|" + object.getId() + "|" @@ -5371,7 +5893,7 @@ private static boolean tryHandleBlockingPathObjectsWithTimeout( .orElse(null); boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || action != null; if (!doorLike) continue; - if (isCatalogTransportObject(object)) continue; + if (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) continue; // Found a likely blocker on-path: hand off to existing door handler (which // includes quest-lock detection, blacklisting, and recalculation). @@ -6295,9 +6817,12 @@ private static int rawEndForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, private static boolean handleDoorsInRawSegment(List rawPath, int rawFrom, int rawTo, long timeoutMs, Map attempted, Map reachableCache) { + WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) - && reachableCache.containsKey(rawPath.get(ri + 1))) { + && reachableCache.containsKey(rawPath.get(ri + 1)) + && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), + playerLoc, HANDLER_RANGE)) { continue; } if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted)) { 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..6b2ccad805 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 @@ -416,6 +416,20 @@ public void wallDoorTouchesSegment_crossingDoorEdge_returnsTrue() { new WorldPoint(3123, 3361, 0))); } + @Test + public void wallDoorTouchesSegment_diagonalStepThroughGateCorner_returnsTrue() { + WallObject gate = mock(WallObject.class); + when(gate.getWorldLocation()).thenReturn(new WorldPoint(3240, 3302, 0)); + when(gate.getOrientationA()).thenReturn(4); // gate blocks 3240,3302 <-> 3241,3302 + + assertTrue(Rs2Walker.wallDoorTouchesSegment(gate, + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3302, 0))); + assertTrue(Rs2Walker.wallDoorTouchesSegment(gate, + new WorldPoint(3241, 3302, 0), + new WorldPoint(3240, 3301, 0))); + } + @Test public void wallDoorTouchesSegment_startingBesideDoorAndMovingAway_returnsFalse() { WallObject door = mock(WallObject.class); @@ -428,6 +442,51 @@ public void wallDoorTouchesSegment_startingBesideDoorAndMovingAway_returnsFalse( new WorldPoint(3122, 3359, 0))); } + @Test + public void isDoorEdgeNudgeResolved_movesToWrongNeighbor_returnsFalse() { + assertFalse(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3240, 3301, 0), + new WorldPoint(3239, 3302, 0), + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3302, 0))); + } + + @Test + public void isDoorEdgeNudgeResolved_crossesToDoorTarget_returnsTrue() { + assertTrue(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3302, 0), + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3302, 0))); + } + + @Test + public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { + assertTrue(Rs2Walker.shouldClearInterimTarget( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2889, 3396, 0), + 1_000L, + 2_000L)); + } + + @Test + public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { + assertTrue(Rs2Walker.shouldClearInterimTarget( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 12_000L)); + } + + @Test + public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { + assertFalse(Rs2Walker.shouldClearInterimTarget( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 5_000L)); + } + @Test public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_returnsTrue() { assertTrue(Rs2Walker.didTraverseInteractedDoor( From 4ea0fa8b7784e1bb55f5adee0ab7401afc0c4ea2 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 6 Jul 2026 18:48:11 +0100 Subject: [PATCH 2/9] Improve walker recovery around tight door blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adjusts Rs2Walker unreachable-tile recovery so it behaves better around tight door clusters. Changes: • Suppresses broad optimistic minimap recovery when an unresolved door-like object is still present on the nearby raw route. • Adds a raw-path door scan before unreachable recovery clicks, using a small backtrack/lookahead window. • Logs door_recovery_suppressed when recovery is skipped because a nearby route door should be retried instead. • Stops session-blacklisting path-adjacent fallback doors after one wrong-way traversal. • Still keeps normal segment-door blacklisting behavior intact. --- docs/entity-guides/movement.md | 22 ++++ .../microbot/util/walker/Rs2Walker.java | 106 ++++++++++++++++-- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 63986f7471..7f30e62a7a 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -231,3 +231,25 @@ if (isStuckTooLong()) { **Where this applies:** `Rs2Walker.processWalk`, recovery minimap clicks, post-transport walking, and any future logic that stores a sticky route checkpoint across loop iterations. **Defensive check:** Reproduce a long route after the Falador crumbling-wall shortcut toward Ardougne through the dead-tree field; if the player reaches one tile from the interim checkpoint, the next pass should log `interim_clear` and select a fresh movement target instead of repeating `STALL_RECALC` until `tail_max`. + +## 12. Do not let optimistic recovery override unresolved door blockers + +Unreachable-tile recovery is useful for outdoor false negatives, but in tight rooms it can fight the door resolver. If a route edge still has a door-like scene object on or adjacent to the raw path, suppress broad minimap recovery and let the door scanners retry after their normal cooldowns. Do not permanently blacklist a path-adjacent fallback door just because one attempt traversed the wrong way; in small door clusters the same object may be the correct blocker again once the player has moved to the other side. + +**Why this matters:** In POH-style tight rooms with several doors close together, a fallback door click can move the player away from the intended route. If that door tile is session-blacklisted and optimistic recovery keeps clicking route tiles beyond the blocker, the walker loops around the room until a user manually opens the final door. + +**Pattern to follow:** + +```java +if (tryResolvePathAdjacentBlocker(...)) { + return MOVING; +} +if (hasUnresolvedDoorLikeObjectNearRawPath(...)) { + return MOVING; // retry door handling next pass; do not broad-click recovery +} +clickOptimisticRecoveryTarget(); +``` + +**Where this applies:** `Rs2Walker.processWalk` unreachable-tile handling, `tryResolvePathAdjacentBlocker`, and any fallback that issues minimap recovery clicks after door/path-adjacent scans fail. + +**Defensive check:** Reproduce a route through a small room with three nearby doors and a POH portal. The walker should retry the route-door blocker and avoid repeated `unreachable optimistic recovery` loops around the room; it should not need the user to manually open the final door. 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 18891a7b43..722f535189 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 @@ -156,6 +156,8 @@ public static WorldPoint getCurrentTarget() { private static final int PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP = 6; private static final int PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP = 6; private static final int SEGMENT_DOOR_FAMILY_MARK_RADIUS = 2; + private static final int UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES = 2; + private static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; @@ -1639,6 +1641,15 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part break; } } + if (hasUnresolvedDoorLikeObjectNearRawPath(rawPath, rawEdgeStart, playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE)) { + WebWalkLog.spInfo("door_recovery_suppressed | reason=nearby-route-door idx={} tile={}", + rawEdgeStart, compactWorldPoint(currentWorldPoint)); + exitReason = "door-recovery-suppressed"; + break; + } // Door/obstacle detection above found nothing to open. The local // reachability BFS is bounded (~39 tiles) and is frequently a FALSE @@ -4887,6 +4898,86 @@ private static boolean hasDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldP return false; } + private static boolean hasUnresolvedDoorLikeObjectNearRawPath(List rawPath, + int rawEdgeStart, + WorldPoint playerLoc, + int backtrackEdges, + int lookaheadEdges, + int radiusTiles) { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null || rawEdgeStart < 0) { + return false; + } + + int start = Math.max(0, rawEdgeStart - Math.max(0, backtrackEdges)); + int endExclusive = Math.min(rawPath.size() - 1, rawEdgeStart + Math.max(1, lookaheadEdges)); + for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { + WorldPoint from = rawPath.get(ri); + WorldPoint to = rawPath.get(ri + 1); + if (from == null || to == null) { + continue; + } + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { + break; + } + if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { + continue; + } + if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + continue; + } + if (hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + private static boolean hasUnresolvedDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { + return false; + } + if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { + return false; + } + + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (isUnresolvedRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (isUnresolvedRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + private static boolean isUnresolvedRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + WorldPoint location = object.getWorldLocation(); + if (location.getPlane() != playerLoc.getPlane() + || location.distanceTo2D(playerLoc) > radiusTiles + || (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) + || !isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + + ObjectComposition comp = resolveCompositionForDoorProbe(object); + if (comp == null + || isNullOrPlaceholderObjectName(comp.getName()) + || doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + return false; + } + String action = pickWalkDoorAction(comp); + return isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + } + private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, WorldPoint playerLoc, int radiusTiles) { if (object == null || object.getWorldLocation() == null) { @@ -5548,14 +5639,15 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< } return true; } - if (bestLoc != null && shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, bestFrom, bestTo)) { - sessionBlacklistedDoors.add(bestLoc); - log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + boolean wrongTraversal = bestLoc != null && shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, bestFrom, bestTo); + if (wrongTraversal) { + log.warn("[Walker] Path-adj door traversed wrong way; not session-blacklisting fallback candidate: door={} from={} to={} before={} after={}", bestLoc, bestFrom, bestTo, posBefore, posAfter); - } - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); + } else { + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } } } log.debug("[Walker] path-adj blocker-scan interact did not traverse (at={} from={} to={} before={} after={})", From 287b52e0f03574cf96bc3cdada3ba8053d736c02 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 6 Jul 2026 20:24:36 +0100 Subject: [PATCH 3/9] Improve Rs2Walker route recovery and checkpoint pacing --- docs/entity-guides/movement.md | 33 +++ .../microbot/util/walker/Rs2Walker.java | 229 +++++++++++++++--- 2 files changed, 233 insertions(+), 29 deletions(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 7f30e62a7a..5b0637fc5a 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -232,6 +232,8 @@ if (isStuckTooLong()) { **Defensive check:** Reproduce a long route after the Falador crumbling-wall shortcut toward Ardougne through the dead-tree field; if the player reaches one tile from the interim checkpoint, the next pass should log `interim_clear` and select a fresh movement target instead of repeating `STALL_RECALC` until `tail_max`. +For long open routes, retarget before the player fully stops when they are already close to the interim checkpoint, and keep normal minimap clicks slightly inside the observed minimap edge. This reduces visible stop/start pauses and outside-clip fallback clicks without reintroducing rapid click thrash. + ## 12. Do not let optimistic recovery override unresolved door blockers Unreachable-tile recovery is useful for outdoor false negatives, but in tight rooms it can fight the door resolver. If a route edge still has a door-like scene object on or adjacent to the raw path, suppress broad minimap recovery and let the door scanners retry after their normal cooldowns. Do not permanently blacklist a path-adjacent fallback door just because one attempt traversed the wrong way; in small door clusters the same object may be the correct blocker again once the player has moved to the other side. @@ -253,3 +255,34 @@ clickOptimisticRecoveryTarget(); **Where this applies:** `Rs2Walker.processWalk` unreachable-tile handling, `tryResolvePathAdjacentBlocker`, and any fallback that issues minimap recovery clicks after door/path-adjacent scans fail. **Defensive check:** Reproduce a route through a small room with three nearby doors and a POH portal. The walker should retry the route-door blocker and avoid repeated `unreachable optimistic recovery` loops around the room; it should not need the user to manually open the final door. + +## 13. Stall recalculation must also issue fresh movement + +Recalculating a path after a stationary stall is not enough by itself. If the player is idle and the next loop still cannot enter a normal click branch, repeated `STALL_RECALC` logs can continue forever until a user manually nudges the player. After clearing stale interim state and refreshing the route, issue a conservative minimap click along the reachable raw route so the server pathfinder gets a new movement command immediately. On active long routes, also nudge after a short stationary idle window, around a few ticks, instead of waiting for the full stall threshold. + +**Why this matters:** Long routes can stop on a tile with no combat, animation, or interaction. Repeated stall recalcs refresh pathfinding state but leave the character standing still, so the route only resumes after manual movement changes the local path context. + +**Pattern to follow:** + +```java +if (isRouteActive() && playerIsIdleForShortWindow()) { + tryIssueRouteRecoveryClick(rawPath, path, target); + continue; +} +if (isStuckTooLong()) { + clearInterimTarget("stall-recalc"); + setTarget(target); + if (playerIsIdle()) { + tryIssueRouteRecoveryClick(rawPath, path, target); + } + continue; +} +``` + +**Where this applies:** `Rs2Walker.processWalk` stall-recalc handling and any future stale-state recovery that clears route state while the player is idle. + +**Defensive check:** Start a long route and observe a stationary pause. A short idle pause should log `active route idle nudge`; if it reaches full stall recalc, the next log sequence should include `stall recovery click` and a position delta, not another idle-only `STALL_RECALC` loop at the same tile. + +After a handled transport, avoid expensive path-adjacent or raw transport scans on ordinary open-ground segments unless a nearby planned transport or recent door attempt exists. Those scans are recovery tools, and on long outdoor routes a no-op scan can add several seconds before the next minimap click. + +For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off around minimap-reachable distance keeps movement continuous without rapid re-clicking. If an interim clears as close and no nearby route door/transport is pending, issue the next route-aligned continuation click immediately instead of waiting for idle-nudge recovery. 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 722f535189..1d4cd6897d 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 @@ -133,6 +133,7 @@ public static WorldPoint getCurrentTarget() { private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; private static final int INTERIM_CLOSE_TILES = 4; + private static final int INTERIM_PRECLICK_TILES = 9; private static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; private static final long INTERIM_MAX_AGE_MS = 10_000L; private static final long INTERIM_RETARGET_COOLDOWN_MS = 900L; @@ -158,6 +159,10 @@ public static WorldPoint getCurrentTarget() { private static final int SEGMENT_DOOR_FAMILY_MARK_RADIUS = 2; private static final int UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES = 2; private static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; + private static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; + private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 12; + private static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 2_500L; + private static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; @@ -177,6 +182,9 @@ public static WorldPoint getCurrentTarget() { private static volatile long lastTransportHandledAtMs = 0L; private static volatile WorldPoint lastTransportHandledAtLocation = null; private static volatile WorldPoint lastTransportOriginLocation = null; + private static volatile WorldPoint idleNudgeLastObservedLocation = null; + private static volatile long idleNudgeStationarySinceMs = 0L; + private static volatile long lastActiveRouteIdleNudgeAtMs = 0L; private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); @@ -1025,10 +1033,13 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance interimTargetWp = null; interimTargetIdx = -1; interimSetAtMs = 0L; - interimLastProgressAtMs = 0L; - interimLastBestPathIdx = -1; - interimLastRetargetAtMs = 0L; - lastPartialTransRecalcMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + lastPartialTransRecalcMs = 0L; + idleNudgeLastObservedLocation = playerLocWalk; + idleNudgeStationarySinceMs = System.currentTimeMillis(); + lastActiveRouteIdleNudgeAtMs = 0L; if (Microbot.getClient().isClientThread()) { log.warn("Please do not call the walker from the main thread"); @@ -1233,6 +1244,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; } + if (shouldIssueActiveRouteIdleNudge() + && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + continue; + } if (isStuckTooLong()) { // Leagues area teleports can have long animations. Never trigger stall-recalc // while the transport is in-flight, or we will interrupt and re-click. @@ -1257,6 +1272,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part stuckCount = 0; clearInterimTarget("stall-recalc"); setTarget(target); + if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { + tryIssueRouteRecoveryClick(rawPath, path, target, "stall recovery click"); + } continue; } if (stuckCount > 10) { @@ -1288,7 +1306,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part primeExpectedTransportDestinations(path, indexOfStartPoint); lastPosition = Rs2Player.getWorldLocation(); - clearInterimTargetIfReachedOrExpired(lastPosition, System.currentTimeMillis()); + boolean clearedInterimTarget = clearInterimTargetIfReachedOrExpired(lastPosition, System.currentTimeMillis()); WorldPoint plImmediate = lastPosition; WorldPoint pathLastForImmediate = path.isEmpty() ? null : path.get(path.size() - 1); @@ -1302,6 +1320,14 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.ARRIVED; } } + if (clearedInterimTarget + && !Rs2Player.isInteracting() + && !Rs2Player.isAnimating() + && !isDoorInteractionSettling() + && !isTransportInteractionSettling() + && tryIssueRouteContinuationClick(rawPath, path, target)) { + continue; + } manageRunEnergy(path.size()); @@ -1477,11 +1503,13 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } int segDistance = currentWorldPoint.distanceTo2D(playerNearSeg); if (segDistance <= HANDLER_RANGE) { - boolean skipPostTransportSegmentHandlers = recentTransportWindow - && !hasUpcomingNearbyTransportStep(path, i, playerNearSeg, + boolean upcomingNearbyTransport = hasUpcomingNearbyTransportStep(path, i, playerNearSeg, POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, - POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST) - && !hasRecentDoorAttemptNearIndex(path, i) + POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST); + boolean recentDoorAttemptNearSegment = hasRecentDoorAttemptNearIndex(path, i); + boolean skipPostTransportSegmentHandlers = recentTransportWindow + && !upcomingNearbyTransport + && !recentDoorAttemptNearSegment && !isDoorInteractionSettling() && !isRecoveryMovementInFlight() && reachableTilesCache.containsKey(currentWorldPoint); @@ -1506,7 +1534,11 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } // Chain step 2: path-adjacent probes after exact segment-door attempt. - if (!Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe()) { + boolean allowPathAdjacentProbe = !recentTransportWindow + || upcomingNearbyTransport + || recentDoorAttemptNearSegment; + if (!Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() + && allowPathAdjacentProbe) { if (tryHandleBlockingPathObjectsWithTimeout(rawPath, rawI, 5, 10, obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { tmarkPostTransport("post_transport_segment_handler", target, @@ -1525,7 +1557,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part break; } - if (PohTeleports.isInHouse() || !inInstance) { + boolean allowSegmentTransportScan = !recentTransportWindow || upcomingNearbyTransport; + if ((PohTeleports.isInHouse() || !inInstance) && allowSegmentTransportScan) { doorOrTransportResult = handleTransportsInRawSegment(rawPath, rawI, rawEnd); } @@ -1629,22 +1662,25 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part exitReason = "recovery-move-in-flight"; break; } + boolean unresolvedDoorNearRawPath = hasUnresolvedDoorLikeObjectNearRawPath(rawPath, + rawEdgeStart, playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE); // Fallback: only interact with objects on/adjacent to blocked path edges // within ~15 tiles. Prevents clicking already-open / unrelated doors. final long nowMs = System.currentTimeMillis(); if (!gateDoorInteraction + && unresolvedDoorNearRawPath && obstaclePolicy.allowNearbyFallback() && nowMs - lastDoorPathAdjAttemptAtMs > 1200) { lastDoorPathAdjAttemptAtMs = nowMs; - if (tryResolvePathAdjacentBlocker(playerLoc, rawPath, rawEdgeStart, 5, 15)) { + if (tryResolvePathAdjacentBlocker(playerLoc, rawPath, rawEdgeStart, 3, 10)) { exitReason = "door-handled-path-adj-scan"; break; } } - if (hasUnresolvedDoorLikeObjectNearRawPath(rawPath, rawEdgeStart, playerLoc, - UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, - UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, - HANDLER_RANGE)) { + if (unresolvedDoorNearRawPath) { WebWalkLog.spInfo("door_recovery_suppressed | reason=nearby-route-door idx={} tile={}", rawEdgeStart, compactWorldPoint(currentWorldPoint)); exitReason = "door-recovery-suppressed"; @@ -1755,7 +1791,7 @@ && walkFastCanvas(recoverTarget)) { // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). - final int MINIMAP_REACH_EUCLIDEAN = 13; + final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; WorldPoint playerLoc = Rs2Player.getWorldLocation(); // Checkpoint-style walking: once we set a minimap flag, let the player actually @@ -1784,17 +1820,23 @@ && walkFastCanvas(recoverTarget)) { } final WorldPoint posBeforeWait = playerLoc; sleepUntil(() -> - interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= INTERIM_CLOSE_TILES + interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= INTERIM_PRECLICK_TILES || !Rs2Player.isMoving(), - 2000); - if (posBeforeWait.distanceTo2D(Rs2Player.getWorldLocation()) > 0 || Rs2Player.isMoving()) { + 650); + WorldPoint posAfterWait = Rs2Player.getWorldLocation(); + if ((posAfterWait != null && posBeforeWait.distanceTo2D(posAfterWait) > 0) + || Rs2Player.isMoving()) { lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; } - exitReason = "interim-in-flight"; - walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", - interimFinal, interimDist, playerLoc); - break; + boolean readyForNextClick = posAfterWait != null + && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_PRECLICK_TILES; + if (!readyForNextClick && Rs2Player.isMoving()) { + exitReason = "interim-in-flight"; + walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", + interimFinal, interimDist, playerLoc); + break; + } } else { // Not moving but still far from the interim checkpoint. Treat the interim // as stale and pick a fresh checkpoint below (could still resolve to the @@ -1955,7 +1997,7 @@ && walkFastCanvas(recoverTarget)) { WorldPoint now = Rs2Player.getWorldLocation(); if (b.distanceTo2D(now) <= proximityWake) return true; return before.distanceTo2D(now) >= progressCap; - }, 2000); + }, 1200); WorldPoint afterClickWait = Rs2Player.getWorldLocation(); if (afterClickWait != null && afterClickWait.equals(before) && !Rs2Player.isMoving() && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { @@ -1963,7 +2005,7 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { if (isWalkCancelled(target)) return true; WorldPoint now = Rs2Player.getWorldLocation(); return now != null && (b.distanceTo2D(now) <= proximityWake || !now.equals(before) || Rs2Player.isMoving()); - }, 2000); + }, 1200); } if (walkCancelledDiag(target, "processWalk:after-click-wait", processWalkTail)) { return WalkerState.EXIT; @@ -2000,7 +2042,7 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; interimLastRetargetAtMs = 0L; - sleepUntil(() -> isWalkCancelled(target) || !Rs2Player.isMoving(), 2000); + sleepUntil(() -> isWalkCancelled(target) || !Rs2Player.isMoving(), 1200); if (walkCancelledDiag(target, "processWalk:after-click-failed-wait", processWalkTail)) { return WalkerState.EXIT; } @@ -2482,6 +2524,135 @@ private static WorldPoint findFurthestReachableRawPathPoint(List raw return best; } + private static boolean shouldIssueActiveRouteIdleNudge() { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + long now = System.currentTimeMillis(); + if (playerLoc == null || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Player.isInteracting() + || Rs2LeaguesTransport.isTeleportInProgress() + || Rs2LeaguesTransport.isLeaguesAreaTeleportPending(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { + idleNudgeLastObservedLocation = playerLoc; + idleNudgeStationarySinceMs = now; + return false; + } + if (!playerLoc.equals(idleNudgeLastObservedLocation)) { + idleNudgeLastObservedLocation = playerLoc; + idleNudgeStationarySinceMs = now; + return false; + } + if (idleNudgeStationarySinceMs <= 0L) { + idleNudgeStationarySinceMs = now; + return false; + } + return now - idleNudgeStationarySinceMs >= ACTIVE_ROUTE_IDLE_NUDGE_MS + && now - lastActiveRouteIdleNudgeAtMs >= ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS; + } + + private static boolean tryIssueRouteRecoveryClick(List rawPath, + List path, + WorldPoint target, + String logLabel) { + return tryIssueRouteMovementClick(rawPath, path, target, logLabel, + STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN, true); + } + + private static boolean tryIssueRouteContinuationClick(List rawPath, + List path, + WorldPoint target) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || path == null || path.isEmpty()) { + return false; + } + int rawIdx = getClosestTileIndex(rawPath); + if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, + rawIdx, + playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE)) { + return false; + } + int pathIdx = Math.max(0, getClosestTileIndex(path)); + if (hasUpcomingNearbyTransportStep(path, pathIdx, playerLoc, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { + return false; + } + return tryIssueRouteMovementClick(rawPath, path, target, "interim close route click", + NORMAL_MINIMAP_REACH_EUCLIDEAN, false); + } + + private static boolean tryIssueRouteMovementClick(List rawPath, + List path, + WorldPoint target, + String logLabel, + int maxEuclidean, + boolean markRecoveryCooldown) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || path == null || path.isEmpty()) { + return false; + } + + WorldPoint clickTarget = findFurthestReachableRawPathPoint( + rawPath, + playerLoc, + maxEuclidean); + int targetIdx = getClosestTileIndex(path); + if (clickTarget == null) { + int startIdx = Math.max(0, targetIdx); + int clickableIdx = findFurthestClickableIndex(path, startIdx, playerLoc, + wp -> { + Set ts = ShortestPathPlugin.getTransports().get(wp); + return ts != null && !ts.isEmpty(); + }, + maxEuclidean); + clickableIdx = Math.max(startIdx, Math.min(clickableIdx, path.size() - 1)); + clickTarget = path.get(clickableIdx); + targetIdx = clickableIdx; + if (euclideanSq(clickTarget, playerLoc) + > maxEuclidean * maxEuclidean) { + clickTarget = interpolateClickableTarget( + path, + startIdx, + playerLoc, + clickTarget, + maxEuclidean - 1, + Rs2Walker::isKnownWalkableOrUnloaded); + } + } + + boolean clicked = clickTarget != null + && !clickTarget.equals(playerLoc) + && (walkMiniMap(clickTarget) + || walkMiniMapToward(clickTarget, playerLoc, maxEuclidean - 1)); + log.info("[Walker] {}: clicked={} to={} player={} idx={}", + logLabel, clicked, clickTarget, playerLoc, targetIdx); + if (!clicked) { + return false; + } + + markFirstMovementClick("stall recovery click".equals(logLabel) ? "stall_recovery_click" : "active_route_idle_nudge", + target, + playerLoc, + "to=" + compactWorldPoint(clickTarget)); + interimTargetWp = clickTarget; + interimTargetIdx = targetIdx; + interimSetAtMs = System.currentTimeMillis(); + interimLastProgressAtMs = interimSetAtMs; + interimLastBestPathIdx = getClosestTileIndex(path); + interimLastRetargetAtMs = interimSetAtMs; + if (markRecoveryCooldown) { + lastUnreachableRecoveryClickAtMs = interimSetAtMs; + } + lastMovedTimeMs = interimSetAtMs; + if ("active route idle nudge".equals(logLabel)) { + lastActiveRouteIdleNudgeAtMs = interimSetAtMs; + } + idleNudgeStationarySinceMs = interimSetAtMs; + idleNudgeLastObservedLocation = playerLoc; + stuckCount = 0; + return true; + } + /** * Used in instances like vorkath, jad, nmz * @@ -4451,7 +4622,7 @@ static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { return true; } - if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { return true; } return setAtMs > 0L && nowMs - setAtMs > INTERIM_MAX_AGE_MS; @@ -4465,7 +4636,7 @@ private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc String reason; if (playerLoc == null || interim == null || playerLoc.getPlane() != interim.getPlane()) { reason = "invalid"; - } else if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + } else if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { reason = "close"; } else { reason = "expired"; From e9d3686f700d9ddda5404d4b1b710a476d706376 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Tue, 7 Jul 2026 00:47:18 +0100 Subject: [PATCH 4/9] =?UTF-8?q?Improve=20walker=20route=20continuity=20and?= =?UTF-8?q?=20route-bound=20minimap=20fallback=20mproves=20Rs2Walker=20beh?= =?UTF-8?q?avior=20on=20long=20routes=20and=20tight/shortcut-heavy=20paths?= =?UTF-8?q?.=20Changes=20include:=20=E2=80=A2=20Clear=20interim=20targets?= =?UTF-8?q?=20earlier=20when=20route=20progress=20goes=20stale,=20reducing?= =?UTF-8?q?=20visible=20stop/start=20pauses.=20=E2=80=A2=20Issue=20immedia?= =?UTF-8?q?te=20route-aligned=20continuation=20clicks=20when=20an=20interi?= =?UTF-8?q?m=20target=20is=20reached,=20without=20consuming=20tail-loop=20?= =?UTF-8?q?budget.=20=E2=80=A2=20Keep=20outside-minimap=20fallback=20click?= =?UTF-8?q?s=20constrained=20to=20the=20raw=20path=20instead=20of=20choosi?= =?UTF-8?q?ng=20generic=20reachable=20tiles=20off-route.=20=E2=80=A2=20Red?= =?UTF-8?q?uce=20interim=20pre-click=20distance=20to=20limit=20rapid=20re-?= =?UTF-8?q?clicking=20while=20still=20keeping=20movement=20flowing.=20?= =?UTF-8?q?=E2=80=A2=20Detect=20failed=20adjacent=20same-plane=20shortcut?= =?UTF-8?q?=20landings=20faster,=20including=20stepping-stone=20style=20fa?= =?UTF-8?q?ilures=20where=20the=20player=20settles=20on=20the=20wrong=20ti?= =?UTF-8?q?le.=20=E2=80=A2=20Add=20unit=20coverage=20for=20stale=20interim?= =?UTF-8?q?=20progress=20clearing.=20=E2=80=A2=20Document=20movement=20got?= =?UTF-8?q?chas=20for=20route=20fallback,=20stale=20interim=20targets,=20t?= =?UTF-8?q?ail-exempt=20continuation=20clicks,=20and=20adjacent=20shortcut?= =?UTF-8?q?=20failures.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/entity-guides/movement.md | 13 +- .../microbot/util/walker/Rs2Walker.java | 150 ++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 13 ++ 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 5b0637fc5a..4a658235f4 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -219,7 +219,8 @@ Sticky interim targets prevent click thrash while the player is moving toward a **Pattern to follow:** ```java -if (shouldClearInterimTarget(interimTargetWp, Rs2Player.getWorldLocation(), interimSetAtMs, nowMs)) { +if (shouldClearInterimTarget(interimTargetWp, Rs2Player.getWorldLocation(), interimSetAtMs, + interimLastProgressAtMs, nowMs)) { clearInterimTarget("close-or-expired"); } if (isStuckTooLong()) { @@ -285,4 +286,12 @@ if (isStuckTooLong()) { After a handled transport, avoid expensive path-adjacent or raw transport scans on ordinary open-ground segments unless a nearby planned transport or recent door attempt exists. Those scans are recovery tools, and on long outdoor routes a no-op scan can add several seconds before the next minimap click. -For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off around minimap-reachable distance keeps movement continuous without rapid re-clicking. If an interim clears as close and no nearby route door/transport is pending, issue the next route-aligned continuation click immediately instead of waiting for idle-nudge recovery. +For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off at a moderate remaining distance keeps movement continuous without rapid re-clicking. If an interim clears as close and no nearby route door/transport is pending, issue the next route-aligned continuation click immediately instead of waiting for idle-nudge recovery. + +Continuation clicks that keep an active route moving should be tail-exempt like `interim-in-flight`; otherwise very long routes can exhaust `MAX_PROCESS_WALK_TAIL_ITERATIONS` while still making progress and trigger an unnecessary auto-retry. + +Sticky interim targets should also clear when route-index progress goes stale. If the player keeps moving but the closest path index does not advance for `INTERIM_PROGRESS_TIMEOUT_MS`, treat the checkpoint as stale and select a fresh route-aligned target instead of waiting for max-age expiry. + +When a route-following minimap click is outside the minimap clip, fallback clicks must stay on the raw path. A generic "reachable tile closer to target" fallback can select a tile far away from the route in open areas, especially near the final destination. + +For adjacent same-plane shortcuts, do not treat any movement away from the origin as success. Some shortcuts, such as stepping stones, can fail and place the player on a fallback tile; once the player is settled away from the expected destination, stop the landing wait and replan from the actual tile. 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 1d4cd6897d..26f4fa6604 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 @@ -133,7 +133,7 @@ public static WorldPoint getCurrentTarget() { private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; private static final int INTERIM_CLOSE_TILES = 4; - private static final int INTERIM_PRECLICK_TILES = 9; + private static final int INTERIM_PRECLICK_TILES = 6; private static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; private static final long INTERIM_MAX_AGE_MS = 10_000L; private static final long INTERIM_RETARGET_COOLDOWN_MS = 900L; @@ -212,6 +212,7 @@ public static WorldPoint getCurrentTarget() { /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; + private static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; /** Teleport “already near destination” skip in path loop — same semantics as prior {@code distanceTo2D < 3}. */ private static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; @@ -1306,7 +1307,7 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) primeExpectedTransportDestinations(path, indexOfStartPoint); lastPosition = Rs2Player.getWorldLocation(); - boolean clearedInterimTarget = clearInterimTargetIfReachedOrExpired(lastPosition, System.currentTimeMillis()); + boolean clearedInterimTarget = clearInterimTargetIfReachedOrExpired(lastPosition, path, System.currentTimeMillis()); WorldPoint plImmediate = lastPosition; WorldPoint pathLastForImmediate = path.isEmpty() ? null : path.get(path.size() - 1); @@ -1326,6 +1327,8 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) && !isDoorInteractionSettling() && !isTransportInteractionSettling() && tryIssueRouteContinuationClick(rawPath, path, target)) { + walkerDiag("tail exempt exitReason=interim-close-route-click tailBefore=%d", processWalkTail); + processWalkTail--; continue; } @@ -1961,7 +1964,10 @@ && walkFastCanvas(recoverTarget)) { markStartupPhase("click_candidate_found", target, "to=" + compactWorldPoint(clickTarget)); boolean clicked = Rs2Walker.walkMiniMap(clickTarget); if (!clicked) { - clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); + clicked = walkRawPathMiniMapToward(rawPath, clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); + if (!clicked && (rawPath == null || rawPath.isEmpty())) { + clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); + } } if (walkCancelledDiag(target, "processWalk:after-minimap-click", processWalkTail)) { return WalkerState.EXIT; @@ -2435,6 +2441,32 @@ public static boolean walkMiniMap(WorldPoint worldPoint) { return walkMiniMap(worldPoint, 5); } + private static boolean isMiniMapClickable(WorldPoint worldPoint, double zoomDistance) { + if (worldPoint == null) { + return false; + } + if (Microbot.getClient().getMinimapZoom() != zoomDistance) { + Microbot.getClient().setMinimapZoom(zoomDistance); + } + Point point = Rs2MiniMap.worldToMinimap(worldPoint); + return point != null && (disableWalkerUpdate || Rs2MiniMap.isPointInsideMinimap(point)); + } + + private static boolean walkRawPathMiniMapToward(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean) { + WorldPoint fallback = findFurthestVisibleReachableRawPathPoint(rawPath, playerLoc, maxEuclidean); + if (fallback == null || fallback.equals(playerLoc) || fallback.equals(target)) { + return false; + } + if (walkMiniMap(fallback)) { + log.info("[Walker] Minimap click target {} was outside clip; used route fallback {}", target, fallback); + return true; + } + return false; + } + static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { if (target == null || playerLoc == null || target.getPlane() != playerLoc.getPlane()) { return false; @@ -2524,6 +2556,37 @@ private static WorldPoint findFurthestReachableRawPathPoint(List raw return best; } + private static WorldPoint findFurthestVisibleReachableRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { + return null; + } + int closestRawIndex = getClosestTileIndex(rawPath); + if (closestRawIndex < 0) { + return null; + } + + int maxSq = maxEuclidean * maxEuclidean; + Set reachable = Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet(); + WorldPoint best = null; + for (int rawIndex = closestRawIndex; rawIndex < rawPath.size(); rawIndex++) { + WorldPoint candidate = rawPath.get(rawIndex); + if (candidate == null || candidate.getPlane() != playerLoc.getPlane()) { + break; + } + if (euclideanSq(candidate, playerLoc) > maxSq) { + break; + } + if (!candidate.equals(playerLoc) + && reachable.contains(candidate) + && isMiniMapClickable(candidate, 5)) { + best = candidate; + } + } + return best; + } + private static boolean shouldIssueActiveRouteIdleNudge() { WorldPoint playerLoc = Rs2Player.getWorldLocation(); long now = System.currentTimeMillis(); @@ -2620,10 +2683,14 @@ private static boolean tryIssueRouteMovementClick(List rawPath, } } - boolean clicked = clickTarget != null - && !clickTarget.equals(playerLoc) - && (walkMiniMap(clickTarget) - || walkMiniMapToward(clickTarget, playerLoc, maxEuclidean - 1)); + boolean clicked = false; + if (clickTarget != null && !clickTarget.equals(playerLoc)) { + clicked = walkMiniMap(clickTarget) + || walkRawPathMiniMapToward(rawPath, clickTarget, playerLoc, maxEuclidean - 1); + if (!clicked && (rawPath == null || rawPath.isEmpty())) { + clicked = walkMiniMapToward(clickTarget, playerLoc, maxEuclidean - 1); + } + } log.info("[Walker] {}: clicked={} to={} player={} idx={}", logLabel, clicked, clickTarget, playerLoc, targetIdx); if (!clicked) { @@ -4615,7 +4682,11 @@ static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, Worl return afterTo <= 1 && afterTo < beforeTo; } - static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc, long setAtMs, long nowMs) { + static boolean shouldClearInterimTarget(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs) { if (interim == null) { return false; } @@ -4625,12 +4696,24 @@ static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { return true; } + if (lastProgressAtMs > 0L && nowMs - lastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + return true; + } return setAtMs > 0L && nowMs - setAtMs > INTERIM_MAX_AGE_MS; } - private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc, long nowMs) { + private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc, + List path, + long nowMs) { WorldPoint interim = interimTargetWp; - if (!shouldClearInterimTarget(interim, playerLoc, interimSetAtMs, nowMs)) { + if (interim != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path); + if (bestIdxNow > interimLastBestPathIdx) { + interimLastBestPathIdx = bestIdxNow; + interimLastProgressAtMs = nowMs; + } + } + if (!shouldClearInterimTarget(interim, playerLoc, interimSetAtMs, interimLastProgressAtMs, nowMs)) { return false; } String reason; @@ -4638,6 +4721,8 @@ private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc reason = "invalid"; } else if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { reason = "close"; + } else if (interimLastProgressAtMs > 0L && nowMs - interimLastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + reason = "stale-progress"; } else { reason = "expired"; } @@ -6879,11 +6964,11 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (destWait == null) { return false; } - boolean landedAfterObject = sleepUntil(() -> isPlayerWithinChebyshevInclusive(destWait, maxInclusive), - POST_HANDLE_OBJECT_LANDING_WAIT_MS); + boolean landedAfterObject = waitForPostHandleObjectLanding(transport, object, destWait, + maxInclusive); if (!landedAfterObject) { WebWalkLog.spWarn( - "post-handleObject landing wait timed out ({}ms) dest={} at={}", + "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", POST_HANDLE_OBJECT_LANDING_WAIT_MS, compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); @@ -6900,6 +6985,45 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i return false; } + private static boolean waitForPostHandleObjectLanding(Transport transport, + TileObject object, + WorldPoint destWait, + int maxInclusive) { + long waitStartedAt = System.currentTimeMillis(); + AtomicBoolean settledAwayFromAdjacentDestination = new AtomicBoolean(false); + boolean completed = sleepUntil(() -> { + if (isPlayerWithinChebyshevInclusive(destWait, maxInclusive)) { + return true; + } + if (!isAdjacentSamePlaneTransport(transport) + || System.currentTimeMillis() - waitStartedAt < POST_HANDLE_OBJECT_FAILED_SETTLE_MS) { + return false; + } + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || destWait == null || playerLoc.getPlane() != destWait.getPlane() + || Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + WorldPoint origin = transport == null ? null : transport.getOrigin(); + WorldPoint objectLoc = object == null ? null : object.getWorldLocation(); + boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; + boolean settledAwayFromObject = objectLoc != null && playerLoc.distanceTo2D(objectLoc) > 1; + if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) + && (settledAwayFromOrigin || settledAwayFromObject)) { + settledAwayFromAdjacentDestination.set(true); + return true; + } + return false; + }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); + + if (settledAwayFromAdjacentDestination.get()) { + WebWalkLog.spInfo("post-handleObject adjacent landing failed | dest={} at={}", + compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); + return false; + } + return completed; + } + /** * Handles the transportation process specifically for instances of PohTransport. * Any Transport param that reaches this is assumed to be a PohTransport. 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 6b2ccad805..ea101559f5 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 @@ -466,6 +466,7 @@ public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { new WorldPoint(2890, 3396, 0), new WorldPoint(2889, 3396, 0), 1_000L, + 1_500L, 2_000L)); } @@ -475,15 +476,27 @@ public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, + 1_500L, 12_000L)); } + @Test + public void shouldClearInterimTarget_staleProgress_returnsTrue() { + assertTrue(Rs2Walker.shouldClearInterimTarget( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 1_500L, + 5_000L)); + } + @Test public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { assertFalse(Rs2Walker.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, + 4_500L, 5_000L)); } From 83b081332d401c6e0419e2b3a975dfed79d8e87c Mon Sep 17 00:00:00 2001 From: infuse21 Date: Tue, 7 Jul 2026 00:56:12 +0100 Subject: [PATCH 5/9] Fixed CI failure --- .../microbot/util/walker/Rs2Walker.java | 8 +-- .../client-thread-guardrail-baseline.txt | 55 +++++++++++-------- 2 files changed, 33 insertions(+), 30 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 26f4fa6604..744b064159 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 @@ -6964,8 +6964,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (destWait == null) { return false; } - boolean landedAfterObject = waitForPostHandleObjectLanding(transport, object, destWait, - maxInclusive); + boolean landedAfterObject = waitForPostHandleObjectLanding(transport, destWait, maxInclusive); if (!landedAfterObject) { WebWalkLog.spWarn( "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", @@ -6986,7 +6985,6 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } private static boolean waitForPostHandleObjectLanding(Transport transport, - TileObject object, WorldPoint destWait, int maxInclusive) { long waitStartedAt = System.currentTimeMillis(); @@ -7005,11 +7003,9 @@ private static boolean waitForPostHandleObjectLanding(Transport transport, return false; } WorldPoint origin = transport == null ? null : transport.getOrigin(); - WorldPoint objectLoc = object == null ? null : object.getWorldLocation(); boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; - boolean settledAwayFromObject = objectLoc != null && playerLoc.distanceTo2D(objectLoc) > 1; if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) - && (settledAwayFromOrigin || settledAwayFromObject)) { + && settledAwayFromOrigin) { settledAwayFromAdjacentDestination.set(true); return true; } 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 f4a8fa96c5..3395d9f6e9 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 @@ -723,6 +723,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#adjacentSamePlaneTran net.runelite.client.plugins.microbot.util.walker.Rs2Walker#closeWorldMap(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint): WorldPoint -> net.runelite.api.CollisionData#getFlags(): int[][] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint): WorldPoint -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] @@ -777,37 +778,43 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorInteractionWithinRange(TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorOnSegment(TileObject, WorldPoint, WorldPoint): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$doorStillHasAction$44(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$41(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$42(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCharterShip$175(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$42(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$43(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCharterShip$181(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$40(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$181(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$152(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$154(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$130(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$136(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$136(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$137(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$137(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$21(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$110(int, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$110(int, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$112(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$113(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$114(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$115(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$41(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$187(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$158(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$160(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$136(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$142(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$142(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$143(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$143(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$22(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(int, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(int, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$118(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$144(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$145(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$119(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$123(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$124(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$150(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$151(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$2(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$3(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$63(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#markNearbyDoorFamilyOpened(TileObject, WorldPoint, String, int): void -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): void -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint From 58b796d2dd1d9fedac36ba776ad577ea8f7892d7 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Tue, 7 Jul 2026 01:21:52 +0100 Subject: [PATCH 6/9] Fix walker stall timing and door action detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit his PR addresses review findings in Rs2Walker with minimal scoped changes. Changes: • Prevent active route idle nudges from resetting lastMovedTimeMs, so failed nudge clicks do not delay stall recalculation. • Guard rawPath before raw-path door scanning in route continuation clicks. • Use impostor-aware door composition resolution when checking whether a door action is still present after interaction. • Align catalog-backed door transport action detection with DOOR_ACTION_PRIORITY, covering traversal actions like pay-toll, pick-lock, climb-over, squeeze-through, cross, and force. --- .../microbot/util/walker/Rs2Walker.java | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 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 744b064159..e14725eadd 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 @@ -2625,14 +2625,16 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, if (playerLoc == null || path == null || path.isEmpty()) { return false; } - int rawIdx = getClosestTileIndex(rawPath); - if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, - rawIdx, - playerLoc, - UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, - UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, - HANDLER_RANGE)) { - return false; + if (rawPath != null && !rawPath.isEmpty()) { + int rawIdx = getClosestTileIndex(rawPath); + if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, + rawIdx, + playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE)) { + return false; + } } int pathIdx = Math.max(0, getClosestTileIndex(path)); if (hasUpcomingNearbyTransportStep(path, pathIdx, playerLoc, @@ -2710,9 +2712,10 @@ private static boolean tryIssueRouteMovementClick(List rawPath, if (markRecoveryCooldown) { lastUnreachableRecoveryClickAtMs = interimSetAtMs; } - lastMovedTimeMs = interimSetAtMs; if ("active route idle nudge".equals(logLabel)) { lastActiveRouteIdleNudgeAtMs = interimSetAtMs; + } else { + lastMovedTimeMs = interimSetAtMs; } idleNudgeStationarySinceMs = interimSetAtMs; idleNudgeLastObservedLocation = playerLoc; @@ -4474,7 +4477,7 @@ private static boolean doorObjectStillHasAction(TileObject object, WorldPoint pr if (!nearProbe && !onSegment) { return false; } - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(object); + ObjectComposition composition = resolveCompositionForDoorProbe(object); String currentAction = getDoorAction(composition, doorActions); return currentAction != null && currentAction.equalsIgnoreCase(action); } @@ -4987,11 +4990,7 @@ private static boolean isDoorLikeTransportAction(String action) { if (action == null) { return false; } - String al = action.toLowerCase(Locale.ROOT); - return al.startsWith("open") - || al.startsWith("pass") - || al.startsWith("go-through") - || al.startsWith("walk-through"); + return doorActionPriorityIndex(action) != Integer.MAX_VALUE; } /** From 0145d552302fef424188177837326e52efd59b6a Mon Sep 17 00:00:00 2001 From: infuse21 Date: Tue, 7 Jul 2026 12:14:27 +0100 Subject: [PATCH 7/9] Improve walker continuity on run paths, transports, and switchbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increased interim pre-click range when run is enabled so the walker queues the next movement earlier. • Added route-progress stabilization to prevent folded/switchback paths, such as White Wolf Mountain, from snapping back to an earlier nearby path branch. • Capped post-transport door scan timing so failed door probes do not consume long waits before movement resumes. • Added walker unit coverage for the switchback/backtracking regression. --- .../microbot/util/walker/Rs2Walker.java | 114 ++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 20 +++ 2 files changed, 124 insertions(+), 10 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 e14725eadd..734f44a4a3 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 @@ -133,7 +133,8 @@ public static WorldPoint getCurrentTarget() { private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; private static final int INTERIM_CLOSE_TILES = 4; - private static final int INTERIM_PRECLICK_TILES = 6; + private static final int INTERIM_PRECLICK_TILES = 6; + private static final int INTERIM_RUN_PRECLICK_TILES = 9; private static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; private static final long INTERIM_MAX_AGE_MS = 10_000L; private static final long INTERIM_RETARGET_COOLDOWN_MS = 900L; @@ -162,9 +163,10 @@ public static WorldPoint getCurrentTarget() { private static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 12; private static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 2_500L; - private static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; - private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; - private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; + private static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; + private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; + private static final int ROUTE_PROGRESS_FORWARD_SEARCH_TILES = 40; + private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; private static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; private static final int PATH_VARIANCE_TOLERANCE_CHEBYSHEV = 6; @@ -185,6 +187,11 @@ public static WorldPoint getCurrentTarget() { private static volatile WorldPoint idleNudgeLastObservedLocation = null; private static volatile long idleNudgeStationarySinceMs = 0L; private static volatile long lastActiveRouteIdleNudgeAtMs = 0L; + private static volatile int routeProgressIdx = -1; + private static volatile WorldPoint routeProgressTarget = null; + private static volatile WorldPoint routeProgressPathStart = null; + private static volatile WorldPoint routeProgressPathEnd = null; + private static volatile int routeProgressPathSize = -1; private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); @@ -255,6 +262,7 @@ private static void markWalkSessionStart(WorldPoint target) { startupPhasesLogged.clear(); lastTransportHandledAtLocation = null; lastTransportOriginLocation = null; + resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); } @@ -697,6 +705,7 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); recentCurrentTileTransportByEdge.clear(); + resetRouteProgress(); } private static volatile List seasonalTransportHandlers = @@ -1293,7 +1302,7 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) } } - int indexOfStartPoint = getClosestTileIndex(path); + int indexOfStartPoint = stabilizeRouteProgressIndex(path, getClosestTileIndex(path), target, Rs2Player.getWorldLocation()); if (indexOfStartPoint == -1) { walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", path.size(), @@ -1823,7 +1832,7 @@ && walkFastCanvas(recoverTarget)) { } final WorldPoint posBeforeWait = playerLoc; sleepUntil(() -> - interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= INTERIM_PRECLICK_TILES + interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= interimPreclickTiles() || !Rs2Player.isMoving(), 650); WorldPoint posAfterWait = Rs2Player.getWorldLocation(); @@ -1833,7 +1842,7 @@ && walkFastCanvas(recoverTarget)) { stuckCount = 0; } boolean readyForNextClick = posAfterWait != null - && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_PRECLICK_TILES; + && interimFinal.distanceTo2D(posAfterWait) <= interimPreclickTiles(); if (!readyForNextClick && Rs2Player.isMoving()) { exitReason = "interim-in-flight"; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", @@ -4685,6 +4694,14 @@ static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, Worl return afterTo <= 1 && afterTo < beforeTo; } + private static int interimPreclickTiles() { + try { + return Rs2Player.isRunEnabled() ? INTERIM_RUN_PRECLICK_TILES : INTERIM_PRECLICK_TILES; + } catch (Exception e) { + return INTERIM_PRECLICK_TILES; + } + } + static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc, long setAtMs, @@ -4696,7 +4713,8 @@ static boolean shouldClearInterimTarget(WorldPoint interim, if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { return true; } - if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { + int preclickTiles = interimPreclickTiles(); + if (playerLoc.distanceTo2D(interim) <= preclickTiles) { return true; } if (lastProgressAtMs > 0L && nowMs - lastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { @@ -4722,7 +4740,7 @@ private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc String reason; if (playerLoc == null || interim == null || playerLoc.getPlane() != interim.getPlane()) { reason = "invalid"; - } else if (playerLoc.distanceTo2D(interim) <= INTERIM_PRECLICK_TILES) { + } else if (playerLoc.distanceTo2D(interim) <= interimPreclickTiles()) { reason = "close"; } else if (interimLastProgressAtMs > 0L && nowMs - interimLastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { reason = "stale-progress"; @@ -6393,6 +6411,72 @@ public static int getClosestTileIndex(List path) { .orElse(-1); } + static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || closestIdx < 0 || closestIdx >= path.size()) { + return closestIdx; + } + + WorldPoint pathStart = path.get(0); + WorldPoint pathEnd = path.get(path.size() - 1); + boolean routeChanged = routeProgressTarget == null + || !routeProgressTarget.equals(target) + || routeProgressPathSize != path.size() + || !Objects.equals(routeProgressPathStart, pathStart) + || !Objects.equals(routeProgressPathEnd, pathEnd) + || routeProgressIdx >= path.size(); + if (routeChanged) { + routeProgressTarget = target; + routeProgressPathStart = pathStart; + routeProgressPathEnd = pathEnd; + routeProgressPathSize = path.size(); + routeProgressIdx = closestIdx; + return closestIdx; + } + + if (routeProgressIdx < 0 || closestIdx >= routeProgressIdx) { + routeProgressIdx = closestIdx; + return closestIdx; + } + + int forwardIdx = closestForwardPathIndex(path, routeProgressIdx, playerLoc); + if (forwardIdx >= routeProgressIdx) { + if (forwardIdx > routeProgressIdx) { + routeProgressIdx = forwardIdx; + } + return routeProgressIdx; + } + return routeProgressIdx; + } + + private static int closestForwardPathIndex(List path, int fromIdx, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || playerLoc == null || fromIdx < 0 || fromIdx >= path.size()) { + return -1; + } + int bestIdx = -1; + int bestDist = Integer.MAX_VALUE; + int toIdxExclusive = Math.min(path.size(), fromIdx + ROUTE_PROGRESS_FORWARD_SEARCH_TILES + 1); + for (int i = fromIdx; i < toIdxExclusive; i++) { + WorldPoint point = path.get(i); + if (point == null || point.getPlane() != playerLoc.getPlane()) { + continue; + } + int dist = playerLoc.distanceTo2D(point); + if (dist < bestDist) { + bestIdx = i; + bestDist = dist; + } + } + return bestIdx; + } + + private static void resetRouteProgress() { + routeProgressIdx = -1; + routeProgressTarget = null; + routeProgressPathStart = null; + routeProgressPathEnd = null; + routeProgressPathSize = -1; + } + /** * Force the walker to recalculate path */ @@ -6447,6 +6531,7 @@ public static void setTarget(WorldPoint target, String clearReasonWhenNull) { currentTarget = target; if (target == null) { + resetRouteProgress(); logRouteClear(clearReasonWhenNull); synchronized (ShortestPathPlugin.getPathfinderMutex()) { final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); @@ -7200,16 +7285,25 @@ private static boolean handleDoorsInRawSegment(List rawPath, int raw long timeoutMs, Map attempted, Map reachableCache) { WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; + long startedAt = System.currentTimeMillis(); for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { + long elapsed = System.currentTimeMillis() - startedAt; + if (elapsed >= timeoutMs) { + return false; + } if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) && reachableCache.containsKey(rawPath.get(ri + 1)) && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), playerLoc, HANDLER_RANGE)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted)) { + long remainingTimeoutMs = Math.max(1L, timeoutMs - elapsed); + if (handleDoorsWithTimeout(rawPath, ri, remainingTimeoutMs, attempted)) { return true; } + if (isDoorInteractionSettling()) { + return false; + } } return false; } 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 ea101559f5..b0a9983638 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 @@ -355,6 +355,26 @@ public void findFurthest_nullPredicate_treatsAsNoTransport() { assertEquals("null predicate must not NPE and must allow full scan", 2, idx); } + @Test + public void stabilizeRouteProgressIndex_doesNotJumpBackToEarlierSwitchbackBranch() { + WorldPoint target = new WorldPoint(3200, 3201, 0); + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3201, 3200, 0), + new WorldPoint(3202, 3200, 0), + new WorldPoint(3203, 3200, 0), + new WorldPoint(3203, 3201, 0), + new WorldPoint(3202, 3201, 0), + new WorldPoint(3201, 3201, 0), + target); + + assertEquals(5, Rs2Walker.stabilizeRouteProgressIndex(path, 5, target, new WorldPoint(3202, 3201, 0))); + assertEquals("nearby earlier branch must not become the active route index", + 5, + Rs2Walker.stabilizeRouteProgressIndex(path, 2, target, new WorldPoint(3202, 3201, 0))); + assertEquals(6, Rs2Walker.stabilizeRouteProgressIndex(path, 6, target, new WorldPoint(3201, 3201, 0))); + } + @Test public void interpolateClickableTarget_usesInterpolatedPointWhenUsable() { WorldPoint player = new WorldPoint(3200, 3200, 0); From 21c944c8a1843d6163f94e4c014484021ca27f72 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Tue, 7 Jul 2026 14:13:18 +0100 Subject: [PATCH 8/9] fixes --- .../microbot/util/walker/Rs2Walker.java | 185 ++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 41 ++++ 2 files changed, 213 insertions(+), 13 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 734f44a4a3..04d088adf0 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 @@ -134,7 +134,8 @@ public static WorldPoint getCurrentTarget() { private static final int INTERIM_CLOSE_TILES = 4; private static final int INTERIM_PRECLICK_TILES = 6; - private static final int INTERIM_RUN_PRECLICK_TILES = 9; + private static final int INTERIM_RUN_PRECLICK_TILES = 11; + private static final int INTERIM_MOVING_POLL_MS = 450; private static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; private static final long INTERIM_MAX_AGE_MS = 10_000L; private static final long INTERIM_RETARGET_COOLDOWN_MS = 900L; @@ -162,6 +163,7 @@ public static WorldPoint getCurrentTarget() { private static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; private static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 12; + private static final int UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES = 24; private static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 2_500L; private static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; @@ -173,6 +175,7 @@ public static WorldPoint getCurrentTarget() { private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6; private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15; private static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L; + private static final long RECENT_TRANSPORT_EDGE_SUPPRESS_MS = 8_000L; private static volatile Integer rawScanFocusedDoorIdx = null; private static volatile long rawScanFocusedDoorSetAtMs = 0L; private static volatile int rawScanFocusedDoorAttempts = 0; @@ -184,6 +187,7 @@ public static WorldPoint getCurrentTarget() { private static volatile long lastTransportHandledAtMs = 0L; private static volatile WorldPoint lastTransportHandledAtLocation = null; private static volatile WorldPoint lastTransportOriginLocation = null; + private static volatile WorldPoint lastTransportDestinationLocation = null; private static volatile WorldPoint idleNudgeLastObservedLocation = null; private static volatile long idleNudgeStationarySinceMs = 0L; private static volatile long lastActiveRouteIdleNudgeAtMs = 0L; @@ -262,6 +266,7 @@ private static void markWalkSessionStart(WorldPoint target) { startupPhasesLogged.clear(); lastTransportHandledAtLocation = null; lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); @@ -705,6 +710,10 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); recentCurrentTileTransportByEdge.clear(); + lastTransportHandledAtMs = 0L; + lastTransportHandledAtLocation = null; + lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; resetRouteProgress(); } @@ -1302,7 +1311,9 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) } } - int indexOfStartPoint = stabilizeRouteProgressIndex(path, getClosestTileIndex(path), target, Rs2Player.getWorldLocation()); + WorldPoint playerLocForIndex = Rs2Player.getWorldLocation(); + int indexOfStartPoint = stabilizeRouteProgressIndex(path, getClosestTileIndex(path), target, playerLocForIndex); + indexOfStartPoint = advanceIndexPastRecentTransportEdge(path, indexOfStartPoint, playerLocForIndex); if (indexOfStartPoint == -1) { walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", path.size(), @@ -1404,8 +1415,10 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { "reason=no_nearby_planned_transport"); } long rawSceneStartAt = System.currentTimeMillis(); + // After a handled transport, let the main path loop dispatch the next planned transport. + // Broad raw transport scans can enter long false-negative waits before returning handled=false. boolean rawSceneHandled = allowRawSceneScan - && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target); + && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target, !postTransportWindow); tmarkPostTransport("post_transport_raw_scene_scan", target, "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); if (rawSceneHandled) { @@ -1709,13 +1722,18 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { // (no reachability gate on the click); isKnownWalkableOrUnloaded only keeps // us from clicking into a known wall, it is NOT the bounded BFS check. final int RECOVERY_MINIMAP_REACH_EUCLIDEAN = 13; - int recoverIdx = findFurthestClickableIndex(path, i, playerLoc, - wp -> { - Set ts = ShortestPathPlugin.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + int recoverIdx = findForwardReachableRecoveryIndex(path, i, playerLoc, RECOVERY_MINIMAP_REACH_EUCLIDEAN); - recoverIdx = Math.min(Math.max(recoverIdx, indexOfStartPoint), path.size() - 1); + if (recoverIdx < 0) { + recoverIdx = findFurthestClickableIndex(path, i, playerLoc, + wp -> { + Set ts = ShortestPathPlugin.getTransports().get(wp); + return ts != null && !ts.isEmpty(); + }, + RECOVERY_MINIMAP_REACH_EUCLIDEAN); + } + int minRecoveryIdx = Math.max(indexOfStartPoint, i); + recoverIdx = Math.min(Math.max(recoverIdx, minRecoveryIdx), path.size() - 1); WorldPoint recoverTarget = path.get(recoverIdx); if (euclideanSq(recoverTarget, playerLoc) > RECOVERY_MINIMAP_REACH_EUCLIDEAN * RECOVERY_MINIMAP_REACH_EUCLIDEAN) { @@ -1734,7 +1752,7 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { if (dangerCfg != null && dangerCfg.isAvoidDangerousNpcs() && recoverTarget != null && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(recoverTarget))) { int safeIdx = recoverIdx; - while (safeIdx > indexOfStartPoint + while (safeIdx > minRecoveryIdx && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(path.get(safeIdx)))) { safeIdx--; } @@ -1834,7 +1852,7 @@ && walkFastCanvas(recoverTarget)) { sleepUntil(() -> interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= interimPreclickTiles() || !Rs2Player.isMoving(), - 650); + INTERIM_MOVING_POLL_MS); WorldPoint posAfterWait = Rs2Player.getWorldLocation(); if ((posAfterWait != null && posBeforeWait.distanceTo2D(posAfterWait) > 0) || Rs2Player.isMoving()) { @@ -3602,6 +3620,13 @@ private static int rawIndexForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw } private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange, WorldPoint target) { + return handleNearbyRawPathSceneObjects(rawPath, handlerRange, target, true); + } + + private static boolean handleNearbyRawPathSceneObjects(List rawPath, + int handlerRange, + WorldPoint target, + boolean allowTransportHandlers) { if (rawPath == null || rawPath.size() < 2) { return false; } @@ -3651,7 +3676,7 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, continue; } - if (hasExplicitTransportStep(rawPath, i)) { + if (allowTransportHandlers && hasExplicitTransportStep(rawPath, i)) { WorldPoint before = Rs2Player.getWorldLocation(); WorldPoint expectedDestination = i + 1 < rawPath.size() ? rawPath.get(i + 1) : null; if (handleTransports(rawPath, i)) { @@ -3941,6 +3966,62 @@ static int findFurthestClickableIndex(List path, int startIdx, World return bestIdx; } + private static int findForwardReachableRecoveryIndex(List path, + int startIdx, + WorldPoint playerLoc, + int maxEuclidean) { + Set reachable = playerLoc == null + ? Collections.emptySet() + : Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet(); + return findForwardRecoveryIndex( + path, + startIdx, + playerLoc, + maxEuclidean, + reachable, + Rs2Walker::isMiniMapRecoveryClickable); + } + + static int findForwardRecoveryIndex(List path, + int startIdx, + WorldPoint playerLoc, + int maxEuclidean, + Set reachable, + java.util.function.Predicate isClickable) { + if (path == null || path.isEmpty() || playerLoc == null || startIdx < 0 || startIdx >= path.size()) { + return -1; + } + int maxSq = maxEuclidean * maxEuclidean; + int endExclusive = Math.min(path.size(), startIdx + UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES + 1); + int bestIdx = -1; + int bestDistFromPlayer = Integer.MAX_VALUE; + for (int idx = startIdx; idx < endExclusive; idx++) { + WorldPoint candidate = path.get(idx); + if (candidate == null || candidate.getPlane() != playerLoc.getPlane()) { + continue; + } + if (candidate.equals(playerLoc) || euclideanSq(candidate, playerLoc) > maxSq) { + continue; + } + if (reachable != null && !reachable.isEmpty() && !reachable.contains(candidate)) { + continue; + } + if (isClickable != null && !isClickable.test(candidate)) { + continue; + } + int distFromPlayer = euclideanSq(candidate, playerLoc); + if (bestIdx < 0 || idx > bestIdx || (idx == bestIdx && distFromPlayer > bestDistFromPlayer)) { + bestIdx = idx; + bestDistFromPlayer = distFromPlayer; + } + } + return bestIdx; + } + + private static boolean isMiniMapRecoveryClickable(WorldPoint worldPoint) { + return isMiniMapClickable(worldPoint, 5); + } + static WorldPoint interpolateClickableTarget(List path, int forwardIdx, WorldPoint playerLoc, @@ -4696,12 +4777,16 @@ static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, Worl private static int interimPreclickTiles() { try { - return Rs2Player.isRunEnabled() ? INTERIM_RUN_PRECLICK_TILES : INTERIM_PRECLICK_TILES; + return interimPreclickTiles(Rs2Player.isRunEnabled()); } catch (Exception e) { return INTERIM_PRECLICK_TILES; } } + static int interimPreclickTiles(boolean runEnabled) { + return runEnabled ? INTERIM_RUN_PRECLICK_TILES : INTERIM_PRECLICK_TILES; + } + static boolean shouldClearInterimTarget(WorldPoint interim, WorldPoint playerLoc, long setAtMs, @@ -5853,6 +5938,15 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< int bestScore = bestComponent.score; WorldPoint bestFrom = chosen.from; WorldPoint bestTo = chosen.to; + if (isRecentTransportEdgeCandidate(chosen.location, bestFrom, bestTo)) { + WebWalkLog.spInfo("path_adj_recent_transport_skip | probe={} from={} to={} origin={} dest={}", + compactWorldPoint(chosen.location), + compactWorldPoint(bestFrom), + compactWorldPoint(bestTo), + compactWorldPoint(lastTransportOriginLocation), + compactWorldPoint(lastTransportDestinationLocation)); + return false; + } log.info("[Walker] path-adj blocker-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, chosen.location); WorldPoint bestLoc = chosen.location; if (shouldThrottleDoorAttempt(bestLoc, bestFrom, bestTo)) { @@ -6448,6 +6542,33 @@ static int stabilizeRouteProgressIndex(List path, int closestIdx, Wo return routeProgressIdx; } + static int advanceIndexPastRecentTransportEdge(List path, int index, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || index < 0 || index >= path.size() + || !isRecentTransportEdgeWindow()) { + return index; + } + WorldPoint origin = lastTransportOriginLocation; + WorldPoint destination = lastTransportDestinationLocation; + if (origin == null || destination == null || playerLoc == null + || playerLoc.getPlane() != destination.getPlane() + || playerLoc.distanceTo2D(destination) > 3) { + return index; + } + + int scanEndExclusive = Math.min(path.size(), index + 8); + int lastTransportEdgeIdx = -1; + for (int i = index; i < scanEndExclusive; i++) { + WorldPoint point = path.get(i); + if (isNearSamePlane(point, origin, 2) || isNearSamePlane(point, destination, 2)) { + lastTransportEdgeIdx = i; + } + } + if (lastTransportEdgeIdx >= index && lastTransportEdgeIdx + 1 < path.size()) { + return lastTransportEdgeIdx + 1; + } + return index; + } + private static int closestForwardPathIndex(List path, int fromIdx, WorldPoint playerLoc) { if (path == null || path.isEmpty() || playerLoc == null || fromIdx < 0 || fromIdx >= path.size()) { return -1; @@ -6477,6 +6598,43 @@ private static void resetRouteProgress() { routeProgressPathSize = -1; } + private static boolean isRecentTransportEdgeWindow() { + long handledAt = lastTransportHandledAtMs; + if (handledAt <= 0L) { + return false; + } + long ageMs = System.currentTimeMillis() - handledAt; + return ageMs >= 0L && ageMs <= RECENT_TRANSPORT_EDGE_SUPPRESS_MS; + } + + private static boolean isNearSamePlane(WorldPoint a, WorldPoint b, int distance) { + return a != null + && b != null + && a.getPlane() == b.getPlane() + && a.distanceTo2D(b) <= distance; + } + + private static boolean isRecentTransportEdgeCandidate(WorldPoint objectLoc, WorldPoint from, WorldPoint to) { + if (!isRecentTransportEdgeWindow()) { + return false; + } + WorldPoint origin = lastTransportOriginLocation; + WorldPoint destination = lastTransportDestinationLocation; + if (origin == null || destination == null) { + return false; + } + boolean objectNearTransport = isNearSamePlane(objectLoc, origin, 2) + || isNearSamePlane(objectLoc, destination, 2); + boolean edgeMatchesTransport = (isNearSamePlane(from, origin, 2) && isNearSamePlane(to, destination, 2)) + || (isNearSamePlane(from, destination, 2) && isNearSamePlane(to, origin, 2)) + || (objectNearTransport + && (isNearSamePlane(from, origin, 2) + || isNearSamePlane(from, destination, 2) + || isNearSamePlane(to, origin, 2) + || isNearSamePlane(to, destination, 2))); + return objectNearTransport && edgeMatchesTransport; + } + /** * Force the walker to recalculate path */ @@ -7336,6 +7494,7 @@ private static boolean finishHandledTransport(Transport transport) { lastTransportHandledAtMs = handoffStartedAt; lastTransportHandledAtLocation = Rs2Player.getWorldLocation(); lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; + lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; WorldPoint goal = currentTarget; WorldPoint transportDest = transport != null ? transport.getDestination() : null; boolean expectedTransport = consumeExpectedTransportDestination(transportDest); 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 b0a9983638..31c84b2018 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 @@ -375,6 +375,41 @@ public void stabilizeRouteProgressIndex_doesNotJumpBackToEarlierSwitchbackBranch assertEquals(6, Rs2Walker.stabilizeRouteProgressIndex(path, 6, target, new WorldPoint(3201, 3201, 0))); } + @Test + public void findForwardRecoveryIndex_prefersLaterReachableSwitchbackBranch() { + WorldPoint player = new WorldPoint(1000, 1000, 0); + List path = Arrays.asList( + new WorldPoint(998, 1000, 0), + new WorldPoint(999, 1000, 0), + new WorldPoint(1000, 1001, 0), + new WorldPoint(1015, 1000, 0), + new WorldPoint(1016, 1001, 0), + new WorldPoint(1002, 1002, 0), + new WorldPoint(1003, 1001, 0)); + Set reachable = new HashSet<>(Arrays.asList( + new WorldPoint(1002, 1002, 0), + new WorldPoint(1003, 1001, 0))); + + int idx = Rs2Walker.findForwardRecoveryIndex(path, 3, player, 13, reachable, wp -> true); + + assertEquals("recovery should scan forward before falling back to an earlier branch", 6, idx); + } + + @Test + public void findFurthestClickableIndex_canReturnEarlierSwitchbackBranch() { + WorldPoint player = new WorldPoint(1000, 1000, 0); + List path = Arrays.asList( + new WorldPoint(998, 1000, 0), + new WorldPoint(999, 1000, 0), + new WorldPoint(1000, 1001, 0), + new WorldPoint(1015, 1000, 0), + new WorldPoint(1016, 1001, 0)); + + int idx = Rs2Walker.findFurthestClickableIndex(path, 3, player, wp -> false, 13); + + assertEquals("generic fallback is allowed to backtrack; recovery clamps this at the call site", 2, idx); + } + @Test public void interpolateClickableTarget_usesInterpolatedPointWhenUsable() { WorldPoint player = new WorldPoint(3200, 3200, 0); @@ -520,6 +555,12 @@ public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { 5_000L)); } + @Test + public void interimPreclickTiles_runHandsOffEarlierThanWalk() { + assertEquals(6, Rs2Walker.interimPreclickTiles(false)); + assertEquals(11, Rs2Walker.interimPreclickTiles(true)); + } + @Test public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_returnsTrue() { assertTrue(Rs2Walker.didTraverseInteractedDoor( From 8f82a57308216dd610339c1094b37094f7bcf646 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Sat, 11 Jul 2026 15:26:50 +0100 Subject: [PATCH 9/9] Fix idle nudge stall accounting in Rs2Walker prevent active route idle nudge clicks from refreshing the stall clock via minimap click grace let stall recalculation run before issuing an idle nudge - clear prior minimap-click grace state after idle nudge clicks --- .../plugins/microbot/util/walker/Rs2Walker.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 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 04d088adf0..2c760efe15 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 @@ -1253,8 +1253,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); } + boolean shouldIssueActiveRouteIdleNudge = shouldIssueActiveRouteIdleNudge(); long nowTickGraceMs = System.currentTimeMillis(); if (lastAttemptedMinimapClickOk && lastAttemptedMinimapClickAtMs > 0L + && !shouldIssueActiveRouteIdleNudge && nowTickGraceMs - lastAttemptedMinimapClickAtMs < MINIMAP_CLICK_STALL_GRACE_MS) { lastMovedTimeMs = nowTickGraceMs; } @@ -1263,10 +1265,6 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; } - if (shouldIssueActiveRouteIdleNudge() - && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { - continue; - } if (isStuckTooLong()) { // Leagues area teleports can have long animations. Never trigger stall-recalc // while the transport is in-flight, or we will interrupt and re-click. @@ -1296,6 +1294,13 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) } continue; } + if (shouldIssueActiveRouteIdleNudge + && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + lastAttemptedMinimapClick = null; + lastAttemptedMinimapClickOk = false; + lastAttemptedMinimapClickAtMs = 0L; + continue; + } if (stuckCount > 10) { var reachable = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 5).keySet(); if (!reachable.isEmpty()) {