diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 470dba4efd..4a658235f4 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -178,3 +178,120 @@ 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, + interimLastProgressAtMs, 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`. + +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. + +**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. + +## 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 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 f58762a457..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 @@ -133,6 +133,9 @@ 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_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; @@ -147,20 +150,32 @@ 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; 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 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 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 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; + 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; 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; @@ -172,6 +187,15 @@ 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; + 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(); @@ -199,6 +223,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; @@ -241,6 +266,8 @@ private static void markWalkSessionStart(WorldPoint target) { startupPhasesLogged.clear(); lastTransportHandledAtLocation = null; lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; + resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); } @@ -683,6 +710,11 @@ 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(); } private static volatile List seasonalTransportHandlers = @@ -1020,10 +1052,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"); @@ -1218,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; } @@ -1250,7 +1287,18 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; + clearInterimTarget("stall-recalc"); setTarget(target); + if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { + tryIssueRouteRecoveryClick(rawPath, path, target, "stall recovery click"); + } + continue; + } + if (shouldIssueActiveRouteIdleNudge + && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + lastAttemptedMinimapClick = null; + lastAttemptedMinimapClickOk = false; + lastAttemptedMinimapClickAtMs = 0L; continue; } if (stuckCount > 10) { @@ -1268,7 +1316,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } - int indexOfStartPoint = getClosestTileIndex(path); + 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(), @@ -1282,6 +1332,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part primeExpectedTransportDestinations(path, indexOfStartPoint); lastPosition = Rs2Player.getWorldLocation(); + boolean clearedInterimTarget = clearInterimTargetIfReachedOrExpired(lastPosition, path, System.currentTimeMillis()); WorldPoint plImmediate = lastPosition; WorldPoint pathLastForImmediate = path.isEmpty() ? null : path.get(path.size() - 1); @@ -1295,6 +1346,16 @@ 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)) { + walkerDiag("tail exempt exitReason=interim-close-route-click tailBefore=%d", processWalkTail); + processWalkTail--; + continue; + } manageRunEnergy(path.size()); @@ -1337,7 +1398,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(); @@ -1360,8 +1420,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part "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) { @@ -1471,11 +1533,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); @@ -1500,7 +1564,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, @@ -1519,7 +1587,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); } @@ -1591,16 +1660,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,18 +1677,45 @@ 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; + } + 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 (unresolvedDoorNearRawPath) { + 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 @@ -1641,13 +1727,18 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // (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) { @@ -1666,7 +1757,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part 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--; } @@ -1735,7 +1826,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 @@ -1749,19 +1840,38 @@ && 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 + interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= interimPreclickTiles() || !Rs2Player.isMoving(), - 2000); - if (posBeforeWait.distanceTo2D(Rs2Player.getWorldLocation()) > 0 || Rs2Player.isMoving()) { + INTERIM_MOVING_POLL_MS); + 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) <= interimPreclickTiles(); + 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 @@ -1867,6 +1977,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) { @@ -1879,7 +1996,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; @@ -1915,7 +2035,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)) { @@ -1923,7 +2043,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; @@ -1960,7 +2080,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; } @@ -2353,6 +2473,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; @@ -2442,6 +2588,173 @@ 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(); + 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; + } + 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, + 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 = 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) { + 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; + } + if ("active route idle nudge".equals(logLabel)) { + lastActiveRouteIdleNudgeAtMs = interimSetAtMs; + } else { + lastMovedTimeMs = interimSetAtMs; + } + idleNudgeStationarySinceMs = interimSetAtMs; + idleNudgeLastObservedLocation = playerLoc; + stuckCount = 0; + return true; + } + /** * Used in instances like vorkath, jad, nmz * @@ -3019,6 +3332,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,7 +3472,166 @@ 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) { + 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; } @@ -3194,7 +3671,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); @@ -3204,7 +3681,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)) { @@ -3241,7 +3718,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() @@ -3494,6 +3971,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, @@ -3680,7 +4213,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 +4255,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 +4326,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 +4342,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 +4370,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 +4415,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 +4428,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 +4470,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 +4486,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,27 +4520,59 @@ 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; } - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(object); + 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 = resolveCompositionForDoorProbe(object); String currentAction = getDoorAction(composition, doorActions); return currentAction != null && currentAction.equalsIgnoreCase(action); } @@ -4139,15 +4711,158 @@ private static boolean tryPostDoorFastMinimapClick(List path, int ed return clicked; } - private static boolean shouldThrottleGlobalDoorInteraction() { - return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(nextDoorInteractionAllowedAtMs); - } - - private static boolean isDoorInteractionSettling() { - return System.currentTimeMillis() < doorInteractionSettleUntilMs; - } - - private static boolean isTransportInteractionSettling() { + 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; + } + + private static int interimPreclickTiles() { + try { + 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, + long lastProgressAtMs, + long nowMs) { + if (interim == null) { + return false; + } + if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { + return true; + } + int preclickTiles = interimPreclickTiles(); + if (playerLoc.distanceTo2D(interim) <= preclickTiles) { + 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, + List path, + long nowMs) { + WorldPoint interim = interimTargetWp; + 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; + if (playerLoc == null || interim == null || playerLoc.getPlane() != interim.getPlane()) { + reason = "invalid"; + } else if (playerLoc.distanceTo2D(interim) <= interimPreclickTiles()) { + reason = "close"; + } else if (interimLastProgressAtMs > 0L && nowMs - interimLastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + reason = "stale-progress"; + } 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); + } + + private static boolean isDoorInteractionSettling() { + return System.currentTimeMillis() < doorInteractionSettleUntilMs; + } + + private static boolean isTransportInteractionSettling() { long handledAt = lastTransportHandledAtMs; if (handledAt <= 0L) { return false; @@ -4276,6 +4991,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 +5019,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 +5059,48 @@ 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; + } + return doorActionPriorityIndex(action) != Integer.MAX_VALUE; + } + /** * 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 +5130,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 +5236,151 @@ 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 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) { + 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 +5389,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 +5423,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 +5445,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 +5481,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 +5519,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 +5536,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 +5639,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 +5665,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 +5731,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 +5768,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 +5863,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 +5902,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; @@ -4967,6 +5943,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)) { @@ -5026,14 +6011,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={})", @@ -5056,7 +6042,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 +6357,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). @@ -5524,6 +6510,136 @@ 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; + } + + 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; + } + 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; + } + + 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 */ @@ -5578,6 +6694,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(); @@ -6094,11 +7211,10 @@ && 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, 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())); @@ -6115,6 +7231,42 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i return false; } + private static boolean waitForPostHandleObjectLanding(Transport transport, + 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(); + boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; + if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) + && settledAwayFromOrigin) { + 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. @@ -6295,14 +7447,26 @@ 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; + 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))) { + && 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; } @@ -6335,6 +7499,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 10363aed35..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 @@ -355,6 +355,61 @@ 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 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); @@ -416,6 +471,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 +497,70 @@ 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, + 1_500L, + 2_000L)); + } + + @Test + public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { + assertTrue(Rs2Walker.shouldClearInterimTarget( + 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)); + } + + @Test + public void interimPreclickTiles_runHandsOffEarlierThanWalk() { + assertEquals(6, Rs2Walker.interimPreclickTiles(false)); + assertEquals(11, Rs2Walker.interimPreclickTiles(true)); + } + @Test public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_returnsTrue() { assertTrue(Rs2Walker.didTraverseInteractedDoor( 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