diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 4a658235f4..235384a743 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -77,6 +77,8 @@ waitForDoorInteractionProgress(fromWp, toWp); **Defensive check:** In live testing, a door should produce one interaction followed by movement/path progress, not repeated `Raw path door handler resolved obstacle` messages every tick while the player is moving. +If a previous minimap click is still moving the player, do not attribute that movement to a new gate interaction. A valid gate can be blacklisted when the walker clicks an interim waypoint, notices the gate while movement is still in flight, then compares the pre/post positions from the old minimap movement against the gate edge. Wrong-traversal blacklisting should only run when the player started near the attempted door edge. + ## 5. Suppress the inverse adjacent transport after crossing a same-plane door Some doors are represented in `transports.tsv` as two adjacent same-plane transports, one for each direction. After the walker clicks one side and arrives on the other, immediately accepting the inverse transport can bounce the player back through the same door instead of letting the next minimap step continue away from it. Mark both tiles of a successful adjacent same-plane transport as recently handled for a short window. @@ -235,9 +237,9 @@ if (isStuckTooLong()) { 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 +## 12. Do not let local 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. +Local-reachability 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. @@ -255,7 +257,7 @@ 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. +**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 `route-backed local recovery click` loops around the room; it should not need the user to manually open the final door. ## 13. Stall recalculation must also issue fresh movement @@ -286,12 +288,115 @@ if (isStuckTooLong()) { After a handled transport, avoid expensive path-adjacent or raw transport scans on ordinary open-ground segments unless a nearby planned transport or recent door attempt exists. Those scans are recovery tools, and on long outdoor routes a no-op scan can add several seconds before the next minimap click. -For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off 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. +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. Stale-progress checks must also count distance progress toward the actual interim checkpoint, not only smoothed path index progress; sparse smoothed paths can otherwise clear valid raw-path checkpoints as stale while the player is still walking toward them. + +Before the first movement click, avoid timeout-backed speculative segment scans for doors, blockers, rockfalls, or transports. Let the first minimap click move the player toward the route; actual visible doors can still be handled by the dedicated pre-click route-door guard, and steady-state handling can resolve later obstacles once movement has started. Spending startup time scanning every nearby raw segment creates a visible cold start. 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. +When a route-following minimap click is outside the minimap clip, fallback clicks must stay on the raw path. This includes the "clicked but no movement yet" retry after a route click and route-backed direct short-walks near the final destination. A generic "reachable tile closer to target" fallback can select a tile far away from the route in open areas, especially near fences and the final destination. Raw fallback must be anchored from the stabilized route index, not a fresh nearest-raw-tile lookup, or the walker can snap backward to an already-travelled branch. + +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. If the player is settled within one tile of the expected destination and is no longer on the origin, accept the landing instead of waiting for an exact tile that server pathing may not choose. + +## 14. Keep local recovery clicks route-backed and paced + +Local-reachability recovery is a fallback for bounded reachability false negatives, not a license to pick an arbitrary minimap point. Recovery clicks should use the same route-backed raw-path fallback as normal route movement, then store the actual clicked target as the sticky interim checkpoint. After issuing the click, wait only for movement to start or the checkpoint/goal to be reached; do not wait for a full idle cycle before returning to the walk loop. + +**Why this matters:** Bounded local reachability can report a valid route tile as unreachable even though the server pathfinder can still walk toward it. Arbitrary or repeated recovery clicks can pull the player away from the planned route and create visible stop-start movement. + +**Pattern to follow:** + +```java +int reach = STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN; +WorldPoint clickTarget = clampToEuclideanRadius(playerLoc, recoverTarget, reach - 1); +WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, reach - 1, false, rawAnchorIndex); +if (clickedTarget != null) { + interimTargetWp = clickedTarget; + waitForMovementStartAfterRecovery(target, playerLoc, clickedTarget, target, finishThreshold); +} +``` + +**Where this applies:** `Rs2Walker.processWalk` local-reachability recovery, route-backed direct short-walks, and any fallback that runs after local reachability says a route tile is not currently reachable. + +**Defensive check:** Exercise a long outdoor route and a route with gates or transports. Recovery logs should select route-backed points, and there should be no long idle pause between `route-backed local recovery click` and the next route-aligned movement. + +After recovery sets a sticky interim target, do not issue another optimistic recovery while that interim is still active and movement/progress is fresh. Yield as `interim-in-flight` or another tail-exempt movement state until the checkpoint is close, stale, or expired. Otherwise the walker can loop through several recovery clicks while the player is already moving, especially when local reachability reports the next smoothed tile as unreachable. + +## 15. Keep primary route clicks on the raw route + +When a shortest-path raw route exists, choose the next minimap target from that raw route before applying smoothed-waypoint wall-distance nudges, bounded reachability shortcuts, or generic directional fallbacks. Sideways reachable tiles can be valid server walk targets while still being the wrong side of a fence, gate, or corridor corner. Generic fallback is only appropriate when no route-backed raw point is available. + +**Why this matters:** Tight routes near gates and fences can fail when the walker clicks a tile left or right of the drawn route line. The game then chooses its own path around the obstacle, which fights the planned door/transport handlers and produces stop-start recovery. + +**Pattern to follow:** + +```java +WorldPoint rawTarget = findFurthestRawPathPointMatching(rawPath, playerLoc, reach, rawAnchor, + Rs2Walker::isKnownWalkableOrUnloaded); +WorldPoint clickTarget = rawTarget != null ? rawTarget : getPointWithWallDistance(smoothedTarget); +``` + +**Where this applies:** `Rs2Walker.processWalk`, recovery clicks after false unreachable reports, route continuation clicks, direct short-walk fallbacks, and any minimap click helper used while a shortest-path raw route exists. + +**Defensive check:** Unit-test raw-route target selection with a route that doubles back near an earlier branch; the selected tile must stay at or ahead of the anchored raw route index. + +## 16. Yield before scans while an interim route click is in flight + +Sticky interim targets only reduce click thrash if they are checked before broad route scans. When a fresh interim checkpoint is still far away and the player is moving or has very recent checkpoint progress, yield before raw-scene scans, current-tile transport scans, direct short-walk attempts, and path segment scans. Resume normal route work once the checkpoint is close, stale, expired, on another plane, or movement has genuinely stopped. + +**Why this matters:** Long post-transport routes can otherwise run several no-op scans and select several small follow-up clicks while the previous minimap flag is still carrying the player. That looks like stop-start walking and burns tail iterations even though the route is making progress. + +**Pattern to follow:** + +```java +if (shouldYieldForActiveRouteInterim(playerLoc, path, nowMs)) { + exitReason = "interim-in-flight"; + continue; +} +``` + +**Where this applies:** `Rs2Walker.processWalk` before broad raw-scene handling, current-tile transport handling, direct short-walk handling, and the main path loop. + +**Defensive check:** Start a long route immediately after a transport. While the player is still moving toward the active minimap checkpoint, the walker should log a tail-exempt `interim-in-flight` yield instead of repeated `post_transport_path_selected` clicks for nearby path indices. + +## 17. Dispatch immediate planned transports before idle nudges + +When the current route index is an explicit shortest-path transport edge, let the segment transport handler run even during startup, before route idle nudges or stall recovery clicks. Keep startup door/rockfall probes skipped, but do not postpone teleports, ships, ladders, or other catalog-backed route transports that start at the player's current segment. + +**Why this matters:** Originless spell/item teleports can be the first edge in a route. If startup logic suppresses the segment handler and idle recovery runs first, the walker can sit on the origin, spam no-op route nudges, trigger stall recovery, and only then cast the teleport. + +**Pattern to follow:** + +```java +boolean immediateTransport = hasImmediatePlannedTransportStep(path, routeStartIdx, playerLoc); +if (shouldRunActiveRouteIdleNudge(idleNudgeDue, immediateTransport)) { + tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge"); +} +``` + +**Where this applies:** `Rs2Walker.processWalk` startup handling, active route idle nudges, stall recovery clicks, and segment transport dispatch. + +**Defensive check:** Start a route whose first planned edge is Home Teleport. Logs should show `transport_handoff_enter` for `TELEPORTATION_SPELL` without several `active route idle nudge: clicked=false` entries first. + +## 18. Model conditional paid gates as transports plus blocked edges + +When a gate can be crossed by either a quest unlock or a currency payment, do not model the gate tiles as quest-only restrictions. A tile restriction cannot express "quest complete OR pay coins", and it can prevent the walker from reaching the paid transport origin. Model each crossing as explicit paid/free transports and add a blocked edge override so normal walking cannot bypass the unavailable transport. + +**Why this matters:** The Lumbridge-to-Al Kharid toll gate can be opened freely after Prince Ali Rescue or paid through with 10 coins. A quest-only restriction blocks the paid route for accounts without the quest, while no edge block can let the pathfinder walk through the gate without paying. + +**Pattern to follow:** + +```java +// Wrong: restrict both sides of the gate to one quest state. +3267 3227 0 Prince Ali Rescue + +// Right: transport data decides availability; blocked_edges prevents free walking. +3267 3227 0 3268 3227 0 Pay-toll(10gp);Gate;2786 ... 10 Coins +3267 3227 0 3268 3227 0 Open;Gate;2786 ... Prince Ali Rescue +``` + +**Where this applies:** `transports.tsv`, `blocked_edges.tsv`, `restrictions.tsv`, `PathfinderConfig`, and `Rs2Walker.handleTransports`. -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. +**Defensive check:** Add resource tests that assert both paid and quest-free transports load, the gate tiles are not quest-only restricted, and the gate edge is blocked without a usable transport. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 6a62b53d07..3f4c1437d1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -308,7 +308,7 @@ protected void shutDown() { //Method from microbot public static void exit() { if (pathfindingExecutor != null) { - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("shortest-path-plugin:exit"); pathfindingExecutor.shutdownNow(); pathfindingExecutor = null; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 211e1fdeef..92855da1cd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -879,7 +879,7 @@ private boolean useTransport(Transport transport) { if (transport.getCurrencyAmount() > 0) { if (refreshCurrencyCache != null) { int[] cached = refreshCurrencyCache.computeIfAbsent(transport.getCurrencyName(), name -> { - int invCount = Rs2Inventory.count(name); + int invCount = Rs2Inventory.itemQuantity(name); int bankCount = useBankItems ? Rs2Bank.count(name) : 0; return new int[]{invCount, bankCount}; }); 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 2c760efe15..6bf2a33f90 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 @@ -48,6 +48,7 @@ import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandlers; import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; import java.util.function.BooleanSupplier; +import java.util.function.Predicate; import org.slf4j.event.Level; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.poh.PohTransport; @@ -126,29 +127,36 @@ public static WorldPoint getCurrentTarget() { private static volatile long interimSetAtMs = 0L; private static volatile long interimLastProgressAtMs = 0L; private static volatile int interimLastBestPathIdx = -1; + private static volatile int interimLastDistanceToTarget = Integer.MAX_VALUE; private static volatile long interimLastRetargetAtMs = 0L; /** Cooldown so partial-segment in-transit {@link #recalculatePath()} does not spam. */ private static volatile long lastPartialTransRecalcMs = 0L; private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; - private static final int INTERIM_CLOSE_TILES = 4; + private static final int INTERIM_CLOSE_TILES = 5; private static final int INTERIM_PRECLICK_TILES = 6; - private static final int INTERIM_RUN_PRECLICK_TILES = 11; + private static final int INTERIM_RUN_PRECLICK_TILES = 8; 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; + private static final long ROUTE_PROGRESS_STALL_GRACE_MS = 4_000L; + private static final long OFF_PATH_RECALC_RECENT_MOVEMENT_MS = 2_000L; + private static final long OFF_PATH_RECALC_ROUTE_PROGRESS_GRACE_MS = 3_500L; + private static final long OFF_PATH_RECALC_MINIMAP_CLICK_GRACE_MS = 2_500L; + private static final int OFF_PATH_RECALC_DEFER_WAIT_MIN_MS = 250; + private static final int OFF_PATH_RECALC_DEFER_WAIT_MAX_MS = 1_200; private static final long RAW_SCAN_DOOR_FOCUS_MAX_MS = 2200L; private static final int RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS = 3; private static final long DOOR_POST_INTERACT_SETTLE_MS = 900L; private static final long DOOR_EDGE_SKIP_COOLDOWN_MS = 700L; - private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 1400L; + private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 3_500L; private static final long DOOR_TRAVERSAL_RECOVERY_BLOCK_MS = 2_200L; + private static final long POST_DOOR_NUDGE_RECENT_ATTEMPT_MS = 6_000L; private static final int PATHFINDER_DONE_POLL_WAIT_MS = 1200; private static final int PATHFINDER_DONE_RETRY_SLEEP_MIN_MS = 120; private static final int PATHFINDER_DONE_RETRY_SLEEP_MAX_MS = 220; - 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; @@ -162,7 +170,7 @@ public static WorldPoint getCurrentTarget() { 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 NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; 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; @@ -196,8 +204,19 @@ public static WorldPoint getCurrentTarget() { private static volatile WorldPoint routeProgressPathStart = null; private static volatile WorldPoint routeProgressPathEnd = null; private static volatile int routeProgressPathSize = -1; + private static volatile long routeProgressAdvancedAtMs = 0L; private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); + private static final Set AL_KHARID_TOLL_GATE_OBJECT_IDS = Set.of( + net.runelite.api.ObjectID.CITY_GATE_2786, + net.runelite.api.ObjectID.CITY_GATE_2787, + net.runelite.api.ObjectID.CITY_GATE_2788, + net.runelite.api.ObjectID.CITY_GATE_2789); + private static final Set AL_KHARID_TOLL_GATE_POINTS = Set.of( + new WorldPoint(3267, 3227, 0), + new WorldPoint(3267, 3228, 0), + new WorldPoint(3268, 3227, 0), + new WorldPoint(3268, 3228, 0)); /** * Max Chebyshev "radius" for Quetzal / near-destination checks — guards use {@code distanceTo2D < OFFSET}. @@ -314,6 +333,24 @@ private enum WalkerPhase { STEADY } + private static final class WalkLoopSnapshot { + private final WorldPoint playerLoc; + private final HashMap closestReachableTiles; + + private WalkLoopSnapshot(WorldPoint playerLoc) { + this.playerLoc = playerLoc; + this.closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + } + + private static WalkLoopSnapshot capture() { + return new WalkLoopSnapshot(Rs2Player.getWorldLocation()); + } + + private int closestTileIndex(List path) { + return getClosestTileIndex(path, playerLoc, closestReachableTiles); + } + } + private interface ObstaclePolicy { long segmentDoorTimeoutMs(); long unreachableDoorTimeoutMs(); @@ -405,13 +442,16 @@ private static WalkerPhase currentWalkerPhase() { if (firstMovementClickMarked) { return WalkerPhase.STEADY; } - long startedAt = walkSessionStartedAtMs; - if (startedAt <= 0) { - return WalkerPhase.STEADY; - } - return (System.currentTimeMillis() - startedAt) <= STARTUP_FIRST_CLICK_BUDGET_MS - ? WalkerPhase.STARTUP - : WalkerPhase.STEADY; + return WalkerPhase.STARTUP; + } + + private static boolean isClientThread() { + Client client = Microbot.getClient(); + return client != null && client.isClientThread(); + } + + private static int reachedDistanceOrDefault() { + return config != null ? config.reachedDistance() : 10; } private static ObstaclePolicy obstaclePolicyForCurrentPhase() { @@ -420,6 +460,26 @@ private static ObstaclePolicy obstaclePolicyForCurrentPhase() { : STEADY_OBSTACLE_POLICY; } + static boolean shouldSkipStartupPreclickSegmentHandlers(boolean startupBeforeFirstClick, + int segmentIdx, + int routeStartIdx, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight) { + if (!startupBeforeFirstClick || routeStartIdx < 0 || segmentIdx < routeStartIdx) { + return false; + } + if (recentDoorAttemptNearSegment || doorSettling || recoveryInFlight) { + return false; + } + return true; + } + + static boolean shouldRunActiveRouteIdleNudge(boolean idleNudgeDue, + boolean immediateRouteTransportPending) { + return idleNudgeDue && !immediateRouteTransportPending; + } + /** * Same-plane Chebyshev distance from player to {@code dest} strictly less than {@code maxChebyshevExclusive}. * Requires matching {@link WorldPoint#getPlane()} before using {@link WorldPoint#distanceTo2D} — that method only @@ -491,6 +551,7 @@ private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalk /** If phase 1 exits on arrival distance while still moving, wait briefly for idle-only (reduces tail churn). */ private static final int POST_SCENE_WALK_IDLE_SECOND_PHASE_MS_MAX = 4_000; + private static final int POST_RECOVERY_MOVEMENT_START_WAIT_MS = 1_200; private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs) { waitUntilIdleAfterSceneWalk(cancelGoal, timeoutMs, null, 0); @@ -749,6 +810,7 @@ public static List getSeasonalTransportHandlers() */ public static final class Telemetry { public static final AtomicInteger offPathRecalcCount = new AtomicInteger(); + public static final AtomicInteger offPathRecalcDeferredCount = new AtomicInteger(); public static final AtomicInteger stallRecalcCount = new AtomicInteger(); public static final AtomicInteger partialRetryCount = new AtomicInteger(); public static final AtomicInteger unreachableCount = new AtomicInteger(); @@ -766,6 +828,9 @@ public static final class Telemetry { private static final ConcurrentHashMap doorRejectByCause = new ConcurrentHashMap<>(); private static final AtomicInteger doorRejectSummaryLogSeq = new AtomicInteger(0); private static final int DOOR_REJECT_SUMMARY_LOG_INTERVAL = 40; + private static final ConcurrentHashMap offPathDeferredByReason = new ConcurrentHashMap<>(); + private static final AtomicInteger offPathDeferredSummaryLogSeq = new AtomicInteger(0); + private static final int OFF_PATH_DEFERRED_SUMMARY_LOG_INTERVAL = 20; /** * Rate-limited debug summary of {@link #doorRejectByCause} tallies (noise control on tight door clusters). @@ -805,6 +870,22 @@ public static void recordOffPathRecalc(WorldPoint playerPos, int pathSize) { playerPos, pathSize, offPathRecalcCount.get(), stallRecalcCount.get()); } + public static void recordOffPathRecalcDeferred(String reason, WorldPoint playerPos, + WorldPoint target, int pathSize) { + if (reason == null || reason.isEmpty()) { + reason = "unknown"; + } + offPathRecalcDeferredCount.incrementAndGet(); + offPathDeferredByReason.computeIfAbsent(reason, k -> new AtomicInteger()).incrementAndGet(); + lastReason = "off-path-deferred:" + reason; + lastEventAtMs.set(System.currentTimeMillis()); + if (Rs2LogRateLimit.everyN(offPathDeferredSummaryLogSeq, OFF_PATH_DEFERRED_SUMMARY_LOG_INTERVAL) + && log.isDebugEnabled()) { + log.debug("[WalkerTelemetry] OFFPATH_RECALC_DEFERRED player={} target={} pathSize={} summary={}", + playerPos, target, pathSize, offPathDeferredByReason); + } + } + public static void recordStallRecalc(long sinceMovedMs, WorldPoint playerPos) { stallRecalcCount.incrementAndGet(); lastReason = "stall"; @@ -836,6 +917,7 @@ public static void recordUnreachable(String cause, WorldPoint player, WorldPoint public static void reset() { offPathRecalcCount.set(0); + offPathRecalcDeferredCount.set(0); stallRecalcCount.set(0); partialRetryCount.set(0); unreachableCount.set(0); @@ -845,6 +927,8 @@ public static void reset() { seasonalHandlerMissCount.set(0); doorRejectByCause.clear(); doorRejectSummaryLogSeq.set(0); + offPathDeferredByReason.clear(); + offPathDeferredSummaryLogSeq.set(0); lastEventAtMs.set(0); lastReason = ""; log.info("[WalkerTelemetry] counters reset"); @@ -862,7 +946,7 @@ public static int totalRecalcs() { ); public static boolean walkTo(int x, int y, int plane) { - return walkTo(x, y, plane, config.reachedDistance()); + return walkTo(x, y, plane, reachedDistanceOrDefault()); } /** @@ -878,7 +962,7 @@ public static boolean walkTo(int x, int y, int plane, int distance) { * result is {@code false}, same as any non-arrival outcome. */ public static boolean walkTo(WorldPoint target) { - return walkWithState(target, config.reachedDistance()) == WalkerState.ARRIVED; + return walkWithState(target, reachedDistanceOrDefault()) == WalkerState.ARRIVED; } /** @@ -932,6 +1016,10 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { log.warn("[Walker] walk rejected: null target"); return WalkerState.EXIT; } + if (isClientThread()) { + log.warn("Please do not call the walker from the main thread"); + return WalkerState.EXIT; + } if (!walkerLock.tryLock()) { log.warn("[Walker] concurrent walk request detected, waiting for in-flight walk (held by {}); new target={}", Thread.currentThread().getName(), target); @@ -972,6 +1060,11 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long log.warn("[Walker] walk rejected: null target"); return WalkerState.EXIT; } + if (isClientThread()) + { + log.warn("Please do not call the walker from the main thread"); + return WalkerState.EXIT; + } if (lockWaitMs < 0) { throw new IllegalArgumentException("lockWaitMs must be >= 0"); @@ -1021,6 +1114,10 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long */ private static WalkerState walkWithStateInternal(WorldPoint target, int distance) { Objects.requireNonNull(target, "walk target"); + if (isClientThread()) { + log.warn("Please do not call the walker from the main thread"); + return WalkerState.EXIT; + } WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -1054,17 +1151,13 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; 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"); - return WalkerState.EXIT; - } - closeWorldMap(); if (Rs2Bank.isOpen()) { Rs2Bank.closeBank(); @@ -1078,7 +1171,7 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance * @return */ public static WalkerState walkWithState(WorldPoint target) { - return walkWithState(target, config.reachedDistance()); + return walkWithState(target, reachedDistanceOrDefault()); } /** @@ -1191,9 +1284,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int rawSize = rawPath == null ? -1 : rawPath.size(); int walkSize = path == null ? -1 : path.size(); markStartupPhase("path_snapshot", target, "raw=" + rawSize + " walk=" + walkSize); + final WalkLoopSnapshot walkLoop = WalkLoopSnapshot.capture(); final WorldPoint dst; if (path == null || path.isEmpty()) { - dst = Rs2Player.getWorldLocation(); + dst = walkLoop.playerLoc; } else { dst = path.get(path.size()-1); } @@ -1204,7 +1298,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part WebWalkLog.partialSegment(dst, dst.distanceTo(target), target, path.size()); partialPath = true; } else { - Telemetry.recordUnreachable("no-walkable-path", Rs2Player.getWorldLocation(), + Telemetry.recordUnreachable("no-walkable-path", walkLoop.playerLoc, target, dst, path == null ? 0 : path.size(), distance, pathfinder); setTarget(null, "rs2walker:processWalk:no-walkable-path"); return WalkerState.UNREACHABLE; @@ -1218,11 +1312,11 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // Partial segment: before standing on the segment endpoint, refresh routing from current // position so the continuation is ready (smooth handoff vs dead stop at segment end). if (partialPath) { - WorldPoint playerPt = Rs2Player.getWorldLocation(); + WorldPoint playerPt = walkLoop.playerLoc; if (playerPt != null && dst != null) { int distToDstSeg = playerPt.distanceTo2D(dst); int distToGoal = playerPt.distanceTo2D(target); - int closestEarly = getClosestTileIndex(path); + int closestEarly = walkLoop.closestTileIndex(path); int remainingSteps = closestEarly >= 0 ? (path.size() - 1 - closestEarly) : Integer.MAX_VALUE; final int nearSegmentEndTiles = 12; final int nearSegmentEndSteps = 10; @@ -1245,6 +1339,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } + int earlyRouteStartIdx = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); + boolean immediateRouteTransportPending = hasImmediatePlannedTransportStep(path, earlyRouteStartIdx, walkLoop.playerLoc); + // Do not clear walk target while a sticky minimap interim is active — breaks // isWalkCancelled and forces EXIT while the flag is still carrying the player. // Partial paths end at an intermediate waypoint (dst still far from {@code target}); @@ -1288,18 +1385,25 @@ 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()) { + if (immediateRouteTransportPending) { + WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); + } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { + setTarget(target); tryIssueRouteRecoveryClick(rawPath, path, target, "stall recovery click"); + continue; + } else { + setTarget(target); + continue; } - continue; } - if (shouldIssueActiveRouteIdleNudge - && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { - lastAttemptedMinimapClick = null; - lastAttemptedMinimapClickOk = false; - lastAttemptedMinimapClickAtMs = 0L; - continue; + if (shouldRunActiveRouteIdleNudge(shouldIssueActiveRouteIdleNudge, immediateRouteTransportPending)) { + if (tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + lastAttemptedMinimapClick = null; + lastAttemptedMinimapClickOk = false; + lastAttemptedMinimapClickAtMs = 0L; + continue; + } + lastActiveRouteIdleNudgeAtMs = System.currentTimeMillis(); } if (stuckCount > 10) { var reachable = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 5).keySet(); @@ -1316,13 +1420,13 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) } } - WorldPoint playerLocForIndex = Rs2Player.getWorldLocation(); - int indexOfStartPoint = stabilizeRouteProgressIndex(path, getClosestTileIndex(path), target, playerLocForIndex); + WorldPoint playerLocForIndex = walkLoop.playerLoc; + int indexOfStartPoint = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, playerLocForIndex); indexOfStartPoint = advanceIndexPastRecentTransportEdge(path, indexOfStartPoint, playerLocForIndex); if (indexOfStartPoint == -1) { walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", path.size(), - Rs2Player.getWorldLocation(), + playerLocForIndex, path.isEmpty() ? null : path.get(0), path.isEmpty() ? null : path.get(path.size() - 1)); traceProcessWalkExit("closest-index-none", target, processWalkTail); @@ -1331,7 +1435,7 @@ && tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) } primeExpectedTransportDestinations(path, indexOfStartPoint); - lastPosition = Rs2Player.getWorldLocation(); + lastPosition = playerLocForIndex; boolean clearedInterimTarget = clearInterimTargetIfReachedOrExpired(lastPosition, path, System.currentTimeMillis()); WorldPoint plImmediate = lastPosition; @@ -1401,6 +1505,31 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { Map doorEdgesAttemptedThisTail = new HashMap<>(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); + WorldPoint activeInterimPlayer = Rs2Player.getWorldLocation(); + long activeInterimNowMs = System.currentTimeMillis(); + if (!Rs2Player.isInteracting() + && !Rs2Player.isAnimating() + && !isDoorInteractionSettling() + && !isTransportInteractionSettling() + && (target == null + || activeInterimPlayer == null + || activeInterimPlayer.distanceTo(target) > immediateFinishTh) + && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { + exitReason = "interim-in-flight"; + WebWalkLog.earlyExit(exitReason, + activeInterimPlayer, + target, + path.get(path.size() - 1), + indexOfStartPoint, + path.size()); + walkerDiag("tail exempt exitReason=%s tailBefore=%d early=true interim=%s", + exitReason, + processWalkTail, + interimTargetWp); + processWalkTail--; + continue; + } + boolean postTransportWindow = lastTransportHandledAtMs > 0 && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; boolean allowRawSceneScan = startupPolicy.allowBroadRawHandlers() && rawPath != null && path != null; @@ -1489,21 +1618,21 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { "nearPath=false variance=" + nearPathByVariance); } if (!nearPath && !recentTransportWindow && !nearPathByVariance) { - // Avoid mid-walk recalculation while the player is still moving. recalculatePath() - // cancels the pathfinder and waits up to 10s for a new one — a visible stall. - // isStuckTooLong() will trigger a real recalculation if progress actually halts. - boolean movingOrRecentlyMoved = Rs2Player.isMoving() - || (lastMovedTimeMs > 0 && System.currentTimeMillis() - lastMovedTimeMs < 2000); - if (movingOrRecentlyMoved) { + // Avoid mid-walk recalculation while recent clicks, route progress, or busy state + // indicate the player may still be advancing. isStuckTooLong() will trigger a + // real recovery path once progress actually halts. + String deferReason = currentOffPathRecalcDeferralReason(lastAttemptedMinimapClickAtMs); + if (deferReason != null) { + Telemetry.recordOffPathRecalcDeferred(deferReason, playerForPathCheck, target, path.size()); if (lastTransportHandledAtMs > 0 && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { WebWalkLog.tmark("post_transport_offpath_moving_yield", System.currentTimeMillis() - lastTransportHandledAtMs, target, playerForPathCheck, - "movingRecent=true"); + "defer=" + deferReason); } - exitReason = "off-path-but-moving"; + exitReason = "off-path-deferred:" + deferReason; break; } Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); @@ -1536,6 +1665,8 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { boolean upcomingNearbyTransport = hasUpcomingNearbyTransportStep(path, i, playerNearSeg, POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST); + boolean startupBeforeFirstClick = currentWalkerPhase() == WalkerPhase.STARTUP; + boolean immediateSegmentTransportStep = hasImmediatePlannedTransportStep(path, i, playerNearSeg); boolean recentDoorAttemptNearSegment = hasRecentDoorAttemptNearIndex(path, i); boolean skipPostTransportSegmentHandlers = recentTransportWindow && !upcomingNearbyTransport @@ -1543,15 +1674,31 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { && !isDoorInteractionSettling() && !isRecoveryMovementInFlight() && reachableTilesCache.containsKey(currentWorldPoint); - if (skipPostTransportSegmentHandlers) { + boolean skipStartupPreclickSegmentHandlers = !immediateSegmentTransportStep + && shouldSkipStartupPreclickSegmentHandlers( + startupBeforeFirstClick, + i, + indexOfStartPoint, + recentDoorAttemptNearSegment, + isDoorInteractionSettling(), + isRecoveryMovementInFlight()); + if (skipPostTransportSegmentHandlers || skipStartupPreclickSegmentHandlers) { + if (skipStartupPreclickSegmentHandlers) { + markStartupPhase("preclick_segment_handler_skip", target, + "i=" + i + " reason=startup_before_first_click"); + } tmarkPostTransport("post_transport_segment_handler_skip", target, - "i=" + i + " reason=no_nearby_planned_transport"); + "i=" + i + " reason=" + (skipPostTransportSegmentHandlers + ? "no_nearby_planned_transport" + : "startup_before_first_click")); } else { long segmentHandlerStartAt = System.currentTimeMillis(); int rawI = (i < smoothedToRaw.length) ? smoothedToRaw[i] : 0; int rawEnd = rawEndForSmoothedIndex(i, smoothedToRaw, rawPath, path); - if (!isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { + boolean startupImmediateTransportOnly = startupBeforeFirstClick && immediateSegmentTransportStep; + if (!startupImmediateTransportOnly + && !Rs2Player.isMoving() && !isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { doorOrTransportResult = handleDoorsInRawSegment(rawPath, rawI, rawEnd, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, reachableTilesCache); @@ -1567,7 +1714,8 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { boolean allowPathAdjacentProbe = !recentTransportWindow || upcomingNearbyTransport || recentDoorAttemptNearSegment; - if (!Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() + if (!startupImmediateTransportOnly + && !Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() && allowPathAdjacentProbe) { if (tryHandleBlockingPathObjectsWithTimeout(rawPath, rawI, 5, 10, obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { @@ -1578,8 +1726,10 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { } } - doorOrTransportResult = handleRockfallInRawSegment(rawPath, rawI, rawEnd, - reachableTilesCache); + if (!startupImmediateTransportOnly) { + doorOrTransportResult = handleRockfallInRawSegment(rawPath, rawI, rawEnd, + reachableTilesCache); + } if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=rockfall handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); @@ -1587,7 +1737,9 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { break; } - boolean allowSegmentTransportScan = !recentTransportWindow || upcomingNearbyTransport; + boolean allowSegmentTransportScan = !recentTransportWindow + || upcomingNearbyTransport + || immediateSegmentTransportStep; if ((PohTeleports.isInHouse() || !inInstance) && allowSegmentTransportScan) { doorOrTransportResult = handleTransportsInRawSegment(rawPath, rawI, rawEnd); } @@ -1623,7 +1775,7 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { - log.info("[Walker] unreachable path tile near player: tile={} idx={}/{} player={} target={}", + log.info("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", currentWorldPoint, i, path.size(), playerLoc, target); int edgeIdx = Math.max(indexOfStartPoint, i - 1); @@ -1677,15 +1829,23 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { exitReason = "door-traversal-pending-yield"; break; } + if (shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())) { + exitReason = "interim-in-flight"; + break; + } + if (tryRecentDoorAttemptEdgeNudge(playerLoc, target)) { + exitReason = "recent-door-edge-nudge"; + break; + } if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { - exitReason = "door-handled-unreachable-raw-scan"; + exitReason = "door-handled-local-reachability-raw-scan"; break; } if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, null)) { - exitReason = "door-handled-unreachable"; + exitReason = "door-handled-local-reachability"; break; } if (isRecoveryMovementInFlight()) { @@ -1697,6 +1857,17 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE); + if (!gateDoorInteraction + && unresolvedDoorNearRawPath + && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, + obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE)) { + exitReason = "door-handled-nearby-route-door"; + break; + } // Fallback: only interact with objects on/adjacent to blocked path edges // within ~15 tiles. Prevents clicking already-open / unrelated doors. final long nowMs = System.currentTimeMillis(); @@ -1726,27 +1897,27 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { // like a human clicking the furthest visible tile. We trust the server path // (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; + final int recoveryMinimapReach = STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN; int recoverIdx = findForwardReachableRecoveryIndex(path, i, playerLoc, - RECOVERY_MINIMAP_REACH_EUCLIDEAN); + recoveryMinimapReach); if (recoverIdx < 0) { recoverIdx = findFurthestClickableIndex(path, i, playerLoc, wp -> { Set ts = ShortestPathPlugin.getTransports().get(wp); return ts != null && !ts.isEmpty(); }, - RECOVERY_MINIMAP_REACH_EUCLIDEAN); + recoveryMinimapReach); } 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) { + > recoveryMinimapReach * recoveryMinimapReach) { // Furthest in-range path tile still beyond the minimap clip (e.g. a // diagonal segment). Interpolate a point near the minimap edge toward // path[i]; the server routes through whatever blocks line-of-sight. recoverTarget = interpolateClickableTarget(path, i, playerLoc, - path.get(i), RECOVERY_MINIMAP_REACH_EUCLIDEAN - 1, + path.get(i), recoveryMinimapReach - 1, wp -> inInstance || isKnownWalkableOrUnloaded(wp)); } // Don't let recovery park the player on a tile next to an aggressive NPC @@ -1764,9 +1935,28 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { recoverIdx = safeIdx; recoverTarget = path.get(safeIdx); } - boolean clicked = recoverTarget != null - && !recoverTarget.equals(playerLoc) - && Rs2Walker.walkMiniMap(recoverTarget); + int rawAnchorIndex = rawIndexForSmoothedIndex(recoverIdx, smoothedToRaw, rawPath); + WorldPoint rawRecoveryTarget = inInstance ? null : findFurthestRawPathPointMatching( + rawPath, + playerLoc, + recoveryMinimapReach - 1, + rawAnchorIndex, + Rs2Walker::isKnownWalkableOrUnloaded); + if (rawRecoveryTarget != null + && !rawRecoveryTarget.equals(playerLoc) + && (dangerCfg == null + || !dangerCfg.isAvoidDangerousNpcs() + || !dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(rawRecoveryTarget)))) { + recoverTarget = rawRecoveryTarget; + } + WorldPoint clickedRecoveryTarget = null; + if (recoverTarget != null && !recoverTarget.equals(playerLoc)) { + recoverTarget = clampToEuclideanRadius(playerLoc, recoverTarget, + recoveryMinimapReach - 1); + clickedRecoveryTarget = clickMiniMapOrFallback(rawPath, recoverTarget, playerLoc, + recoveryMinimapReach - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + } + boolean clicked = clickedRecoveryTarget != null; // Scene-click fallback only on final-adjacent approach (minimap click may // miss the clip when very close); kept gated on reachability since it is a // last resort, not the primary recovery path. @@ -1777,32 +1967,40 @@ && tryIssueRouteContinuationClick(rawPath, path, target)) { && Rs2Tile.isTileReachable(recoverTarget) && walkFastCanvas(recoverTarget)) { clicked = true; + clickedRecoveryTarget = recoverTarget; log.debug("[Walker] unreachable recovery: scene click -> {}", recoverTarget); } - log.info("[Walker] unreachable optimistic recovery: clicked={} to={} pathTile={} idx={}", - clicked, recoverTarget, currentWorldPoint, recoverIdx); + log.info("[Walker] route-backed local recovery click: clicked={} to={} pathTile={} idx={}", + clicked, clicked ? clickedRecoveryTarget : recoverTarget, currentWorldPoint, recoverIdx); if (clicked) { - markFirstMovementClick("first_recovery_click", target, playerLoc, - "to=" + compactWorldPoint(recoverTarget)); + markFirstMovementClick("first_local_recovery_click", target, playerLoc, + "to=" + compactWorldPoint(clickedRecoveryTarget)); + hintRouteProgressIndex(path, + Math.min(recoverIdx, i + INTERIM_CLOSE_TILES), + target); lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); // Sticky interim: subsequent iterations travel toward this point via the // interim-in-flight path instead of re-running the (false-negative) // reachability check and re-clicking every tick. - interimTargetWp = recoverTarget; + interimTargetWp = clickedRecoveryTarget; interimTargetIdx = recoverIdx; interimSetAtMs = System.currentTimeMillis(); + interimLastProgressAtMs = interimSetAtMs; + interimLastBestPathIdx = getClosestTileIndex(path, playerLoc); + interimLastDistanceToTarget = distanceToInterimOrMax(clickedRecoveryTarget, playerLoc); + interimLastRetargetAtMs = interimSetAtMs; WorldPoint pathLastRecovery = path.get(path.size() - 1); int finishThRecovery = tightFinishThreshold(target, pathLastRecovery, distance); - waitUntilIdleAfterSceneWalk(target, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, target, + waitForMovementStartAfterRecovery(target, playerLoc, clickedRecoveryTarget, target, finishThRecovery); // Next outer iteration runs checkIfStuck/isStuckTooLong before tile delta — avoid // spurious stall-recalc right after issuing recovery movement. lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; - exitReason = "unreachable-recovery-click"; + exitReason = "local-recovery-click"; break; } - exitReason = "tile-unreachable-near-player"; + exitReason = "local-reachability-miss-no-click"; break; } } @@ -1826,8 +2024,8 @@ && 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 = NORMAL_MINIMAP_REACH_EUCLIDEAN; WorldPoint playerLoc = Rs2Player.getWorldLocation(); + final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; // Checkpoint-style walking: once we set a minimap flag, let the player actually // travel toward it. Do not keep recalculating/clicking new targets mid-run. @@ -1848,6 +2046,7 @@ && walkFastCanvas(recoverTarget)) { interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; doorOrTransportResult = true; exitReason = "door-handled-during-interim"; @@ -1859,17 +2058,20 @@ && walkFastCanvas(recoverTarget)) { || !Rs2Player.isMoving(), INTERIM_MOVING_POLL_MS); WorldPoint posAfterWait = Rs2Player.getWorldLocation(); + recordInterimDistanceProgress(interimFinal, posAfterWait, System.currentTimeMillis()); if ((posAfterWait != null && posBeforeWait.distanceTo2D(posAfterWait) > 0) || Rs2Player.isMoving()) { lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; } - boolean readyForNextClick = posAfterWait != null - && interimFinal.distanceTo2D(posAfterWait) <= interimPreclickTiles(); - if (!readyForNextClick && Rs2Player.isMoving()) { + boolean closeEnoughForNextClick = posAfterWait != null + && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_CLOSE_TILES; + if (!closeEnoughForNextClick && Rs2Player.isMoving()) { exitReason = "interim-in-flight"; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", - interimFinal, interimDist, playerLoc); + interimFinal, + posAfterWait == null ? interimDist : interimFinal.distanceTo2D(posAfterWait), + posAfterWait == null ? playerLoc : posAfterWait); break; } } else { @@ -1881,6 +2083,7 @@ && walkFastCanvas(recoverTarget)) { interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; } } @@ -1890,34 +2093,21 @@ && walkFastCanvas(recoverTarget)) { interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; } - int targetIdx = findFurthestClickableIndex(path, i, playerLoc, + int targetIdx = findFurthestForwardClickableIndex(path, i, playerLoc, wp -> { Set ts = ShortestPathPlugin.getTransports().get(wp); return ts != null && !ts.isEmpty(); }, MINIMAP_REACH_EUCLIDEAN); WorldPoint targetWp = path.get(targetIdx); - // Forward waypoint out of minimap reach (e.g., diagonal PathSmoother - // segment at Chebyshev 10 = Euclidean 14.1). Backward-scan returns - // the previous in-reach waypoint, but clicking it walks backward or - // barely advances. Instead, interpolate a point close to the minimap - // edge toward the forward waypoint — the server's walk-here - // pathfinder routes through whatever is blocking line-of-sight - // (door, gate, diagonal offset). Each interpolated click covers - // ~12 tiles, matching a human clicking the furthest visible tile. - if (targetIdx < i) { - targetWp = interpolateClickableTarget( - path, - i, - playerLoc, - targetWp, - MINIMAP_REACH_EUCLIDEAN - 1, - wp -> inInstance || isKnownWalkableOrUnloaded(wp)); - targetIdx = Math.max(indexOfStartPoint, i - 1); - } else if (euclideanSq(targetWp, playerLoc) > MINIMAP_REACH_EUCLIDEAN * MINIMAP_REACH_EUCLIDEAN) { + // If the forward waypoint is outside minimap reach, interpolate a + // visible point in that direction instead of falling back to an + // earlier path tile. + if (euclideanSq(targetWp, playerLoc) > MINIMAP_REACH_EUCLIDEAN * MINIMAP_REACH_EUCLIDEAN) { targetWp = interpolateClickableTarget( path, i, @@ -1934,17 +2124,19 @@ && walkFastCanvas(recoverTarget)) { WorldPoint sticky = interimTargetWp; if (sticky != null && sticky.getPlane() == playerLoc.getPlane()) { int stickyDist = sticky.distanceTo2D(playerLoc); + recordInterimDistanceProgress(sticky, playerLoc, nowMs); if (stickyDist <= INTERIM_CLOSE_TILES || nowMs - interimSetAtMs > INTERIM_MAX_AGE_MS) { interimTargetWp = null; interimTargetIdx = -1; interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; } else { // U-turn safe progress: track progress along the path index, not Euclidean // distance-to-target (which can increase on U-shaped routes). - int bestIdxNow = getClosestTileIndex(path); + int bestIdxNow = getClosestTileIndex(path, playerLoc); if (bestIdxNow > interimLastBestPathIdx) { interimLastBestPathIdx = bestIdxNow; interimLastProgressAtMs = nowMs; @@ -1968,11 +2160,24 @@ && walkFastCanvas(recoverTarget)) { } WorldPoint posBefore = playerLoc; - WorldPoint clickTarget = inInstance ? targetWp : getPointWithWallDistance(targetWp); - if (!inInstance && !Rs2Tile.isTileReachable(clickTarget)) { + int rawAnchorIndex = rawIndexForSmoothedIndex(i, smoothedToRaw, rawPath); + WorldPoint rawRouteTarget = inInstance ? null : findFurthestRawPathPointMatching( + rawPath, + playerLoc, + MINIMAP_REACH_EUCLIDEAN - 1, + rawAnchorIndex, + Rs2Walker::isKnownWalkableOrUnloaded); + WorldPoint clickTarget; + if (rawRouteTarget != null && !rawRouteTarget.equals(playerLoc)) { + targetWp = rawRouteTarget; + clickTarget = rawRouteTarget; + } else { + clickTarget = inInstance ? targetWp : getPointWithWallDistance(targetWp); + } + if (!inInstance && rawRouteTarget == null) { WorldPoint rawReachableTarget = findFurthestReachableRawPathPoint(rawPath, playerLoc, - MINIMAP_REACH_EUCLIDEAN - 1); - if (rawReachableTarget != null) { + MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); + if (!Rs2Tile.isTileReachable(clickTarget) && rawReachableTarget != null) { targetWp = rawReachableTarget; clickTarget = rawReachableTarget; } @@ -1984,6 +2189,7 @@ && walkFastCanvas(recoverTarget)) { exitReason = "door-handled-before-minimap-click"; break; } + clickTarget = clampToEuclideanRadius(playerLoc, clickTarget, MINIMAP_REACH_EUCLIDEAN - 1); long nowBeforeClick = System.currentTimeMillis(); if (lastTransportHandledAtMs > 0 && nowBeforeClick - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { @@ -1994,27 +2200,27 @@ && walkFastCanvas(recoverTarget)) { "to=" + compactWorldPoint(clickTarget)); } markStartupPhase("click_candidate_found", target, "to=" + compactWorldPoint(clickTarget)); - boolean clicked = Rs2Walker.walkMiniMap(clickTarget); - if (!clicked) { - clicked = walkRawPathMiniMapToward(rawPath, clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); - if (!clicked && (rawPath == null || rawPath.isEmpty())) { - clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); - } - } + WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, + MINIMAP_REACH_EUCLIDEAN - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + boolean clicked = clickedTarget != null; if (walkCancelledDiag(target, "processWalk:after-minimap-click", processWalkTail)) { return WalkerState.EXIT; } - lastAttemptedMinimapClick = targetWp; + lastAttemptedMinimapClick = clicked ? clickedTarget : clickTarget; lastAttemptedMinimapClickOk = clicked; lastAttemptedMinimapClickAtMs = nowMs; if (clicked) { markFirstMovementClick("first_minimap_click", target, posBefore, - "to=" + compactWorldPoint(clickTarget)); - interimTargetWp = targetWp; + "to=" + compactWorldPoint(clickedTarget)); + hintRouteProgressIndex(path, + Math.min(targetIdx, i + INTERIM_CLOSE_TILES), + target); + interimTargetWp = clickedTarget; interimTargetIdx = targetIdx; interimSetAtMs = nowMs; interimLastProgressAtMs = nowMs; - interimLastBestPathIdx = getClosestTileIndex(path); + interimLastBestPathIdx = getClosestTileIndex(path, posBefore); + interimLastDistanceToTarget = distanceToInterimOrMax(clickedTarget, posBefore); interimLastRetargetAtMs = nowMs; final WorldPoint b = targetWp; @@ -2037,8 +2243,26 @@ && walkFastCanvas(recoverTarget)) { return before.distanceTo2D(now) >= progressCap; }, 1200); WorldPoint afterClickWait = Rs2Player.getWorldLocation(); + boolean routeRetryClicked = false; + if (afterClickWait != null && afterClickWait.equals(before) && !Rs2Player.isMoving()) { + WorldPoint routeRetryTarget = clickMiniMapOrFallback(rawPath, b, before, + MINIMAP_REACH_EUCLIDEAN - 1, false, rawAnchorIndex); + if (routeRetryTarget != null) { + routeRetryClicked = true; + hintRouteProgressIndex(path, + Math.min(targetIdx, i + INTERIM_CLOSE_TILES), + target); + lastAttemptedMinimapClick = routeRetryTarget; + lastAttemptedMinimapClickOk = true; + lastAttemptedMinimapClickAtMs = System.currentTimeMillis(); + interimTargetWp = routeRetryTarget; + interimLastProgressAtMs = lastAttemptedMinimapClickAtMs; + interimLastDistanceToTarget = distanceToInterimOrMax(routeRetryTarget, before); + interimLastRetargetAtMs = lastAttemptedMinimapClickAtMs; + } + } if (afterClickWait != null && afterClickWait.equals(before) && !Rs2Player.isMoving() - && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { + && routeRetryClicked) { sleepUntil(() -> { if (isWalkCancelled(target)) return true; WorldPoint now = Rs2Player.getWorldLocation(); @@ -2079,6 +2303,7 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; sleepUntil(() -> isWalkCancelled(target) || !Rs2Player.isMoving(), 1200); if (walkCancelledDiag(target, "processWalk:after-click-failed-wait", processWalkTail)) { @@ -2124,8 +2349,8 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { } // Only do the final-tile canvas click if we iterated the whole path cleanly. - // Exiting because the player left the path ("off-path-but-moving"/"not-near-path") - // means the player is still walking somewhere else — don't clobber that destination. + // Exiting because the player left the path may still mean movement is active. + // so don't clobber that destination. if (!doorOrTransportResult && "end-of-path".equals(exitReason)) { if (walkCancelledDiag(target, "processWalk:before-final-canvas", processWalkTail)) { return WalkerState.EXIT; @@ -2148,7 +2373,16 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= finishTh) { final WorldPoint canvasClickWp = finalTile; - if (Rs2Walker.walkFastCanvas(canvasClickWp)) { + WorldPoint finalPlayerLoc = Rs2Player.getWorldLocation(); + boolean finalClick; + if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { + int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); + finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, + NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); + } else { + finalClick = Rs2Walker.walkFastCanvas(canvasClickWp); + } + if (finalClick) { waitUntilIdleAfterSceneWalk(target, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, target, finishTh); if (walkCancelledDiag(target, "processWalk:after-final-canvas-wait", processWalkTail)) { return WalkerState.EXIT; @@ -2180,10 +2414,16 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { - if ("off-path-but-moving".equals(exitReason)) { - // Wait for the player to re-enter the path or to stop moving. Prevents a tight - // recursion loop that would spin on isNearPath() while the player is walking. - long offPathWaitMs = 2000L; + if (isOffPathRecalcDeferredExit(exitReason)) { + // Wait briefly for the player to re-enter the path or for the progress signal + // that deferred the recalc to expire. Prevents a tight loop around isNearPath(). + String deferReason = offPathDeferredReasonFromExit(exitReason); + long offPathWaitMs = offPathRecalcDeferredWaitMs(deferReason, + System.currentTimeMillis(), + lastMovedTimeMs, + routeProgressAdvancedAtMs, + lastAttemptedMinimapClickAtMs, + interimLastProgressAtMs); long now = System.currentTimeMillis(); if (lastTransportHandledAtMs > 0 && now - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { @@ -2196,6 +2436,7 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { } } if (offPathWaitMs > 0) { + final long deferredLastClickAtMs = lastAttemptedMinimapClickAtMs; if (lastTransportHandledAtMs > 0 && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { WebWalkLog.tmark("post_transport_offpath_sleep", @@ -2204,7 +2445,9 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { Rs2Player.getWorldLocation(), "ms=" + offPathWaitMs); } - sleepUntil(() -> isWalkCancelled(target) || isNearPath() || !Rs2Player.isMoving(), + sleepUntil(() -> isWalkCancelled(target) + || isNearPath() + || currentOffPathRecalcDeferralReason(deferredLastClickAtMs) == null, (int) offPathWaitMs); } if (walkCancelledDiag(target, "processWalk:after-off-path-wait", processWalkTail)) { @@ -2213,7 +2456,9 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { } // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. - if ("interim-in-flight".equals(exitReason) || "off-path-but-moving".equals(exitReason)) { + if ("interim-in-flight".equals(exitReason) + || "recovery-move-in-flight".equals(exitReason) + || isOffPathRecalcDeferredExit(exitReason)) { walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); processWalkTail--; } @@ -2488,15 +2733,55 @@ private static boolean walkRawPathMiniMapToward(List rawPath, WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { - WorldPoint fallback = findFurthestVisibleReachableRawPathPoint(rawPath, playerLoc, maxEuclidean); + return walkRawPathMiniMapTargetToward(rawPath, target, playerLoc, maxEuclidean, -1) != null; + } + + private static WorldPoint clickMiniMapOrFallback(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean, + boolean allowDirectionalFallback) { + return clickMiniMapOrFallback(rawPath, target, playerLoc, maxEuclidean, allowDirectionalFallback, -1); + } + + private static WorldPoint clickMiniMapOrFallback(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean, + boolean allowDirectionalFallback, + int rawAnchorIndex) { + if (target == null || playerLoc == null || target.equals(playerLoc)) { + return null; + } + if (walkMiniMap(target)) { + return target; + } + WorldPoint rawFallback = walkRawPathMiniMapTargetToward(rawPath, target, playerLoc, + maxEuclidean, rawAnchorIndex); + if (rawFallback != null) { + return rawFallback; + } + if (allowDirectionalFallback && walkMiniMapToward(target, playerLoc, maxEuclidean)) { + return target; + } + return null; + } + + private static WorldPoint walkRawPathMiniMapTargetToward(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex) { + WorldPoint fallback = findFurthestVisibleKnownRawPathPoint(rawPath, playerLoc, + maxEuclidean, rawAnchorIndex); if (fallback == null || fallback.equals(playerLoc) || fallback.equals(target)) { - return false; + return null; } if (walkMiniMap(fallback)) { log.info("[Walker] Minimap click target {} was outside clip; used route fallback {}", target, fallback); - return true; + return fallback; } - return false; + return null; } static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { @@ -2559,64 +2844,119 @@ && euclideanSq(tile, target) < currentDistance) .orElse(false); } - private static WorldPoint findFurthestReachableRawPathPoint(List rawPath, - WorldPoint playerLoc, - int maxEuclidean) { + static WorldPoint findFurthestReachableRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean) { + return findFurthestReachableRawPathPoint(rawPath, playerLoc, maxEuclidean, -1); + } + + static WorldPoint findFurthestReachableRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex) { + Set reachable = playerLoc == null + ? Collections.emptySet() + : Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet(); + return findFurthestRawPathPointMatching(rawPath, playerLoc, maxEuclidean, rawAnchorIndex, + reachable::contains); + } + + static WorldPoint findFurthestRawPathPointMatching(List rawPath, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex, + Predicate isCandidate) { if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { return null; } - int closestRawIndex = getClosestTileIndex(rawPath); + int closestRawIndex = rawPathForwardAnchorIndex(rawPath, playerLoc, rawAnchorIndex); if (closestRawIndex < 0) { return null; } int maxSq = maxEuclidean * maxEuclidean; - Set reachable = Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet(); + int maxRouteSteps = maxEuclidean + 2; + int routeSteps = 0; 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 (rawIndex > closestRawIndex) { + WorldPoint previous = rawPath.get(rawIndex - 1); + routeSteps += rawPathStepDistance(previous, candidate); + if (routeSteps > maxRouteSteps) { + break; + } + } if (euclideanSq(candidate, playerLoc) > maxSq) { break; } - if (reachable.contains(candidate)) { + if (!candidate.equals(playerLoc) && (isCandidate == null || isCandidate.test(candidate))) { best = candidate; } } return best; } - private static WorldPoint findFurthestVisibleReachableRawPathPoint(List rawPath, - WorldPoint playerLoc, - int maxEuclidean) { + static WorldPoint findFurthestVisibleKnownRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean) { + return findFurthestVisibleKnownRawPathPoint(rawPath, playerLoc, maxEuclidean, -1); + } + + static WorldPoint findFurthestVisibleKnownRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex) { if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { return null; } - int closestRawIndex = getClosestTileIndex(rawPath); - if (closestRawIndex < 0) { - return null; + + return findFurthestRawPathPointMatching(rawPath, playerLoc, maxEuclidean, rawAnchorIndex, + candidate -> !candidate.equals(playerLoc) + && isKnownWalkableOrUnloaded(candidate) + && isMiniMapClickable(candidate, 5)); + } + + private static int rawPathStepDistance(WorldPoint previous, WorldPoint current) { + if (previous == null || current == null || previous.getPlane() != current.getPlane()) { + return Integer.MAX_VALUE / 4; } + return Math.max(1, previous.distanceTo2D(current)); + } - 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; + static int rawPathForwardAnchorIndex(List rawPath, WorldPoint playerLoc, int rawAnchorIndex) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { + return -1; + } + if (rawAnchorIndex < 0) { + return getClosestTileIndex(rawPath, playerLoc); + } + + int start = Math.max(0, Math.min(rawAnchorIndex, rawPath.size() - 1)); + int endExclusive = Math.min(rawPath.size(), start + ROUTE_PROGRESS_FORWARD_SEARCH_TILES + 1); + int bestIdx = -1; + int bestDist = Integer.MAX_VALUE; + for (int i = start; i < endExclusive; i++) { + WorldPoint point = rawPath.get(i); + if (point == null) { + continue; } - if (euclideanSq(candidate, playerLoc) > maxSq) { + if (point.getPlane() != playerLoc.getPlane()) { break; } - if (!candidate.equals(playerLoc) - && reachable.contains(candidate) - && isMiniMapClickable(candidate, 5)) { - best = candidate; + int dist = point.distanceTo2D(playerLoc); + if (dist < bestDist) { + bestIdx = i; + bestDist = dist; + if (dist == 0) { + break; + } } } - return best; + return bestIdx >= 0 ? bestIdx : start; } private static boolean shouldIssueActiveRouteIdleNudge() { @@ -2658,7 +2998,7 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, return false; } if (rawPath != null && !rawPath.isEmpty()) { - int rawIdx = getClosestTileIndex(rawPath); + int rawIdx = getClosestTileIndex(rawPath, playerLoc); if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, rawIdx, playerLoc, @@ -2668,7 +3008,7 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, return false; } } - int pathIdx = Math.max(0, getClosestTileIndex(path)); + int pathIdx = Math.max(0, getClosestTileIndex(path, playerLoc)); if (hasUpcomingNearbyTransportStep(path, pathIdx, playerLoc, POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { @@ -2688,15 +3028,19 @@ private static boolean tryIssueRouteMovementClick(List rawPath, if (playerLoc == null || path == null || path.isEmpty()) { return false; } + int targetIdx = stabilizeRouteProgressIndex(path, getClosestTileIndex(path, playerLoc), target, playerLoc); + int startIdx = Math.max(0, targetIdx); + int[] routeSmoothedToRaw = mapSmoothedToRaw(path, rawPath); + int rawAnchorIndex = rawIndexForSmoothedIndex(targetIdx, routeSmoothedToRaw, rawPath); - WorldPoint clickTarget = findFurthestReachableRawPathPoint( + WorldPoint clickTarget = findFurthestRawPathPointMatching( rawPath, playerLoc, - maxEuclidean); - int targetIdx = getClosestTileIndex(path); + maxEuclidean, + rawAnchorIndex, + Rs2Walker::isKnownWalkableOrUnloaded); if (clickTarget == null) { - int startIdx = Math.max(0, targetIdx); - int clickableIdx = findFurthestClickableIndex(path, startIdx, playerLoc, + int clickableIdx = findFurthestForwardClickableIndex(path, startIdx, playerLoc, wp -> { Set ts = ShortestPathPlugin.getTransports().get(wp); return ts != null && !ts.isEmpty(); @@ -2718,28 +3062,37 @@ private static boolean tryIssueRouteMovementClick(List rawPath, } boolean clicked = false; + WorldPoint clickedTarget = null; 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); - } + clickTarget = clampToEuclideanRadius(playerLoc, clickTarget, maxEuclidean - 1); + clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, + maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + clicked = clickedTarget != null; + } + if (clicked && "interim close route click".equals(logLabel)) { + log.debug("[Walker] {}: clicked={} to={} player={} idx={}", + logLabel, true, clickedTarget, playerLoc, targetIdx); + } else { + log.info("[Walker] {}: clicked={} to={} player={} idx={}", + logLabel, clicked, clicked ? clickedTarget : clickTarget, playerLoc, targetIdx); } - 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", + markFirstMovementClick(routeMovementClickPhase(logLabel), target, playerLoc, - "to=" + compactWorldPoint(clickTarget)); - interimTargetWp = clickTarget; + "to=" + compactWorldPoint(clickedTarget)); + hintRouteProgressIndex(path, + Math.min(targetIdx, startIdx + INTERIM_CLOSE_TILES), + target); + interimTargetWp = clickedTarget; interimTargetIdx = targetIdx; interimSetAtMs = System.currentTimeMillis(); interimLastProgressAtMs = interimSetAtMs; - interimLastBestPathIdx = getClosestTileIndex(path); + interimLastBestPathIdx = getClosestTileIndex(path, playerLoc); + interimLastDistanceToTarget = distanceToInterimOrMax(clickedTarget, playerLoc); interimLastRetargetAtMs = interimSetAtMs; if (markRecoveryCooldown) { lastUnreachableRecoveryClickAtMs = interimSetAtMs; @@ -2755,6 +3108,19 @@ private static boolean tryIssueRouteMovementClick(List rawPath, return true; } + static String routeMovementClickPhase(String logLabel) { + if ("stall recovery click".equals(logLabel)) { + return "stall_recovery_click"; + } + if ("active route idle nudge".equals(logLabel)) { + return "active_route_idle_nudge"; + } + if ("interim close route click".equals(logLabel)) { + return "interim_close_route_click"; + } + return "route_movement_click"; + } + /** * Used in instances like vorkath, jad, nmz * @@ -2782,6 +3148,46 @@ public static boolean walkFastCanvas(WorldPoint worldPoint) { return walkFastCanvas(worldPoint, true); } + private static boolean walkFastCanvasOnScreenOnly(WorldPoint worldPoint, boolean toggleRun) { + LocalPoint localPoint = localPointForWorld(worldPoint); + if (localPoint == null || !Rs2Camera.isTileOnScreen(localPoint)) { + return false; + } + Point canvasPoint = Perspective.localToCanvas( + Microbot.getClient(), + localPoint, + Microbot.getClient().getTopLevelWorldView().getPlane()); + int canvasX = canvasPoint != null ? canvasPoint.getX() : -1; + int canvasY = canvasPoint != null ? canvasPoint.getY() : -1; + if (canvasX < 0 || canvasY < 0) { + return false; + } + + Rs2Player.toggleRunEnergy(toggleRun); + NewMenuEntry entry = new NewMenuEntry() + .param0(canvasX) + .param1(canvasY) + .type(MenuAction.WALK) + .identifier(0) + .itemId(0) + .option("Walk here"); + + Microbot.doInvoke(entry, + new Rectangle(canvasX, canvasY, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight())); + return true; + } + + private static LocalPoint localPointForWorld(WorldPoint worldPoint) { + if (worldPoint == null) { + return null; + } + LocalPoint localPoint = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), worldPoint); + if (Microbot.getClient().getTopLevelWorldView().isInstance() && localPoint == null) { + localPoint = Rs2LocalPoint.fromWorldInstance(worldPoint); + } + return localPoint; + } + public static boolean walkFastCanvas(WorldPoint worldPoint, boolean toggleRun) { if (worldPoint == null) { log.debug("[Walker] walkFastCanvas rejected: null worldPoint"); @@ -3086,32 +3492,38 @@ public static List getTransportsForPath(List path, int in */ public static List getTransportsForPath(List path, int indexOfStartPoint, TransportType prefTransportType, boolean applyFiltering) { List transportList = new ArrayList<>(); + if (path == null || path.isEmpty() || indexOfStartPoint < 0 || indexOfStartPoint >= path.size()) { + return transportList; + } + Map pathFirstIndex = buildPathFirstIndex(path); + WorldPoint playerLoc = Rs2Player.getWorldLocation(); int currentIndex = indexOfStartPoint; // Loop through the path until the end while (currentIndex < path.size()) { WorldPoint currentPoint = path.get(currentIndex); // Get any transports that start at this point (or keyed by this point) - Set transportsAtPoint = ShortestPathPlugin.getTransports() - .getOrDefault(currentPoint, new HashSet<>()); + Set transportsAtPoint = ShortestPathPlugin.getTransports().get(currentPoint); + if (transportsAtPoint == null || transportsAtPoint.isEmpty()) { + currentIndex++; + continue; + } boolean foundTransport = false; // sort by type to prioritize teleportation items first, then other types - transportsAtPoint = transportsAtPoint.stream() - .sorted(Comparator.comparing(Transport::getType, (type1, type2) -> { - // sort teleportation items by preference transport type for the current path point. - - if (type1 == prefTransportType && type2 != prefTransportType) { - return -1; - } - if (type2 == prefTransportType && type1 != prefTransportType) { - return 1; - } - // For all other types, use natural enum ordering - return type1.compareTo(type2); - })) - .collect(Collectors.toCollection(LinkedHashSet::new)); + List orderedTransports = new ArrayList<>(transportsAtPoint); + orderedTransports.sort(Comparator.comparing(Transport::getType, (type1, type2) -> { + // sort teleportation items by preference transport type for the current path point. + if (type1 == prefTransportType && type2 != prefTransportType) { + return -1; + } + if (type2 == prefTransportType && type1 != prefTransportType) { + return 1; + } + // For all other types, use natural enum ordering + return type1.compareTo(type2); + })); // Iterate over each available transport - for (Transport transport : transportsAtPoint) { + for (Transport transport : orderedTransports) { // Special handling for teleportation-like transports (originless) // NOTE: Leagues "Area" teleports are injected as SEASONAL_TRANSPORT with null origin. @@ -3125,8 +3537,8 @@ public static List getTransportsForPath(List path, int in || isLeaguesAreaTeleport) { // For teleportation, we assume origin is null and simply check if the destination exists in the path. - int destIndex = path.indexOf(transport.getDestination()); - if (destIndex != -1) { + Integer destIndex = pathFirstIndex.get(transport.getDestination()); + if (destIndex != null) { transportList.add(transport); // Advance the current index to the destination tile (or at least one forward) currentIndex = destIndex > currentIndex ? destIndex : currentIndex + 1; @@ -3146,19 +3558,20 @@ public static List getTransportsForPath(List path, int in for (WorldPoint origin : originPoints) { // If an origin is defined but the player's plane doesn't match, skip it. - WorldPoint plTransportFilter = Rs2Player.getWorldLocation(); - if (transport.getOrigin() != null && plTransportFilter != null - && plTransportFilter.getPlane() != transport.getOrigin().getPlane()) { + if (transport.getOrigin() != null && playerLoc != null + && playerLoc.getPlane() != transport.getOrigin().getPlane()) { continue; } // For non-teleportation transports, ensure both origin and destination exist in the path // and that the destination comes after the origin. - int indexOfDestination = path.indexOf(transport.getDestination()); + Integer indexOfDestinationValue = pathFirstIndex.get(transport.getDestination()); + int indexOfDestination = indexOfDestinationValue != null ? indexOfDestinationValue : -1; if (transport.getType() != TransportType.TELEPORTATION_ITEM && transport.getType() != TransportType.TELEPORTATION_SPELL && !isLeaguesAreaTeleport) { - int indexOfOrigin = path.indexOf(transport.getOrigin()); + Integer indexOfOriginValue = pathFirstIndex.get(transport.getOrigin()); + int indexOfOrigin = indexOfOriginValue != null ? indexOfOriginValue : -1; if (indexOfOrigin == -1 || indexOfDestination == -1 || indexOfDestination < indexOfOrigin) { continue; } @@ -3192,6 +3605,14 @@ public static List getTransportsForPath(List path, int in return transportList; } + private static Map buildPathFirstIndex(List path) { + Map pathFirstIndex = new HashMap<>(path.size()); + for (int i = 0; i < path.size(); i++) { + pathFirstIndex.putIfAbsent(path.get(i), i); + } + return pathFirstIndex; + } + /** * Applies transport filtering and requirement setup for transport items. * This method filters transports to only include those that require items and @@ -3207,6 +3628,7 @@ private static List applyTransportFiltering(List transport t.getType() == TransportType.BOAT || t.getType() == TransportType.CHARTER_SHIP || t.getType() == TransportType.SHIP || t.getType() == TransportType.MINECART || t.getType() == TransportType.MAGIC_CARPET || t.getType() == TransportType.SPIRIT_TREE || + (t.getType() == TransportType.TRANSPORT && t.getCurrencyAmount() > 0) || (t.getType() == TransportType.SEASONAL_TRANSPORT && Rs2LeaguesTransport.isLeaguesActive() && t.getDisplayInfo() != null @@ -3347,7 +3769,6 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, if (!inInstance && localRouteDetoursFromComputedRoute(rawPath, end, directClickMaxDistance)) { return WalkerState.MOVING; } - long suppressUntil = suppressTryDirectShortWalkUntilMs; if (suppressUntil != 0L && System.currentTimeMillis() < suppressUntil) { log.debug("[Walker] defer tryDirectShortWalk minimap (post door canvas nudge, {}ms window)", @@ -3355,12 +3776,20 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, return WalkerState.MOVING; } - boolean clicked = walkMiniMap(end); - if (!clicked) { - clicked = walkMiniMapToward(end, playerLoc, directClickMaxDistance - 1); - } - if (!clicked) { - clicked = walkFastCanvas(end); + boolean routeBacked = rawPath != null && !rawPath.isEmpty(); + int rawAnchorIndex = routeBacked ? rawAnchorIndexForPathPosition(rawPath, path, playerLoc) : -1; + boolean clicked; + if (routeBacked) { + clicked = clickRouteBackedShortWalk(rawPath, end, playerLoc, + directClickMaxDistance - 1, rawAnchorIndex); + } else { + clicked = walkMiniMap(end); + if (!clicked) { + clicked = walkMiniMapToward(end, playerLoc, directClickMaxDistance - 1); + } + if (!clicked) { + clicked = walkFastCanvas(end); + } } if (!clicked) { return WalkerState.MOVING; @@ -3373,7 +3802,14 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, }, 800); if (!moved) { - clicked = walkFastCanvas(end); + WorldPoint retryPlayerLoc = Rs2Player.getWorldLocation(); + if (routeBacked && retryPlayerLoc != null) { + int retryRawAnchorIndex = rawPathForwardAnchorIndex(rawPath, retryPlayerLoc, rawAnchorIndex); + clicked = clickRouteBackedShortWalk(rawPath, end, retryPlayerLoc, + directClickMaxDistance - 1, retryRawAnchorIndex); + } else { + clicked = walkFastCanvas(end); + } if (!clicked) { return WalkerState.MOVING; } @@ -3403,6 +3839,28 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, return WalkerState.MOVING; } + private static int rawAnchorIndexForPathPosition(List rawPath, + List path, + WorldPoint playerLoc) { + int closestPathIdx = getClosestTileIndex(path, playerLoc); + int[] smoothedToRaw = mapSmoothedToRaw(path, rawPath); + int rawAnchorIndex = rawIndexForSmoothedIndex(closestPathIdx, smoothedToRaw, rawPath); + return rawPathForwardAnchorIndex(rawPath, playerLoc, rawAnchorIndex); + } + + private static boolean clickRouteBackedShortWalk(List rawPath, + WorldPoint end, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex) { + WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, end, playerLoc, + maxEuclidean, false, rawAnchorIndex); + if (clickedTarget != null) { + return true; + } + return walkFastCanvasOnScreenOnly(end, true); + } + private static boolean hasPendingExplicitTransportStepBeforeArrival(List path, WorldPoint target, int distance) { @@ -3441,7 +3899,7 @@ private static boolean localRouteDetoursFromComputedRoute(List rawPa return false; } - int rawStart = getClosestTileIndex(rawPath); + int rawStart = getClosestTileIndex(rawPath, playerLoc); if (rawStart < 0 || rawStart >= rawPath.size() - 1) { return false; } @@ -3481,7 +3939,7 @@ private static boolean hasPendingDoorLikeSceneObjectBeforeDirectClick(List= route.size()) { return false; } @@ -3524,6 +3982,9 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat || playerLoc == null || targetPathIdx < fromPathIdx) { return false; } + if (Rs2Player.isMoving()) { + return false; + } int rawStart = rawIndexForSmoothedIndex(fromPathIdx, smoothedToRaw, rawPath); int rawTarget = rawIndexForSmoothedIndex(targetPathIdx, smoothedToRaw, rawPath); @@ -3564,7 +4025,7 @@ private static boolean handlePendingDoorDuringInterim(List rawPath, WorldPoint playerLoc) { if (rawPath == null || rawPath.size() < 2 || playerLoc == null || isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown() - || isRecoveryMovementInFlight()) { + || isRecoveryMovementInFlight() || Rs2Player.isMoving()) { return false; } @@ -3580,8 +4041,11 @@ private static boolean handlePendingDoorNearRawPath(List rawPath, if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { return false; } + if (Rs2Player.isMoving()) { + return false; + } - int rawStart = getClosestTileIndex(rawPath); + int rawStart = getClosestTileIndex(rawPath, playerLoc); if (rawStart < 0) { return false; } @@ -3613,6 +4077,45 @@ private static boolean handlePendingDoorNearRawPath(List rawPath, return false; } + private static boolean handleUnresolvedDoorNearRawPath(List rawPath, + int rawEdgeStart, + long timeoutMs, + Map attempted, + WorldPoint playerLoc, + int backtrackEdges, + int lookaheadEdges, + int radiusTiles) { + if (rawPath == null || rawPath.size() < 2 || rawEdgeStart < 0 || playerLoc == null || Rs2Player.isMoving()) { + 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)) { + 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; @@ -3654,7 +4157,7 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, return false; } - int rawStart = getClosestTileIndex(rawPath); + int rawStart = getClosestTileIndex(rawPath, playerLoc); if (rawStart < 0) { clearRawScanDoorFocus("raw-start-missing"); return false; @@ -3878,10 +4381,19 @@ private static void addForwardPathIndices(Map forwardIndex, return; } - int closestIndex = IntStream.range(0, path.size()) - .boxed() - .min(Comparator.comparingInt(i -> playerLoc.distanceTo(path.get(i)))) - .orElse(0); + int closestIndex = 0; + int closestDistance = Integer.MAX_VALUE; + for (int i = 0; i < path.size(); i++) { + WorldPoint point = path.get(i); + if (point == null) { + continue; + } + int distance = playerLoc.distanceTo(point); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } for (int i = closestIndex; i < path.size(); i++) { forwardIndex.putIfAbsent(path.get(i), i); } @@ -3895,6 +4407,9 @@ private static void addForwardPathIndices(Map forwardIndex, private static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; private static final Map recentDoorAttemptByEdge = new ConcurrentHashMap<>(); private static final long DOOR_ATTEMPT_EDGE_COOLDOWN_MS = 2_500; + private static volatile WorldPoint lastDoorAttemptFrom = null; + private static volatile WorldPoint lastDoorAttemptTo = null; + private static volatile long lastDoorAttemptAtMs = 0L; private static final Map recentCurrentTileTransportByEdge = new ConcurrentHashMap<>(); private static final long CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS = 2_200; private static final long DOOR_INTERACTION_GLOBAL_COOLDOWN_MS = 1_800; @@ -3971,6 +4486,25 @@ static int findFurthestClickableIndex(List path, int startIdx, World return bestIdx; } + static int findFurthestForwardClickableIndex(List path, int startIdx, WorldPoint playerLoc, + java.util.function.Predicate isTransportOrigin, + int maxEuclidean) { + if (path == null || startIdx < 0 || startIdx >= path.size()) return startIdx; + WorldPoint startWp = path.get(startIdx); + if (startWp == null) return startIdx; + final int maxSq = maxEuclidean * maxEuclidean; + + int bestIdx = startIdx; + for (int j = startIdx; j < path.size(); j++) { + WorldPoint candidate = path.get(j); + if (candidate == null || candidate.getPlane() != startWp.getPlane()) break; + if (j > startIdx && isTransportOrigin != null && isTransportOrigin.test(candidate)) break; + if (playerLoc != null && euclideanSq(candidate, playerLoc) > maxSq) break; + bestIdx = j; + } + return bestIdx; + } + private static int findForwardReachableRecoveryIndex(List path, int startIdx, WorldPoint playerLoc, @@ -4064,6 +4598,47 @@ static WorldPoint interpolateClickableTarget(List path, return fallbackWp; } + static WorldPoint clampToEuclideanRadius(WorldPoint playerLoc, WorldPoint target, int maxEuclidean) { + if (playerLoc == null || target == null || target.getPlane() != playerLoc.getPlane() || maxEuclidean <= 0) { + return target; + } + int maxSq = maxEuclidean * maxEuclidean; + if (euclideanSq(playerLoc, target) <= maxSq) { + return target; + } + + int dx = target.getX() - playerLoc.getX(); + int dy = target.getY() - playerLoc.getY(); + double distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= 1) { + return target; + } + + double scale = maxEuclidean / distance; + int stepX = (int) Math.round(dx * scale); + int stepY = (int) Math.round(dy * scale); + if (stepX == 0 && stepY == 0) { + if (Math.abs(dx) >= Math.abs(dy)) { + stepX = Integer.signum(dx); + } else { + stepY = Integer.signum(dy); + } + } + + WorldPoint clamped = new WorldPoint(playerLoc.getX() + stepX, playerLoc.getY() + stepY, playerLoc.getPlane()); + while (!clamped.equals(playerLoc) && euclideanSq(playerLoc, clamped) > maxSq) { + if (Math.abs(stepX) >= Math.abs(stepY) && stepX != 0) { + stepX -= Integer.signum(stepX); + } else if (stepY != 0) { + stepY -= Integer.signum(stepY); + } else { + break; + } + clamped = new WorldPoint(playerLoc.getX() + stepX, playerLoc.getY() + stepY, playerLoc.getPlane()); + } + return clamped.equals(playerLoc) ? target : clamped; + } + private static int euclideanSq(WorldPoint a, WorldPoint b) { int dx = a.getX() - b.getX(); int dy = a.getY() - b.getY(); @@ -4333,6 +4908,11 @@ && isNullOrPlaceholderObjectName(comp.getName())) { compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; } + if (Rs2Player.isMoving()) { + WebWalkLog.spInfo("door_interact_deferred | reason=moving mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } markDoorAttempt(probe, fromWp, toWp); markGlobalDoorInteractionCooldown(); WorldPoint posBefore = Rs2Player.getWorldLocation(); @@ -4477,6 +5057,11 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; } + if (Rs2Player.isMoving()) { + WebWalkLog.spInfo("door_interact_deferred | reason=moving mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } markDoorAttempt(probe, fromWp, toWp); markGlobalDoorInteractionCooldown(); WorldPoint posBefore = Rs2Player.getWorldLocation(); @@ -4760,6 +5345,31 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, return progressed; } + private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { + WorldPoint from = lastDoorAttemptFrom; + WorldPoint to = lastDoorAttemptTo; + long attemptedAt = lastDoorAttemptAtMs; + if (playerLoc == null || from == null || to == null || attemptedAt <= 0L) { + return false; + } + long ageMs = System.currentTimeMillis() - attemptedAt; + if (ageMs < 0L || ageMs > POST_DOOR_NUDGE_RECENT_ATTEMPT_MS) { + return false; + } + if (playerLoc.getPlane() != to.getPlane() || playerLoc.distanceTo2D(to) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { + return false; + } + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + boolean nudged = tryDoorEdgeCrossNudge(from, to, target); + if (nudged) { + WebWalkLog.tmark("recent_door_edge_nudge", System.currentTimeMillis() - walkSessionStartedAtMs, + target, playerLoc, "from=" + compactWorldPoint(from) + " to=" + compactWorldPoint(to)); + } + return nudged; + } + static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, WorldPoint fromWp, WorldPoint toWp) { if (before == null || after == null || fromWp == null || toWp == null) { return false; @@ -4803,54 +5413,211 @@ static boolean shouldClearInterimTarget(WorldPoint interim, if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { return true; } - int preclickTiles = interimPreclickTiles(); - if (playerLoc.distanceTo2D(interim) <= preclickTiles) { - return true; + if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + return true; + } + if (lastProgressAtMs > 0L && nowMs - lastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + return true; + } + return setAtMs > 0L && nowMs - setAtMs > INTERIM_MAX_AGE_MS; + } + + static int distanceToInterimOrMax(WorldPoint interim, WorldPoint playerLoc) { + if (interim == null || playerLoc == null || interim.getPlane() != playerLoc.getPlane()) { + return Integer.MAX_VALUE; + } + return playerLoc.distanceTo2D(interim); + } + + private static void recordInterimDistanceProgress(WorldPoint interim, WorldPoint playerLoc, long nowMs) { + int distance = distanceToInterimOrMax(interim, playerLoc); + if (distance < interimLastDistanceToTarget) { + interimLastDistanceToTarget = distance; + interimLastProgressAtMs = nowMs; + } + } + + private static void waitForMovementStartAfterRecovery(WorldPoint cancelGoal, + WorldPoint playerBefore, + WorldPoint interimGoal, + WorldPoint arrivalGoal, + int arrivalMaxChebyshev) { + if (cancelGoal == null || playerBefore == null) { + return; + } + sleepUntil(() -> { + if (isWalkCancelled(cancelGoal)) { + return true; + } + WorldPoint playerNow = Rs2Player.getWorldLocation(); + if (playerNow == null) { + return false; + } + if (!playerNow.equals(playerBefore) || Rs2Player.isMoving()) { + return true; + } + if (interimGoal != null + && interimGoal.getPlane() == playerNow.getPlane() + && playerNow.distanceTo2D(interimGoal) <= INTERIM_CLOSE_TILES) { + return true; + } + return arrivalGoal != null + && arrivalMaxChebyshev >= 0 + && arrivalGoal.getPlane() == playerNow.getPlane() + && playerNow.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev; + }, POST_RECOVERY_MOVEMENT_START_WAIT_MS); + } + + private static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = interimTargetWp; + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (interim != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + 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) <= INTERIM_CLOSE_TILES) { + reason = "close"; + } else if (interimLastProgressAtMs > 0L && nowMs - interimLastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + reason = "stale-progress"; + } else { + reason = "expired"; + } + clearInterimTarget(reason); + return true; + } + + private static boolean shouldYieldForActiveRecoveryInterim(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = interimTargetWp; + if (interim == null) { + return false; + } + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (playerLoc != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + if (bestIdxNow > interimLastBestPathIdx) { + interimLastBestPathIdx = bestIdxNow; + interimLastProgressAtMs = nowMs; + } + } + return shouldYieldForActiveRecoveryInterim(interim, + playerLoc, + interimSetAtMs, + interimLastProgressAtMs, + nowMs, + lastMovedTimeMs, + lastUnreachableRecoveryClickAtMs, + Rs2Player.isMoving()); + } + + private static boolean shouldYieldForActiveRouteInterim(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = interimTargetWp; + if (interim == null) { + return false; + } + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (playerLoc != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + if (bestIdxNow > interimLastBestPathIdx) { + interimLastBestPathIdx = bestIdxNow; + interimLastProgressAtMs = nowMs; + } + } + return shouldDeferRouteWorkForActiveInterim(interim, + playerLoc, + interimSetAtMs, + interimLastProgressAtMs, + nowMs, + lastMovedTimeMs, + Rs2Player.isMoving(), + INTERIM_CLOSE_TILES); + } + + static boolean shouldYieldForActiveRecoveryInterim(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs, + long lastMovedAtMs, + long lastRecoveryClickAtMs, + boolean playerMoving) { + if (interim == null) { + return false; + } + if (shouldClearInterimTarget(interim, playerLoc, setAtMs, lastProgressAtMs, nowMs)) { + return false; } - if (lastProgressAtMs > 0L && nowMs - lastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + if (shouldDeferRouteWorkForActiveInterim(interim, + playerLoc, + setAtMs, + lastProgressAtMs, + nowMs, + lastMovedAtMs, + playerMoving, + INTERIM_CLOSE_TILES)) { return true; } - return setAtMs > 0L && nowMs - setAtMs > INTERIM_MAX_AGE_MS; + return isRecentEvent(nowMs, lastRecoveryClickAtMs, RECOVERY_MOVEMENT_IN_FLIGHT_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; - } + static boolean shouldDeferRouteWorkForActiveInterim(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs, + long lastMovedAtMs, + boolean playerMoving, + int handoffTiles) { + if (interim == null) { + return false; } - if (!shouldClearInterimTarget(interim, playerLoc, interimSetAtMs, interimLastProgressAtMs, nowMs)) { + if (shouldClearInterimTarget(interim, playerLoc, setAtMs, lastProgressAtMs, 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"; + if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { + return false; } - clearInterimTarget(reason); - return true; + if (playerLoc.distanceTo2D(interim) <= Math.max(0, handoffTiles)) { + return false; + } + if (playerMoving) { + return true; + } + if (isRecentEvent(nowMs, lastProgressAtMs, INTERIM_PROGRESS_TIMEOUT_MS)) { + return true; + } + return isRecentEvent(nowMs, lastMovedAtMs, RECOVERY_MOVEMENT_IN_FLIGHT_MS); } private static void clearInterimTarget(String reason) { WorldPoint old = interimTargetWp; if (old != null) { - WebWalkLog.spInfo("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + if ("close".equals(reason)) { + WebWalkLog.spDebug("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + } else { + WebWalkLog.spInfo("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + } } interimTargetWp = null; interimTargetIdx = -1; interimSetAtMs = 0L; interimLastProgressAtMs = 0L; interimLastBestPathIdx = -1; + interimLastDistanceToTarget = Integer.MAX_VALUE; interimLastRetargetAtMs = 0L; } @@ -4899,6 +5666,11 @@ private static void markGlobalDoorInteractionCooldown() { private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { Rs2DoorHandler.markDoorAttempt(recentDoorAttemptByEdge, doorTile, fromWp, toWp); + if (fromWp != null && toWp != null) { + lastDoorAttemptFrom = fromWp; + lastDoorAttemptTo = toWp; + lastDoorAttemptAtMs = System.currentTimeMillis(); + } } private static boolean shouldThrottleCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { @@ -4958,6 +5730,20 @@ private static boolean hasExplicitTransportStep(List path, int index return matchesDirectedTransportCatalogEdge(path.get(index), path.get(index + 1)); } + private static boolean hasImmediatePlannedTransportStep(List path, + int routeStartIdx, + WorldPoint playerLoc) { + if (path == null || routeStartIdx < 0 || routeStartIdx >= path.size() - 1 || playerLoc == null) { + return false; + } + WorldPoint origin = path.get(routeStartIdx); + if (origin == null || origin.getPlane() != playerLoc.getPlane() + || origin.distanceTo2D(playerLoc) > HANDLER_RANGE) { + return false; + } + return hasExplicitTransportStep(path, routeStartIdx); + } + /** * Whether this path edge is covered by a transport catalog row (same coordinates loaded from TSV into * {@link ShortestPathPlugin#getTransports()}). Includes strict origin-destination steps (including @@ -5190,6 +5976,9 @@ static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoi if (start.getPlane() != end.getPlane()) { return true; } + if (!startedNearDoorEdge(start, fromWp, toWp)) { + return false; + } int moved = start.distanceTo2D(end); if (moved < 3) { return false; @@ -5207,6 +5996,20 @@ static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoi return endFrom >= startFrom + 2; } + private static boolean startedNearDoorEdge(WorldPoint start, WorldPoint fromWp, WorldPoint toWp) { + if (start == null) { + return false; + } + final int maxDoorStartDistance = 3; + boolean nearFrom = fromWp != null + && fromWp.getPlane() == start.getPlane() + && start.distanceTo2D(fromWp) <= maxDoorStartDistance; + boolean nearTo = toWp != null + && toWp.getPlane() == start.getPlane() + && start.distanceTo2D(toWp) <= maxDoorStartDistance; + return nearFrom || nearTo; + } + private static boolean movedAcrossInteractedObject(WorldPoint start, WorldPoint end, WorldPoint objectLoc) { int startRelX = Integer.compare(start.getX(), objectLoc.getX()); int endRelX = Integer.compare(end.getX(), objectLoc.getX()); @@ -6474,40 +7277,72 @@ private static boolean searchNeighborPoint(int orientation, WorldPoint point, Wo * @return closest tile index */ public static int getClosestTileIndex(List path) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); + } - var tiles = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 20); + static int getClosestTileIndex(List path, WorldPoint playerLoc) { + return getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); + } - /** - * Exception to handle objects that handle long animations or walk - * ignore colission if we did not find a valid tile to walk on - * this is to ensure we stay on the path even if we are on a agility obstacle - */ - if (tiles.keySet().isEmpty()) { - tiles = Rs2Tile.getReachableTilesFromTileIgnoreCollision(Rs2Player.getWorldLocation(), 20); + static int getClosestTileIndex(List path, + WorldPoint playerLoc, + Map reachableTiles) { + if (path == null || path.isEmpty() || playerLoc == null) { + return -1; } - final HashMap _tiles = tiles; - WorldPoint startPoint = path.stream() - .min(Comparator.comparingInt(a -> _tiles.getOrDefault(a, Integer.MAX_VALUE))) - .orElse(null); + int bestReachableIndex = -1; + int bestReachableDistance = Integer.MAX_VALUE; + if (reachableTiles != null && !reachableTiles.isEmpty()) { + for (int i = 0; i < path.size(); i++) { + WorldPoint point = path.get(i); + if (point == null) { + continue; + } + int reachableDistance = reachableTiles.getOrDefault(point, Integer.MAX_VALUE); + if (reachableDistance < bestReachableDistance) { + bestReachableDistance = reachableDistance; + bestReachableIndex = i; + if (reachableDistance == 0) { + break; + } + } + } + } - /** - * Check if the startPoint is null or no matching tile is found - * If either condition is true, proceed to find the closest index in the path list. - */ - if (startPoint == null || _tiles.getOrDefault(startPoint, Integer.MAX_VALUE) == Integer.MAX_VALUE) { - Optional closestIndexOptional = IntStream.range(0, path.size()) - .boxed() - .min(Comparator.comparingInt(i -> Rs2Player.getWorldLocation().distanceTo(path.get(i)))); - if (closestIndexOptional.isPresent()) { - return closestIndexOptional.get(); + if (bestReachableIndex >= 0 && bestReachableDistance != Integer.MAX_VALUE) { + return bestReachableIndex; + } + + int closestIndex = -1; + int closestDistance = Integer.MAX_VALUE; + for (int i = 0; i < path.size(); i++) { + WorldPoint point = path.get(i); + if (point == null) { + continue; + } + int distance = playerLoc.distanceTo(point); + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; } } + return closestIndex; + } - return IntStream.range(0, path.size()) - .filter(i -> path.get(i).equals(startPoint)) - .findFirst() - .orElse(-1); + private static HashMap getClosestIndexReachableTiles(WorldPoint playerLoc) { + if (playerLoc == null) { + return new HashMap<>(); + } + HashMap tiles = Rs2Tile.getReachableTilesFromTile(playerLoc, 20); + + // If an animation/shortcut puts the player on a collision-odd tile, keep route progress + // anchored by distance instead of repeatedly recalculating an empty reachable set. + if (tiles.isEmpty()) { + tiles = Rs2Tile.getReachableTilesFromTileIgnoreCollision(playerLoc, 20); + } + return tiles; } static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { @@ -6529,10 +7364,14 @@ static int stabilizeRouteProgressIndex(List path, int closestIdx, Wo routeProgressPathEnd = pathEnd; routeProgressPathSize = path.size(); routeProgressIdx = closestIdx; + routeProgressAdvancedAtMs = System.currentTimeMillis(); return closestIdx; } if (routeProgressIdx < 0 || closestIdx >= routeProgressIdx) { + if (closestIdx > routeProgressIdx) { + recordRouteProgressAdvanced(); + } routeProgressIdx = closestIdx; return closestIdx; } @@ -6541,12 +7380,42 @@ static int stabilizeRouteProgressIndex(List path, int closestIdx, Wo if (forwardIdx >= routeProgressIdx) { if (forwardIdx > routeProgressIdx) { routeProgressIdx = forwardIdx; + recordRouteProgressAdvanced(); } return routeProgressIdx; } return routeProgressIdx; } + static void hintRouteProgressIndex(List path, int hintedIdx, WorldPoint target) { + if (path == null || path.isEmpty() || hintedIdx < 0 || hintedIdx >= path.size()) { + return; + } + + 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 = hintedIdx; + recordRouteProgressAdvanced(); + return; + } + + if (hintedIdx > routeProgressIdx) { + routeProgressIdx = hintedIdx; + recordRouteProgressAdvanced(); + } + } + static int advanceIndexPastRecentTransportEdge(List path, int index, WorldPoint playerLoc) { if (path == null || path.isEmpty() || index < 0 || index >= path.size() || !isRecentTransportEdgeWindow()) { @@ -6601,6 +7470,14 @@ private static void resetRouteProgress() { routeProgressPathStart = null; routeProgressPathEnd = null; routeProgressPathSize = -1; + routeProgressAdvancedAtMs = 0L; + } + + private static void recordRouteProgressAdvanced() { + long now = System.currentTimeMillis(); + routeProgressAdvancedAtMs = now; + lastMovedTimeMs = now; + stuckCount = 0; } private static boolean isRecentTransportEdgeWindow() { @@ -6809,7 +7686,10 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i pathFirstIndex.putIfAbsent(path.get(idx), idx); } - for (Transport transport : transports) { + List orderedTransports = new ArrayList<>(transports); + orderedTransports.sort(Comparator.comparingInt(Rs2Walker::transportHandlingPreference)); + + for (Transport transport : orderedTransports) { Collection worldPointCollections; //in some cases the getOrigin is null, for teleports that start the player location if (transport.getOrigin() == null) { @@ -7133,8 +8013,10 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i final boolean allowClosedVariant = "Climb-down".equalsIgnoreCase(transportAction) || "Climb down".equalsIgnoreCase(transportAction); + final boolean allowAlKharidTollGateVariant = isAlKharidTollGateObjectId(transportObjectId); List objects = Rs2GameObject.getAll(o -> { if (o.getId() == transportObjectId) return true; + if (allowAlKharidTollGateVariant && isAlKharidTollGateObjectId(o.getId())) return true; Integer legacyClosed = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); if (legacyClosed != null && o.getId() == legacyClosed) return true; if (!allowClosedVariant) return false; @@ -7236,6 +8118,7 @@ private static boolean waitForPostHandleObjectLanding(Transport transport, int maxInclusive) { long waitStartedAt = System.currentTimeMillis(); AtomicBoolean settledAwayFromAdjacentDestination = new AtomicBoolean(false); + AtomicBoolean settledNearAdjacentDestination = new AtomicBoolean(false); boolean completed = sleepUntil(() -> { if (isPlayerWithinChebyshevInclusive(destWait, maxInclusive)) { return true; @@ -7249,6 +8132,10 @@ private static boolean waitForPostHandleObjectLanding(Transport transport, || Rs2Player.isMoving() || Rs2Player.isAnimating()) { return false; } + if (isSettledNearAdjacentSamePlaneLanding(transport, playerLoc, destWait, maxInclusive)) { + settledNearAdjacentDestination.set(true); + return true; + } WorldPoint origin = transport == null ? null : transport.getOrigin(); boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) @@ -7259,6 +8146,11 @@ private static boolean waitForPostHandleObjectLanding(Transport transport, return false; }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); + if (settledNearAdjacentDestination.get()) { + WebWalkLog.spInfo("post-handleObject adjacent landing accepted | dest={} at={}", + compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); + return true; + } if (settledAwayFromAdjacentDestination.get()) { WebWalkLog.spInfo("post-handleObject adjacent landing failed | dest={} at={}", compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); @@ -7267,6 +8159,25 @@ private static boolean waitForPostHandleObjectLanding(Transport transport, return completed; } + static boolean isSettledNearAdjacentSamePlaneLanding(Transport transport, + WorldPoint playerLoc, + WorldPoint destWait, + int maxInclusive) { + if (!isAdjacentSamePlaneTransport(transport) + || playerLoc == null + || destWait == null + || playerLoc.getPlane() != destWait.getPlane()) { + return false; + } + WorldPoint origin = transport.getOrigin(); + if (origin == null || playerLoc.equals(origin)) { + return false; + } + int destinationDistance = playerLoc.distanceTo2D(destWait); + return destinationDistance <= Math.max(1, maxInclusive) + && playerLoc.distanceTo2D(origin) > 0; + } + /** * Handles the transportation process specifically for instances of PohTransport. * Any Transport param that reaches this is assumed to be a PohTransport. @@ -7618,7 +8529,8 @@ private static boolean hasPrecomputedContinuationFromTransport(Transport transpo if (walkPath == null || walkPath.size() < 2) { return false; } - int closest = getClosestTileIndex(walkPath); + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + int closest = getClosestTileIndex(walkPath, playerLoc); if (closest < 0) { return false; } @@ -7666,6 +8578,55 @@ static Set adjacentSamePlaneTransportSuppressionPoints(Transport tra return points; } + private static int transportHandlingPreference(Transport transport) { + if (isAlKharidTollGateTransport(transport) && transport.getCurrencyAmount() > 0) { + return 1; + } + return 0; + } + + private static boolean isAlKharidTollGateTransport(Transport transport) { + return transport != null + && isAlKharidTollGateObjectId(transport.getObjectId()) + && AL_KHARID_TOLL_GATE_POINTS.contains(transport.getOrigin()) + && AL_KHARID_TOLL_GATE_POINTS.contains(transport.getDestination()); + } + + private static boolean isAlKharidTollGateObjectId(int objectId) { + return AL_KHARID_TOLL_GATE_OBJECT_IDS.contains(objectId); + } + + private static boolean isPayTollAction(String action) { + return action != null && action.toLowerCase(Locale.ROOT).startsWith("pay-toll"); + } + + private static boolean handleAlKharidTollGate(Transport transport) { + if (Rs2Player.isMoving()) { + Rs2Player.waitForWalking(); + } + + boolean confirmed = false; + if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2500)) { + confirmed = Rs2Dialogue.clickOption("Yes, okay", "Yes"); + } + + boolean reachedDestination = sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint destination = transport.getDestination(); + return now != null + && destination != null + && now.getPlane() == destination.getPlane() + && now.distanceTo2D(destination) <= 1; + }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); + if (!confirmed && !reachedDestination) { + WebWalkLog.spWarn( + "Al Kharid toll gate confirmation unresolved dest={} at={}", + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + return true; + } + private static boolean handleObjectExceptions(Transport transport, TileObject tileObject) { for (Map.Entry entry : OPEN_TO_CLOSED_MAPPINGS.entrySet()) { final int closedTrapdoorId = entry.getKey(); @@ -7697,6 +8658,10 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti } } + if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { + return handleAlKharidTollGate(transport); + } + //Al kharid broken wall will animate once and then stop and then animate again if (tileObject.getId() == ObjectID.KHARID_POSHWALL_TOPLESS || tileObject.getId() == ObjectID.KHARID_BIGWINDOW) { Rs2Player.waitForAnimation(); @@ -7905,7 +8870,7 @@ private static boolean handleTeleportItem(Transport transport) { for (Integer itemId : itemIds) { if (Rs2Walker.currentTarget == null) break; // reachedDistance <= 0: do not treat as "already at destination" (legacy: raw distance < 0 never true). - int reachRd = config.reachedDistance(); + int reachRd = reachedDistanceOrDefault(); if (reachRd > 0 && isPlayerWithinChebyshevOf(transport.getDestination(), reachRd)) { break; } @@ -8150,7 +9115,7 @@ private static boolean isNearPathByVariance(List path, WorldPoint pl if (path == null || path.isEmpty() || playerLoc == null) { return false; } - int closestIdx = getClosestTileIndex(path); + int closestIdx = getClosestTileIndex(path, playerLoc); if (closestIdx < 0 || closestIdx >= path.size()) { return false; } @@ -8160,6 +9125,104 @@ private static boolean isNearPathByVariance(List path, WorldPoint pl && closest.distanceTo2D(playerLoc) <= PATH_VARIANCE_TOLERANCE_CHEBYSHEV; } + static String offPathRecalcDeferralReason(boolean playerMoving, + boolean playerAnimating, + boolean playerInteracting, + boolean doorSettling, + boolean transportSettling, + boolean interimActive, + long nowMs, + long lastMovedAtMs, + long routeProgressAtMs, + long minimapClickAtMs, + long interimProgressAtMs) { + if (doorSettling) { + return "door-settling"; + } + if (transportSettling) { + return "transport-settling"; + } + if (playerMoving) { + return "moving"; + } + if (playerAnimating) { + return "animating"; + } + if (playerInteracting) { + return "interacting"; + } + if (isRecentEvent(nowMs, routeProgressAtMs, OFF_PATH_RECALC_ROUTE_PROGRESS_GRACE_MS)) { + return "route-progress"; + } + if (isRecentEvent(nowMs, minimapClickAtMs, OFF_PATH_RECALC_MINIMAP_CLICK_GRACE_MS)) { + return "recent-click"; + } + if (interimActive && isRecentEvent(nowMs, interimProgressAtMs, INTERIM_PROGRESS_TIMEOUT_MS)) { + return "interim-progress"; + } + if (isRecentEvent(nowMs, lastMovedAtMs, OFF_PATH_RECALC_RECENT_MOVEMENT_MS)) { + return "recent-movement"; + } + return null; + } + + private static String currentOffPathRecalcDeferralReason(long minimapClickAtMs) { + return offPathRecalcDeferralReason( + Rs2Player.isMoving(), + Rs2Player.isAnimating(), + Rs2Player.isInteracting(), + isDoorInteractionSettling(), + isTransportInteractionSettling(), + interimTargetWp != null, + System.currentTimeMillis(), + lastMovedTimeMs, + routeProgressAdvancedAtMs, + minimapClickAtMs, + interimLastProgressAtMs); + } + + static int offPathRecalcDeferredWaitMs(String reason, + long nowMs, + long lastMovedAtMs, + long routeProgressAtMs, + long minimapClickAtMs, + long interimProgressAtMs) { + long remainingMs = OFF_PATH_RECALC_DEFER_WAIT_MAX_MS; + if ("route-progress".equals(reason)) { + remainingMs = remainingRecentEventMs(nowMs, routeProgressAtMs, OFF_PATH_RECALC_ROUTE_PROGRESS_GRACE_MS); + } else if ("recent-click".equals(reason)) { + remainingMs = remainingRecentEventMs(nowMs, minimapClickAtMs, OFF_PATH_RECALC_MINIMAP_CLICK_GRACE_MS); + } else if ("interim-progress".equals(reason)) { + remainingMs = remainingRecentEventMs(nowMs, interimProgressAtMs, INTERIM_PROGRESS_TIMEOUT_MS); + } else if ("recent-movement".equals(reason)) { + remainingMs = remainingRecentEventMs(nowMs, lastMovedAtMs, OFF_PATH_RECALC_RECENT_MOVEMENT_MS); + } + return (int) Math.max(OFF_PATH_RECALC_DEFER_WAIT_MIN_MS, + Math.min(OFF_PATH_RECALC_DEFER_WAIT_MAX_MS, remainingMs)); + } + + private static boolean isOffPathRecalcDeferredExit(String exitReason) { + return exitReason != null && exitReason.startsWith("off-path-deferred:"); + } + + private static String offPathDeferredReasonFromExit(String exitReason) { + if (!isOffPathRecalcDeferredExit(exitReason)) { + return ""; + } + return exitReason.substring("off-path-deferred:".length()); + } + + private static boolean isRecentEvent(long nowMs, long eventAtMs, long graceMs) { + return eventAtMs > 0L && nowMs >= eventAtMs && nowMs - eventAtMs < graceMs; + } + + private static long remainingRecentEventMs(long nowMs, long eventAtMs, long graceMs) { + if (!isRecentEvent(nowMs, eventAtMs, graceMs)) { + return OFF_PATH_RECALC_DEFER_WAIT_MIN_MS; + } + return graceMs - (nowMs - eventAtMs); + } + private static boolean hasUpcomingNearbyTransportStep(List path, int startIdx, WorldPoint playerLoc, @@ -8279,6 +9342,11 @@ private static boolean isStuckTooLong() { return false; } + long routeProgressAt = routeProgressAdvancedAtMs; + if (routeProgressAt > 0L && System.currentTimeMillis() - routeProgressAt < ROUTE_PROGRESS_STALL_GRACE_MS) { + return false; + } + return lastMovedTimeMs > 0 && System.currentTimeMillis() - lastMovedTimeMs > stallThresholdMs(); } @@ -8286,14 +9354,16 @@ private static boolean isStuckTooLong() { * @param start */ public void setStart(WorldPoint start) { - if (ShortestPathPlugin.getPathfinder() == null) { + Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); + if (pathfinder == null) { return; } + Set targets = pathfinder.getTargets(); ShortestPathPlugin.setStartPointSet(true); - if (Microbot.getClient().isClientThread()) { - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, ShortestPathPlugin.getPathfinder().getTargets())); + if (isClientThread()) { + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); } else { - restartPathfinding(start, ShortestPathPlugin.getPathfinder().getTargets()); + restartPathfinding(start, targets); } } @@ -8959,33 +10029,160 @@ private static boolean handleCharterShip(Transport transport) { log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { Rs2Player.waitForWalking(); - sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4)); - List destinationWidgets = Arrays.stream(Rs2Widget.getWidget(885, 4).getDynamicChildren()) - .filter(w -> w.getActions() != null) - .collect(Collectors.toList()); + if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4), 5000)) { + return false; + } + + Widget destinationWidget = findCharterDestinationWidget(transport.getDisplayInfo()); + if (!invokeCharterDestinationWidget(destinationWidget, transport.getDisplayInfo())) { + return false; + } + confirmCharterTravelIfPrompted(); + return true; + } + return false; + } + + private static Widget findCharterDestinationWidget(String destinationText) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget root = Microbot.getClient().getWidget(885, 4); + if (root == null || root.isHidden()) { + return null; + } - if (destinationWidgets.isEmpty()) return false; + Widget textMatch = findCharterDestinationTextWidget(root, destinationText); + if (textMatch == null) { + return null; + } - String destinationText = transport.getDisplayInfo(); + Widget clickable = findClickableCharterWidget(textMatch, root); + return clickable != null ? clickable : textMatch; + }).orElse(null); + } - Widget destinationWidget = Rs2Widget.findWidget(destinationText, destinationWidgets); - if (destinationWidget == null) return false; + private static Widget findCharterDestinationTextWidget(Widget widget, String destinationText) { + if (widget == null || widget.isHidden()) { + return null; + } + if (charterWidgetMatchesDestination(widget, destinationText)) { + return widget; + } - boolean isWidgetVisible = Microbot.getClientThread().runOnClientThreadOptional(() -> !destinationWidget.isHidden()).orElse(false); + Widget[] staticChildren = widget.getStaticChildren(); + Widget found = findCharterDestinationTextWidget(staticChildren, destinationText); + if (found != null) { + return found; + } - NewMenuEntry destinationMenuEntry = new NewMenuEntry() - .option(destinationText) - .target("") - .identifier(1) - .type(MenuAction.CC_OP) - .param0(destinationWidget.getIndex()) - .param1(destinationWidget.getId()) - .forceLeftClick(false); + Widget[] dynamicChildren = widget.getDynamicChildren(); + found = findCharterDestinationTextWidget(dynamicChildren, destinationText); + if (found != null) { + return found; + } - Microbot.doInvoke(destinationMenuEntry, new Rectangle(1, 1)); + return findCharterDestinationTextWidget(widget.getNestedChildren(), destinationText); + } + + private static Widget findCharterDestinationTextWidget(Widget[] widgets, String destinationText) { + if (widgets == null) { + return null; + } + for (Widget widget : widgets) { + Widget found = findCharterDestinationTextWidget(widget, destinationText); + if (found != null) { + return found; + } + } + return null; + } + + private static boolean charterWidgetMatchesDestination(Widget widget, String destinationText) { + String needle = normalizeCharterWidgetText(destinationText); + if (needle.isEmpty()) { + return false; + } + if (normalizeCharterWidgetText(widget.getText()).contains(needle) + || normalizeCharterWidgetText(widget.getName()).contains(needle)) { return true; } - return false; + String[] actions = widget.getActions(); + if (actions == null) { + return false; + } + return Arrays.stream(actions) + .filter(Objects::nonNull) + .map(Rs2Walker::normalizeCharterWidgetText) + .anyMatch(action -> action.contains(needle)); + } + + private static String normalizeCharterWidgetText(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + return Rs2UiHelper.stripTagsToSpace(text) + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", " "); + } + + private static Widget findClickableCharterWidget(Widget widget, Widget root) { + Widget current = widget; + while (current != null) { + if (hasWidgetActions(current)) { + return current; + } + if (current == root) { + return null; + } + current = current.getParent(); + } + return null; + } + + private static boolean hasWidgetActions(Widget widget) { + String[] actions = widget.getActions(); + return actions != null && Arrays.stream(actions).anyMatch(action -> action != null && !action.isEmpty()); + } + + private static boolean invokeCharterDestinationWidget(Widget widget, String destinationText) { + if (widget == null) { + return false; + } + + String option = getFirstWidgetAction(widget); + if (option == null || option.isBlank()) { + option = destinationText; + } + + NewMenuEntry destinationMenuEntry = new NewMenuEntry() + .option(option) + .target("") + .identifier(1) + .type(MenuAction.CC_OP) + .param0(widget.getIndex()) + .param1(widget.getId()) + .forceLeftClick(false); + + Rectangle bounds = widget.getBounds(); + Microbot.doInvoke(destinationMenuEntry, bounds != null ? bounds : Rs2UiHelper.getDefaultRectangle()); + return true; + } + + private static String getFirstWidgetAction(Widget widget) { + String[] actions = widget.getActions(); + if (actions == null) { + return null; + } + return Arrays.stream(actions) + .filter(action -> action != null && !action.isEmpty()) + .findFirst() + .orElse(null); + } + + private static void confirmCharterTravelIfPrompted() { + if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2000)) { + Rs2Dialogue.clickOption("Yes", true); + } } /** * interact with interfaces like spirit tree etc... @@ -9456,7 +10653,8 @@ private static boolean isCurrencyBasedTransport(TransportType transportType) { || transportType == TransportType.CHARTER_SHIP || transportType == TransportType.SHIP || transportType == TransportType.MINECART - || transportType == TransportType.MAGIC_CARPET; + || transportType == TransportType.MAGIC_CARPET + || transportType == TransportType.TRANSPORT; } private static int getCurrencyItemId(String currencyName) { @@ -9506,7 +10704,7 @@ public static boolean walkWithBankedTransports(WorldPoint target) { return walkWithBankedTransports(target, false); } public static boolean walkWithBankedTransports(WorldPoint target, boolean forceBanking) { - int d = config != null ? config.reachedDistance() : 10; + int d = reachedDistanceOrDefault(); return walkWithBankedTransportsAndState(target, d, forceBanking) == WalkerState.ARRIVED; } public static boolean walkWithBankedTransports(WorldPoint target, int distance, boolean forceBanking){ @@ -9527,7 +10725,7 @@ public static WalkerState walkWithBankedTransportsAndState(WorldPoint target, in log.warn("Cannot travel to null target location"); return WalkerState.EXIT; } - if (Microbot.getClient().isClientThread()) { + if (isClientThread()) { log.error("Please do not call the walker from the main thread"); return WalkerState.EXIT; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java index cec154d812..63d7d7c4d1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java @@ -22,7 +22,7 @@ public static void routeClearMissingReason(String threadName) { LOG.warn("[WebWalk] clear | reason= thread={}", threadName); } - /** Cancel / EXIT / traceProcessWalkExit — compact WARN for production diagnosis. */ + /** Cancel / EXIT / traceProcessWalkExit - compact WARN for production diagnosis. */ public static void exitWarn(String reason, boolean nullCurrent, boolean targetMismatch, boolean interrupted, WorldPoint goal, WorldPoint current, int tailIdx, int tailMax, WorldPoint player) { LOG.warn("[WebWalk] exit | r={} nullCur={} mismatch={} intr={} goal={} cur={} tail={}/{} at={}", @@ -33,18 +33,23 @@ public static void exitDetailDebug(String fmt, Object... args) { LOG.debug("[WebWalk] exit_dbg | " + fmt, args); } - /** interim-in-flight and similar yields — DEBUG to avoid tick spam. */ + /** interim-in-flight and similar yields - DEBUG to avoid tick spam. */ public static void yieldDebug(String reason, WorldPoint player, WorldPoint goal, WorldPoint pathEnd, int idxStart, int pathLen) { LOG.debug("[WebWalk] yield | r={} player={} goal={} end={} idx={}/{}", reason, player, goal, pathEnd, idxStart, pathLen); } public static void earlyExit(String reason, WorldPoint player, WorldPoint goal, WorldPoint pathEnd, int idxStart, int pathLen) { + if ("interim-in-flight".equals(reason)) { + LOG.debug("[WebWalk] early_exit | r={} at={} goal={} end={} idx={}/{}", + reason, player, goal, pathEnd, idxStart, pathLen); + return; + } LOG.info("[WebWalk] early_exit | r={} at={} goal={} end={} idx={}/{}", reason, player, goal, pathEnd, idxStart, pathLen); } - /** Path ends far from goal — walking multi-hop segment. */ + /** Path ends far from goal - walking multi-hop segment. */ public static void partialSegment(WorldPoint pathEnd, int distToGoal, WorldPoint goal, int waypointCount) { LOG.info("[WebWalk] partial_seg | end={} dGoal={} goal={} nWp={}", pathEnd, distToGoal, goal, waypointCount); } @@ -65,7 +70,7 @@ public static void stallContextDebug(WorldPoint lastClick, boolean clickOk, long LOG.debug("[WebWalk] stall_ctx | lastClick={} ok={} ageMs={} interim={}", lastClick, clickOk, clickAgeMs, interim); } - /** Off-path / unreachable recovery / generic replan — single INFO line. */ + /** Off-path / unreachable recovery / generic replan - single INFO line. */ public static void recalc(String reason) { LOG.info("[WebWalk] recalc | {}", reason); } @@ -86,12 +91,12 @@ public static void tailExceeded(int maxTail, WorldPoint target, WorldPoint curre maxTail, target, current, interim, stuck, player); } - /** Pathfinder {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder} — DEBUG volume. */ + /** Pathfinder {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder} - DEBUG volume. */ public static void pf(String fmt, Object... args) { LOG.debug("[WebWalk] pf | " + fmt, args); } - /** {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig} refresh — DEBUG volume. */ + /** {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig} refresh - DEBUG volume. */ public static void cfg(String fmt, Object... args) { LOG.debug("[WebWalk] cfg | " + fmt, args); } @@ -100,7 +105,7 @@ public static void leagues(String fmt, Object... args) { LOG.debug("[WebWalk] leagues | " + fmt, args); } - /** Leagues calibration, region unlock, explicit no-op — INFO (not tick-spam paths). */ + /** Leagues calibration, region unlock, explicit no-op - INFO (not tick-spam paths). */ public static void leaguesInfo(String fmt, Object... args) { LOG.info("[WebWalk] leagues | " + fmt, args); } @@ -131,7 +136,7 @@ public static void compareError(double totalMs, WorldPoint target, String err) { LOG.warn("[WebWalk] compare_err | {}ms target={} err={}", String.format("%.1f", totalMs), target, err); } - /** Banked-route helper: per-path transport scan — DEBUG volume. */ + /** Banked-route helper: per-path transport scan - DEBUG volume. */ public static void bankPathTransportsDebug(int count, WorldPoint from, WorldPoint to) { LOG.debug("[WebWalk] bank_path | transports={} {} -> {}", count, from, to); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java index 64f3c61a2e..287a41bba8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java @@ -80,7 +80,8 @@ public static boolean hasRequiredTransportItems(Transport transport) { || transport.getType() == TransportType.CHARTER_SHIP || transport.getType() == TransportType.SHIP || transport.getType() == TransportType.MINECART - || transport.getType() == TransportType.MAGIC_CARPET) { + || transport.getType() == TransportType.MAGIC_CARPET + || (transport.getType() == TransportType.TRANSPORT && transport.getCurrencyAmount() > 0)) { if (transport.getType() == TransportType.TELEPORTATION_SPELL && transport.getDisplayInfo() != null) { String spellName = transport.getDisplayInfo().contains(":") ? transport.getDisplayInfo().split(":")[0].trim() @@ -405,7 +406,8 @@ private static boolean isCurrencyBasedTransport(TransportType transportType) { || transportType == TransportType.CHARTER_SHIP || transportType == TransportType.SHIP || transportType == TransportType.MINECART - || transportType == TransportType.MAGIC_CARPET; + || transportType == TransportType.MAGIC_CARPET + || transportType == TransportType.TRANSPORT; } private static int getCurrencyItemId(String currencyName) { diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv index 49bc9a2141..4b6822e52d 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv @@ -1,5 +1,7 @@ # Origin Destination Bidirectional Display info # Static collision edge overrides for map data gaps. These block movement across the edge, not the whole tile. +3267 3227 0 3268 3227 0 true Al Kharid toll gate +3267 3228 0 3268 3228 0 true Al Kharid toll gate 3229 3472 0 3229 3471 0 true Varrock Palace garden south fence 3230 3472 0 3230 3471 0 true Varrock Palace garden south fence 3231 3472 0 3231 3471 0 true Varrock Palace garden south fence diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv index 539c6d881f..c66bd5a99f 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv @@ -20,11 +20,7 @@ 3207 3217 0 Recipe for Disaster 3213 3222 0 Recipe for Disaster 3213 3221 0 Recipe for Disaster -# Al Kharid Gate -3267 3228 0 Prince Ali Rescue -3267 3227 0 Prince Ali Rescue -3268 3227 0 Prince Ali Rescue -3268 3228 0 Prince Ali Rescue +# Al Kharid toll gate is modeled as transports plus blocked edge overrides. 2936 9810 0 63 Agility # Tree near Draynor Manor 3138 3352 0 @@ -236,4 +232,4 @@ 2432 3118 0 2431 3118 0 2385 3068 0 -2385 3069 0 \ No newline at end of file +2385 3069 0 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index 28fc583725..da52a59975 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -1018,6 +1018,14 @@ 3097 3432 2 3097 3432 1 Climb-down;Ladder;16685 # Al Kharid +3267 3227 0 3268 3227 0 Pay-toll(10gp);Gate;2786 10 Coins 2 +3267 3228 0 3268 3228 0 Pay-toll(10gp);Gate;2787 10 Coins 2 +3268 3227 0 3267 3227 0 Pay-toll(10gp);Gate;2788 10 Coins 2 +3268 3228 0 3267 3228 0 Pay-toll(10gp);Gate;2789 10 Coins 2 +3267 3227 0 3268 3227 0 Open;Gate;2786 Prince Ali Rescue 1 +3267 3228 0 3268 3228 0 Open;Gate;2787 Prince Ali Rescue 1 +3268 3227 0 3267 3227 0 Open;Gate;2788 Prince Ali Rescue 1 +3268 3228 0 3267 3228 0 Open;Gate;2789 Prince Ali Rescue 1 3298 3124 0 3297 3124 0 Open;Prison door;2692 3297 3124 0 3298 3124 0 Open;Prison door;2692 3303 3117 0 3304 3115 0 Go-through;Shantay pass;4031 1854 2 diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java index 4adaf188ef..2bbc7972bc 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java @@ -1,5 +1,6 @@ package net.runelite.client.plugins.microbot.shortestpath; +import net.runelite.api.Quest; import net.runelite.api.VarPlayer; import net.runelite.api.coords.WorldArea; import net.runelite.api.coords.WorldPoint; @@ -14,6 +15,10 @@ public class ShortestPathCoreTest { private static SplitFlagMap collisionMap; + private static final WorldPoint AL_KHARID_GATE_WEST_SOUTH = new WorldPoint(3267, 3227, 0); + private static final WorldPoint AL_KHARID_GATE_WEST_NORTH = new WorldPoint(3267, 3228, 0); + private static final WorldPoint AL_KHARID_GATE_EAST_SOUTH = new WorldPoint(3268, 3227, 0); + private static final WorldPoint AL_KHARID_GATE_EAST_NORTH = new WorldPoint(3268, 3228, 0); @BeforeClass public static void loadCollisionMap() { @@ -193,6 +198,52 @@ public void testTransportLoadingDoesNotThrow() { assertTrue("Should load at least 100 transport origins", transports.size() > 100); } + @Test + public void testAlKharidTollGateTransportsLoaded() { + HashMap> transports = Transport.loadAllFromResources(); + + assertTollGateTransport(transports, AL_KHARID_GATE_WEST_SOUTH, AL_KHARID_GATE_EAST_SOUTH, + "Pay-toll(10gp)", 10, false); + assertTollGateTransport(transports, AL_KHARID_GATE_WEST_NORTH, AL_KHARID_GATE_EAST_NORTH, + "Pay-toll(10gp)", 10, false); + assertTollGateTransport(transports, AL_KHARID_GATE_EAST_SOUTH, AL_KHARID_GATE_WEST_SOUTH, + "Pay-toll(10gp)", 10, false); + assertTollGateTransport(transports, AL_KHARID_GATE_EAST_NORTH, AL_KHARID_GATE_WEST_NORTH, + "Pay-toll(10gp)", 10, false); + + assertTollGateTransport(transports, AL_KHARID_GATE_WEST_SOUTH, AL_KHARID_GATE_EAST_SOUTH, + "Open", 0, true); + assertTollGateTransport(transports, AL_KHARID_GATE_WEST_NORTH, AL_KHARID_GATE_EAST_NORTH, + "Open", 0, true); + assertTollGateTransport(transports, AL_KHARID_GATE_EAST_SOUTH, AL_KHARID_GATE_WEST_SOUTH, + "Open", 0, true); + assertTollGateTransport(transports, AL_KHARID_GATE_EAST_NORTH, AL_KHARID_GATE_WEST_NORTH, + "Open", 0, true); + } + + @Test + public void testAlKharidTollGateIsEdgeBlockedNotTileRestricted() { + Set gateTiles = new HashSet<>(Arrays.asList( + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_WEST_SOUTH), + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_WEST_NORTH), + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_EAST_SOUTH), + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_EAST_NORTH))); + + List restrictions = Restriction.loadAllFromResources(); + assertFalse("Al Kharid gate tiles must not be quest-only restrictions", + restrictions.stream().anyMatch(r -> gateTiles.contains(r.getPackedWorldPoint()))); + + PathfinderConfig config = createMinimalConfig(); + assertTrue("South Al Kharid gate edge should be blocked without transport", + config.isBlockedTransportStep( + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_WEST_SOUTH), + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_EAST_SOUTH))); + assertTrue("North Al Kharid gate edge should be blocked without transport", + config.isBlockedTransportStep( + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_WEST_NORTH), + WorldPointUtil.packWorldPoint(AL_KHARID_GATE_EAST_NORTH))); + } + @Test public void testNewTransportTypesLoaded() { HashMap> transports = Transport.loadAllFromResources(); @@ -259,6 +310,27 @@ && new WorldPoint(3221, 3218, 0).equals(t.getDestination())) return lumbridgeHomeTeleport.get(); } + private static void assertTollGateTransport(HashMap> transports, + WorldPoint origin, WorldPoint destination, String action, int currencyAmount, boolean princeAliRequired) { + Optional match = transports.getOrDefault(origin, Collections.emptySet()).stream() + .filter(t -> destination.equals(t.getDestination())) + .filter(t -> action.equals(t.getAction())) + .filter(t -> "Gate".equals(t.getName())) + .findFirst(); + + assertTrue("Missing Al Kharid toll gate transport " + action + " from " + origin + " to " + destination, + match.isPresent()); + Transport transport = match.get(); + assertEquals("Gate transport should use normal TRANSPORT type", + TransportType.TRANSPORT, transport.getType()); + assertEquals("Unexpected gate currency amount", currencyAmount, transport.getCurrencyAmount()); + if (currencyAmount > 0) { + assertEquals("Unexpected gate currency name", "Coins", transport.getCurrencyName()); + } + assertEquals("Unexpected Prince Ali Rescue requirement on gate transport", + princeAliRequired, transport.getQuests().containsKey(Quest.PRINCE_ALI_RESCUE)); + } + @Test public void testFairyRingTransportsExist() { HashMap> transports = Transport.loadAllFromResources(); 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 31c84b2018..b117cae7f6 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 @@ -11,8 +11,10 @@ import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Predicate; @@ -20,6 +22,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; @@ -114,6 +117,63 @@ public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport( assertFalse(Rs2Walker.shouldRecalculatePathAfterTransport(door)); } + @Test + public void isSettledNearAdjacentSamePlaneLanding_acceptsNearDestinationOffOrigin() { + Transport door = new Transport( + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Door", + TransportType.TRANSPORT, + false, + "Open", + "Door", + 136); + + assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + door, + new WorldPoint(3154, 3363, 0), + new WorldPoint(3153, 3363, 0), + 0)); + } + + @Test + public void isSettledNearAdjacentSamePlaneLanding_rejectsOriginTile() { + Transport door = new Transport( + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Door", + TransportType.TRANSPORT, + false, + "Open", + "Door", + 136); + + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + door, + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + 0)); + } + + @Test + public void isSettledNearAdjacentSamePlaneLanding_rejectsTilesTooFarFromDestination() { + Transport door = new Transport( + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Door", + TransportType.TRANSPORT, + false, + "Open", + "Door", + 136); + + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + door, + new WorldPoint(3155, 3363, 0), + new WorldPoint(3153, 3363, 0), + 0)); + } + @Test public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() { Transport ship = new Transport( @@ -238,6 +298,30 @@ public void rankSidestep_preservesAllTilesIncludingEquidistant() { assertTrue(ranked.contains(north)); } + @Test + public void getClosestTileIndex_usesReachableDistanceWhenAvailable() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint farByWorldDistance = new WorldPoint(3210, 3200, 0); + WorldPoint nearReachable = new WorldPoint(3220, 3200, 0); + List path = Arrays.asList(farByWorldDistance, nearReachable); + Map reachable = new HashMap<>(); + reachable.put(farByWorldDistance, 8); + reachable.put(nearReachable, 3); + + assertEquals(1, Rs2Walker.getClosestTileIndex(path, player, reachable)); + } + + @Test + public void getClosestTileIndex_fallsBackToWorldDistanceWhenNoReachablePathTile() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + List path = Arrays.asList( + new WorldPoint(3210, 3200, 0), + new WorldPoint(3202, 3200, 0), + new WorldPoint(3220, 3200, 0)); + + assertEquals(1, Rs2Walker.getClosestTileIndex(path, player, Collections.emptyMap())); + } + // --------------------------------------------------------------------------- // #21 — Minimap forward-scan // --------------------------------------------------------------------------- @@ -356,7 +440,7 @@ public void findFurthest_nullPredicate_treatsAsNoTransport() { } @Test - public void stabilizeRouteProgressIndex_doesNotJumpBackToEarlierSwitchbackBranch() { + public void stabilizeRouteProgressIndex_doesNotJumpBackToEarlierNearbyBranch() { WorldPoint target = new WorldPoint(3200, 3201, 0); List path = Arrays.asList( new WorldPoint(3200, 3200, 0), @@ -376,7 +460,27 @@ public void stabilizeRouteProgressIndex_doesNotJumpBackToEarlierSwitchbackBranch } @Test - public void findForwardRecoveryIndex_prefersLaterReachableSwitchbackBranch() { + public void hintRouteProgressIndex_keepsProgressAnchoredAheadOfEarlierBranch() { + 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); + + Rs2Walker.hintRouteProgressIndex(path, 5, target); + + assertEquals("a clicked-ahead route checkpoint should not snap back to a nearby earlier branch", + 5, + Rs2Walker.stabilizeRouteProgressIndex(path, 2, target, new WorldPoint(3202, 3201, 0))); + } + + @Test + public void findForwardRecoveryIndex_prefersLaterReachableBranch() { WorldPoint player = new WorldPoint(1000, 1000, 0); List path = Arrays.asList( new WorldPoint(998, 1000, 0), @@ -396,7 +500,7 @@ public void findForwardRecoveryIndex_prefersLaterReachableSwitchbackBranch() { } @Test - public void findFurthestClickableIndex_canReturnEarlierSwitchbackBranch() { + public void findFurthestClickableIndex_canReturnEarlierNearbyBranch() { WorldPoint player = new WorldPoint(1000, 1000, 0); List path = Arrays.asList( new WorldPoint(998, 1000, 0), @@ -410,6 +514,164 @@ public void findFurthestClickableIndex_canReturnEarlierSwitchbackBranch() { assertEquals("generic fallback is allowed to backtrack; recovery clamps this at the call site", 2, idx); } + @Test + public void findFurthestForwardClickableIndex_doesNotBacktrackToEarlierNearbyBranch() { + 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.findFurthestForwardClickableIndex(path, 3, player, wp -> false, 13); + + assertEquals("normal route following should interpolate toward the forward tile, not backtrack", 3, idx); + } + + @Test + public void findFurthestRawPathPointMatching_keepsPrimaryClickOnForwardRawRoute() { + WorldPoint player = new WorldPoint(1000, 1000, 0); + List rawPath = Arrays.asList( + new WorldPoint(998, 1000, 0), + new WorldPoint(999, 1000, 0), + new WorldPoint(1000, 1001, 0), + new WorldPoint(1010, 1000, 0), + new WorldPoint(1009, 1000, 0), + new WorldPoint(1008, 1000, 0)); + + WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + rawPath, + player, + 13, + 3, + wp -> true); + + assertEquals("raw-route selection must not snap back to an earlier nearby branch", + new WorldPoint(1008, 1000, 0), target); + } + + @Test + public void findFurthestRawPathPointMatching_honorsCandidatePredicate() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint blocked = new WorldPoint(3203, 3200, 0); + WorldPoint allowed = new WorldPoint(3202, 3200, 0); + List rawPath = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3201, 3200, 0), + allowed, + blocked); + + WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + rawPath, + player, + 13, + 0, + wp -> !wp.equals(blocked)); + + assertEquals("primary raw-route target must be the furthest acceptable raw tile", + allowed, target); + } + + @Test + public void findFurthestRawPathPointMatching_doesNotReturnCurrentTile() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + List rawPath = Arrays.asList( + player, + new WorldPoint(3215, 3200, 0)); + + WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + rawPath, + player, + 10, + 0, + wp -> true); + + assertNull("same-tile route targets are no-op clicks, not recovery candidates", target); + } + + @Test + public void findFurthestRawPathPointMatching_stopsBeforeRouteStepBudgetIsExceeded() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + List rawPath = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3205, 3200, 0), + new WorldPoint(3210, 3200, 0), + new WorldPoint(3216, 3200, 0)); + + WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + rawPath, + player, + 13, + 0, + wp -> true); + + assertEquals("route step budget keeps one minimap click from spanning too far ahead", + new WorldPoint(3210, 3200, 0), target); + } + + @Test + public void findFurthestForwardClickableIndex_stopsBeforeTransportOrigin() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint transportOrigin = new WorldPoint(3203, 3200, 0); + List path = Arrays.asList( + new WorldPoint(3201, 3200, 0), + new WorldPoint(3202, 3200, 0), + transportOrigin, + new WorldPoint(3204, 3200, 0)); + + int idx = Rs2Walker.findFurthestForwardClickableIndex(path, 0, player, transportOrigin::equals, 13); + + assertEquals("route clicks must not skip a planned transport origin", 1, idx); + } + + @Test + public void offPathRecalcDeferralReason_prefersSettlingAndBusyState() { + long now = 10_000L; + + assertEquals("door-settling", Rs2Walker.offPathRecalcDeferralReason( + true, false, false, true, false, false, + now, 0L, 0L, 0L, 0L)); + assertEquals("transport-settling", Rs2Walker.offPathRecalcDeferralReason( + true, false, false, false, true, false, + now, 0L, 0L, 0L, 0L)); + assertEquals("moving", Rs2Walker.offPathRecalcDeferralReason( + true, false, false, false, false, false, + now, 0L, 0L, 0L, 0L)); + } + + @Test + public void offPathRecalcDeferralReason_recentProgressDefersAfterMovementStops() { + long now = 10_000L; + + assertEquals("route-progress", Rs2Walker.offPathRecalcDeferralReason( + false, false, false, false, false, false, + now, 0L, 8_000L, 0L, 0L)); + assertEquals("recent-click", Rs2Walker.offPathRecalcDeferralReason( + false, false, false, false, false, false, + now, 0L, 0L, 8_000L, 0L)); + assertEquals("interim-progress", Rs2Walker.offPathRecalcDeferralReason( + false, false, false, false, false, true, + now, 0L, 0L, 0L, 8_000L)); + } + + @Test + public void offPathRecalcDeferralReason_allowsRecalcWhenSignalsExpired() { + long now = 10_000L; + + assertEquals(null, Rs2Walker.offPathRecalcDeferralReason( + false, false, false, false, false, false, + now, 7_000L, 6_000L, 7_000L, 7_000L)); + } + + @Test + public void offPathRecalcDeferredWaitMs_isBounded() { + assertEquals(1200, Rs2Walker.offPathRecalcDeferredWaitMs( + "route-progress", 10_000L, 0L, 9_700L, 0L, 0L)); + assertEquals(250, Rs2Walker.offPathRecalcDeferredWaitMs( + "route-progress", 10_000L, 0L, 6_600L, 0L, 0L)); + } + @Test public void interpolateClickableTarget_usesInterpolatedPointWhenUsable() { WorldPoint player = new WorldPoint(3200, 3200, 0); @@ -453,6 +715,26 @@ public void interpolateClickableTarget_shortensOutOfReachForwardWaypoint() { new WorldPoint(3212, 3200, 0), target); } + @Test + public void clampToEuclideanRadius_shortensDiagonalTargetInsideCircle() { + WorldPoint player = new WorldPoint(2875, 3418, 0); + WorldPoint target = new WorldPoint(2886, 3428, 0); + + WorldPoint clamped = Rs2Walker.clampToEuclideanRadius(player, target, 10); + + assertTrue(clamped.distanceTo2D(player) <= 10); + assertTrue(clamped.getX() > player.getX()); + assertTrue(clamped.getY() > player.getY()); + } + + @Test + public void clampToEuclideanRadius_keepsInRangeTarget() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3206, 3203, 0); + + assertEquals(target, Rs2Walker.clampToEuclideanRadius(player, target, 10)); + } + // --------------------------------------------------------------------------- // Raw-path wall-door segment probing // --------------------------------------------------------------------------- @@ -525,6 +807,23 @@ public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { 2_000L)); } + @Test + public void shouldClearInterimTarget_preclickDistanceStillKeepsCheckpoint() { + assertFalse(Rs2Walker.shouldClearInterimTarget( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2884, 3396, 0), + 1_000L, + 1_500L, + 2_000L)); + } + + @Test + public void distanceToInterimOrMax_samePlaneReturnsDistance() { + assertEquals(8, Rs2Walker.distanceToInterimOrMax( + new WorldPoint(2850, 3506, 0), + new WorldPoint(2849, 3498, 0))); + } + @Test public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { assertTrue(Rs2Walker.shouldClearInterimTarget( @@ -555,10 +854,176 @@ public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { 5_000L)); } + @Test + public void shouldYieldForActiveRecoveryInterim_recentProgress_returnsTrue() { + assertTrue(Rs2Walker.shouldYieldForActiveRecoveryInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2884, 3396, 0), + 1_000L, + 2_500L, + 3_000L, + 0L, + 0L, + false)); + } + + @Test + public void shouldYieldForActiveRecoveryInterim_staleProgress_returnsFalse() { + assertFalse(Rs2Walker.shouldYieldForActiveRecoveryInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 1_500L, + 5_000L, + 0L, + 0L, + false)); + } + + @Test + public void shouldYieldForActiveRecoveryInterim_recentRecoveryClick_returnsTrue() { + assertTrue(Rs2Walker.shouldYieldForActiveRecoveryInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 0L, + 3_000L, + 0L, + 2_000L, + false)); + } + + @Test + public void shouldDeferRouteWorkForActiveInterim_movingFarCheckpoint_returnsTrue() { + assertTrue(Rs2Walker.shouldDeferRouteWorkForActiveInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 4_500L, + 5_000L, + 0L, + true, + 5)); + } + + @Test + public void shouldDeferRouteWorkForActiveInterim_recentProgressStoppedFar_returnsTrue() { + assertTrue(Rs2Walker.shouldDeferRouteWorkForActiveInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 4_900L, + 5_000L, + 0L, + false, + 5)); + } + + @Test + public void shouldDeferRouteWorkForActiveInterim_closeCheckpoint_returnsFalse() { + assertFalse(Rs2Walker.shouldDeferRouteWorkForActiveInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2886, 3396, 0), + 1_000L, + 4_500L, + 5_000L, + 0L, + true, + 5)); + } + + @Test + public void shouldDeferRouteWorkForActiveInterim_staleStoppedCheckpoint_returnsFalse() { + assertFalse(Rs2Walker.shouldDeferRouteWorkForActiveInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 1_500L, + 5_000L, + 0L, + false, + 5)); + } + @Test public void interimPreclickTiles_runHandsOffEarlierThanWalk() { assertEquals(6, Rs2Walker.interimPreclickTiles(false)); - assertEquals(11, Rs2Walker.interimPreclickTiles(true)); + assertEquals(8, Rs2Walker.interimPreclickTiles(true)); + } + + @Test + public void routeMovementClickPhase_labelsContinuationSeparatelyFromRecovery() { + assertEquals("stall_recovery_click", Rs2Walker.routeMovementClickPhase("stall recovery click")); + assertEquals("active_route_idle_nudge", Rs2Walker.routeMovementClickPhase("active route idle nudge")); + assertEquals("interim_close_route_click", Rs2Walker.routeMovementClickPhase("interim close route click")); + assertEquals("route_movement_click", Rs2Walker.routeMovementClickPhase("other")); + } + + @Test + public void shouldRunActiveRouteIdleNudge_waitsForImmediateTransport() { + assertFalse(Rs2Walker.shouldRunActiveRouteIdleNudge(true, true)); + assertTrue(Rs2Walker.shouldRunActiveRouteIdleNudge(true, false)); + assertFalse(Rs2Walker.shouldRunActiveRouteIdleNudge(false, false)); + } + + @Test + public void shouldSkipStartupPreclickSegmentHandlers_skipsBeforeFirstMovementClick() { + assertTrue(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( + true, + 5, + 5, + false, + false, + false)); + } + + @Test + public void shouldSkipStartupPreclickSegmentHandlers_keepsDoorRecoveryAndSteadyEdges() { + assertFalse(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( + true, + 8, + 5, + true, + false, + false)); + assertFalse(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( + false, + 8, + 5, + false, + false, + false)); + } + + @Test + public void rawPathForwardAnchorIndex_keepsFallbackAheadOfAnchor() { + List rawPath = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3201, 3200, 0), + new WorldPoint(3202, 3200, 0), + new WorldPoint(3203, 3200, 0), + new WorldPoint(3204, 3200, 0), + new WorldPoint(3205, 3200, 0), + new WorldPoint(3206, 3200, 0), + new WorldPoint(3207, 3200, 0), + new WorldPoint(3208, 3200, 0), + new WorldPoint(3209, 3200, 0), + new WorldPoint(3210, 3200, 0), + new WorldPoint(3210, 3201, 0), + new WorldPoint(3210, 3202, 0), + new WorldPoint(3210, 3203, 0), + new WorldPoint(3210, 3204, 0), + new WorldPoint(3209, 3204, 0), + new WorldPoint(3208, 3204, 0), + new WorldPoint(3207, 3204, 0), + new WorldPoint(3206, 3204, 0), + new WorldPoint(3205, 3204, 0), + new WorldPoint(3204, 3204, 0)); + WorldPoint playerOnReturnBranch = rawPath.get(20); + + assertEquals("forward anchor must keep raw fallback on the current return branch", + 20, + Rs2Walker.rawPathForwardAnchorIndex(rawPath, playerOnReturnBranch, 14)); } @Test @@ -600,6 +1065,16 @@ public void shouldBlacklistDoorAfterWrongTraversal_teleportAway_returnsTrue() { new WorldPoint(1988, 5569, 0))); } + @Test + public void shouldBlacklistDoorAfterWrongTraversal_startedFarFromDoor_returnsFalse() { + assertFalse("movement from an earlier minimap click must not blacklist a valid gate", + Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + new WorldPoint(3270, 3320, 0), + new WorldPoint(3275, 3325, 0), + new WorldPoint(3262, 3322, 0), + new WorldPoint(3261, 3321, 0))); + } + @Test public void shouldBlacklistDoorAfterWrongTraversal_progressTowardEdge_returnsFalse() { assertFalse(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( @@ -768,6 +1243,18 @@ public void telemetry_counterIsIndependentOfOtherReasons() { assertEquals(1, Rs2Walker.Telemetry.partialRetryCount.get()); } + @Test + public void telemetry_recordOffPathRecalcDeferred_setsReasonButDoesNotCountAsRecalc() { + Rs2Walker.Telemetry.recordOffPathRecalcDeferred("route-progress", + new WorldPoint(3200, 3200, 0), + new WorldPoint(3210, 3210, 0), + 20); + + assertEquals(1, Rs2Walker.Telemetry.offPathRecalcDeferredCount.get()); + assertEquals("off-path-deferred:route-progress", Rs2Walker.Telemetry.lastReason); + assertEquals(0, Rs2Walker.Telemetry.totalRecalcs()); + } + @Test public void telemetry_reset_clearsUnreachable() { Rs2Walker.Telemetry.recordUnreachable("no-walkable-path", 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 3395d9f6e9..dab8036f3b 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 @@ -720,10 +720,18 @@ net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(World net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#adjacentSamePlaneTransportSuppressionPoints(Transport, TileObject): Set -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String 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#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findClickableCharterWidget(Widget, Widget): Widget -> net.runelite.api.widgets.Widget#getParent(): Widget +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getFirstWidgetAction(Widget): String -> net.runelite.api.widgets.Widget#getActions(): String[] 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[] @@ -735,9 +743,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -771,9 +776,14 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRaw net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasWidgetActions(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getIndex(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isClientThread(): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] @@ -788,33 +798,35 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObj 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$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$handleDoors$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint 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$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$handleMinigameTeleport$156(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$158(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$134(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$140(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$140(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$141(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$141(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$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$handleTransports$113(int, boolean, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$113(int, boolean, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$115(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$118(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$148(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$149(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$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint 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 @@ -827,7 +839,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoin net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setStart(WorldPoint): void -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint, String): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Player#getName(): String @@ -848,15 +859,15 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldP net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvasOnScreenOnly(WorldPoint, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvasOnScreenOnly(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastLocal(LocalPoint): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastLocal(LocalPoint): void -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkNextToInstance(GameObject): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkNextToInstance(GameObject): void -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTransportsAndState(WorldPoint, int, boolean): WalkerState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTransportsAndStateLocked(WorldPoint, int, boolean): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTransportsAndStateLocked(WorldPoint, int, boolean): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkBoundsOverlapWidgetInMainModal(Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle