diff --git a/runelite-client/build.gradle.kts b/runelite-client/build.gradle.kts
index cf18d3072d9..a314700e46a 100644
--- a/runelite-client/build.gradle.kts
+++ b/runelite-client/build.gradle.kts
@@ -190,6 +190,51 @@ tasks.register Home teleports exist on every spellbook but only the standard-book variant is represented by
+ * {@link MagicAction}. The active spellbook and cooldown are planner requirements; at execution time
+ * the exact visible widget is the authoritative capability check.
+ * This is the direct answer to "can I walk through that door now", and it is deliberately not
+ * {@link #isTileReachable}: that runs a BFS, so a shut door with a long way round it still reports
+ * the far tile as reachable, and it costs a whole scene search. This reads one flag.
+ *
+ * Answers {@code false} for anything it cannot decide — off-scene, an instance (raw coordinates
+ * make the scene conversion unreliable), or a plane other than the one loaded. Callers use it to
+ * release early, so an unknown must never read as "open".
+ *
+ * @return true only when the step is known to be unobstructed
+ */
+ public static boolean isEdgePassable(WorldPoint from, WorldPoint to) {
+ return runClientReadBoolean(() -> isEdgePassableInternal(from, to));
+ }
+
+ /**
+ * Why the last {@link #isEdgePassable} call answered as it did. A bare {@code false} is ambiguous
+ * between "the door is shut" and "this could not be decided", and callers that release a wait on
+ * {@code true} behave very differently depending on which it was.
+ */
+ private static volatile String lastEdgeDecision = "-";
+
+ /** @see #lastEdgeDecision */
+ public static String lastEdgeDecision() {
+ return lastEdgeDecision;
+ }
+
+ private static boolean isEdgePassableInternal(WorldPoint from, WorldPoint to) {
+ if (from == null || to == null || from.getPlane() != to.getPlane()) {
+ lastEdgeDecision = "bad-args";
+ return false;
+ }
+
+ final int dx = to.getX() - from.getX();
+ final int dy = to.getY() - from.getY();
+ if (dx == 0 && dy == 0) {
+ lastEdgeDecision = "same-tile";
+ return true;
+ }
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
+ lastEdgeDecision = "not-adjacent";
+ return false;
+ }
+
+ final WorldView wv = Microbot.getClient().getTopLevelWorldView();
+ if (wv == null) {
+ lastEdgeDecision = "no-worldview";
+ return false;
+ }
+ if (wv.getPlane() != from.getPlane()) {
+ lastEdgeDecision = "plane-not-loaded";
+ return false;
+ }
+ // Instance scenes repeat template chunks, so world -> scene by base offset is wrong there.
+ if (wv.getScene() != null && wv.getScene().isInstance()) {
+ lastEdgeDecision = "instance";
+ return false;
+ }
+
+ final int[][] flags = getFlagsInternal();
+ if (flags == null) {
+ lastEdgeDecision = "no-flags";
+ return false;
+ }
+
+ final int fx = from.getX() - Microbot.getClient().getBaseX();
+ final int fy = from.getY() - Microbot.getClient().getBaseY();
+ final int tx = fx + dx;
+ final int ty = fy + dy;
+ if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) {
+ lastEdgeDecision = "off-scene";
+ return false;
+ }
+
+ boolean allowed = isStepAllowed(flags, fx, fy, dx, dy);
+ lastEdgeDecision = allowed ? "open" : "blocked";
+ return allowed;
+ }
+
+ /**
+ * The collision rule alone, with no client reads: is a single {@code (dx, dy)} step out of
+ * {@code (fx, fy)} unobstructed by these flags? Split out so the cardinal/diagonal rules are
+ * covered by a decision table rather than only by a live client.
+ */
+ static boolean isStepAllowed(int[][] flags, int fx, int fy, int dx, int dy) {
+ if (dx == 0 && dy == 0) return true;
+ final int tx = fx + dx;
+ final int ty = fy + dy;
+
+ if ((flags[tx][ty] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) != 0) return false;
+
+ if (dx == 0 || dy == 0) {
+ return (flags[fx][fy] & cardinalBlockFlag(dx, dy)) == 0;
+ }
+ // Diagonal: both cardinal components must be clear, and so must the two tiles cut through —
+ // the same rule the reachability search uses for corners.
+ return (flags[fx][fy] & cardinalBlockFlag(dx, 0)) == 0
+ && (flags[fx][fy] & cardinalBlockFlag(0, dy)) == 0
+ && (flags[tx][fy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(0, dy))) == 0
+ && (flags[fx][ty] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(dx, 0))) == 0;
+ }
+
+ private static int cardinalBlockFlag(int dx, int dy) {
+ if (dx > 0) return CollisionDataFlag.BLOCK_MOVEMENT_EAST;
+ if (dx < 0) return CollisionDataFlag.BLOCK_MOVEMENT_WEST;
+ if (dy > 0) return CollisionDataFlag.BLOCK_MOVEMENT_NORTH;
+ return CollisionDataFlag.BLOCK_MOVEMENT_SOUTH;
+ }
+
private static boolean isTileReachableInternal(WorldPoint targetPoint) {
if (targetPoint == null) return false;
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicy.java
new file mode 100644
index 00000000000..c5da8b38eff
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicy.java
@@ -0,0 +1,13 @@
+package net.runelite.client.plugins.microbot.util.walker;
+
+/** Pure decision used after the walker gives a transient logged-out sample a short grace window. */
+final class LoginStabilityPolicy {
+ private LoginStabilityPolicy() {
+ }
+
+ static boolean shouldExit(boolean initiallyLoggedIn,
+ boolean loggedInAfterGrace,
+ boolean walkCancelled) {
+ return !walkCancelled && !initiallyLoggedIn && !loggedInAfterGrace;
+ }
+}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java
new file mode 100644
index 00000000000..354a6f22224
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java
@@ -0,0 +1,152 @@
+package net.runelite.client.plugins.microbot.util.walker;
+
+import net.runelite.api.coords.WorldPoint;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Immutable, planner-independent snapshot of the route currently owned by the walker.
+ *
+ * The generation identifies one published calculation without exposing the concrete planner.
+ * Callers that wait asynchronously can therefore reject a result when a newer route replaced the
+ * one they observed.
Contract. This is a thin, 1:1 delegation. Every method here forwards verbatim to - * the corresponding {@link ShortestPathPlugin} static member catalogued in the Stage 1 sweep. It - * intentionally introduces no behaviour change and holds no state of its own. The - * value types it returns ({@link Pathfinder}, {@link PathfinderConfig}, {@link Transport}, - * {@code TransportType}, {@code WorldPointUtil}) are treated as the stable Microbot-facing path API - * and are deliberately not re-wrapped — they are pure data / pure functions.
+ *Contract. Legacy methods here retain thin delegation to the corresponding + * {@link ShortestPathPlugin} static member. New planning operations accept Microbot-owned immutable + * request/result values and keep refresh, construction, cancellation, executor ownership and temporary + * policy changes behind this seam. The + * legacy concrete accessors for {@link Pathfinder}, {@link PathfinderConfig} and {@link Transport} + * remain for binary compatibility, but new operations expose immutable route values or narrow named + * queries. That makes this a compatibility seam, not yet the final stable planning contract: active route + * state, lifecycle, synchronous queries and migrated catalog consumers no longer require concrete planner + * types outside this class.
* *Migration status. Stage 3 is complete: every consumer under {@code microbot/util/} now * routes through this facade, so the only remaining references to {@link ShortestPathPlugin}'s - * static members outside the {@code shortestpath} package are the delegations below. That invariant - * is greppable, and is what keeps the blast radius of an upstream backport confined to this class: - *
grep -rn "ShortestPathPlugin\." microbot/util/ # expect hits in Rs2PathApi only- * {@link ShortestPathPlugin}'s members remain public and binary-compatible for out-of-tree callers. - * Do not add logic here — if a call needs new behaviour, put it behind the plugin and expose it - * through a matching delegate. + * static members outside the {@code shortestpath} package are the delegations below, plus the plugin + * class literal used by {@code MicrobotPluginChoice}. That invariant is enforced by + * {@code scripts/check-shortest-path-boundary.py}. + * {@link ShortestPathPlugin}'s members remain public and binary-compatible for out-of-tree callers. */ public final class Rs2PathApi { + private static volatile Pathfinder activeRouteSnapshotSource; + private static volatile Rs2RouteResult activeRouteSnapshot; + private static final AtomicLong activeRouteGeneration = new AtomicLong(); + private static volatile boolean activeRouteComparisonEligible; + private static final Object shadowEvidenceMutex = new Object(); + private static final long shadowEvidenceStartedAtEpochMillis = System.currentTimeMillis(); + private static final long[][] shadowCoverageOutcomes = new long + [Rs2PlannerShadowContext.Coverage.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static final long[][] shadowTransportExecutorOutcomes = new long + [Rs2TransportExecutor.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static final long[][] shadowTransportTypeOutcomes = new long + [Rs2TransportType.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static long shadowGeneration; + private static long shadowSubmitted; + private static long shadowCompleted; + private static long shadowMatches; + private static long shadowDivergences; + private static long shadowFailures; + private static long shadowStaleResults; + private static long shadowDiscarded; + private static long shadowRouteShapeDifferences; + private static long upstreamCanarySelections; + private static long localFallbackDivergences; + private static long localFallbackFailures; + private static long shadowWalkerArrivals; + private static long shadowWalkerUnreachable; + private static long shadowWalkerExits; + private static long shadowRecoveryArrivals; + private static long shadowRecoveryUnreachable; + private static long shadowRecoveryExits; + private static long canaryPlanningSamples; + private static long canaryPlanningNanosTotal; + private static long canaryPlanningNanosMax; + private static long canaryLocalSearchNanosTotal; + private static long canaryLocalSearchNanosMax; + private static long canaryUpstreamSearchSamples; + private static long canaryUpstreamSearchNanosTotal; + private static long canaryUpstreamSearchNanosMax; + private static volatile Rs2PlannerShadowComparison lastShadowComparison; + private static volatile Rs2PlannerShadowComparison lastRouteShapeDifference; + private static volatile Rs2PlannerShadowComparison lastDivergence; + private static volatile Rs2PlannerShadowComparison lastPlannerFailure; + private static final ThreadPoolExecutor SHADOW_EXECUTOR = new ThreadPoolExecutor( + 1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(1), + new ThreadFactoryBuilder().setDaemon(true) + .setNameFormat("microbot-upstream-planner-shadow-%d").build(), + (command, executor) -> + { + if (executor.isShutdown()) + { + recordShadowDiscarded(); + return; + } + Runnable discarded = executor.getQueue().poll(); + if (discarded != null) + { + recordShadowDiscarded(); + } + if (!executor.getQueue().offer(command)) + { + recordShadowDiscarded(); + } + }); + private static final Map
The lifecycle mutex prevents a route replacement halfway through the copy. The pathfinder may + * continue improving its partial path while calculating, but the returned lists cannot change under + * the caller.
+ */ + public static Rs2ActiveRouteStatus getActiveRouteStatus() + { + synchronized (getPathfinderMutex()) + { + long generation = activeRouteGeneration.get(); + Pathfinder source = getPathfinder(); + if (source == null) + { + return Rs2ActiveRouteStatus.absent(generation); + } + + Future> activeFuture = getPathfinderFuture(); + boolean selectionComplete = activeFuture == null || activeFuture.isDone(); + boolean ready = source.isDone() && selectionComplete; + ListThe cave preference preserves the existing walker policy: calculate both the normal route and + * a walking-only route, then prefer walking when it reaches any requested target and is not longer. + * Non-cave planning remains asynchronous on the owned single-thread executor.
+ */ + public static boolean restartActiveRoute( + Rs2RouteRequest request, boolean preferWalkingOnly, int reachedDistance) + { + return restartActiveRoute( + request, preferWalkingOnly, reachedDistance, + Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } + + /** + * Restart an active route with coordinate-free invocation metadata for shadow evidence. + */ + public static boolean restartActiveRoute( + Rs2RouteRequest request, + boolean preferWalkingOnly, + int reachedDistance, + Rs2PlannerShadowContext.Invocation invocation) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(invocation, "invocation"); + if (invocation == Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY) + { + throw new IllegalArgumentException( + "an active route cannot use the synchronous-query shadow invocation"); + } + if (reachedDistance < 0) + { + throw new IllegalArgumentException("reachedDistance must be non-negative"); + } + if (request.getUseBankItems() != null) + { + throw new IllegalArgumentException( + "active asynchronous routes cannot use a temporary bank-item policy"); + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + + synchronized (getPathfinderMutex()) + { + cancelAndClearActiveRouteLocked(); + if (shouldRefresh(request, config)) + { + config.refresh(request.getRefreshTarget()); + } + + if (preferWalkingOnly) + { + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + boolean comparisonEnabled = plannerMode.comparisonEnabled(); + Rs2RouteRequest normalRequest = comparisonEnabled + ? resolvePolicy(request, config) : null; + Rs2PlanningSnapshot normalSnapshot = comparisonEnabled + ? resolvePlanningSnapshot(normalRequest, config) : null; + long canaryPlanningStarted = System.nanoTime(); + Pathfinder normal = runLocalPlanner(config, request); + Pathfinder walkingOnly; + Rs2RouteRequest walkingRequest = null; + Rs2PlanningSnapshot walkingSnapshot = null; + try + { + config.setIgnoreTeleportAndItems(true); + if (comparisonEnabled) + { + walkingRequest = resolvePolicy(request, config); + walkingSnapshot = resolvePlanningSnapshot(walkingRequest, config); + } + walkingOnly = runLocalPlanner(config, request); + } + finally + { + config.setIgnoreTeleportAndItems(false); + } + Pathfinder selected = selectCaveRoute( + normal, walkingOnly, request.getTargets(), reachedDistance); + setPathfinder(selected); + boolean walkingSelected = selected == walkingOnly; + Rs2RouteRequest selectedRequest = walkingSelected ? walkingRequest : normalRequest; + Rs2PlanningSnapshot selectedSnapshot = walkingSelected ? walkingSnapshot : normalSnapshot; + activeRouteComparisonEligible = plannerMode == PlannerSelectionMode.SHADOW + || isF2pCanary(plannerMode, selectedRequest); + if (isF2pCanary(plannerMode, selectedRequest)) + { + Rs2RouteResult local = snapshot(selected, activeSearchNanos(selected)); + PlannerEvaluation evaluation = evaluateUpstream( + selectedRequest, selectedSnapshot, local, invocation, walkingSelected); + Pathfinder materialized = null; + if (shouldSelectUpstream(evaluation.comparison)) + { + try + { + materialized = materializeUpstreamRoute( + evaluation.candidate, config); + } + catch (RuntimeException failure) + { + evaluation = evaluation.materializationFailed(failure); + } + } + if (materialized != null) + { + replaceActivePathfinderLocked(selected, materialized); + } + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + combinedSearchNanos(normal, walkingOnly)); + } + else if (plannerMode == PlannerSelectionMode.SHADOW) + { + submitUpstreamShadow( + selectedRequest, + selectedSnapshot, + snapshot(selected, activeSearchNanos(selected)), + invocation, + walkingSelected); + } + return true; + } + + ExecutorService executor = getPathfindingExecutor(); + if (executor == null || executor.isShutdown()) + { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("shortest-path-%d") + .build(); + executor = Executors.newSingleThreadExecutor(threadFactory); + setPathfindingExecutor(executor); + } + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + boolean comparisonEnabled = plannerMode.comparisonEnabled(); + Rs2RouteRequest comparisonRequest = comparisonEnabled + ? resolvePolicy(request, config) : null; + Rs2PlanningSnapshot comparisonSnapshot = comparisonEnabled + ? resolvePlanningSnapshot(comparisonRequest, config) : null; + Pathfinder active = new Pathfinder(config, request.getStart(), request.getTargets()); + setPathfinder(active); + activeRouteComparisonEligible = plannerMode == PlannerSelectionMode.SHADOW + || isF2pCanary(plannerMode, comparisonRequest); + long routeGeneration = activeRouteGeneration.get(); + long canaryPlanningStarted = System.nanoTime(); + setPathfinderFuture(executor.submit(() -> + { + active.run(); + if (!comparisonEnabled) + { + return; + } + if (isF2pCanary(plannerMode, comparisonRequest)) + { + runActiveCanarySelection( + active, routeGeneration, comparisonRequest, comparisonSnapshot, + config, invocation, canaryPlanningStarted); + return; + } + if (plannerMode != PlannerSelectionMode.SHADOW) + { + return; + } + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active + || activeRouteGeneration.get() != routeGeneration) + { + return; + } + submitUpstreamShadow( + comparisonRequest, + comparisonSnapshot, + snapshot(active, activeSearchNanos(active)), + invocation, + false); + } + })); + return true; + } + } + + private static Pathfinder runLocalPlanner(PathfinderConfig config, Rs2RouteRequest request) + { + Pathfinder pathfinder = new Pathfinder(config, request.getStart(), request.getTargets()); + pathfinder.run(); + return pathfinder; + } + + static boolean isF2pCanary( + PlannerSelectionMode plannerMode, Rs2RouteRequest request) + { + return plannerMode != null + && plannerMode.f2pCanaryEnabled() + && request != null + && request.getPolicy().map(policy -> !policy.isMembersWorld()).orElse(false); + } + + private static void runActiveCanarySelection( + Pathfinder active, + long routeGeneration, + Rs2RouteRequest request, + Rs2PlanningSnapshot planningSnapshot, + PathfinderConfig config, + Rs2PlannerShadowContext.Invocation invocation, + long canaryPlanningStarted) + { + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active || activeRouteGeneration.get() != routeGeneration) + { + return; + } + } + + Rs2RouteResult local = snapshot(active, activeSearchNanos(active)); + PlannerEvaluation evaluation = evaluateUpstream( + request, planningSnapshot, local, invocation, false); + Pathfinder materialized = null; + if (shouldSelectUpstream(evaluation.comparison)) + { + try + { + materialized = materializeUpstreamRoute(evaluation.candidate, config); + } + catch (RuntimeException failure) + { + evaluation = evaluation.materializationFailed(failure); + } + } + + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active || activeRouteGeneration.get() != routeGeneration) + { + recordPlannerComparison(evaluation); + return; + } + if (materialized != null) + { + replaceActivePathfinderLocked(active, materialized); + } + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + evaluation.comparison.getLocalSearchNanos()); + } + } + + /** Package-private selection seam for cave lifecycle policy regressions. */ + static Pathfinder selectCaveRoute( + Pathfinder normal, + Pathfinder walkingOnly, + SetThe shared configuration and its transport snapshots are mutable, so synchronous searches are + * serialized with walker start/cancel transitions. A request-level bank-item policy is temporary and + * always restored, including when refresh or pathfinding fails. This operation must run on a script or + * worker thread because refresh and search are blocking.
+ */ + public static Rs2RouteResult plan(Rs2RouteRequest request) + { + return calculate(request); + } + + private static Rs2RouteResult calculate(Rs2RouteRequest request) + { + Objects.requireNonNull(request, "request"); + if (Microbot.getClientThread() != null && Microbot.getClientThread().isClientThread()) + { + throw new IllegalStateException("synchronous route planning must not run on the client thread"); + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + throw new IllegalStateException("shortest-path configuration is not initialized"); + } + + synchronized (getPathfinderMutex()) + { + Boolean requestedBankItems = request.getUseBankItems(); + boolean originalUseBankItems = config.isUseBankItems(); + boolean bankPolicyChanged = requestedBankItems != null + && requestedBankItems != originalUseBankItems; + try + { + if (bankPolicyChanged) + { + config.setUseBankItems(requestedBankItems); + } + if (shouldRefresh(request, config)) + { + config.refresh(request.getRefreshTarget()); + } + Rs2RouteRequest resolved = resolvePolicy(request, config); + Rs2PlanningSnapshot snapshot = resolvePlanningSnapshot(resolved, config); + long canaryPlanningStarted = System.nanoTime(); + Rs2RouteResult local = localPlanner(config).plan(resolved, snapshot); + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + if (isF2pCanary(plannerMode, resolved)) + { + PlannerEvaluation evaluation = evaluateUpstream( + resolved, + snapshot, + local, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false); + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + evaluation.comparison.getLocalSearchNanos()); + return shouldSelectUpstream(evaluation.comparison) + ? evaluation.candidate : local; + } + if (plannerMode == PlannerSelectionMode.SHADOW) + { + submitUpstreamShadow( + resolved, + snapshot, + local, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false); + } + return local; + } + finally + { + if (bankPolicyChanged) + { + config.setUseBankItems(originalUseBankItems); + config.refresh(request.getRefreshTarget()); + } + } + } + } + + private static boolean shouldRefresh(Rs2RouteRequest request, PathfinderConfig config) + { + if (request.getUseBankItems() != null + || request.getRefreshPolicy() == Rs2RouteRequest.RefreshPolicy.ALWAYS) + { + return true; + } + return request.getRefreshPolicy() == Rs2RouteRequest.RefreshPolicy.IF_TRANSPORTS_EMPTY + && config.getTransports().isEmpty(); + } + + /** Package-private calculation seam for headless tests with an isolated configuration. */ + static Rs2RouteResult planWithConfig(Rs2RouteRequest request, PathfinderConfig config) + { + Rs2RouteRequest resolved = resolvePolicy(request, config); + return localPlanner(config).plan(resolved, resolvePlanningSnapshot(resolved, config)); + } + + static Rs2RoutePlanner localPlanner(PathfinderConfig config) + { + return new LocalRoutePlanner(config); + } + + static Rs2RoutePlanner upstreamPlanner() + { + UpstreamRoutePlanner delegate = new UpstreamRoutePlanner(); + if (Boolean.getBoolean("microbot.test.mode") + && Boolean.getBoolean("microbot.test.walker.forceUpstreamPlannerFailure")) + { + return new Rs2RoutePlanner() + { + @Override + public String getEngineId() + { + return delegate.getEngineId(); + } + + @Override + public Rs2RouteResult plan( + Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + throw new IllegalStateException("synthetic upstream rollout failure"); + } + }; + } + return delegate; + } + + public static OptionalUnlike {@link #getLastShadowComparison()}, this process-lifetime diagnostic survives active-route + * teardown so a settled harness snapshot can still classify a non-zero aggregate shape-difference count.
+ */ + public static OptionalThis process-lifetime diagnostic deliberately survives active-route teardown. It contains only the + * coordinate-free comparison summary exposed by the shadow endpoint, so a harness can explain a non-zero + * divergence counter without retaining the player's route.
+ */ + public static OptionalThe serialized comparison exposes only the exception class name, never its message or route data.
+ */ + public static OptionalThis is captured when a blocking walk first consumes a generation-matched ready route because arrival + * normally clears the active route before the terminal outcome is recorded. In F2P-canary mode it is false + * for members-policy routes, even though the process-wide planner mode still has comparison capability + * enabled.
+ */ + static boolean isActiveRouteComparisonEligible() + { + return activeRouteComparisonEligible; + } + + /** Generation-matched form used when a walker consumes a previously captured active-route snapshot. */ + static boolean isActiveRouteComparisonEligible(long routeGeneration) + { + synchronized (getPathfinderMutex()) + { + return activeRouteGeneration.get() == routeGeneration + && activeRouteComparisonEligible; + } + } + + /** Record one blocking walk's terminal result for a route that actually entered planner comparison. */ + static void recordShadowWalkerOutcome( + WalkerState state, boolean recoveryTriggered, boolean comparisonEligible) + { + Objects.requireNonNull(state, "state"); + if (!comparisonEligible || state == WalkerState.MOVING) + { + return; + } + synchronized (shadowEvidenceMutex) + { + switch (state) + { + case ARRIVED: shadowWalkerArrivals++; break; + case UNREACHABLE: shadowWalkerUnreachable++; break; + case EXIT: shadowWalkerExits++; break; + default: throw new IllegalStateException("unhandled walker state " + state); + } + if (recoveryTriggered) + { + switch (state) + { + case ARRIVED: shadowRecoveryArrivals++; break; + case UNREACHABLE: shadowRecoveryUnreachable++; break; + case EXIT: shadowRecoveryExits++; break; + default: throw new IllegalStateException("unhandled walker state " + state); + } + } + } + } + + private static void recordShadowDiscarded() + { + synchronized (shadowEvidenceMutex) + { + shadowDiscarded++; + } + } + + private static void submitUpstreamShadow( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local, + Rs2PlannerShadowContext.Invocation invocation, + boolean walkingOnlySelected) + { + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + invocation, walkingOnlySelected, request, local); + PlannerComparisonTicket ticket = beginPlannerComparison(context); + SHADOW_EXECUTOR.execute(() -> recordPlannerComparison( + evaluateUpstream(ticket, request, snapshot, local))); + } + + private static PlannerEvaluation evaluateUpstream( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local, + Rs2PlannerShadowContext.Invocation invocation, + boolean walkingOnlySelected) + { + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + invocation, walkingOnlySelected, request, local); + return evaluateUpstream(beginPlannerComparison(context), request, snapshot, local); + } + + private static PlannerComparisonTicket beginPlannerComparison( + Rs2PlannerShadowContext context) + { + synchronized (shadowEvidenceMutex) + { + long generation = ++shadowGeneration; + shadowSubmitted++; + lastShadowComparison = null; + return new PlannerComparisonTicket(generation, context); + } + } + + private static PlannerEvaluation evaluateUpstream( + PlannerComparisonTicket ticket, + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local) + { + Rs2RoutePlanner planner = upstreamPlanner(); + try + { + Rs2RouteResult candidate = planner.plan(request, snapshot); + return new PlannerEvaluation( + ticket, + local, + candidate, + Rs2PlannerShadowComparison.compare( + planner.getEngineId(), ticket.context, local, candidate)); + } + catch (RuntimeException failure) + { + return new PlannerEvaluation( + ticket, + local, + null, + Rs2PlannerShadowComparison.failed( + planner.getEngineId(), ticket.context, local, failure)); + } + } + + private static void recordPlannerComparison(PlannerEvaluation evaluation) + { + Rs2PlannerShadowComparison comparison = evaluation.comparison; + synchronized (shadowEvidenceMutex) + { + switch (comparison.getStatus()) + { + case MATCH: shadowMatches++; break; + case DIVERGENCE: + shadowDivergences++; + lastDivergence = comparison; + break; + case FAILED: + shadowFailures++; + lastPlannerFailure = comparison; + break; + default: throw new IllegalStateException( + "unhandled shadow comparison status " + comparison.getStatus()); + } + for (Rs2PlannerShadowContext.Coverage value + : comparison.getContext().getCoverage()) + { + shadowCoverageOutcomes[value.ordinal()][comparison.getStatus().ordinal()]++; + } + for (Rs2TransportExecutor value + : comparison.getContext().getTransportExecutors()) + { + shadowTransportExecutorOutcomes[value.ordinal()] + [comparison.getStatus().ordinal()]++; + } + for (Rs2TransportType value : comparison.getContext().getTransportTypes()) + { + shadowTransportTypeOutcomes[value.ordinal()] + [comparison.getStatus().ordinal()]++; + } + if (comparison.getStatus() != Rs2PlannerShadowComparison.Status.FAILED + && !comparison.isPathMatches()) + { + shadowRouteShapeDifferences++; + lastRouteShapeDifference = comparison; + } + shadowCompleted++; + if (shadowGeneration == evaluation.ticket.generation) + { + lastShadowComparison = comparison; + } + else + { + shadowStaleResults++; + } + } + } + + static void recordCanaryOutcome( + Rs2PlannerShadowComparison comparison, + long planningNanos, + long localPlanningNanos) + { + synchronized (shadowEvidenceMutex) + { + if (planningNanos < 0L || localPlanningNanos < 0L) + { + throw new IllegalArgumentException( + "canary planning and local search durations must be available"); + } + switch (comparison.getStatus()) + { + case MATCH: upstreamCanarySelections++; break; + case DIVERGENCE: localFallbackDivergences++; break; + case FAILED: localFallbackFailures++; break; + default: throw new IllegalStateException( + "unhandled canary comparison status " + comparison.getStatus()); + } + canaryPlanningSamples++; + canaryPlanningNanosTotal = saturatedAdd( + canaryPlanningNanosTotal, planningNanos); + canaryPlanningNanosMax = Math.max(canaryPlanningNanosMax, planningNanos); + canaryLocalSearchNanosTotal = saturatedAdd( + canaryLocalSearchNanosTotal, localPlanningNanos); + canaryLocalSearchNanosMax = Math.max( + canaryLocalSearchNanosMax, localPlanningNanos); + long upstreamSearchNanos = comparison.getShadowSearchNanos(); + if (upstreamSearchNanos >= 0L) + { + canaryUpstreamSearchSamples++; + canaryUpstreamSearchNanosTotal = saturatedAdd( + canaryUpstreamSearchNanosTotal, upstreamSearchNanos); + canaryUpstreamSearchNanosMax = Math.max( + canaryUpstreamSearchNanosMax, upstreamSearchNanos); + } + } + } + + private static long elapsedNanos(long started) + { + return Math.max(0L, System.nanoTime() - started); + } + + private static long combinedSearchNanos(Pathfinder... pathfinders) + { + long total = 0L; + for (Pathfinder pathfinder : pathfinders) + { + long searchNanos = activeSearchNanos(pathfinder); + if (searchNanos < 0L) + { + return Rs2RouteMetrics.UNAVAILABLE; + } + total = saturatedAdd(total, searchNanos); + } + return total; + } + + private static long saturatedAdd(long left, long right) + { + if (right > Long.MAX_VALUE - left) + { + return Long.MAX_VALUE; + } + return left + right; + } + + /** A route-shape-only difference is a semantic match and remains eligible for the canary. */ + static boolean shouldSelectUpstream(Rs2PlannerShadowComparison comparison) + { + return comparison != null + && comparison.getStatus() == Rs2PlannerShadowComparison.Status.MATCH; + } + + static Rs2RouteRequest resolvePolicy( + Rs2RouteRequest request, PathfinderConfig config) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(config, "config"); + EnumSetThe source pathfinder identity is checked on every call. Replanning therefore invalidates the + * cached value even if the new route happens to contain the same points. In-flight pathfinders do not + * publish partial executor contracts.
+ */ + public static OptionalThe immutable edge and its executor are the engine-independent contract. The local transport is + * an opaque implementation payload for the existing handlers (not a public planning value); POH in + * particular carries executable subtype behavior. It is always the exact object selected by search, + * never a catalog lookup or origin/destination rematch.
+ */ + static final class ActiveTransportSelection { - return ShortestPathPlugin.getPathfinder(); + private final int pathIndex; + private final Rs2TransportEdge edge; + private final Transport localExecutionTransport; + + private ActiveTransportSelection(int pathIndex, Rs2TransportEdge edge, Transport localExecutionTransport) + { + this.pathIndex = pathIndex; + this.edge = edge; + this.localExecutionTransport = localExecutionTransport; + } + + int getPathIndex() { return pathIndex; } + Rs2TransportEdge getEdge() { return edge; } + Rs2TransportExecutor getExecutor() { return edge.getExecutor(); } + Transport getLocalExecutionTransport() { return localExecutionTransport; } + boolean isExecutable() { return getExecutor() != Rs2TransportExecutor.UNSUPPORTED; } } - public static void setPathfinder(Pathfinder pathfinder) + static OptionalThis check deliberately abstains when the configuration, target or map region is absent. + * Instances and newly added regions must be left to the planner rather than rejected as blocked.
*/ + public static boolean hasWalkableTileWithin(WorldPoint target, int distance) + { + PathfinderConfig config = getPathfinderConfig(); + return hasWalkableTileWithin(config == null ? null : config.getMap(), target, distance); + } + + /** Package-private collision seam for deterministic headless tests. */ + static boolean hasWalkableTileWithin(CollisionMap map, WorldPoint target, int distance) + { + if (map == null || target == null) + { + return true; + } + if (!map.hasRegion(target.getX(), target.getY())) + { + return true; + } + int radius = Math.max(0, distance); + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + int x = target.getX() + dx; + int y = target.getY() + dy; + if (!map.hasRegion(x, y) || !map.isBlocked(x, y, target.getPlane())) + { + return true; + } + } + } + return false; + } + + /** Nearest mapped standable tile to {@code target} within {@code maxRadius}, or {@code null}. */ + public static WorldPoint nearestWalkableTile(WorldPoint target, int maxRadius) + { + PathfinderConfig config = getPathfinderConfig(); + return nearestWalkableTile(config == null ? null : config.getMap(), target, maxRadius); + } + + /** Package-private collision seam for deterministic headless tests. */ + static WorldPoint nearestWalkableTile(CollisionMap map, WorldPoint target, int maxRadius) + { + if (map == null || target == null) + { + return null; + } + for (int radius = 1; radius <= Math.max(0, maxRadius); radius++) + { + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + if (Math.max(Math.abs(dx), Math.abs(dy)) != radius) + { + continue; + } + int x = target.getX() + dx; + int y = target.getY() + dy; + if (map.hasRegion(x, y) && !map.isBlocked(x, y, target.getPlane())) + { + return new WorldPoint(x, y, target.getPlane()); + } + } + } + } + return null; + } + + /** Refresh transport and restriction policy without exposing the mutable configuration. */ + public static boolean refreshPlanningConfiguration() + { + return refreshPlanningConfiguration(null); + } + + /** Refresh transport and restriction policy for an optional route target. */ + public static boolean refreshPlanningConfiguration(WorldPoint target) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.refresh(target); + } + return true; + } + + /** Invalidate cached transport-policy snapshots so the next refresh observes external state. */ public static boolean invalidateTransportRefreshCache() { PathfinderConfig config = getPathfinderConfig(); @@ -120,6 +1742,120 @@ public static boolean invalidateTransportRefreshCache() return true; } + /** Record a stable walking edge failure in the planner's learned-block store. */ + public static boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + return config.learnBlockedEdge(origin, destination, reason); + } + } + + /** + * Remove a learned block again. For blocks whose cause is condition-scoped rather than stable — + * a door that refused to open for game-state reasons — the walker unlearns them at the next walk + * session start so a later walk under changed conditions (the Tithe Farm seed gate with seeds in + * the inventory) gets the door back. + */ + public static boolean unlearnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + return config.unlearnBlockedEdge(origin, destination, reason); + } + } + + /** Whether runtime recovery policy should avoid this dangerous-NPC adjacency tile. */ + public static boolean shouldAvoidDangerousTile(WorldPoint tile) + { + PathfinderConfig config = getPathfinderConfig(); + return config != null + && config.isAvoidDangerousNpcs() + && tile != null + && config.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(tile)); + } + + /** Whether the currently refreshed planning policy permits spirit-tree travel. */ + public static boolean isSpiritTreeTravelEnabled() + { + PathfinderConfig config = getPathfinderConfig(); + return config != null && config.isUseSpiritTrees(); + } + + /** Planner-consistent Wilderness classification without exposing its implementation class. */ + public static boolean isInWilderness(WorldPoint point) + { + return point != null && PathfinderConfig.isInWilderness(point); + } + + /** + * Whether {@code itemId} belongs to a currently known item-teleport requirement. + * + *An empty catalog is refreshed under the lifecycle mutex before it is inspected. Additional + * compatibility IDs cover items such as fairy-ring staves that are not ordinary teleport rows.
+ */ + public static boolean isTeleportItem(int itemId, int... additionalItemIds) + { + if (additionalItemIds != null) + { + for (int additionalItemId : additionalItemIds) + { + if (itemId == additionalItemId) + { + return true; + } + } + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + if (config.getAllTransports().isEmpty()) + { + config.refresh(); + } + return config.getAllTransports().values().stream() + .flatMap(Set::stream) + .filter(transport -> TransportType.isTeleport( + transport.getType(), transport.getOrigin())) + .flatMap(transport -> transport.getItemIdRequirements().stream()) + .flatMap(Set::stream) + .anyMatch(requiredItemId -> requiredItemId == itemId); + } + } + + /** + * Switch the shared active-route policy from bank visibility to inventory/equipment visibility + * after required transport items have been withdrawn, then rebuild the target-specific catalog. + */ + public static boolean prepareInventoryOnlyRoute(WorldPoint target) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.setUseBankItems(false); + config.refresh(target); + } + return true; + } + /** @return the shared pathfinder configuration (transports, restrictions, toggles). */ public static PathfinderConfig getPathfinderConfig() { @@ -191,7 +1927,115 @@ public static void setMarker(WorldMapPoint marker) // Transport data // ------------------------------------------------------------------ - /** @return the transport graph keyed by origin tile. */ + /** + * Whether the static catalog contains at least one transport keyed by {@code origin}. + * + *This is the planner-independent query used by recovery and obstacle classification. Callers + * that only need transport presence must not consume the mutable concrete catalog.
+ */ + public static boolean hasCatalogTransportOrigin(WorldPoint origin) + { + return hasCatalogTransportOrigin(ShortestPathPlugin.getTransports(), origin); + } + + static boolean hasCatalogTransportOrigin( + MapThis is intended for catalog classification (for example distinguishing a door row from a + * ladder), not route execution. Runtime execution must use the exact edge selected in the active + * route so ambiguous same-endpoint transports cannot be rematched incorrectly.
+ */ + public static ListThe planning duration starts when an active route is submitted (or immediately before a + * synchronous/cave local search) and ends only after comparison, fallback/selection and upstream-route + * materialization have completed. It therefore measures when a route can actually become executable, + * rather than adding two independently reported engine search times after the fact.
+ */ +public final class Rs2PlannerCanaryPerformanceStats +{ + private final long planningSamples; + private final long planningNanosTotal; + private final long planningNanosMax; + private final long localSearchNanosTotal; + private final long localSearchNanosMax; + private final long upstreamSearchSamples; + private final long upstreamSearchNanosTotal; + private final long upstreamSearchNanosMax; + + Rs2PlannerCanaryPerformanceStats( + long planningSamples, + long planningNanosTotal, + long planningNanosMax, + long localSearchNanosTotal, + long localSearchNanosMax, + long upstreamSearchSamples, + long upstreamSearchNanosTotal, + long upstreamSearchNanosMax) + { + this.planningSamples = requireNonNegative(planningSamples, "planningSamples"); + this.planningNanosTotal = requireNonNegative(planningNanosTotal, "planningNanosTotal"); + this.planningNanosMax = requireNonNegative(planningNanosMax, "planningNanosMax"); + this.localSearchNanosTotal = requireNonNegative( + localSearchNanosTotal, "localSearchNanosTotal"); + this.localSearchNanosMax = requireNonNegative(localSearchNanosMax, "localSearchNanosMax"); + this.upstreamSearchSamples = requireNonNegative( + upstreamSearchSamples, "upstreamSearchSamples"); + this.upstreamSearchNanosTotal = requireNonNegative( + upstreamSearchNanosTotal, "upstreamSearchNanosTotal"); + this.upstreamSearchNanosMax = requireNonNegative( + upstreamSearchNanosMax, "upstreamSearchNanosMax"); + if (upstreamSearchSamples > planningSamples) + { + throw new IllegalArgumentException( + "upstream search samples cannot exceed canary planning samples"); + } + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0L) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getPlanningSamples() { return planningSamples; } + public long getPlanningNanosTotal() { return planningNanosTotal; } + public long getPlanningNanosMax() { return planningNanosMax; } + public long getLocalSearchNanosTotal() { return localSearchNanosTotal; } + public long getLocalSearchNanosMax() { return localSearchNanosMax; } + public long getUpstreamSearchSamples() { return upstreamSearchSamples; } + public long getUpstreamSearchNanosTotal() { return upstreamSearchNanosTotal; } + public long getUpstreamSearchNanosMax() { return upstreamSearchNanosMax; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java new file mode 100644 index 00000000000..7b98e1d0531 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java @@ -0,0 +1,118 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Immutable summary of the latest completed production shadow comparison. */ +public final class Rs2PlannerShadowComparison +{ + public enum Status + { + MATCH, + DIVERGENCE, + FAILED + } + + private final Status status; + private final String shadowEngineId; + private final Rs2PlannerShadowContext context; + private final boolean terminationMatches; + private final boolean endpointMatches; + private final boolean costComparable; + private final boolean costMatches; + private final boolean selectedTransportsMatch; + private final boolean pathMatches; + private final long shadowSearchNanos; + private final long localSearchNanos; + private final String failureType; + + private Rs2PlannerShadowComparison( + Status status, + String shadowEngineId, + Rs2PlannerShadowContext context, + boolean terminationMatches, + boolean endpointMatches, + boolean costComparable, + boolean costMatches, + boolean selectedTransportsMatch, + boolean pathMatches, + long shadowSearchNanos, + long localSearchNanos, + String failureType) + { + this.status = status; + this.shadowEngineId = shadowEngineId; + this.context = context; + this.terminationMatches = terminationMatches; + this.endpointMatches = endpointMatches; + this.costComparable = costComparable; + this.costMatches = costMatches; + this.selectedTransportsMatch = selectedTransportsMatch; + this.pathMatches = pathMatches; + this.shadowSearchNanos = shadowSearchNanos; + this.localSearchNanos = localSearchNanos; + this.failureType = failureType; + } + + static Rs2PlannerShadowComparison compare( + String engineId, + Rs2PlannerShadowContext context, + Rs2RouteResult local, + Rs2RouteResult shadow) + { + boolean termination = local.getTerminationReason() == shadow.getTerminationReason(); + boolean endpoint = local.getEndpoint().equals(shadow.getEndpoint()); + boolean comparable = local.getMetrics().hasPathCost() && shadow.getMetrics().hasPathCost(); + boolean cost = comparable + && local.getMetrics().getPathCost() == shadow.getMetrics().getPathCost(); + boolean transports = sameSelectedTransports(local, shadow); + boolean path = local.getPath().equals(shadow.getPath()); + Status status = termination && endpoint && comparable && cost && transports + ? Status.MATCH : Status.DIVERGENCE; + return new Rs2PlannerShadowComparison( + status, engineId, context, termination, endpoint, comparable, cost, transports, path, + shadow.getMetrics().getSearchNanos(), local.getMetrics().getSearchNanos(), null); + } + + static Rs2PlannerShadowComparison failed( + String engineId, Rs2PlannerShadowContext context, Rs2RouteResult local, + RuntimeException failure) + { + return new Rs2PlannerShadowComparison( + Status.FAILED, engineId, context, false, false, false, false, false, false, + Rs2RouteMetrics.UNAVAILABLE, local.getMetrics().getSearchNanos(), + failure.getClass().getSimpleName()); + } + + private static boolean sameSelectedTransports(Rs2RouteResult local, Rs2RouteResult shadow) + { + java.util.ListThe static collision archive is pinned separately and shared by both engines. This value owns the + * per-search overlays and already-filtered exact transport catalog, so an engine never reads mutable + * Microbot plugin state while searching.
+ */ +public final class Rs2PlanningSnapshot +{ + @FunctionalInterface + public interface CollisionOverride + { + /** Known north/east edge value, or {@code null} to fall back to pinned static collision. */ + Boolean edge(int x, int y, int plane, int flag); + } + + private static final int DANGEROUS_TILE_PENALTY = 100; + private static final CollisionOverride NO_COLLISION_OVERRIDE = (x, y, plane, flag) -> null; + + private final Rs2RoutePolicy policy; + private final ListA value of {@link #UNAVAILABLE} means an engine cannot expose that measurement without + * changing its search contract. Keeping that distinction explicit prevents the comparison harness + * from silently treating missing data as zero.
+ */ +public final class Rs2RouteMetrics +{ + public static final long UNAVAILABLE = -1L; + + private final long searchNanos; + private final long pathCost; + private final long nodesChecked; + private final long transportsChecked; + private final long liveCollisionEdgesChecked; + + Rs2RouteMetrics( + long searchNanos, + long pathCost, + long nodesChecked, + long transportsChecked) + { + this(searchNanos, pathCost, nodesChecked, transportsChecked, UNAVAILABLE); + } + + Rs2RouteMetrics( + long searchNanos, + long pathCost, + long nodesChecked, + long transportsChecked, + long liveCollisionEdgesChecked) + { + validateOptionalMetric("searchNanos", searchNanos); + validateOptionalMetric("pathCost", pathCost); + validateOptionalMetric("nodesChecked", nodesChecked); + validateOptionalMetric("transportsChecked", transportsChecked); + validateOptionalMetric("liveCollisionEdgesChecked", liveCollisionEdgesChecked); + this.searchNanos = searchNanos; + this.pathCost = pathCost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; + } + + private static void validateOptionalMetric(String name, long value) + { + if (value < 0 && value != UNAVAILABLE) + { + throw new IllegalArgumentException(name + " must be non-negative or UNAVAILABLE"); + } + } + + public long getSearchNanos() + { + return searchNanos; + } + + public boolean hasSearchNanos() + { + return searchNanos != UNAVAILABLE; + } + + public long getPathCost() + { + return pathCost; + } + + public boolean hasPathCost() + { + return pathCost != UNAVAILABLE; + } + + public long getNodesChecked() + { + return nodesChecked; + } + + public boolean hasNodesChecked() + { + return nodesChecked != UNAVAILABLE; + } + + public long getTransportsChecked() + { + return transportsChecked; + } + + public boolean hasTransportsChecked() + { + return transportsChecked != UNAVAILABLE; + } + + public long getLiveCollisionEdgesChecked() + { + return liveCollisionEdgesChecked; + } + + public boolean hasLiveCollisionEdgesChecked() + { + return liveCollisionEdgesChecked != UNAVAILABLE; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java new file mode 100644 index 00000000000..1fe39222da6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java @@ -0,0 +1,14 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Interchangeable route-engine boundary used after Microbot resolves an immutable request policy. */ +public interface Rs2RoutePlanner +{ + /** Stable diagnostic id such as {@code microbot-local} or a pinned upstream revision. */ + String getEngineId(); + + /** + * Calculate one route. Implementations must reject requests without a resolved policy rather than + * consulting mutable Microbot or plugin globals. + */ + Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot); +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java new file mode 100644 index 00000000000..379886f3b0a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java @@ -0,0 +1,108 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable, engine-neutral policy resolved for one route calculation. + * + *Graph data and the engine's static collision representation remain engine inputs; every mutable + * Microbot routing choice that can change admission or search behavior is copied here before dispatch. + * An upstream adapter must consume this value rather than reading {@code ShortestPathPlugin} globals.
+ */ +public final class Rs2RoutePolicy +{ + public enum TeleportationItemMode + { + NONE, + INVENTORY, + INVENTORY_NON_CONSUMABLE + } + + private final boolean useBankItems; + private final boolean avoidWilderness; + private final boolean avoidDangerousNpcs; + private final boolean ignoreTeleportAndItems; + private final boolean teleportsDisabled; + private final boolean membersWorld; + private final boolean liveCollisionEnabled; + private final long calculationCutoffMillis; + private final int distanceBeforeUsingTeleport; + private final TeleportationItemMode teleportationItemMode; + private final SetThe request deliberately describes policy without exposing {@code PathfinderConfig}. New + * synchronous planning consumers should use this value through {@link Rs2PathApi#plan(Rs2RouteRequest)} + * instead of constructing a shortest-path {@code Pathfinder} directly.
+ */ +public final class Rs2RouteRequest +{ + /** Caller-owned workflow intent used only to classify planner evidence. */ + public enum Purpose + { + GENERAL, + BANK_ROUTE_DIRECT, + BANK_ROUTE_TO_BANK, + BANK_ROUTE_FROM_BANK + } + + public enum RefreshPolicy + { + NEVER, + IF_TRANSPORTS_EMPTY, + ALWAYS + } + + private final WorldPoint start; + private final SetWithdrawals and equipment changes are one contract: a route that depends on a banked staff or + * tome is not prepared until the item is both withdrawn and equipped. An unsatisfiable loadout is + * distinct from an empty loadout so callers cannot mistake a missing bank item for "nothing to do".
+ */ +public final class Rs2TransportLoadout +{ + private static final Rs2TransportLoadout EMPTY = + new Rs2TransportLoadout(Collections.emptyMap(), Collections.emptyList(), true); + private static final Rs2TransportLoadout UNAVAILABLE = + new Rs2TransportLoadout(Collections.emptyMap(), Collections.emptyList(), false); + + private final MapThe superset includes names used by both the local planner and the tracked upstream planner so + * an adapter can normalize either engine without exposing its enum.
+ */ +public enum Rs2TransportType +{ + TRANSPORT, + AGILITY_SHORTCUT, + GRAPPLE_SHORTCUT, + BOAT, + CANOE, + CHARTER_SHIP, + SHIP, + FAIRY_RING, + QUETZAL, + QUETZAL_WHISTLE, + GNOME_GLIDER, + MINECART, + POH, + SPIRIT_TREE, + TELEPORTATION_BOX, + TELEPORTATION_LEVER, + TELEPORTATION_PORTAL, + TELEPORTATION_PORTAL_POH, + TELEPORTATION_MINIGAME, + TELEPORTATION_ITEM, + TELEPORTATION_SPELL, + TELEPORTATION_SPELL_HOME, + WILDERNESS_OBELISK, + MAGIC_CARPET, + HOT_AIR_BALLOON, + MAGIC_MUSHTREE, + SEASONAL_TRANSPORT, + NPC, + UNKNOWN +} 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 78749de0b47..db09b5502c1 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 @@ -19,9 +19,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; import net.runelite.client.plugins.microbot.shortestpath.*; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; @@ -51,6 +48,7 @@ import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; import java.util.function.BooleanSupplier; import java.util.function.Predicate; +import java.util.function.Supplier; import org.slf4j.event.Level; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.poh.PohTransport; @@ -58,6 +56,7 @@ import net.runelite.client.plugins.microbot.util.leaguetransport.LeaguesRegion; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.walker.door.DoorAttemptLedger; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier; import net.runelite.client.plugins.microbot.util.walker.door.DoorProbeContext; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection; @@ -68,7 +67,11 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.MineableResolver; import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; +import net.runelite.client.plugins.microbot.util.walker.recovery.FrontierDecision; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate; +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; import net.runelite.client.plugins.microbot.util.walker.door.Rs2WalkerAwaits; @@ -89,7 +92,7 @@ import java.util.*; import java.util.List; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; @@ -101,6 +104,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement.*; import static net.runelite.client.plugins.microbot.util.Global.*; /** @@ -115,6 +120,8 @@ public class Rs2Walker { public static ShortestPathConfig config; // stuck/movement tracking state migrated to WalkerRouteState (see routeState) static volatile WorldPoint currentTarget; + /** The active walk's configured finish distance — the goal-object guard needs it outside processWalk. */ + static volatile int currentWalkDistance; static int nextWalkingDistance = 10; /** @@ -134,25 +141,37 @@ public static WorldPoint getCurrentTarget() { // interim-target state migrated to WalkerRouteState (see routeState) private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; + /** + * Partial-regression guard: a fresh partial route whose endpoint sits farther from the goal + * than the session's best by more than max(this, best/4) tiles gets replanned instead of + * walked, up to {@link #MAX_PARTIAL_REGRESS_REPLANS} consecutive times before being accepted + * as the new baseline (a door may genuinely have closed). The slack absorbs honest endpoint + * drift between equal-cost plans; the flip-flop this guards against is two orders larger + * (observed segEnd dGoal alternating 124 vs 1459 on the same walk). + */ + private static final int PARTIAL_REGRESS_MIN_SLACK_TILES = 40; + private static final int MAX_PARTIAL_REGRESS_REPLANS = 2; + /** A single delayed client tick must not turn an otherwise healthy blocking walk into EXIT. */ + private static final int CLIENT_THREAD_TIMEOUT_RETRIES = 2; - private static final int INTERIM_CLOSE_TILES = 5; + static final int INTERIM_CLOSE_TILES = 5; /** * Floor for the jittered per-click route reach. Deliberately above {@link #INTERIM_CLOSE_TILES} * so a short click cannot land inside the interim-close threshold, which would clear the * checkpoint immediately and cause click thrash. */ - private static final int ROUTE_CLICK_REACH_MIN_TILES = 7; - private static final int INTERIM_PRECLICK_TILES = 6; - private static final int INTERIM_RUN_PRECLICK_TILES = 8; + static final int ROUTE_CLICK_REACH_MIN_TILES = 7; + static final int INTERIM_PRECLICK_TILES = 6; + 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; + static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; /** * How much further than its closest approach the player may get from an interim checkpoint before * it counts as abandoned. Wide enough to tolerate rounding a wall or a corner on the way to it. */ - private static final int INTERIM_ABANDON_MARGIN_TILES = 4; - private static final long INTERIM_MAX_AGE_MS = 10_000L; + static final int INTERIM_ABANDON_MARGIN_TILES = 4; + 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; @@ -167,39 +186,42 @@ public static WorldPoint getCurrentTarget() { private static final long WALKER_MOVEMENT_OWNERSHIP_WINDOW_MS = 10_000L; 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; + static final long RAW_SCAN_DOOR_FOCUS_MAX_MS = 2200L; + static final int RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS = 3; + static final long DOOR_POST_INTERACT_SETTLE_MS = 900L; + static final long DOOR_EDGE_SKIP_COOLDOWN_MS = 700L; /** Longest the walker will hold off re-clicking a door while an unanswered option menu is up. */ - private static final long DOOR_DIALOGUE_DEFER_MAX_MS = 5_000L; + static final long DOOR_DIALOGUE_DEFER_MAX_MS = 5_000L; /** Above this, a single transport object scan is worth naming in the log. */ - private static final long TRANSPORT_OBJECT_SCAN_SLOW_MS = 400L; + static final long TRANSPORT_OBJECT_SCAN_SLOW_MS = 400L; /** Furthest a door may be and still be opened while the player is mid-walk toward it. */ - private static final int DOOR_APPROACH_INTERACT_MAX_TILES = 4; - private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 3_500L; + static final int DOOR_APPROACH_INTERACT_MAX_TILES = 4; + 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; + 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 int POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN = 13; - private static final int POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER = 3; - private static final int POST_DOOR_EDGE_NUDGE_WAIT_MS = 1200; - private static final int HANDLER_RANGE = 13; + static final int POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN = 13; + static final int POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER = 3; + static final int POST_DOOR_EDGE_NUDGE_WAIT_MS = 1200; + static final int HANDLER_RANGE = 13; + // Position-staleness allowance for the far-unreachable pre-gate: the gate compares against the + // pass-stale snapshot position, and this covers ground run since it was read. + static final int FAR_UNREACHABLE_STALENESS_MARGIN = 10; // Raw/smoothed segments can span several walkable tiles before their transport edge. // Do not let the transport handler turn that future edge into a long movement command: // normal route clicks own the approach, then the handler takes over beside the origin. private static final int RAW_TRANSPORT_DISPATCH_MAX_DISTANCE = 2; - private static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; - private static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; - private static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; - private static final int PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP = 6; - private static final int PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP = 6; - private static final int SEGMENT_DOOR_FAMILY_MARK_RADIUS = 2; - private static final 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; + static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; + static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; + static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; + static final int PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP = 6; + static final int PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP = 6; + static final int SEGMENT_DOOR_FAMILY_MARK_RADIUS = 2; + static final int UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES = 2; + static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; + static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; /** * A spatially-near smoothed waypoint can be hundreds of raw route steps ahead when a route * doubles back around a mountain or fence. Do not treat that future branch as the immediate @@ -207,8 +229,25 @@ public static WorldPoint getCurrentTarget() { * false-negative recovery while rejecting distant route folds. */ private static final int LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS = 48; - private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; - // UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES moved into recovery/RouteRecovery (P1) + static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; + /** + * Ceiling for zoom-extended minimap strides. NOT the minimap's limit — zoomed out it shows ~38 + * tiles — but the walled-click net's: every stride target must sit inside the player-origin + * reachability BFS ({@link #CLOSEST_INDEX_REACHABLE_STEP_BUDGET} = 20 steps), or a wall between + * could not be detected and the Clock Tower click-through-the-wall class comes back. 18 leaves + * two steps of path-vs-Euclidean slack inside that budget. + */ + static final int ZOOMED_OUT_MINIMAP_REACH_CAP = 18; + /** + * Floor for zoom-shrunk strides. The first cut of zoom awareness floored at the flat + * {@link #NORMAL_MINIMAP_REACH_EUCLIDEAN}, which quietly broke the zoomed-IN half of the + * feature: a fully zoomed-in minimap shows ~8 tiles of radius, so an 11-tile stride selected a + * point on or past the rim. The floor exists only to keep the walker functional at degenerate + * zooms, not to preserve the old reach. + */ + static final int MIN_MINIMAP_REACH_EUCLIDEAN = 5; + + /** * Stationary window before an active route issues a recovery nudge. *@@ -220,22 +259,22 @@ public static WorldPoint getCurrentTarget() { * genuinely still (not moving/animating/interacting) on the same tile, and * {@link #ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS} still prevents click spam. */ - private static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 1_200L; - private static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; + static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 1_200L; + static final long ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS = 2_000L; /** * How long after a door-recovery-suppressed tick the idle nudge stays disabled. Rolling — the suppress * branch re-stamps it every tick the door stays unresolved, so the nudge is held off for the whole * suppression episode plus this tail. Long enough to cover the door cooldowns that cause suppression; * short enough that a genuinely abandoned door (player walked away, route replanned) frees the nudge. */ - private static final long DOOR_SUPPRESS_NUDGE_HOLDOFF_MS = 6_000L; + static final long DOOR_SUPPRESS_NUDGE_HOLDOFF_MS = 6_000L; private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; /** Floor for the post-plane-change settle sleep, so an unbounded Gaussian draw cannot go negative. */ - private static final int MIN_PLANE_CHANGE_SETTLE_MS = 60; - private static final int ROUTE_PROGRESS_FORWARD_SEARCH_TILES = 40; + static final int MIN_PLANE_CHANGE_SETTLE_MS = 60; + static final int ROUTE_PROGRESS_FORWARD_SEARCH_TILES = 40; /** - * How long to wait for {@code Rs2PathApi.getPathfinder()} to become non-null at route start. + * How long to wait for an active route to be published at route start. *
* The pathfinder is only published after {@code PathfinderConfig.refresh()} completes
* (see {@code Rs2WalkerLifecycleRuntime.restartPathfinding}), and a cache-missing refresh has been
@@ -247,11 +286,11 @@ public static WorldPoint getCurrentTarget() {
private static final int PATHFINDER_NULL_WAIT_MS = 6_000;
private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L;
private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450;
- private static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1;
+ static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1;
private static final int PATH_VARIANCE_TOLERANCE_CHEBYSHEV = 6;
- private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6;
- private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15;
- private static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L;
+ static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6;
+ static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15;
+ static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L;
private static final long RECENT_TRANSPORT_EDGE_SUPPRESS_MS = 8_000L;
// door-interaction state migrated to WalkerRouteState (see routeState)
/**
@@ -262,37 +301,28 @@ public static WorldPoint getCurrentTarget() {
* when the transport was marked handled (always true while standing at the destination) and the door
* settle had no early exit at all.
*/
- private static final long POST_INTERACT_SETTLE_MIN_MS = 300L;
+ static final long POST_INTERACT_SETTLE_MIN_MS = 300L;
// misc route-timer state migrated to WalkerRouteState (see routeState)
/**
* Consolidated route state (P1 walker decomposition, enabling step). Fields are migrated here in
* cohesive clusters; first cluster: transport handoff. See {@link WalkerRouteState}.
*/
- private static final WalkerRouteState routeState = new WalkerRouteState();
+ static final WalkerRouteState routeState = new WalkerRouteState();
// idle-nudge state migrated to WalkerRouteState (see routeState)
// route-progress state migrated to WalkerRouteState (see routeState)
- private static final java.util.Deque Every {@code processWalk} entry runs through here ({@code walkWithStateInternal} is its only
+ * caller, banked walks included), which is why clearing here is sufficient and the walk-ending
+ * paths do not each need their own clear.
+ */
+ static void resetWalkSessionState() {
routeState.walkSessionStartedAtMs = System.currentTimeMillis();
routeState.firstMovementClickMarked = false;
startupPhasesLogged.clear();
- routeState.lastTransportHandledAtLocation = null;
- routeState.lastTransportOriginLocation = null;
- routeState.lastTransportDestinationLocation = null;
+ TERMINAL_TRAVEL_ATTEMPTED_EDGES.clear();
+ // The transport handoff belongs to the PREVIOUS walk. Only the three location fields used to
+ // be nulled here, leaving lastTransportHandledAtMs — the field every window check actually
+ // reads — armed for its full 15s. A walk starting inside that window (after an interrupted,
+ // errored or tail-exceeded walk, which do not clear the target) then ran degraded: raw scene
+ // scan skipped, per-segment door/rockfall/transport handlers skipped, ranged door dispatch
+ // disabled for the whole pass, and off-path recalc bypassed entirely. setTarget(null) already
+ // cleared all four on the normal completion path; this makes the two agree.
+ clearRecentTransportContext();
+ lastExemptRunLocation = null;
+ reachableBfsCalls.set(0);
+ reachableBfsMillis.set(0L);
+ // Seed rather than zero: a fresh walk has not moved yet, and an unknown tile-change time
+ // credits the pose flag, which would hand a spinning player the benefit of the doubt for the
+ // whole first stall window.
+ routeState.lastTileChangeAtMs = System.currentTimeMillis();
// The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk
// makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as
// interim= Permissive by design. An unmapped region reads as fully blocked (see
- * {@link CollisionMap#hasRegion}), so every ambiguous case returns {@code true} and lets the
+ * Permissive by design. An unmapped collision region reads as fully blocked, so every
+ * ambiguous case returns {@code true} and lets the
* pathfinder decide. Only a target sitting in mapped, wholly blocked terrain is rejected —
* otherwise this would refuse instances and any region missing from the collision map.
*/
@@ -723,160 +740,21 @@ private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeo
* Cove staircase approach at (2531,2834) reads walkable in the scene and blocked here.
*/
public static boolean isWalkableInCollisionMap(WorldPoint tile) {
- PathfinderConfig config = Rs2PathApi.getPathfinderConfig();
- return hasWalkableTileWithin(config != null ? config.getMap() : null, tile, 0);
- }
-
- static boolean hasWalkableTileWithin(CollisionMap map, WorldPoint target, int distance) {
- if (map == null || target == null) {
- return true;
- }
- if (!map.hasRegion(target.getX(), target.getY())) {
- return true;
- }
- int radius = Math.max(0, distance);
- for (int dx = -radius; dx <= radius; dx++) {
- for (int dy = -radius; dy <= radius; dy++) {
- int x = target.getX() + dx;
- int y = target.getY() + dy;
- if (!map.hasRegion(x, y)) {
- return true;
- }
- if (!map.isBlocked(x, y, target.getPlane())) {
- return true;
- }
- }
- }
- return false;
- }
-
- /** Nearest walkable tile to {@code target} within {@code maxRadius}, or null. Diagnostics only. */
- static WorldPoint nearestWalkableTile(CollisionMap map, WorldPoint target, int maxRadius) {
- if (map == null || target == null) {
- return null;
- }
- for (int r = 1; r <= maxRadius; r++) {
- for (int dx = -r; dx <= r; dx++) {
- for (int dy = -r; dy <= r; dy++) {
- if (Math.max(Math.abs(dx), Math.abs(dy)) != r) {
- continue; // ring only; inner rings already scanned
- }
- int x = target.getX() + dx;
- int y = target.getY() + dy;
- if (map.hasRegion(x, y) && !map.isBlocked(x, y, target.getPlane())) {
- return new WorldPoint(x, y, target.getPlane());
- }
- }
- }
- }
- return null;
- }
-
- /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */
- private static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) {
- if (exitReason == null) {
- return false;
- }
- if (exitReason.startsWith("door-handled")) {
- return true;
- }
- return "raw-path-scene-object-handled".equals(exitReason)
- || "post-click-raw-path-scene-object-handled".equals(exitReason);
+ return Rs2PathApi.hasWalkableTileWithin(tile, 0);
}
- /**
- * Exit reasons meaning the path loop ended because the walker did something that
- * advances the route — opened a door, took a transport, cleared a blocker — or because
- * movement is already in flight. These are progress, not a failed attempt.
- *
- * The partial-retry budget exists for "the goal is unreachable and we are stuck". Spending
- * it on these instead conflated the two: a door open ends the iteration, lands in the partial
- * branch, and burns a retry even though the walker just made progress. On a route whose path
- * end is permanently short of the goal (any partial path), the budget is armed for the whole
- * walk, so an ordinary door could exhaust it ~100 tiles into a working route and report
- * UNREACHABLE while the player was still advancing. See {@code movement.md} #25.
- */
- static boolean isRouteProgressExit(String exitReason) {
- if (exitReason == null) {
- return false;
- }
- if (exitReason.startsWith("door-handled")) {
- return true;
- }
- switch (exitReason) {
- case "raw-path-scene-object-handled":
- case "post-click-raw-path-scene-object-handled":
- case "current-tile-transport-handled":
- case "post-click-current-tile-transport-handled":
- case "transport-handled":
- case "rockfall-handled":
- case "path-blocker-handled":
- case "interim-in-flight":
- case "recovery-move-in-flight":
- case "route-fold-continuation-click":
- return true;
- default:
- return false;
- }
- }
- /** @return true only when a canvas click was actually issued, so the caller can size its minimap hold-off. */
- private static boolean maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List Originally measured from walk start, which made it a slow-journey detector: the 18:23 run
+ * (two quetzal flights, two shortcuts, 314s, arrived cleanly) warned at the 300s mark while
+ * cruising. The clock now restarts every time the player gets {@link #WALK_BUDGET_PROGRESS_TILES}
+ * closer to the goal than ever before this walk, so only a walk that has genuinely stopped
+ * converging trips it. Legs that move AWAY first (a flight via a distant hub) do not restart the
+ * clock, so they must complete within the budget — at 300s that holds comfortably. Currently
+ * OBSERVE-ONLY — it logs and does not abort — because a budget that kills a working walk would
+ * be a worse bug than the livelock it guards against.
+ */
+ private static final long WALK_WALL_CLOCK_BUDGET_MS = 300_000L;
+ /** Distance improvement (tiles, 2D, vs the walk's best so far) that counts as real progress. */
+ private static final int WALK_BUDGET_PROGRESS_TILES = 10;
+ /** Uninterrupted tail-exempt iterations before the loop is reported as yielding without advancing. */
+ private static final int MAX_CONSECUTIVE_EXEMPT_ITERATIONS = 24;
+ /** One budget report per walk session; 0 when this session has not reported yet. */
+ private static volatile long walkBudgetReportedForSessionAtMs = 0L;
+ /** Walk session the progress tracker below belongs to. */
+ private static long walkBudgetTrackedSessionMs = 0L;
+ /** Closest (2D) the player has been to this walk's goal, and when that record was last beaten. */
+ private static int walkBudgetBestDistToGoal = Integer.MAX_VALUE;
+ private static long walkBudgetLastProgressAtMs = 0L;
+
+ /**
+ * Reports a walk that has outlived its no-progress budget.
+ *
+ * {@code MAX_PROCESS_WALK_TAIL_ITERATIONS} is not a bound on its own: several exit reasons
+ * decrement the tail counter, so a walk that keeps producing one of them loops forever, and
+ * nothing else in the call chain imposes a time limit. This makes that state visible in the log
+ * instead of silent.
+ */
+ private static void reportWalkBudgetIfExhausted(WorldPoint target, long nowMs, int processWalkTail) {
+ long startedAt = routeState.walkSessionStartedAtMs;
+ if (walkBudgetTrackedSessionMs != startedAt) {
+ walkBudgetTrackedSessionMs = startedAt;
+ walkBudgetBestDistToGoal = Integer.MAX_VALUE;
+ walkBudgetLastProgressAtMs = startedAt;
+ }
+ WorldPoint at = Rs2Player.getWorldLocation();
+ if (at != null && target != null) {
+ int dist = at.distanceTo2D(target);
+ if (dist <= walkBudgetBestDistToGoal - WALK_BUDGET_PROGRESS_TILES
+ || walkBudgetBestDistToGoal == Integer.MAX_VALUE) {
+ walkBudgetBestDistToGoal = Math.min(dist, walkBudgetBestDistToGoal);
+ walkBudgetLastProgressAtMs = nowMs;
+ }
+ }
+ if (!TailDecision.isWallClockExhausted(walkBudgetLastProgressAtMs, nowMs, WALK_WALL_CLOCK_BUDGET_MS)
+ || walkBudgetReportedForSessionAtMs == startedAt) {
+ return;
+ }
+ walkBudgetReportedForSessionAtMs = startedAt;
+ log.warn("[Walker] walk exceeded its {}ms no-progress budget (no distance gain for {}ms,"
+ + " running {}ms total) target={} at={} tail={} — probable livelock; the tail"
+ + " cap cannot catch this because exempt exits refund it",
+ WALK_WALL_CLOCK_BUDGET_MS, nowMs - walkBudgetLastProgressAtMs, nowMs - startedAt,
+ target, Rs2Player.getWorldLocation(), processWalkTail);
+ }
+
+ /** How long the route progress index may hold still before the route is declared stagnant. */
+ private static final long ROUTE_STAGNATION_BUDGET_MS = 60_000L;
+ /** Stagnation replans per walk before the goal is called unreachable. */
+ private static final int MAX_ROUTE_STAGNATION_REPLANS = 2;
+
+ /**
+ * The enforced oscillation bound (TailDecision.decideRouteStagnation). Unlike the two observe-only
+ * budgets above, this one acts: the wall-clock budget is sized for whole journeys and the
+ * exempt-run counter resets on any movement, so a walk ping-ponging between two tiles — the Tithe
+ * Farm door/recovery oscillation ran 4+ minutes until a human cancelled it — trips neither.
+ * Returns null to continue the loop (spending a replan restarts the clock), or the honest
+ * terminal state.
+ */
+ private static WalkerState handleRouteStagnation(WorldPoint target, int distance, List Counting every exempt iteration was wrong, and a real farm-run log proved it: a completely
+ * healthy Catherby-to-Ardougne walk yielded {@code interim-in-flight} 28 times in a row while
+ * steadily covering ground, because that is simply what travelling between minimap clicks looks
+ * like. A bound on yields is a bound on walking; the state actually worth reporting is yielding
+ * while STATIONARY, which no number of tail refunds can ever surface through the iteration cap.
+ */
+ private static int trackExemptRun(int run, WorldPoint target, WalkExit exit, String detail) {
+ WorldPoint at = Rs2Player.getWorldLocation();
+ int next = (at != null && !at.equals(lastExemptRunLocation)) ? 1 : run + 1;
+ lastExemptRunLocation = at;
+ if (TailDecision.isExemptRunTooLong(next, MAX_CONSECUTIVE_EXEMPT_ITERATIONS)) {
+ reportExemptRunTooLong(target, exit.wireName(detail), next);
+ }
+ return next;
+ }
+
+ /**
+ * Reports a loop that keeps yielding without advancing. Every one of these iterations refunds
+ * its own tail charge, so no number of them can trip the iteration cap.
+ */
+ private static void reportExemptRunTooLong(WorldPoint target, String exitWireName, int run) {
+ if (run % MAX_CONSECUTIVE_EXEMPT_ITERATIONS != 1) {
+ return;
+ }
+ log.warn("[Walker] {} consecutive tail-exempt iterations (exit={}) target={} at={} —"
+ + " the loop is yielding without advancing and cannot exhaust the tail cap",
+ run, exitWireName, target, Rs2Player.getWorldLocation());
+ }
+
+ // Pass-anatomy tracking (task #25 slice 2): walkerHeartbeat is called at every pass start, so
+ // the gap since the previous call IS the previous pass's duration. Stage timers accumulate
+ // inside the handler bodies (WalkPassStats); the residual names what they do not explain.
+ private static long lastPassStartAtMs;
+ private static int lastPassTail = -1;
+ private static final long SLOW_PASS_EMIT_MS = 2_000;
+
private static void walkerHeartbeat(WorldPoint target, int processWalkTail) {
long now = System.currentTimeMillis();
+ if (processWalkTail > 0 && lastPassStartAtMs > 0) {
+ long prevPassMs = now - lastPassStartAtMs;
+ if (prevPassMs >= SLOW_PASS_EMIT_MS) {
+ WebWalkLog.spInfo("pass_slow | tail={} prevPassMs={} {}",
+ lastPassTail, prevPassMs, WalkPassStats.snapshot(prevPassMs));
+ }
+ }
+ lastPassStartAtMs = now;
+ lastPassTail = processWalkTail;
+ WalkPassStats.reset();
+ reportWalkBudgetIfExhausted(target, now, processWalkTail);
if (now - lastHeartbeatAtMs < WALKER_HEARTBEAT_INTERVAL_MS) {
return;
}
@@ -1566,15 +1673,16 @@ private static void walkerHeartbeat(WorldPoint target, int processWalkTail) {
// DEBUG, not INFO: this fires every second for the whole of every walk, and it exists to
// diagnose stalls, not to narrate healthy ones. Behind the verbose toggle it costs nothing
// until someone is actually chasing a silent stretch in the log.
- WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={}",
+ WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={} bfs={}/{}ms",
processWalkTail,
compactWorldPoint(playerLoc), compactWorldPoint(target),
Rs2Player.isMoving(), Rs2Player.isAnimating(),
compactWorldPoint(routeState.interimTargetWp),
routeState.interimSetAtMs > 0L ? now - routeState.interimSetAtMs : -1L,
routeState.lastMovedTimeMs > 0L ? now - routeState.lastMovedTimeMs : -1L,
- routeState.doorInteractionSettleStartedAtMs > 0L
- ? now - routeState.doorInteractionSettleStartedAtMs : -1L);
+ doorAttemptLedger.settleStartedAtMs() > 0L
+ ? now - doorAttemptLedger.settleStartedAtMs() : -1L,
+ reachableBfsCalls.get(), reachableBfsMillis.get());
}
/**
@@ -1599,6 +1707,31 @@ static boolean walkStepPathReachesTarget(List
+ * This is the difference between "close to the object" and "able to use the object". Straight-line
+ * distance says yes through a wall; this says no.
+ */
+ static boolean hasReachableNeighbour(WorldPoint target, Map
- * Preference order:
- *
- * The floor matters: it must stay clear of {@link #INTERIM_CLOSE_TILES} or the interim
- * checkpoint clears almost immediately and the walker re-clicks constantly, producing visible
- * stop-start movement. The ceiling is the caller's reach, which is already tuned to the minimap
- * clip — going above it just produces outside-clip fallbacks.
- */
- static int routeClickReach(int maxEuclidean) {
- int floor = Math.min(ROUTE_CLICK_REACH_MIN_TILES, maxEuclidean);
- if (maxEuclidean <= floor) {
- return maxEuclidean;
- }
- return Rs2Random.betweenInclusive(floor, maxEuclidean);
- }
- private static WorldPoint selectRouteClickTargetAnchored(List
- *
- * Returns {@code null} when neither exists; the caller then falls back to wall-distance nudging
- * plus {@link #findReachableRejoinRawPathPoint} rejoin handling.
- */
- private static WorldPoint selectRouteClickTarget(List