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("runTests") { } } +tasks.register("exportLocalPlannerComparison") { + group = "verification" + description = "Export deterministic local planner results for the opt-in upstream comparison harness" + + dependsOn(":client:compileJava", ":client:compileTestJava") + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain") + + val corpus = providers.gradleProperty("plannerCorpus") + val output = providers.gradleProperty("plannerOutput") + doFirst { + require(corpus.isPresent && output.isPresent) { + "exportLocalPlannerComparison requires -PplannerCorpus= and -PplannerOutput=" + } + args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath) + systemProperty("microbot.planner.revision", + providers.gradleProperty("plannerRevision").getOrElse("unknown")) + } + + outputs.upToDateWhen { false } +} + +tasks.register("exportEmbeddedUpstreamPlannerComparison") { + group = "verification" + description = "Export results from the production-packaged pinned upstream planner adapter" + + dependsOn(":client:compileJava", ":client:compileTestJava") + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain") + + val corpus = providers.gradleProperty("plannerCorpus") + val output = providers.gradleProperty("plannerOutput") + doFirst { + require(corpus.isPresent && output.isPresent) { + "exportEmbeddedUpstreamPlannerComparison requires -PplannerCorpus= and -PplannerOutput=" + } + args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath) + systemProperty("microbot.planner.revision", + providers.gradleProperty("plannerRevision").getOrElse("unknown")) + systemProperty("microbot.planner.embedded-upstream", "true") + } + + outputs.upToDateWhen { false } +} + tasks.register("runUnitTests") { group = "verification" description = "Run unit tests only (no client, no login) — safe for CI" 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 27ce274dee3..d3241bd525d 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 @@ -56,12 +56,14 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionConflicts; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionPersistence; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionView; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveRouteValidator; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.JagexColors; import net.runelite.client.ui.NavigationButton; @@ -91,7 +93,7 @@ name = PluginDescriptor.Mocrosoft + "Web Walker", description = "Draws the shortest path to a chosen destination on the map (right click a spot on the world map to use)", tags = {"pathfinder", "map", "waypoint", "navigation", "microbot"}, - enabledByDefault = true, + enabledByDefault = false, alwaysOn = true ) public class ShortestPathPlugin extends Plugin implements KeyListener { @@ -227,7 +229,9 @@ protected void startUp() { Map> transports = Transport.loadAllFromResources(); List restrictions = Restriction.loadAllFromResources(); - pathfinderConfig = new PathfinderConfig(map, transports, restrictions, client, config); + pathfinderConfig = new PathfinderConfig( + map, transports, restrictions, client, config, + Rs2TransportPlanningPolicy.INSTANCE); panel = injector.getInstance(ShortestPathPanel.class); pohPanel = new PohPanel(config); @@ -660,7 +664,7 @@ private void markLiveCollisionDirty() { * decision with magnitudes instead of anecdotes. Runs off the fresh immutable snapshot, never on * the pathfinder hot path. */ - private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) { + private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot, LiveCollisionView priorOverlayView) { if (staticCollisionData == null) { return; } @@ -673,9 +677,14 @@ private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) { return; } lastCollisionConflictLogAtMs = now; - WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{} — live scene disagrees with the shipped map", + LiveCollisionConflicts.Coverage coverage = + LiveCollisionConflicts.coverage(snapshot, staticCollisionData, priorOverlayView); + WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{}" + + " | overlayKnew={}% (known={} new={} changed={}) — live scene disagrees with the shipped map", tally.liveOpensStatic, tally.liveBlocksStatic, tally.liveOpensSealed, - snapshot.getBaseX(), snapshot.getBaseY()); + snapshot.getBaseX(), snapshot.getBaseY(), + coverage.alreadyKnownPercent(), coverage.alreadyKnown, + coverage.newInformation, coverage.changed); } private void resetLearnedCollision() { @@ -785,8 +794,12 @@ void refreshLiveCollision() { return; } + // Pinned BEFORE the merge: this is what we knew on arrival, which is the only way to tell + // whether the persistent store spared us a blind first visit. mergeScene replaces regions + // rather than mutating them, so this view stays a true "before". + final LiveCollisionView priorOverlayView = overlay.current(); overlay.set(snapshot); - logLiveStaticConflicts(snapshot); + logLiveStaticConflicts(snapshot, priorOverlayView); // Persist the regions this capture just changed so the learned collision survives a restart. if (liveCollisionPersistence != null) { liveCollisionPersistence.persist(overlay.drainDirty()); @@ -834,7 +847,26 @@ private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) final CollisionMap map = pathfinderConfig.getMap(); map.beginSearch(); // pin the freshly captured snapshot for this validation final int from = LiveRouteValidator.nearestIndex(path, me); - final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map); + // A door transport joins two adjacent same-plane tiles, so to the validator its step looks + // like walking — and while the door is SHUT the edge honestly reads blocked. That is its + // normal state, not an obstruction: the walker's executor opens it on contact. Recalculating + // here yanked the route out from under the walker while it stood at the door handling it. + final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map, + (a, b) -> { + // The walker's door subsystem has claimed this edge — catalog or not. Quest doors + // (fightarena_door1) are in no catalog, yet the recalc mid-interaction is just as + // wrong there. + if (Rs2Walker.isActiveDoorEdge(a, b)) { + return true; + } + for (Transport t : pathfinderConfig.getTransportsPacked() + .getOrDefault(WorldPointUtil.packWorldPoint(a), java.util.Collections.emptySet())) { + if (b.equals(t.getDestination())) { + return true; + } + } + return false; + }); if (blocked >= 0) { lastLiveRecalcMs = now; log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating", @@ -970,15 +1002,6 @@ private Color override(String configOverrideKey, Color defaultValue) { return defaultValue; } - public static PlannerSelectionMode override( - String configOverrideKey, PlannerSelectionMode defaultValue) { - if (!configOverride.isEmpty()) { - return PlannerSelectionMode.fromConfigValue( - configOverride.get(configOverrideKey), defaultValue); - } - return defaultValue; - } - public static int override(String configOverrideKey, int defaultValue) { if (!configOverride.isEmpty()) { Object value = configOverride.get(configOverrideKey); @@ -1002,6 +1025,15 @@ public static TeleportationItem override(String configOverrideKey, Teleportation return defaultValue; } + public static PlannerSelectionMode override( + String configOverrideKey, PlannerSelectionMode defaultValue) { + if (!configOverride.isEmpty()) { + return PlannerSelectionMode.fromConfigValue( + configOverride.get(configOverrideKey), defaultValue); + } + return defaultValue; + } + private TileCounter override(String configOverrideKey, TileCounter defaultValue) { if (!configOverride.isEmpty()) { Object value = configOverride.get(configOverrideKey); @@ -1044,7 +1076,7 @@ private void onMenuOptionClicked(MenuEntry entry) { } if (entry.getOption().equals(CLEAR) && entry.getTarget().equals(PATH)) { - shortestPathScript.setTriggerWalker(null); + shortestPathScript.setTriggerWalker(null, "menu:clear-path"); } } @@ -1357,7 +1389,7 @@ public void keyPressed(KeyEvent e) { * Therefor CTRL + X seemed a bit more robust and userfriendly */ if (e.getKeyCode() == KeyEvent.VK_X && e.isControlDown()) { - shortestPathScript.setTriggerWalker(null); + shortestPathScript.setTriggerWalker(null, "hotkey:ctrl+x"); e.consume(); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java index c03ee07d937..5c3b5bb813a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java @@ -190,6 +190,25 @@ public static boolean quickCast(Spell spell) { return quickCast(spell.getMagicAction()); } + /** + * Click a zero-rune spellbook action by its exact displayed name. + * + *

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.

+ */ + public static boolean quickCast(String spellName) { + if (spellName == null || spellName.trim().isEmpty()) return false; + + Microbot.status = "Casting " + spellName; + if (Rs2Tab.getCurrentTab() != InterfaceTab.MAGIC) { + Rs2Tab.switchToMagicTab(); + if (!sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.MAGIC)) return false; + } + + return Rs2Widget.clickWidget(spellName, Optional.of(218), 3, true); + } + public static boolean quickCast(MagicAction magicSpell) { Microbot.status = "Casting " + magicSpell.getName(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 3a8a22ef202..5e9d34033ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -503,6 +503,119 @@ public static boolean isTileReachable(WorldPoint targetPoint) { return runClientReadBoolean(() -> isTileReachableInternal(targetPoint)); } + /** + * Whether a single step from {@code from} to {@code to} is currently permitted by the CLIENT's + * collision data — the live flags the server drives, so a door that has just opened clears its + * blocking flag here on the same tick. + *

+ * 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.

+ */ +public final class Rs2ActiveRouteStatus +{ + public enum Phase + { + ABSENT, + CALCULATING, + READY + } + + private final long generation; + private final Phase phase; + private final WorldPoint start; + private final Set targets; + private final List rawPath; + private final List walkablePath; + private final Rs2RouteTermination terminationReason; + private final Rs2RouteMetrics metrics; + + Rs2ActiveRouteStatus( + long generation, + Phase phase, + WorldPoint start, + Set targets, + List rawPath, + List walkablePath, + Rs2RouteTermination terminationReason, + Rs2RouteMetrics metrics) + { + if (generation < 0) + { + throw new IllegalArgumentException("generation must be non-negative"); + } + this.generation = generation; + this.phase = Objects.requireNonNull(phase, "phase"); + this.start = start; + this.targets = targets == null + ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet<>(targets)); + this.rawPath = rawPath == null ? Collections.emptyList() : List.copyOf(rawPath); + this.walkablePath = walkablePath == null + ? Collections.emptyList() + : List.copyOf(walkablePath); + this.terminationReason = terminationReason; + this.metrics = metrics; + validatePhase(); + } + + private void validatePhase() + { + if (phase == Phase.ABSENT + && (start != null || !targets.isEmpty() || !rawPath.isEmpty() || !walkablePath.isEmpty() + || terminationReason != null || metrics != null)) + { + throw new IllegalArgumentException("an absent route cannot carry planner state"); + } + if (phase == Phase.READY && (terminationReason == null || metrics == null)) + { + throw new IllegalArgumentException("a ready route must include termination and metrics"); + } + if (phase == Phase.CALCULATING && (terminationReason != null || metrics != null)) + { + throw new IllegalArgumentException("a calculating route cannot include completed search state"); + } + } + + static Rs2ActiveRouteStatus absent(long generation) + { + return new Rs2ActiveRouteStatus( + generation, Phase.ABSENT, null, Collections.emptySet(), + Collections.emptyList(), Collections.emptyList(), null, null); + } + + public long getGeneration() + { + return generation; + } + + public Phase getPhase() + { + return phase; + } + + public boolean isPresent() + { + return phase != Phase.ABSENT; + } + + public boolean isCalculating() + { + return phase == Phase.CALCULATING; + } + + public boolean isReady() + { + return phase == Phase.READY; + } + + public Optional getStart() + { + return Optional.ofNullable(start); + } + + public Set getTargets() + { + return targets; + } + + public List getRawPath() + { + return rawPath; + } + + public List getWalkablePath() + { + return walkablePath; + } + + public Optional getEndpoint() + { + return rawPath.isEmpty() + ? Optional.empty() + : Optional.of(rawPath.get(rawPath.size() - 1)); + } + + public Optional getTerminationReason() + { + return Optional.ofNullable(terminationReason); + } + + public Optional getMetrics() + { + return Optional.ofNullable(metrics); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java new file mode 100644 index 00000000000..b1daed5d3e9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java @@ -0,0 +1,114 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.GameObject; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +import java.util.Optional; +import java.util.List; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntilTrue; + +/** Executes an already-unlocked hot-air-balloon network edge. */ +final class Rs2HotAirBalloon +{ + private static final int MAP_WAIT_POLL_MS = 100; + private static final int MAP_WAIT_TIMEOUT_MS = 5_000; + private static final int BASKET_SEARCH_RADIUS = 8; + private static final List BASKET_OBJECT_IDS = List.of( + ObjectID.ZEP_BASKET_ENTRANA, + ObjectID.ZEP_BASKET, + ObjectID.ZEP_MULTI_BASKET_ENTRANA, + ObjectID.ZEP_MULTI_BASKET_TAV, + ObjectID.ZEP_MULTI_BASKET_CAST, + ObjectID.ZEP_MULTI_BASKET_GNO, + ObjectID.ZEP_MULTI_BASKET_CRAFT, + ObjectID.ZEP_MULTI_BASKET_VARR); + + private Rs2HotAirBalloon() + { + } + + static boolean handle(Rs2TransportEdge transport) + { + if (transport == null || transport.getOrigin() == null) + { + return false; + } + Optional destination = + TransportExecutionRegistry.balloonDestinationFor(transport.getDisplayInfo()); + if (destination.isEmpty()) + { + return false; + } + + if (!isMapVisible()) + { + GameObject basket = findBasket(transport.getOrigin()); + if (basket == null || !Rs2GameObject.interact(basket, transport.getAction())) + { + return false; + } + if (!sleepUntilTrue(Rs2HotAirBalloon::isMapVisible, + MAP_WAIT_POLL_MS, MAP_WAIT_TIMEOUT_MS)) + { + return false; + } + } + + return Rs2Widget.clickWidget(destinationButton(destination.get())); + } + + static boolean isBasketObjectId(int objectId) + { + return BASKET_OBJECT_IDS.contains(objectId); + } + + private static GameObject findBasket(WorldPoint origin) + { + for (int objectId : BASKET_OBJECT_IDS) + { + GameObject basket = Rs2GameObject.getGameObject( + objectId, origin, BASKET_SEARCH_RADIUS); + if (basket != null) + { + return basket; + } + } + return null; + } + + static int destinationButton(TransportExecutionRegistry.BalloonDestination destination) + { + if (destination == null) + { + return -1; + } + switch (destination) + { + case CASTLE_WARS: + return InterfaceID.ZepBalloonMap.BTN_CAST; + case GRAND_TREE: + return InterfaceID.ZepBalloonMap.BTN_GNO; + case CRAFTING_GUILD: + return InterfaceID.ZepBalloonMap.BTN_CRAFT; + case ENTRANA: + return InterfaceID.ZepBalloonMap.BTN_ENT; + case TAVERLEY: + return InterfaceID.ZepBalloonMap.BTN_TAV; + case VARROCK: + return InterfaceID.ZepBalloonMap.BTN_VARR; + default: + return -1; + } + } + + private static boolean isMapVisible() + { + return Rs2Widget.isWidgetVisible(InterfaceID.ZepBalloonMap.ROOT_RECT0); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java index 828c84dfe54..805ab0ac4f0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java @@ -1,18 +1,45 @@ package net.runelite.client.plugins.microbot.util.walker; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.shortestpath.PlannerSelectionMode; import net.runelite.client.plugins.microbot.shortestpath.TeleportationItem; import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathEdge; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathTerminationReason; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionView; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * Microbot-owned facade over the shortest-path plugin's mutable static state. @@ -22,79 +49,1581 @@ * Automation code ({@code Rs2Walker} and ~25 other consumers) currently reaches directly into * {@link ShortestPathPlugin}'s public static fields and accessors. Every time an upstream * (Skretzo/shortest-path) fix touches that internal wiring, the walker is at risk. Routing all - * plugin-state access through this single class freezes the surface the walker sees, so future - * upstream backports can change the plugin internals while only this facade (and not every - * consumer) has to move with them.

+ * plugin-state access through this single class confines direct static coupling, so future upstream + * backports have one compatibility seam instead of many consumers to update.

* - *

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 catalogEdgeSnapshots = + new ConcurrentHashMap<>(); + + private static final class CatalogEdgeSnapshot + { + private final Set source; + private final List edges; + + private CatalogEdgeSnapshot(Set source, List edges) + { + this.source = source; + this.edges = edges; + } + } + + private static final class PlannerComparisonTicket + { + private final long generation; + private final Rs2PlannerShadowContext context; + + private PlannerComparisonTicket(long generation, Rs2PlannerShadowContext context) + { + this.generation = generation; + this.context = context; + } + } + + private static final class PlannerEvaluation + { + private final PlannerComparisonTicket ticket; + private final Rs2RouteResult local; + private final Rs2RouteResult candidate; + private final Rs2PlannerShadowComparison comparison; + + private PlannerEvaluation( + PlannerComparisonTicket ticket, + Rs2RouteResult local, + Rs2RouteResult candidate, + Rs2PlannerShadowComparison comparison) + { + this.ticket = ticket; + this.local = local; + this.candidate = candidate; + this.comparison = comparison; + } + + private PlannerEvaluation materializationFailed(RuntimeException failure) + { + return new PlannerEvaluation( + ticket, + local, + null, + Rs2PlannerShadowComparison.failed( + comparison.getShadowEngineId(), ticket.context, + local, failure)); + } + } + private Rs2PathApi() { } - /** Config group key for the shortest-path plugin ({@link ShortestPathPlugin#CONFIG_GROUP}). */ - public static final String CONFIG_GROUP = ShortestPathPlugin.CONFIG_GROUP; + /** Config group key for the shortest-path plugin ({@link ShortestPathPlugin#CONFIG_GROUP}). */ + public static final String CONFIG_GROUP = ShortestPathPlugin.CONFIG_GROUP; + + /** Shared world-map marker sprite ({@link ShortestPathPlugin#MARKER_IMAGE}). */ + public static final BufferedImage MARKER_IMAGE = ShortestPathPlugin.MARKER_IMAGE; + + // ------------------------------------------------------------------ + // Pathfinder lifecycle + // ------------------------------------------------------------------ + + /** @return the current pathfinder instance, or {@code null} if none is running. */ + public static Pathfinder getPathfinder() + { + return ShortestPathPlugin.getPathfinder(); + } + + public static void setPathfinder(Pathfinder pathfinder) + { + if (ShortestPathPlugin.getPathfinder() != pathfinder) + { + clearActiveRouteSnapshot(); + activeRouteComparisonEligible = false; + activeRouteGeneration.incrementAndGet(); + // A route replacement makes any in-flight comparison evidence stale, even when + // the replacement has shadow mode disabled and therefore submits no newer task. + synchronized (shadowEvidenceMutex) + { + shadowGeneration++; + lastShadowComparison = null; + } + } + ShortestPathPlugin.setPathfinder(pathfinder); + } + + /** Replace the concrete compatibility view without starting a new logical route generation. */ + private static boolean replaceActivePathfinderLocked( + Pathfinder expected, Pathfinder replacement) + { + if (getPathfinder() != expected) + { + return false; + } + clearActiveRouteSnapshot(); + ShortestPathPlugin.setPathfinder(replacement); + return true; + } + + private static void clearActiveRouteSnapshot() + { + activeRouteSnapshotSource = null; + activeRouteSnapshot = null; + } + + /** @return the {@link Future} tracking the in-flight pathfinding task, or {@code null}. */ + public static Future getPathfinderFuture() + { + return ShortestPathPlugin.getPathfinderFuture(); + } + + public static void setPathfinderFuture(Future future) + { + ShortestPathPlugin.setPathfinderFuture(future); + } + + /** @return the single-threaded executor pathfinding runs on. */ + public static ExecutorService getPathfindingExecutor() + { + return ShortestPathPlugin.getPathfindingExecutor(); + } + + public static void setPathfindingExecutor(ExecutorService executor) + { + ShortestPathPlugin.setPathfindingExecutor(executor); + } + + /** @return the monitor guarding pathfinder start/cancel transitions. */ + public static Object getPathfinderMutex() + { + return ShortestPathPlugin.getPathfinderMutex(); + } + + /** Immutable start point of the currently published route, if one exists. */ + public static Optional getActiveRouteStart() + { + return getActiveRouteStatus().getStart(); + } + + /** Immutable target snapshot of the currently published route. */ + public static Set getActiveRouteTargets() + { + return getActiveRouteStatus().getTargets(); + } + + /** + * Capture a coherent immutable view of the currently published route. + * + *

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; + List rawPath = immutablePath(source.getPath()); + List walkablePath = ready + ? immutablePath(source.getWalkablePath()) + : rawPath; + if (!ready) + { + return new Rs2ActiveRouteStatus( + generation, + Rs2ActiveRouteStatus.Phase.CALCULATING, + source.getStart(), + source.getTargets(), + rawPath, + walkablePath, + null, + null); + } + + Pathfinder.PathfinderStats stats = source.getStats(); + Rs2RouteMetrics metrics = new Rs2RouteMetrics( + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getElapsedTimeNanos(), + source.getSelectedPathCost(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getNodesChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getTransportsChecked()); + return new Rs2ActiveRouteStatus( + generation, + Rs2ActiveRouteStatus.Phase.READY, + source.getStart(), + source.getTargets(), + rawPath, + walkablePath, + mapTermination(source.getTerminationReason()), + metrics); + } + } + + private static List immutablePath(List path) + { + return path == null || path.isEmpty() ? Collections.emptyList() : List.copyOf(path); + } + + /** + * Cancel and unpublish the active local planner without exposing its concrete lifecycle to callers. + */ + public static void cancelAndClearActiveRoute() + { + synchronized (getPathfinderMutex()) + { + cancelAndClearActiveRouteLocked(); + } + } + + private static void cancelAndClearActiveRouteLocked() + { + Pathfinder active = getPathfinder(); + if (active != null) + { + active.cancel(); + } + Future activeFuture = getPathfinderFuture(); + if (activeFuture != null && !activeFuture.isDone()) + { + activeFuture.cancel(true); + } + setPathfinderFuture(null); + setPathfinder(null); + } + + /** + * Refresh, create and publish the active route using only Microbot-owned request policy. + * + *

The 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, + Set targets, + int reachedDistance) + { + boolean normalAvailable = normal != null && !normal.getPath().isEmpty(); + boolean walkingAvailable = walkingOnly != null && !walkingOnly.getPath().isEmpty(); + if (!walkingAvailable) + { + return normalAvailable ? normal : walkingOnly; + } + WorldPoint endpoint = walkingOnly.getPath().get(walkingOnly.getPath().size() - 1); + boolean walkingReachesTarget = targets.stream() + .anyMatch(target -> target.getPlane() == endpoint.getPlane() + && target.distanceTo2D(endpoint) <= reachedDistance); + if (walkingReachesTarget + && normalAvailable + && normal.getPath().size() >= walkingOnly.getPath().size()) + { + return walkingOnly; + } + return normalAvailable ? normal : walkingOnly; + } + + // ------------------------------------------------------------------ + // Microbot-owned synchronous planning contract + // ------------------------------------------------------------------ + + /** + * Calculate a route without publishing it as the active walker pathfinder. + * + *

The 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 Optional getLastShadowComparison() + { + return Optional.ofNullable(lastShadowComparison); + } + + /** + * Most recently completed semantic match whose exact walking shape differed. + * + *

Unlike {@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 Optional getLastRouteShapeDifference() + { + return Optional.ofNullable(lastRouteShapeDifference); + } + + /** + * Most recently completed semantic divergence. + * + *

This 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 Optional getLastDivergence() + { + return Optional.ofNullable(lastDivergence); + } + + /** + * Most recently completed upstream planner failure, retained for the process lifetime. + * + *

The serialized comparison exposes only the exception class name, never its message or route data.

+ */ + public static Optional getLastPlannerFailure() + { + return Optional.ofNullable(lastPlannerFailure); + } + + public static boolean isUpstreamPlannerShadowEnabled() + { + PathfinderConfig config = getPathfinderConfig(); + return config != null && config.getPlannerSelectionMode().comparisonEnabled(); + } + + public static PlannerSelectionMode getPlannerSelectionMode() + { + PathfinderConfig config = getPathfinderConfig(); + return config == null ? PlannerSelectionMode.LOCAL : config.getPlannerSelectionMode(); + } + + public static String getUpstreamPlannerEngineId() + { + return upstreamPlanner().getEngineId(); + } + + /** Process-lifetime counters for bounded, non-authoritative shadow evidence. */ + public static Rs2PlannerShadowStats getShadowStats() + { + synchronized (shadowEvidenceMutex) + { + EnumMap + coverage = new EnumMap<>(Rs2PlannerShadowContext.Coverage.class); + for (Rs2PlannerShadowContext.Coverage value + : Rs2PlannerShadowContext.Coverage.values()) + { + long[] outcomes = shadowCoverageOutcomes[value.ordinal()]; + coverage.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + EnumMap + transportExecutors = new EnumMap<>(Rs2TransportExecutor.class); + for (Rs2TransportExecutor value : Rs2TransportExecutor.values()) + { + long[] outcomes = shadowTransportExecutorOutcomes[value.ordinal()]; + transportExecutors.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + EnumMap + transportTypes = new EnumMap<>(Rs2TransportType.class); + for (Rs2TransportType value : Rs2TransportType.values()) + { + long[] outcomes = shadowTransportTypeOutcomes[value.ordinal()]; + transportTypes.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + return new Rs2PlannerShadowStats( + shadowSubmitted, + shadowCompleted, + shadowMatches, + shadowDivergences, + shadowFailures, + shadowStaleResults, + shadowDiscarded, + shadowRouteShapeDifferences, + upstreamCanarySelections, + localFallbackDivergences, + localFallbackFailures, + shadowEvidenceStartedAtEpochMillis, + coverage, + transportExecutors, + transportTypes, + new Rs2WalkerShadowExecutionStats( + shadowWalkerArrivals, + shadowWalkerUnreachable, + shadowWalkerExits, + shadowRecoveryArrivals, + shadowRecoveryUnreachable, + shadowRecoveryExits), + new Rs2PlannerCanaryPerformanceStats( + canaryPlanningSamples, + canaryPlanningNanosTotal, + canaryPlanningNanosMax, + canaryLocalSearchNanosTotal, + canaryLocalSearchNanosMax, + canaryUpstreamSearchSamples, + canaryUpstreamSearchNanosTotal, + canaryUpstreamSearchNanosMax)); + } + } + + /** + * Whether the current logical active route was admitted to planner comparison. + * + *

This 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"); + EnumSet enabledTypes = EnumSet.noneOf(Rs2TransportType.class); + for (TransportType type : config.getEnabledTransportTypes()) + { + enabledTypes.add(mapTransportType(type)); + } + Set restrictedPoints = new LinkedHashSet<>(); + for (int packed : config.getRestrictedPointsPacked()) + { + restrictedPoints.add(WorldPointUtil.unpackWorldPoint(packed)); + } + Rs2RoutePolicy policy = new Rs2RoutePolicy( + config.isUseBankItems(), + config.isAvoidWilderness(), + config.isAvoidDangerousNpcs(), + config.isIgnoreTeleportAndItems(), + Rs2Walker.disableTeleports, + config.isMembersWorld(), + config.getLiveCollisionOverlay().isEnabled(), + config.getCalculationCutoffMillis(), + config.getDistanceBeforeUsingTeleport(), + Rs2RoutePolicy.TeleportationItemMode.valueOf( + config.getTeleportationItemPolicy().name()), + enabledTypes, + restrictedPoints); + return request.withPolicy(policy); + } + + static Rs2PlanningSnapshot resolvePlanningSnapshot( + Rs2RouteRequest request, PathfinderConfig config) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(config, "config"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("planning snapshot requires a resolved policy")); + Set admitted = Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + Map> activeTransports = config.getTransports(); + if (activeTransports != null) + { + for (Set values : activeTransports.values()) + { + admitted.addAll(values); + } + } + Set usableTeleports = config.getUsableTeleportsSnapshot(); + if (usableTeleports != null) + { + admitted.addAll(usableTeleports); + } + List edges = new ArrayList<>(admitted.size()); + for (Transport transport : admitted) + { + edges.add(toTransportEdge(transport)); + } + net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay overlay = + config.getLiveCollisionOverlay(); + LiveCollisionView live = overlay == null ? null : overlay.current(); + Rs2PlanningSnapshot.CollisionOverride collision = live == null ? null : live::edge; + Set blocked = config.getBlockedTransportEdgesPacked(); + return new Rs2PlanningSnapshot( + policy, + edges, + collision, + blocked == null ? Collections.emptySet() : new LinkedHashSet<>(blocked), + config::isDangerousAdjacentTile); + } + + private static final class LocalRoutePlanner implements Rs2RoutePlanner + { + private final PathfinderConfig config; + + private LocalRoutePlanner(PathfinderConfig config) + { + this.config = Objects.requireNonNull(config, "config"); + } + + @Override + public String getEngineId() + { + return "microbot-local"; + } + + @Override + public Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(snapshot, "snapshot"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("route planner requires a resolved policy")); + if (snapshot.getPolicy() != policy) + { + throw new IllegalArgumentException("route request and planning snapshot policy differ"); + } + long started = System.nanoTime(); + Pathfinder pathfinder = new Pathfinder( + config, request.getStart(), request.getTargets()); + pathfinder.run(); + long elapsed = System.nanoTime() - started; + return snapshot(pathfinder, elapsed); + } + } + + /** Transitional concrete view for legacy overlays; the immutable route remains authoritative. */ + static Pathfinder materializeUpstreamRoute( + Rs2RouteResult result, PathfinderConfig config) + { + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(config, "config"); + List path = result.getPath(); + List steps = result.getSteps(); + if (steps.size() != Math.max(0, path.size() - 1)) + { + throw new IllegalArgumentException( + "upstream route steps must align with the materialized path"); + } + List transportsByStep = new ArrayList<>(steps.size()); + for (int i = 0; i < steps.size(); i++) + { + Rs2RouteStep step = steps.get(i); + if (!path.get(i).equals(step.getFrom()) + || !path.get(i + 1).equals(step.getTo())) + { + throw new IllegalArgumentException( + "upstream route step endpoints must align with the materialized path"); + } + if (!step.isTransport()) + { + transportsByStep.add(null); + continue; + } + Object sourceIdentity = step.getTransport().orElseThrow( + IllegalStateException::new).getSourceIdentity(); + if (!(sourceIdentity instanceof Transport)) + { + throw new IllegalArgumentException( + "upstream transport step is missing exact local execution identity"); + } + transportsByStep.add((Transport) sourceIdentity); + } + + Rs2RouteMetrics metrics = result.getMetrics(); + return Pathfinder.completedRoute( + config, + result.getStart(), + result.getTargets(), + path, + transportsByStep, + mapTermination(result.getTerminationReason()), + metrics.getPathCost(), + metrics.getSearchNanos(), + metrics.getNodesChecked(), + metrics.getTransportsChecked(), + metrics.getLiveCollisionEdgesChecked()); + } + + /** + * Immutable view of the completed path currently published to the walker. + * + *

The 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 Optional getActiveRoute() + { + Pathfinder source = getPathfinder(); + Future activeFuture = getPathfinderFuture(); + if (source == null || !source.isDone() + || (activeFuture != null && !activeFuture.isDone())) + { + return Optional.empty(); + } + Rs2RouteResult snapshot = activeRouteSnapshot; + if (activeRouteSnapshotSource == source && snapshot != null) + { + return Optional.of(snapshot); + } + synchronized (getPathfinderMutex()) + { + activeFuture = getPathfinderFuture(); + if (getPathfinder() != source || !source.isDone() + || (activeFuture != null && !activeFuture.isDone())) + { + return Optional.empty(); + } + snapshot = snapshot(source, Rs2RouteMetrics.UNAVAILABLE); + activeRouteSnapshotSource = source; + activeRouteSnapshot = snapshot; + return Optional.of(snapshot); + } + } - /** Shared world-map marker sprite ({@link ShortestPathPlugin#MARKER_IMAGE}). */ - public static final BufferedImage MARKER_IMAGE = ShortestPathPlugin.MARKER_IMAGE; + private static Rs2RouteResult snapshot(Pathfinder pathfinder, long elapsed) + { + Pathfinder.PathfinderStats stats = pathfinder.getStats(); + List path = pathfinder.getPath() == null + ? Collections.emptyList() + : pathfinder.getPath(); + List steps = new ArrayList<>(); + for (PathEdge edge : pathfinder.getPathEdges()) + { + if (edge.isTransport()) + { + steps.add(Rs2RouteStep.transport( + edge.getFrom(), edge.getTo(), toTransportEdge(edge.getTransport()))); + } + else + { + steps.add(Rs2RouteStep.walk(edge.getFrom(), edge.getTo())); + } + } + return new Rs2RouteResult( + pathfinder.getStart(), + pathfinder.getTargets(), + path, + steps, + mapTermination(pathfinder.getTerminationReason()), + new Rs2RouteMetrics( + elapsed, + pathfinder.getSelectedPathCost(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getNodesChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getTransportsChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE + : stats.getLiveCollisionEdgesChecked())); + } - // ------------------------------------------------------------------ - // Pathfinder lifecycle - // ------------------------------------------------------------------ + private static long activeSearchNanos(Pathfinder pathfinder) + { + Pathfinder.PathfinderStats stats = pathfinder.getStats(); + return stats == null + ? Rs2RouteMetrics.UNAVAILABLE : stats.getElapsedTimeNanos(); + } - /** @return the current pathfinder instance, or {@code null} if none is running. */ - public static Pathfinder getPathfinder() + /** + * Exact local execution selection for the runtime walker. + * + *

The 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 Optional getActiveTransportSelection( + List expectedPath, int pathIndex) { - ShortestPathPlugin.setPathfinder(pathfinder); + List selections = getActiveTransportSelections(expectedPath); + return selections.stream() + .filter(selection -> selection.getPathIndex() == pathIndex) + .findFirst(); } - /** @return the {@link Future} tracking the in-flight pathfinding task, or {@code null}. */ - public static Future getPathfinderFuture() + static List getActiveTransportSelections(List expectedPath) { - return ShortestPathPlugin.getPathfinderFuture(); + Pathfinder source = getPathfinder(); + List selections = getTransportSelections(source, expectedPath); + return getPathfinder() == source ? selections : Collections.emptyList(); } - public static void setPathfinderFuture(Future future) + static Optional getActiveTransportEdge(WorldPoint from, WorldPoint to) { - ShortestPathPlugin.setPathfinderFuture(future); + if (from == null || to == null) + { + return Optional.empty(); + } + return getActiveRoute().flatMap(route -> route.getTransportEdge(from, to)); } - /** @return the single-threaded executor pathfinding runs on. */ - public static ExecutorService getPathfindingExecutor() + /** Package-private pure seam for route-selection regressions. */ + static List getTransportSelections( + Pathfinder source, List expectedPath) { - return ShortestPathPlugin.getPathfindingExecutor(); + if (source == null || !source.isDone() || expectedPath == null) + { + return Collections.emptyList(); + } + List actualPath = source.getPath(); + if (actualPath == null || !actualPath.equals(expectedPath)) + { + return Collections.emptyList(); + } + List pathEdges = source.getPathEdges(); + if (pathEdges.size() != Math.max(0, actualPath.size() - 1)) + { + return Collections.emptyList(); + } + List selections = new ArrayList<>(); + for (int i = 0; i < pathEdges.size(); i++) + { + PathEdge pathEdge = pathEdges.get(i); + Transport selected = pathEdge.getTransport(); + if (selected == null) + { + continue; + } + if (!actualPath.get(i).equals(pathEdge.getFrom()) + || !actualPath.get(i + 1).equals(pathEdge.getTo())) + { + return Collections.emptyList(); + } + selections.add(new ActiveTransportSelection(i, toTransportEdge(selected), selected)); + } + return List.copyOf(selections); } - public static void setPathfindingExecutor(ExecutorService executor) + private static Rs2TransportEdge toTransportEdge(Transport transport) { - ShortestPathPlugin.setPathfindingExecutor(executor); + List itemRequirements = new ArrayList<>(); + for (TransportItemRequirement requirement : transport.getItemRequirements()) + { + itemRequirements.add(new Rs2TransportItemRequirement( + requirement.getAlternatives(), + requirement.getStaffAlternatives(), + requirement.getOffhandAlternatives(), + requirement.isRuneOnly())); + } + return new Rs2TransportEdge( + transport.getOrigin(), + transport.getDestination(), + mapTransportType(transport.getType()), + mapExecutor(TransportExecutionRegistry.executorFor(transport).orElse(null)), + mapTerminalTravelMode(transport), + transport.getDisplayInfo(), + transport.getAction(), + transport.getName(), + transport.getObjectId(), + transport.getDuration(), + TransportType.isTeleport(transport.getType(), transport.getOrigin()), + transport.isConsumable(), + transport.isMembers(), + transport.getMaxWildernessLevel(), + transport.getCurrencyName(), + transport.getCurrencyAmount(), + itemRequirements, + Arrays.stream(transport.getSkillLevels()).anyMatch(level -> level > 0), + transport.isQuestLocked(), + !transport.getVarbits().isEmpty() || !transport.getVarplayers().isEmpty(), + transport); } - /** @return the monitor guarding pathfinder start/cancel transitions. */ - public static Object getPathfinderMutex() + private static Rs2TerminalTravelMode mapTerminalTravelMode(Transport transport) { - return ShortestPathPlugin.getPathfinderMutex(); + return TransportExecutionRegistry.terminalTravelModeFor(transport) + .map(mode -> Rs2TerminalTravelMode.valueOf(mode.name())) + .orElse(Rs2TerminalTravelMode.UNSUPPORTED); + } + + private static Rs2TransportExecutor mapExecutor(TransportExecutionRegistry.Executor executor) + { + if (executor == null) + { + return Rs2TransportExecutor.UNSUPPORTED; + } + try + { + return Rs2TransportExecutor.valueOf(executor.name()); + } + catch (IllegalArgumentException ignored) + { + return Rs2TransportExecutor.UNSUPPORTED; + } + } + + private static Rs2TransportType mapTransportType(TransportType type) + { + if (type == null) + { + return Rs2TransportType.UNKNOWN; + } + try + { + return Rs2TransportType.valueOf(type.name()); + } + catch (IllegalArgumentException ignored) + { + return Rs2TransportType.UNKNOWN; + } + } + + private static Rs2RouteTermination mapTermination(PathTerminationReason reason) + { + if (reason == null) + { + return Rs2RouteTermination.FAILED; + } + switch (reason) + { + case TARGET_REACHED: + return Rs2RouteTermination.TARGET_REACHED; + case SEARCH_EXHAUSTED: + return Rs2RouteTermination.SEARCH_EXHAUSTED; + case CUTOFF_REACHED: + return Rs2RouteTermination.CUTOFF_REACHED; + case CANCELLED: + return Rs2RouteTermination.CANCELLED; + case FAILED: + default: + return Rs2RouteTermination.FAILED; + } + } + + private static PathTerminationReason mapTermination(Rs2RouteTermination reason) + { + if (reason == null) + { + return PathTerminationReason.FAILED; + } + switch (reason) + { + case TARGET_REACHED: + return PathTerminationReason.TARGET_REACHED; + case SEARCH_EXHAUSTED: + return PathTerminationReason.SEARCH_EXHAUSTED; + case CUTOFF_REACHED: + return PathTerminationReason.CUTOFF_REACHED; + case CANCELLED: + return PathTerminationReason.CANCELLED; + case FAILED: + default: + return PathTerminationReason.FAILED; + } } // ------------------------------------------------------------------ @@ -102,10 +1631,103 @@ public static Object getPathfinderMutex() // ------------------------------------------------------------------ /** - * Invalidate the planner's transport refresh cache so the next plan re-evaluates transport - * availability (league relics and similar unlocks change what is usable without any - * inventory change). + * Whether at least one tile within {@code distance} of {@code target} is standable in the + * configured static collision map. + * + *

This 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( + Map> transports, WorldPoint origin) + { + if (transports == null || origin == null) + { + return false; + } + Set atOrigin = transports.get(origin); + return atOrigin != null && !atOrigin.isEmpty(); + } + + /** Whether the static catalog contains the exact directed {@code origin -> destination} edge. */ + public static boolean hasCatalogTransportEdge(WorldPoint origin, WorldPoint destination) + { + return hasCatalogTransportEdge(ShortestPathPlugin.getTransports(), origin, destination); + } + + static boolean hasCatalogTransportEdge( + Map> transports, + WorldPoint origin, + WorldPoint destination) + { + if (transports == null || origin == null || destination == null) + { + return false; + } + Set atOrigin = transports.get(origin); + if (atOrigin == null || atOrigin.isEmpty()) + { + return false; + } + return atOrigin.stream() + .filter(Objects::nonNull) + .anyMatch(transport -> destination.equals(transport.getDestination())); + } + + /** + * Immutable planner-independent descriptions of every catalog entry keyed by {@code origin}. + * + *

This 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 List getCatalogTransportEdges(WorldPoint origin) + { + if (origin == null) + { + return Collections.emptyList(); + } + Map> transports = ShortestPathPlugin.getTransports(); + Set source = transports == null ? null : transports.get(origin); + if (source == null || source.isEmpty()) + { + catalogEdgeSnapshots.remove(origin); + return Collections.emptyList(); + } + CatalogEdgeSnapshot cached = catalogEdgeSnapshots.get(origin); + if (cached != null && cached.source == source) + { + return cached.edges; + } + List edges = toCatalogTransportEdges(source); + catalogEdgeSnapshots.put(origin, new CatalogEdgeSnapshot(source, edges)); + return edges; + } + + static List getCatalogTransportEdges( + Map> transports, WorldPoint origin) + { + if (transports == null || origin == null) + { + return Collections.emptyList(); + } + return toCatalogTransportEdges(transports.get(origin)); + } + + private static List toCatalogTransportEdges(Set atOrigin) + { + if (atOrigin == null || atOrigin.isEmpty()) + { + return Collections.emptyList(); + } + List result = new ArrayList<>(atOrigin.size()); + for (Transport transport : atOrigin) + { + if (transport != null) + { + result.add(toTransportEdge(transport)); + } + } + return List.copyOf(result); + } + + /** + * Compatibility access to the concrete mutable transport graph. + * New code should use the named catalog queries above or exact immutable route steps. + */ + @Deprecated public static Map> getTransports() { return ShortestPathPlugin.getTransports(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java new file mode 100644 index 00000000000..fe990dfa63d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java @@ -0,0 +1,68 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Coordinate-free process-lifetime timing aggregate for authoritative canary decisions. + * + *

The 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.List localSteps = local.getTransportSteps(); + java.util.List shadowSteps = shadow.getTransportSteps(); + if (localSteps.size() != shadowSteps.size()) + { + return false; + } + for (int i = 0; i < localSteps.size(); i++) + { + Rs2TransportEdge localEdge = localSteps.get(i).getTransport().orElseThrow( + IllegalStateException::new); + Rs2TransportEdge shadowEdge = shadowSteps.get(i).getTransport().orElseThrow( + IllegalStateException::new); + if (localEdge.getSourceIdentity() != shadowEdge.getSourceIdentity()) + { + return false; + } + } + return true; + } + + public Status getStatus() { return status; } + public String getShadowEngineId() { return shadowEngineId; } + public Rs2PlannerShadowContext getContext() { return context; } + public boolean isTerminationMatches() { return terminationMatches; } + public boolean isEndpointMatches() { return endpointMatches; } + public boolean isCostComparable() { return costComparable; } + public boolean isCostMatches() { return costMatches; } + public boolean isSelectedTransportsMatch() { return selectedTransportsMatch; } + public boolean isPathMatches() { return pathMatches; } + public long getShadowSearchNanos() { return shadowSearchNanos; } + public long getLocalSearchNanos() { return localSearchNanos; } + public String getFailureType() { return failureType; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java new file mode 100644 index 00000000000..e38a416ed33 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java @@ -0,0 +1,203 @@ +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.Objects; +import java.util.Set; + +/** Coordinate-free route classification attached to one production shadow comparison. */ +public final class Rs2PlannerShadowContext +{ + public enum Invocation + { + SYNCHRONOUS_QUERY, + ACTIVE_ROUTE, + ACTIVE_REPLAN, + RECOVERY_REPLAN + } + + public enum Coverage + { + SYNCHRONOUS_QUERY, + ACTIVE_ROUTE, + ACTIVE_REPLAN, + RECOVERY_REPLAN, + MEMBERS_WORLD_POLICY, + SURFACE_COORDINATES_ONLY, + UNDERGROUND_COORDINATES, + WALKING_ONLY_SELECTED, + USES_TRANSPORT, + SELECTS_MEMBERS_TRANSPORT, + SELECTS_ITEM_GATED_TRANSPORT, + SELECTS_SKILL_GATED_TRANSPORT, + SELECTS_QUEST_GATED_TRANSPORT, + SELECTS_STATE_GATED_TRANSPORT, + SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT, + BANK_ITEMS_ENABLED, + BANK_ROUTE_DIRECT, + BANK_ROUTE_TO_BANK, + BANK_ROUTE_FROM_BANK, + BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT, + LIVE_COLLISION_ENABLED, + LIVE_COLLISION_CONSULTED + } + + private static final int UNDERGROUND_Y = 6400; + + private final Invocation invocation; + private final Set coverage; + private final Set transportExecutors; + private final Set transportTypes; + + private Rs2PlannerShadowContext( + Invocation invocation, + Set coverage, + Set transportExecutors, + Set transportTypes) + { + this.invocation = Objects.requireNonNull(invocation, "invocation"); + this.coverage = Collections.unmodifiableSet(EnumSet.copyOf(coverage)); + this.transportExecutors = Collections.unmodifiableSet( + EnumSet.copyOf(transportExecutors)); + this.transportTypes = Collections.unmodifiableSet(EnumSet.copyOf(transportTypes)); + } + + static Rs2PlannerShadowContext from( + Invocation invocation, + boolean walkingOnlySelected, + Rs2RouteRequest request, + Rs2RouteResult local) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(local, "local"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("shadow context requires resolved policy")); + EnumSet coverage = EnumSet.noneOf(Coverage.class); + switch (invocation) + { + case SYNCHRONOUS_QUERY: coverage.add(Coverage.SYNCHRONOUS_QUERY); break; + case ACTIVE_ROUTE: coverage.add(Coverage.ACTIVE_ROUTE); break; + case ACTIVE_REPLAN: coverage.add(Coverage.ACTIVE_REPLAN); break; + case RECOVERY_REPLAN: coverage.add(Coverage.RECOVERY_REPLAN); break; + default: throw new IllegalStateException("unhandled invocation " + invocation); + } + boolean underground = isUnderground(request.getStart()) + || request.getTargets().stream().anyMatch(Rs2PlannerShadowContext::isUnderground) + || local.getPath().stream().anyMatch(Rs2PlannerShadowContext::isUnderground); + coverage.add(underground + ? Coverage.UNDERGROUND_COORDINATES : Coverage.SURFACE_COORDINATES_ONLY); + if (walkingOnlySelected) + { + coverage.add(Coverage.WALKING_ONLY_SELECTED); + } + EnumSet transportExecutors = + EnumSet.noneOf(Rs2TransportExecutor.class); + EnumSet transportTypes = EnumSet.noneOf(Rs2TransportType.class); + boolean itemGatedTransport = false; + boolean membersTransport = false; + boolean skillGatedTransport = false; + boolean questGatedTransport = false; + boolean stateGatedTransport = false; + for (Rs2RouteStep step : local.getTransportSteps()) + { + Rs2TransportEdge edge = step.getTransport().orElseThrow(IllegalStateException::new); + transportExecutors.add(edge.getExecutor()); + transportTypes.add(edge.getType()); + itemGatedTransport |= !edge.getItemRequirements().isEmpty() + || edge.getCurrencyAmount() > 0; + membersTransport |= edge.isMembers(); + skillGatedTransport |= edge.isSkillGated(); + questGatedTransport |= edge.isQuestGated(); + stateGatedTransport |= edge.isStateGated(); + } + if (policy.isMembersWorld()) + { + coverage.add(Coverage.MEMBERS_WORLD_POLICY); + } + if (!transportExecutors.isEmpty()) + { + coverage.add(Coverage.USES_TRANSPORT); + } + if (itemGatedTransport) + { + coverage.add(Coverage.SELECTS_ITEM_GATED_TRANSPORT); + } + if (membersTransport) + { + coverage.add(Coverage.SELECTS_MEMBERS_TRANSPORT); + } + if (skillGatedTransport) + { + coverage.add(Coverage.SELECTS_SKILL_GATED_TRANSPORT); + } + if (questGatedTransport) + { + coverage.add(Coverage.SELECTS_QUEST_GATED_TRANSPORT); + } + if (stateGatedTransport) + { + coverage.add(Coverage.SELECTS_STATE_GATED_TRANSPORT); + } + if (skillGatedTransport || questGatedTransport || stateGatedTransport) + { + coverage.add(Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT); + } + if (policy.isUseBankItems()) + { + coverage.add(Coverage.BANK_ITEMS_ENABLED); + } + switch (request.getPurpose()) + { + case GENERAL: break; + case BANK_ROUTE_DIRECT: coverage.add(Coverage.BANK_ROUTE_DIRECT); break; + case BANK_ROUTE_TO_BANK: coverage.add(Coverage.BANK_ROUTE_TO_BANK); break; + case BANK_ROUTE_FROM_BANK: + coverage.add(Coverage.BANK_ROUTE_FROM_BANK); + if (itemGatedTransport) + { + coverage.add(Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT); + } + break; + default: throw new IllegalStateException( + "unhandled route request purpose " + request.getPurpose()); + } + if (policy.isLiveCollisionEnabled()) + { + coverage.add(Coverage.LIVE_COLLISION_ENABLED); + } + if (local.getMetrics().hasLiveCollisionEdgesChecked() + && local.getMetrics().getLiveCollisionEdgesChecked() > 0L) + { + coverage.add(Coverage.LIVE_COLLISION_CONSULTED); + } + return new Rs2PlannerShadowContext( + invocation, coverage, transportExecutors, transportTypes); + } + + private static boolean isUnderground(WorldPoint point) + { + return point != null && point.getY() >= UNDERGROUND_Y; + } + + public Invocation getInvocation() + { + return invocation; + } + + public Set getCoverage() + { + return coverage; + } + + public Set getTransportExecutors() + { + return transportExecutors; + } + + public Set getTransportTypes() + { + return transportTypes; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java new file mode 100644 index 00000000000..766e25d179c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Immutable outcome counters for one overlapping shadow-evidence coverage tag. */ +public final class Rs2PlannerShadowCoverageStats +{ + private final long completed; + private final long matches; + private final long divergences; + private final long failures; + + Rs2PlannerShadowCoverageStats(long matches, long divergences, long failures) + { + this.matches = requireNonNegative(matches, "matches"); + this.divergences = requireNonNegative(divergences, "divergences"); + this.failures = requireNonNegative(failures, "failures"); + this.completed = Math.addExact(Math.addExact(matches, divergences), failures); + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getCompleted() { return completed; } + public long getMatches() { return matches; } + public long getDivergences() { return divergences; } + public long getFailures() { return failures; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java new file mode 100644 index 00000000000..8d849350673 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java @@ -0,0 +1,125 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** Immutable process-lifetime aggregate for production planner shadow evidence. */ +public final class Rs2PlannerShadowStats +{ + private final long submitted; + private final long completed; + private final long matches; + private final long divergences; + private final long failures; + private final long staleResults; + private final long discarded; + private final long routeShapeDifferences; + private final long upstreamCanarySelections; + private final long localFallbackDivergences; + private final long localFallbackFailures; + private final long startedAtEpochMillis; + private final Map coverage; + private final Map transportExecutors; + private final Map transportTypes; + private final Rs2WalkerShadowExecutionStats execution; + private final Rs2PlannerCanaryPerformanceStats canaryPerformance; + + Rs2PlannerShadowStats( + long submitted, + long completed, + long matches, + long divergences, + long failures, + long staleResults, + long discarded, + long routeShapeDifferences, + long upstreamCanarySelections, + long localFallbackDivergences, + long localFallbackFailures, + long startedAtEpochMillis, + Map coverage, + Map transportExecutors, + Map transportTypes, + Rs2WalkerShadowExecutionStats execution, + Rs2PlannerCanaryPerformanceStats canaryPerformance) + { + this.submitted = requireNonNegative(submitted, "submitted"); + this.completed = requireNonNegative(completed, "completed"); + this.matches = requireNonNegative(matches, "matches"); + this.divergences = requireNonNegative(divergences, "divergences"); + this.failures = requireNonNegative(failures, "failures"); + this.staleResults = requireNonNegative(staleResults, "staleResults"); + this.discarded = requireNonNegative(discarded, "discarded"); + this.routeShapeDifferences = requireNonNegative( + routeShapeDifferences, "routeShapeDifferences"); + this.upstreamCanarySelections = requireNonNegative( + upstreamCanarySelections, "upstreamCanarySelections"); + this.localFallbackDivergences = requireNonNegative( + localFallbackDivergences, "localFallbackDivergences"); + this.localFallbackFailures = requireNonNegative( + localFallbackFailures, "localFallbackFailures"); + this.startedAtEpochMillis = requireNonNegative(startedAtEpochMillis, "startedAtEpochMillis"); + EnumMap copy = + new EnumMap<>(Rs2PlannerShadowContext.Coverage.class); + copy.putAll(coverage); + this.coverage = Collections.unmodifiableMap(copy); + EnumMap executorCopy = + new EnumMap<>(Rs2TransportExecutor.class); + executorCopy.putAll(transportExecutors); + this.transportExecutors = Collections.unmodifiableMap(executorCopy); + EnumMap typeCopy = + new EnumMap<>(Rs2TransportType.class); + typeCopy.putAll(transportTypes); + this.transportTypes = Collections.unmodifiableMap(typeCopy); + this.execution = java.util.Objects.requireNonNull(execution, "execution"); + this.canaryPerformance = java.util.Objects.requireNonNull( + canaryPerformance, "canaryPerformance"); + if (matches + divergences + failures != completed) + { + throw new IllegalArgumentException( + "completed comparisons must equal matches, divergences and failures"); + } + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getSubmitted() { return submitted; } + public long getCompleted() { return completed; } + public long getMatches() { return matches; } + public long getDivergences() { return divergences; } + public long getFailures() { return failures; } + public long getStaleResults() { return staleResults; } + public long getDiscarded() { return discarded; } + public long getRouteShapeDifferences() { return routeShapeDifferences; } + public long getUpstreamCanarySelections() { return upstreamCanarySelections; } + public long getLocalFallbackDivergences() { return localFallbackDivergences; } + public long getLocalFallbackFailures() { return localFallbackFailures; } + public long getStartedAtEpochMillis() { return startedAtEpochMillis; } + public Map getCoverage() + { + return coverage; + } + public Map getTransportExecutors() + { + return transportExecutors; + } + public Map getTransportTypes() + { + return transportTypes; + } + public Rs2WalkerShadowExecutionStats getExecution() { return execution; } + public Rs2PlannerCanaryPerformanceStats getCanaryPerformance() { return canaryPerformance; } + + public long getPending() + { + return Math.max(0L, submitted - completed - discarded); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java new file mode 100644 index 00000000000..baca095994e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java @@ -0,0 +1,141 @@ +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.Set; +import java.util.function.IntPredicate; + +/** + * Immutable engine-neutral graph inputs captured after Microbot has resolved runtime admission. + * + *

The 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 List admittedTransports; + private final CollisionOverride collisionOverride; + private final Set blockedWalkingEdges; + private final IntPredicate dangerousTilePredicate; + + Rs2PlanningSnapshot( + Rs2RoutePolicy policy, + List admittedTransports, + CollisionOverride collisionOverride, + Set blockedWalkingEdges, + IntPredicate dangerousTilePredicate) + { + this.policy = Objects.requireNonNull(policy, "policy"); + this.admittedTransports = List.copyOf(admittedTransports); + this.collisionOverride = collisionOverride == null + ? NO_COLLISION_OVERRIDE : collisionOverride; + this.blockedWalkingEdges = Collections.unmodifiableSet( + new LinkedHashSet<>(blockedWalkingEdges)); + this.dangerousTilePredicate = Objects.requireNonNull( + dangerousTilePredicate, "dangerousTilePredicate"); + } + + public Rs2RoutePolicy getPolicy() + { + return policy; + } + + public List getAdmittedTransports() + { + return admittedTransports; + } + + public Boolean collisionOverride(int x, int y, int plane, int flag) + { + return collisionOverride.edge(x, y, plane, flag); + } + + public boolean isWalkingEdgeBlocked(int originPacked, int destinationPacked) + { + if (blockedWalkingEdges.isEmpty()) + { + return false; + } + if (blockedWalkingEdges.contains(edgeKey(originPacked, destinationPacked))) + { + return true; + } + int ox = unpackX(originPacked); + int oy = unpackY(originPacked); + int oz = unpackPlane(originPacked); + int dx = Integer.signum(unpackX(destinationPacked) - ox); + int dy = Integer.signum(unpackY(destinationPacked) - oy); + if (unpackPlane(destinationPacked) != oz || dx == 0 || dy == 0) + { + return false; + } + int xThenY = pack(ox + dx, oy, oz); + int yThenX = pack(ox, oy + dy, oz); + return blockedWalkingEdges.contains(edgeKey(originPacked, xThenY)) + || blockedWalkingEdges.contains(edgeKey(xThenY, destinationPacked)) + || blockedWalkingEdges.contains(edgeKey(originPacked, yThenX)) + || blockedWalkingEdges.contains(edgeKey(yThenX, destinationPacked)); + } + + public int getAdditionalWalkingCost(int packedDestination, Set targets) + { + if (!policy.isAvoidDangerousNpcs() || containsPacked(targets, packedDestination)) + { + return 0; + } + return dangerousTilePredicate.test(packedDestination) ? DANGEROUS_TILE_PENALTY : 0; + } + + private static boolean containsPacked(Set points, int packed) + { + for (WorldPoint point : points) + { + if (pack(point.getX(), point.getY(), point.getPlane()) == packed) + { + return true; + } + } + return false; + } + + private static long edgeKey(int originPacked, int destinationPacked) + { + return ((long) originPacked << 32) ^ (destinationPacked & 0xffffffffL); + } + + private static int pack(int x, int y, int plane) + { + return (x & 0x7FFF) | ((y & 0x7FFF) << 15) | ((plane & 0x3) << 30); + } + + private static int unpackX(int packed) + { + return packed & 0x7FFF; + } + + private static int unpackY(int packed) + { + return (packed >> 15) & 0x7FFF; + } + + private static int unpackPlane(int packed) + { + return (packed >> 30) & 0x3; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java new file mode 100644 index 00000000000..ef63e28a844 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java @@ -0,0 +1,105 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Immutable, planner-independent measurements for one route calculation. + * + *

A 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 Set enabledTransportTypes; + private final Set restrictedPoints; + + public Rs2RoutePolicy( + boolean useBankItems, + boolean avoidWilderness, + boolean avoidDangerousNpcs, + boolean ignoreTeleportAndItems, + boolean teleportsDisabled, + boolean membersWorld, + boolean liveCollisionEnabled, + long calculationCutoffMillis, + int distanceBeforeUsingTeleport, + TeleportationItemMode teleportationItemMode, + Set enabledTransportTypes, + Set restrictedPoints) + { + if (calculationCutoffMillis <= 0) + { + throw new IllegalArgumentException("calculationCutoffMillis must be positive"); + } + if (distanceBeforeUsingTeleport < 0) + { + throw new IllegalArgumentException("distanceBeforeUsingTeleport must be non-negative"); + } + this.useBankItems = useBankItems; + this.avoidWilderness = avoidWilderness; + this.avoidDangerousNpcs = avoidDangerousNpcs; + this.ignoreTeleportAndItems = ignoreTeleportAndItems; + this.teleportsDisabled = teleportsDisabled; + this.membersWorld = membersWorld; + this.liveCollisionEnabled = liveCollisionEnabled; + this.calculationCutoffMillis = calculationCutoffMillis; + this.distanceBeforeUsingTeleport = distanceBeforeUsingTeleport; + this.teleportationItemMode = Objects.requireNonNull( + teleportationItemMode, "teleportationItemMode"); + Objects.requireNonNull(enabledTransportTypes, "enabledTransportTypes"); + EnumSet enabledCopy = enabledTransportTypes.isEmpty() + ? EnumSet.noneOf(Rs2TransportType.class) + : EnumSet.copyOf(enabledTransportTypes); + this.enabledTransportTypes = Collections.unmodifiableSet(enabledCopy); + Objects.requireNonNull(restrictedPoints, "restrictedPoints"); + LinkedHashSet restrictionCopy = new LinkedHashSet<>(); + for (WorldPoint point : restrictedPoints) + { + restrictionCopy.add(Objects.requireNonNull(point, "restricted point")); + } + this.restrictedPoints = Collections.unmodifiableSet(restrictionCopy); + } + + public Rs2RoutePolicy withUseBankItems(boolean enabled) + { + return new Rs2RoutePolicy( + enabled, avoidWilderness, avoidDangerousNpcs, ignoreTeleportAndItems, + teleportsDisabled, membersWorld, liveCollisionEnabled, calculationCutoffMillis, + distanceBeforeUsingTeleport, teleportationItemMode, enabledTransportTypes, + restrictedPoints); + } + + public boolean isUseBankItems() { return useBankItems; } + public boolean isAvoidWilderness() { return avoidWilderness; } + public boolean isAvoidDangerousNpcs() { return avoidDangerousNpcs; } + public boolean isIgnoreTeleportAndItems() { return ignoreTeleportAndItems; } + public boolean isTeleportsDisabled() { return teleportsDisabled; } + public boolean isMembersWorld() { return membersWorld; } + public boolean isLiveCollisionEnabled() { return liveCollisionEnabled; } + public long getCalculationCutoffMillis() { return calculationCutoffMillis; } + public int getDistanceBeforeUsingTeleport() { return distanceBeforeUsingTeleport; } + public TeleportationItemMode getTeleportationItemMode() { return teleportationItemMode; } + public Set getEnabledTransportTypes() { return enabledTransportTypes; } + public Set getRestrictedPoints() { return restrictedPoints; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java new file mode 100644 index 00000000000..d50f7d08e5f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java @@ -0,0 +1,159 @@ +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.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Immutable Microbot-owned input for a synchronous route calculation. + * + *

The 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 Set targets; + private final RefreshPolicy refreshPolicy; + private final WorldPoint refreshTarget; + private final Boolean useBankItems; + private final Rs2RoutePolicy policy; + private final Purpose purpose; + + private Rs2RouteRequest( + WorldPoint start, + Set targets, + RefreshPolicy refreshPolicy, + WorldPoint refreshTarget, + Boolean useBankItems, + Rs2RoutePolicy policy, + Purpose purpose) + { + this.start = Objects.requireNonNull(start, "start"); + Objects.requireNonNull(targets, "targets"); + if (targets.isEmpty()) + { + throw new IllegalArgumentException("targets must not be empty"); + } + LinkedHashSet targetCopy = new LinkedHashSet<>(); + for (WorldPoint target : targets) + { + targetCopy.add(Objects.requireNonNull(target, "target")); + } + this.targets = Collections.unmodifiableSet(targetCopy); + this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refreshPolicy"); + this.refreshTarget = refreshTarget; + this.useBankItems = useBankItems; + this.policy = policy; + this.purpose = Objects.requireNonNull(purpose, "purpose"); + } + + public static Rs2RouteRequest to(WorldPoint start, WorldPoint target) + { + return toAny(start, Collections.singleton(target)); + } + + public static Rs2RouteRequest toAny(WorldPoint start, Set targets) + { + return new Rs2RouteRequest( + start, targets, RefreshPolicy.IF_TRANSPORTS_EMPTY, null, null, null, + Purpose.GENERAL); + } + + public Rs2RouteRequest withRefreshPolicy(RefreshPolicy policy) + { + return new Rs2RouteRequest( + start, targets, policy, refreshTarget, useBankItems, this.policy, purpose); + } + + public Rs2RouteRequest withRefreshTarget(WorldPoint target) + { + return new Rs2RouteRequest( + start, targets, refreshPolicy, target, useBankItems, policy, purpose); + } + + /** + * Include or exclude bank contents while evaluating transport requirements. Planning with this + * temporary policy always refreshes the transport snapshot and restores the previous policy after + * the search. + */ + public Rs2RouteRequest withBankItems(boolean enabled) + { + return new Rs2RouteRequest( + start, targets, RefreshPolicy.ALWAYS, refreshTarget, enabled, + policy == null ? null : policy.withUseBankItems(enabled), purpose); + } + + /** Classify this request without changing planning behavior. */ + public Rs2RouteRequest withPurpose(Purpose purpose) + { + return new Rs2RouteRequest( + start, targets, refreshPolicy, refreshTarget, useBankItems, policy, + Objects.requireNonNull(purpose, "purpose")); + } + + /** Attach the fully resolved policy passed to an engine implementation. */ + public Rs2RouteRequest withPolicy(Rs2RoutePolicy resolvedPolicy) + { + Rs2RoutePolicy nonNullPolicy = Objects.requireNonNull(resolvedPolicy, "resolvedPolicy"); + return new Rs2RouteRequest( + start, targets, refreshPolicy, refreshTarget, + nonNullPolicy.isUseBankItems(), nonNullPolicy, purpose); + } + + public WorldPoint getStart() + { + return start; + } + + public Set getTargets() + { + return targets; + } + + public RefreshPolicy getRefreshPolicy() + { + return refreshPolicy; + } + + public WorldPoint getRefreshTarget() + { + return refreshTarget; + } + + public Boolean getUseBankItems() + { + return useBankItems; + } + + public Optional getPolicy() + { + return Optional.ofNullable(policy); + } + + public Purpose getPurpose() + { + return purpose; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java new file mode 100644 index 00000000000..cfd2f82e29b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java @@ -0,0 +1,185 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** Immutable result of a synchronous Microbot route calculation. */ +public final class Rs2RouteResult +{ + private final WorldPoint start; + private final Set targets; + private final List path; + private final List steps; + private final Map> transportEdgesByEndpoints; + private final Rs2RouteTermination terminationReason; + private final Rs2RouteMetrics metrics; + + Rs2RouteResult( + WorldPoint start, + Set targets, + List path, + List steps, + Rs2RouteTermination terminationReason, + Rs2RouteMetrics metrics) + { + this.start = start; + this.targets = Collections.unmodifiableSet(new LinkedHashSet<>(targets)); + this.path = path == null ? Collections.emptyList() : List.copyOf(path); + this.steps = steps == null ? Collections.emptyList() : List.copyOf(steps); + validateSteps(this.path, this.steps); + this.transportEdgesByEndpoints = indexTransportEdges(this.steps); + this.terminationReason = Objects.requireNonNull(terminationReason, "terminationReason"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + } + + public WorldPoint getStart() + { + return start; + } + + public Set getTargets() + { + return targets; + } + + public List getPath() + { + return path; + } + + public List getSteps() + { + return steps; + } + + public List getTransportSteps() + { + List transportSteps = new ArrayList<>(); + for (Rs2RouteStep step : steps) + { + if (step.isTransport()) + { + transportSteps.add(step); + } + } + return List.copyOf(transportSteps); + } + + /** Exact selected transport for a directed route edge, if that edge is a transport. */ + public Optional getTransportEdge(WorldPoint from, WorldPoint to) + { + if (from == null || to == null) + { + return Optional.empty(); + } + Map byDestination = transportEdgesByEndpoints.get(from); + return byDestination == null + ? Optional.empty() + : Optional.ofNullable(byDestination.get(to)); + } + + /** Whether the search ended normally rather than through cancellation or an internal failure. */ + public boolean isSearchCompleted() + { + return terminationReason != Rs2RouteTermination.CANCELLED + && terminationReason != Rs2RouteTermination.FAILED; + } + + public Rs2RouteTermination getTerminationReason() + { + return terminationReason; + } + + public long getSearchNanos() + { + return metrics.getSearchNanos(); + } + + public boolean hasSearchNanos() + { + return metrics.hasSearchNanos(); + } + + public Rs2RouteMetrics getMetrics() + { + return metrics; + } + + public Optional getEndpoint() + { + return path.isEmpty() ? Optional.empty() : Optional.of(path.get(path.size() - 1)); + } + + public Optional getReachedTarget(int tolerance) + { + if (tolerance < 0) + { + throw new IllegalArgumentException("tolerance must be non-negative"); + } + Optional endpoint = getEndpoint(); + if (endpoint.isEmpty()) + { + return Optional.empty(); + } + WorldPoint end = endpoint.get(); + return targets.stream() + .filter(target -> target.getPlane() == end.getPlane()) + .filter(target -> target.distanceTo2D(end) <= tolerance) + .findFirst(); + } + + public boolean isTargetReached(int tolerance) + { + return getReachedTarget(tolerance).isPresent(); + } + + private static void validateSteps(List path, List steps) + { + int expectedSteps = Math.max(0, path.size() - 1); + if (steps.size() != expectedSteps) + { + throw new IllegalArgumentException( + "route steps must describe every path edge: expected " + expectedSteps + + ", got " + steps.size()); + } + for (int i = 0; i < steps.size(); i++) + { + Rs2RouteStep step = steps.get(i); + if (!path.get(i).equals(step.getFrom()) || !path.get(i + 1).equals(step.getTo())) + { + throw new IllegalArgumentException("route step " + i + " is not contiguous with path"); + } + } + } + + private static Map> indexTransportEdges( + List steps) + { + Map> mutable = new LinkedHashMap<>(); + for (Rs2RouteStep step : steps) + { + if (!step.isTransport()) + { + continue; + } + Rs2TransportEdge edge = step.getTransport().orElseThrow(IllegalStateException::new); + mutable.computeIfAbsent(step.getFrom(), ignored -> new LinkedHashMap<>()) + .putIfAbsent(step.getTo(), edge); + } + Map> immutable = new LinkedHashMap<>(); + for (Map.Entry> entry : mutable.entrySet()) + { + immutable.put(entry.getKey(), Collections.unmodifiableMap(entry.getValue())); + } + return Collections.unmodifiableMap(immutable); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java new file mode 100644 index 00000000000..55a166caef8 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Objects; +import java.util.Optional; + +/** One immutable, planner-independent edge in a route. */ +public final class Rs2RouteStep +{ + public enum Kind + { + WALK, + TRANSPORT + } + + private final WorldPoint from; + private final WorldPoint to; + private final Kind kind; + private final Rs2TransportEdge transport; + + private Rs2RouteStep(WorldPoint from, WorldPoint to, Kind kind, Rs2TransportEdge transport) + { + this.from = Objects.requireNonNull(from, "from"); + this.to = Objects.requireNonNull(to, "to"); + this.kind = Objects.requireNonNull(kind, "kind"); + this.transport = transport; + if ((kind == Kind.TRANSPORT) != (transport != null)) + { + throw new IllegalArgumentException("transport metadata must match route-step kind"); + } + if (transport != null && !to.equals(transport.getDestination())) + { + throw new IllegalArgumentException("transport destination must match route-step destination"); + } + } + + public static Rs2RouteStep walk(WorldPoint from, WorldPoint to) + { + return new Rs2RouteStep(from, to, Kind.WALK, null); + } + + public static Rs2RouteStep transport(WorldPoint from, WorldPoint to, Rs2TransportEdge transport) + { + return new Rs2RouteStep(from, to, Kind.TRANSPORT, transport); + } + + public WorldPoint getFrom() { return from; } + public WorldPoint getTo() { return to; } + public Kind getKind() { return kind; } + public Optional getTransport() { return Optional.ofNullable(transport); } + public boolean isTransport() { return kind == Kind.TRANSPORT; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java new file mode 100644 index 00000000000..dd982ae5329 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java @@ -0,0 +1,11 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Stable Microbot-owned reason why route planning stopped. */ +public enum Rs2RouteTermination +{ + TARGET_REACHED, + SEARCH_EXHAUSTED, + CUTOFF_REACHED, + CANCELLED, + FAILED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java new file mode 100644 index 00000000000..5b6a70b0249 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Planner-independent interaction sequence for a terminal SHIP, NPC or BOAT edge. */ +public enum Rs2TerminalTravelMode +{ + DIRECT, + DIALOGUE_DESTINATION, + UNSUPPORTED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java new file mode 100644 index 00000000000..3a647784432 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java @@ -0,0 +1,133 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Immutable execution-facing description of the exact transport selected for a route edge. */ +public final class Rs2TransportEdge +{ + private final WorldPoint origin; + private final WorldPoint destination; + private final Rs2TransportType type; + private final Rs2TransportExecutor executor; + private final Rs2TerminalTravelMode terminalTravelMode; + private final String displayInfo; + private final String action; + private final String target; + private final int objectId; + private final int duration; + private final boolean teleport; + private final boolean consumable; + private final boolean members; + private final boolean skillGated; + private final boolean questGated; + private final boolean stateGated; + private final int maxWildernessLevel; + private final String currencyName; + private final int currencyAmount; + private final List itemRequirements; + /** Opaque local-engine identity; never exposed by the public planner-independent contract. */ + private final Object sourceIdentity; + + public Rs2TransportEdge( + WorldPoint origin, + WorldPoint destination, + Rs2TransportType type, + Rs2TransportExecutor executor, + Rs2TerminalTravelMode terminalTravelMode, + String displayInfo, + String action, + String target, + int objectId, + int duration, + boolean teleport, + boolean consumable, + boolean members, + int maxWildernessLevel, + String currencyName, + int currencyAmount, + List itemRequirements) + { + this(origin, destination, type, executor, terminalTravelMode, displayInfo, action, target, + objectId, duration, teleport, consumable, members, maxWildernessLevel, currencyName, + currencyAmount, itemRequirements, false, false, false, null); + } + + Rs2TransportEdge( + WorldPoint origin, + WorldPoint destination, + Rs2TransportType type, + Rs2TransportExecutor executor, + Rs2TerminalTravelMode terminalTravelMode, + String displayInfo, + String action, + String target, + int objectId, + int duration, + boolean teleport, + boolean consumable, + boolean members, + int maxWildernessLevel, + String currencyName, + int currencyAmount, + List itemRequirements, + boolean skillGated, + boolean questGated, + boolean stateGated, + Object sourceIdentity) + { + this.origin = origin; + this.destination = Objects.requireNonNull(destination, "destination"); + this.type = Objects.requireNonNull(type, "type"); + this.executor = Objects.requireNonNull(executor, "executor"); + this.terminalTravelMode = Objects.requireNonNull(terminalTravelMode, "terminalTravelMode"); + if ((executor == Rs2TransportExecutor.TERMINAL_TRAVEL) + != (terminalTravelMode != Rs2TerminalTravelMode.UNSUPPORTED)) + { + throw new IllegalArgumentException("terminal travel executor and mode must agree"); + } + this.displayInfo = displayInfo; + this.action = action; + this.target = target; + this.objectId = objectId; + this.duration = duration; + this.teleport = teleport; + this.consumable = consumable; + this.members = members; + this.skillGated = skillGated; + this.questGated = questGated; + this.stateGated = stateGated; + this.maxWildernessLevel = maxWildernessLevel; + this.currencyName = currencyName == null ? "" : currencyName; + this.currencyAmount = currencyAmount; + this.itemRequirements = itemRequirements == null + ? Collections.emptyList() + : List.copyOf(itemRequirements); + this.sourceIdentity = sourceIdentity; + } + + public WorldPoint getOrigin() { return origin; } + public WorldPoint getDestination() { return destination; } + public Rs2TransportType getType() { return type; } + public Rs2TransportExecutor getExecutor() { return executor; } + public Rs2TerminalTravelMode getTerminalTravelMode() { return terminalTravelMode; } + public String getDisplayInfo() { return displayInfo; } + public String getAction() { return action; } + public String getTarget() { return target; } + public int getObjectId() { return objectId; } + public int getDuration() { return duration; } + public boolean isTeleport() { return teleport; } + public boolean isConsumable() { return consumable; } + public boolean isMembers() { return members; } + public boolean isSkillGated() { return skillGated; } + public boolean isQuestGated() { return questGated; } + public boolean isStateGated() { return stateGated; } + public int getMaxWildernessLevel() { return maxWildernessLevel; } + public String getCurrencyName() { return currencyName; } + public int getCurrencyAmount() { return currencyAmount; } + public List getItemRequirements() { return itemRequirements; } + Object getSourceIdentity() { return sourceIdentity; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java new file mode 100644 index 00000000000..71f9b6d0f5a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java @@ -0,0 +1,25 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Planner-independent Microbot runtime capability selected for a transport edge. */ +public enum Rs2TransportExecutor +{ + BARROWS_DIG, + CANOE, + CHARTER_SHIP, + FAIRY_RING, + GNOME_GLIDER, + HOT_AIR_BALLOON, + ITEM_TELEPORT, + MAGIC_CARPET, + MAGIC_MUSHTREE, + MINIGAME_TELEPORT, + OBJECT, + POH, + QUETZAL, + SEASONAL, + SPELL_TELEPORT, + SPIRIT_TREE, + TERMINAL_TRAVEL, + WILDERNESS_OBELISK, + UNSUPPORTED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java new file mode 100644 index 00000000000..b9844c73e71 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java @@ -0,0 +1,224 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; + +/** One immutable AND-clause whose item/quantity alternatives are OR-ed. */ +public final class Rs2TransportItemRequirement +{ + private final Map alternatives; + private final Set staffAlternatives; + private final Set offhandAlternatives; + private final boolean runeOnly; + + public Rs2TransportItemRequirement(Map alternatives) + { + this(alternatives, Collections.emptySet(), Collections.emptySet(), false); + } + + public Rs2TransportItemRequirement( + Map alternatives, + Set staffAlternatives, + Set offhandAlternatives, + boolean runeOnly) + { + if (alternatives == null || alternatives.isEmpty()) + { + throw new IllegalArgumentException("item requirement must contain an alternative"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry alternative : alternatives.entrySet()) + { + Integer itemId = Objects.requireNonNull(alternative.getKey(), "itemId"); + Integer quantity = Objects.requireNonNull(alternative.getValue(), "quantity"); + if (itemId <= 0 || quantity < 0) + { + throw new IllegalArgumentException("invalid item requirement: " + alternative); + } + copy.put(itemId, quantity); + } + this.alternatives = Collections.unmodifiableMap(copy); + this.staffAlternatives = immutablePositiveIds(staffAlternatives, "staff"); + this.offhandAlternatives = immutablePositiveIds(offhandAlternatives, "offhand"); + this.runeOnly = runeOnly; + } + + private static Set immutablePositiveIds(Set itemIds, String label) + { + if (itemIds == null || itemIds.isEmpty()) + { + return Collections.emptySet(); + } + LinkedHashSet copy = new LinkedHashSet<>(); + for (Integer itemId : itemIds) + { + if (itemId == null || itemId <= 0) + { + throw new IllegalArgumentException(label + " item id must be positive: " + itemId); + } + copy.add(itemId); + } + return Collections.unmodifiableSet(copy); + } + + public Map getAlternatives() + { + return alternatives; + } + + public Set getStaffAlternatives() { return staffAlternatives; } + public Set getOffhandAlternatives() { return offhandAlternatives; } + public boolean isRuneOnly() { return runeOnly; } + + public boolean isSatisfiedBy(IntUnaryOperator availableQuantity) + { + Objects.requireNonNull(availableQuantity, "availableQuantity"); + return alternatives.entrySet().stream().anyMatch(alternative -> + { + int required = alternative.getValue(); + int available = Math.max(0, availableQuantity.applyAsInt(alternative.getKey())); + return (required == 0 && available == 0) || (required > 0 && available >= required); + }); + } + + private boolean isSatisfiedBy(IntUnaryOperator availableQuantity, int staffItemId, int offhandItemId) + { + return isSatisfiedBy(availableQuantity) + || staffAlternatives.contains(staffItemId) + || offhandAlternatives.contains(offhandItemId); + } + + public static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) + { + return selectProviders( + requirements, availableQuantity, staffAvailable, offhandAvailable, false); + } + + /** + * Select equipment for provider-bearing clauses while deferring ordinary missing items to the + * withdrawal/purchase planner. This prevents an unbanked purchasable pass from rejecting the + * equipment phase before its fare fallback can be evaluated. + */ + public static Optional selectEquipmentProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) + { + return selectProviders( + requirements, availableQuantity, staffAvailable, offhandAvailable, true); + } + + private static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable, + boolean deferOrdinaryRequirements) + { + if (requirements == null || requirements.isEmpty()) + { + return Optional.of(ProviderSelection.NONE); + } + TreeSet staffs = new TreeSet<>(); + TreeSet offhands = new TreeSet<>(); + for (Rs2TransportItemRequirement requirement : requirements) + { + requirement.staffAlternatives.stream().filter(staffAvailable::test).forEach(staffs::add); + requirement.offhandAlternatives.stream().filter(offhandAvailable::test).forEach(offhands::add); + } + List staffCandidates = new ArrayList<>(); + staffCandidates.add(ProviderSelection.NO_ITEM); + staffCandidates.addAll(staffs); + List offhandCandidates = new ArrayList<>(); + offhandCandidates.add(ProviderSelection.NO_ITEM); + offhandCandidates.addAll(offhands); + for (Integer staff : staffCandidates) + { + for (Integer offhand : offhandCandidates) + { + boolean satisfied = true; + for (Rs2TransportItemRequirement requirement : requirements) + { + if (deferOrdinaryRequirements + && requirement.staffAlternatives.isEmpty() + && requirement.offhandAlternatives.isEmpty()) + { + continue; + } + if (!requirement.isSatisfiedBy(availableQuantity, staff, offhand)) + { + satisfied = false; + break; + } + } + if (satisfied) + { + return Optional.of(new ProviderSelection(staff, offhand)); + } + } + } + return Optional.empty(); + } + + public static final class ProviderSelection + { + private static final int NO_ITEM = -1; + private static final ProviderSelection NONE = new ProviderSelection(NO_ITEM, NO_ITEM); + + private final int staffItemId; + private final int offhandItemId; + + private ProviderSelection(int staffItemId, int offhandItemId) + { + this.staffItemId = staffItemId; + this.offhandItemId = offhandItemId; + } + + public int getStaffItemId() { return staffItemId; } + public int getOffhandItemId() { return offhandItemId; } + public boolean hasStaff() { return staffItemId > 0; } + public boolean hasOffhand() { return offhandItemId > 0; } + } + + @Override + public boolean equals(Object other) + { + if (this == other) + { + return true; + } + if (!(other instanceof Rs2TransportItemRequirement)) + { + return false; + } + Rs2TransportItemRequirement that = (Rs2TransportItemRequirement) other; + return alternatives.equals(that.alternatives) + && staffAlternatives.equals(that.staffAlternatives) + && offhandAlternatives.equals(that.offhandAlternatives) + && runeOnly == that.runeOnly; + } + + @Override + public int hashCode() + { + int result = alternatives.hashCode(); + result = 31 * result + staffAlternatives.hashCode(); + result = 31 * result + offhandAlternatives.hashCode(); + return 31 * result + Boolean.hashCode(runeOnly); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java new file mode 100644 index 00000000000..bfe494468ce --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java @@ -0,0 +1,78 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable bank preparation required by the exact transport edges selected for a route. + * + *

Withdrawals 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 Map withdrawals; + private final List equipmentItemIds; + private final boolean satisfiable; + + public Rs2TransportLoadout( + Map withdrawals, + List equipmentItemIds, + boolean satisfiable) + { + LinkedHashMap withdrawalCopy = new LinkedHashMap<>(); + if (withdrawals != null) + { + for (Map.Entry withdrawal : withdrawals.entrySet()) + { + Integer itemId = withdrawal.getKey(); + Integer quantity = withdrawal.getValue(); + if (itemId == null || itemId <= 0 || quantity == null || quantity <= 0) + { + throw new IllegalArgumentException("invalid transport withdrawal: " + withdrawal); + } + withdrawalCopy.merge(itemId, quantity, Integer::sum); + } + } + this.withdrawals = Collections.unmodifiableMap(withdrawalCopy); + if (equipmentItemIds == null) + { + this.equipmentItemIds = Collections.emptyList(); + } + else + { + for (Integer itemId : equipmentItemIds) + { + if (itemId == null || itemId <= 0) + { + throw new IllegalArgumentException("invalid equipment item id: " + itemId); + } + } + this.equipmentItemIds = List.copyOf(equipmentItemIds); + } + this.satisfiable = satisfiable; + } + + public static Rs2TransportLoadout empty() + { + return EMPTY; + } + + public static Rs2TransportLoadout unavailable() + { + return UNAVAILABLE; + } + + public Map getWithdrawals() { return withdrawals; } + public List getEquipmentItemIds() { return equipmentItemIds; } + public boolean isSatisfiable() { return satisfiable; } + public boolean isEmpty() { return withdrawals.isEmpty() && equipmentItemIds.isEmpty(); } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java new file mode 100644 index 00000000000..2b363812c0c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java @@ -0,0 +1,40 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Planner-independent transport categories understood by the Microbot walker boundary. + * + *

The 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 expectedTransportDestinations = new ArrayDeque<>(); + 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( + 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( + 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)); - /** Exact object-less Barrows mound edges that are executed by digging with a spade. */ - private static final Map BARROWS_DIG_DESTINATIONS = Map.of( - new WorldPoint(3564, 3291, 0), new WorldPoint(3559, 9703, 3), - new WorldPoint(3575, 3299, 0), new WorldPoint(3558, 9718, 3), - new WorldPoint(3578, 3281, 0), new WorldPoint(3534, 9706, 3), - new WorldPoint(3567, 3274, 0), new WorldPoint(3546, 9686, 3), - new WorldPoint(3553, 3281, 0), new WorldPoint(3566, 9683, 3), - new WorldPoint(3556, 3297, 0), new WorldPoint(3578, 9704, 3)); - /** * Max Chebyshev "radius" for Quetzal / near-destination checks — guards use {@code distanceTo2D < OFFSET}. * {@link WorldPoint#distanceTo(WorldPoint)} delegates to {@link WorldPoint#distanceTo2D(WorldPoint)} when both @@ -306,21 +336,22 @@ public static WorldPoint getCurrentTarget() { static final int OFFSET = 10; /** Post-travel poll/timeout for Spirit Tree, Quetzal, glider, fairy ring, and other same-plane landing waits. */ - private static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; - private static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; + static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; + static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; /** Ship / charter / glider — landing predicate uses {@link #isPlayerWithinChebyshevOf} with this exclusive bound. */ - private static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; + static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; /** Max wait after ship/NPC/boat dialogue until near destination (must match {@link #sleepUntil} timeout + warn text). */ - private static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; + static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ - private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; - private static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; + static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; + static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; + static final int AL_KHARID_TOLL_INTERACTION_START_WAIT_MS = 2_500; /** Teleport “already near destination” skip in path loop — same semantics as prior {@code distanceTo2D < 3}. */ - private static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; + static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; /** * When the last walkable path tile is within this Chebyshev distance of the goal, treat the leg as a @@ -339,7 +370,7 @@ public static WorldPoint getCurrentTarget() { * Verbose walker traces — enable DEBUG logging for {@code net.runelite.client.plugins.microbot}. * Uses {@link Microbot#log(Level, String, Object...)} so levels route consistently. */ - private static void walkerDiag(String format, Object... args) { + static void walkerDiag(String format, Object... args) { Microbot.log(Level.DEBUG, "[WalkerDiag] " + format, args); } @@ -347,7 +378,7 @@ private static void walkerDiag(String format, Object... args) { * Compact {@code x,y,p} for logs (world API coords). Similar comma coords exist in test harnesses — keep here until * a shared microbot util is justified. */ - private static String compactWorldPoint(WorldPoint wp) { + static String compactWorldPoint(WorldPoint wp) { if (wp == null) { return "?"; } @@ -355,42 +386,82 @@ private static String compactWorldPoint(WorldPoint wp) { } private static void markWalkSessionStart(WorldPoint target) { + testRecoveryReplanRequests.set(0); + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null) + { + evidence.started = true; + } + resetWalkSessionState(); + routeState.requestedGoal = target; + WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); + } + + /** + * The per-walk state reset. Split out from {@link #markWalkSessionStart} because it performs no + * game reads, so the staleness invariants below can be unit-tested instead of re-discovered live. + * + *

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= camping at Clock Tower when the script restarts walks every ~40s. clearInterimTarget("walk-start"); + // Same staleness, door flavour: the latest door claim belongs to the PREVIOUS walk, and its + // 6s window comfortably spans a script's walk-to-walk gap. A fresh walk re-nudged the old + // door — observed as a first_door_edge_nudge pointing BACKWARD at walk start, ~2s of standing + // still (or worse, a step the wrong way) before the new route's first click. Per-edge + // cooldowns survive on purpose: hammering one door across two walks is still hammering. + doorAttemptLedger.clearLatestAttempt(); + // The partial-regression baseline measures endpoints against the PREVIOUS walk's goal; a + // stale small baseline would read the new walk's honest first partial as a regression. + routeState.bestPartialDGoal = Integer.MAX_VALUE; + routeState.partialRegressReplans = 0; + routeState.recoveryGateEnteredAtMs = 0L; + routeState.walledDoorEdgeFrom = null; + routeState.walledDoorEdgeTo = null; + routeState.walledDoorEdgeAtMs = 0L; + routeState.requestedGoal = null; + routeState.sealedRimRetargets = 0; resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); } - WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); } - private static void clearRecentTransportContext() { - routeState.lastTransportHandledAtMs = 0L; - routeState.lastTransportHandledAtLocation = null; - routeState.lastTransportOriginLocation = null; - routeState.lastTransportDestinationLocation = null; + /** Same package (e.g. unit tests) only — not part of the script API. */ + static WalkerRouteState routeStateForTesting() { + return routeState; } - private static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { - if (routeState.firstMovementClickMarked) { - return; - } - long startedAt = routeState.walkSessionStartedAtMs; - if (startedAt <= 0) { - return; - } - routeState.firstMovementClickMarked = true; - WebWalkLog.tmark(phase, System.currentTimeMillis() - startedAt, target, at, detail); + + private static void clearRecentTransportContext() { + routeState.clearRecentTransportContext(); } + private static void markStartupPhase(String phase, WorldPoint target, String detail) { if (routeState.firstMovementClickMarked || !startupPhasesLogged.add(phase)) { return; @@ -422,20 +493,43 @@ private enum WalkerPhase { STEADY } + /** + * One consistent view of the world per loop pass (B2). Captured at the top of the pass and + * RE-CAPTURED after any branch that blocks (a click-and-sleep, a handler wait) — a pass-start + * position is a lie after a second of sleeping, which is the same staleness class the + * reachable-recapture above the recovery scan exists for. Consumers between blocking points + * share the snapshot instead of re-reading the client, so they cannot disagree about where the + * player is — the disagreement that produced the Stronghold gate bounce. + */ private static final class WalkLoopSnapshot { private final WorldPoint playerLoc; - private final HashMap closestReachableTiles; - - private WalkLoopSnapshot(WorldPoint playerLoc) { + private final boolean moving; + private final boolean animating; + private final boolean interacting; + // Lazy: capture() is cheap enough to run once per SEGMENT iteration; the reachability BFS + // only runs if a consumer actually asks for the closest index (once per snapshot). + private HashMap closestReachableTiles; + + private WalkLoopSnapshot(WorldPoint playerLoc, boolean moving, boolean animating, boolean interacting) { this.playerLoc = playerLoc; - this.closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + this.moving = moving; + this.animating = animating; + this.interacting = interacting; } private static WalkLoopSnapshot capture() { - return new WalkLoopSnapshot(Rs2Player.getWorldLocation()); + return new WalkLoopSnapshot(Rs2Player.getWorldLocation(), + Rs2Player.isMoving(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + } + + private boolean idle() { + return !moving && !animating && !interacting; } private int closestTileIndex(List path) { + if (closestReachableTiles == null) { + closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + } return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, closestReachableTiles); } } @@ -451,6 +545,7 @@ private interface ObstaclePolicy { } private static final class StartupObstaclePolicy implements ObstaclePolicy { + @Override public long segmentDoorTimeoutMs() { return 800L; @@ -488,6 +583,7 @@ public boolean allowNearbyFallback() { } private static final class SteadyObstaclePolicy implements ObstaclePolicy { + @Override public long segmentDoorTimeoutMs() { return 1500L; @@ -550,7 +646,7 @@ private static boolean isClientThread() { return client != null && client.isClientThread(); } - private static int reachedDistanceOrDefault() { + static int reachedDistanceOrDefault() { return config != null ? config.reachedDistance() : 10; } @@ -560,51 +656,8 @@ 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 - * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. - */ - private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) < maxChebyshevExclusive; - } - /** - * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). - */ - private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; - } /** * Caps configured finish distance when the route already ends very close to the marked goal. @@ -612,7 +665,7 @@ private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int max * wrong side of a wall/door for small interiors. When {@code dLast < TIGHT_PATH_GOAL_GAP}, cap is {@code 1}; * when {@code dLast == TIGHT_PATH_GOAL_GAP}, cap is {@code 2} (outdoor micro-walking relief at the gap radius). */ - private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalkable, int configuredChebyshev) { + static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalkable, int configuredChebyshev) { int cfg = Math.max(0, configuredChebyshev); if (goal == null || pathLastWalkable == null) { return cfg; @@ -630,21 +683,23 @@ private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalk return cfg; } + + /** * After opening a door, if the walk goal is still close, scene-click a random walkable tile near the * goal so the next movement is not an immediate minimap path segment (less robotic than * door → minimap in the same beat). */ - private static final int DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST = 18; - private static final int DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS = 3; - private static final int DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER = 15; + static final int DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST = 18; + static final int DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS = 3; + static final int DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER = 15; /** * After a successful door canvas nudge, {@link #tryDirectShortWalk} is skipped briefly so the next * movement beat is not minimap (same-frame minimap after scene click looks robotic). */ // routeState.suppressTryDirectShortWalkUntilMs migrated to WalkerRouteState (see routeState) - private static final long POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS = 2200L; + static final long POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS = 2200L; /** * Hold-off when the door opened but no canvas nudge was issued (the mid-route case). Long enough that * the next beat is not on the door interaction's own tick, short enough that the walk does not stall @@ -653,51 +708,13 @@ private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalk private static final long POST_DOOR_NO_NUDGE_SUPPRESS_TRY_DIRECT_MS = 500L; /** Max wait after scene canvas / recovery clicks until movement stops (avoids minimap churn while in-flight). */ - private static final int POST_SCENE_WALK_IDLE_WAIT_MS_MAX = 10_000; + static final int POST_SCENE_WALK_IDLE_WAIT_MS_MAX = 10_000; /** 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; + 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); - } - /** - * Waits until idle, walk cancel, or player within {@code arrivalMaxChebyshev} Chebyshev steps of - * {@code arrivalGoal} (same plane; see {@link WorldPoint#distanceTo2D(WorldPoint)}) — avoids burning full - * timeout when {@code Rs2Player#isMoving()} lies during animations. Arrival uses an inclusive bound: - * {@code distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev} (unlike {@link #OFFSET}-style guards that use - * {@code distanceTo2D < OFFSET}). If arrival distance triggers while still - * moving, runs a short second phase idle-only wait. Phase 2 does not run when phase 1 ends only due to the - * outer timeout while still far from {@code arrivalGoal} (by design). - */ - private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs, - WorldPoint arrivalGoal, int arrivalMaxChebyshev) { - assert cancelGoal != null; - assert timeoutMs > 0; - sleepUntil(() -> { - if (isWalkCancelled(cancelGoal)) { - return true; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - if (arrivalGoal != null && arrivalMaxChebyshev >= 0 && pl != null - && arrivalGoal.getPlane() == pl.getPlane() - && pl.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev) { - return true; - } - return !Rs2Player.isMoving(); - }, timeoutMs); - // Sample player once after phase 1 — rare tick skew vs isMoving(); phase 2 only refines idle after arrival exit. - WorldPoint plAfter = Rs2Player.getWorldLocation(); - boolean withinArrival = arrivalGoal != null && arrivalMaxChebyshev >= 0 && plAfter != null - && arrivalGoal.getPlane() == plAfter.getPlane() - && plAfter.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev; - if (withinArrival && Rs2Player.isMoving()) { - sleepUntil(() -> isWalkCancelled(cancelGoal) || !Rs2Player.isMoving(), - POST_SCENE_WALK_IDLE_SECOND_PHASE_MS_MAX); - } - } /** * Whether any tile within {@code distance} of {@code target} is walkable in the collision map. @@ -708,8 +725,8 @@ private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeo * the nearest reachable tile and then reported {@code partial-retries-exhausted}, which reads * as a walker fault rather than a bad coordinate. * - *

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 path) { - if (goal == null || path == null || path.isEmpty()) { - return false; - } - WorldPoint p = Rs2Player.getWorldLocation(); - if (p == null || goal.getPlane() != p.getPlane()) { - return false; - } - if (isWalkCancelled(goal)) { - return false; - } - WorldPoint pathLast = path.get(path.size() - 1); - int finishTh = tightFinishThreshold(goal, pathLast, configuredDistance); - int dGoal = p.distanceTo2D(goal); - if (dGoal <= finishTh) { - return false; - } - // Only nudge with fast-canvas when we are effectively on the final approach. - // This avoids immediate scene-click jumps after ordinary mid-route door opens. - if (dGoal > finishTh + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) { - return false; - } - if (dGoal > DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST) { - return false; - } - LocalPoint goalLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), goal); - if (goalLocal == null || !Rs2Camera.isTileOnScreen(goalLocal)) { - return false; - } - Map around = Rs2Tile.getReachableTilesFromTile(goal, DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS); - if (around == null || around.isEmpty()) { - return false; - } - List candidates = new ArrayList<>(); - for (WorldPoint t : around.keySet()) { - if (t == null || !Rs2Tile.isTileReachable(t)) { - continue; - } - if (p.distanceTo2D(t) > DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER) { - continue; - } - candidates.add(t); - } - if (candidates.isEmpty()) { + /** A single scene-transition sample must not kill a healthy, moving route. */ + private static boolean isStableLoggedOut() { + boolean initiallyLoggedIn = Microbot.isLoggedIn(); + if (initiallyLoggedIn) { return false; } - // candidates non-empty: index range [0, size-1] is valid for betweenInclusive. - WorldPoint pick = candidates.get(Rs2Random.betweenInclusive(0, candidates.size() - 1)); - if (walkFastCanvas(pick)) { - log.debug("[Walker] door nudge: canvas -> {} (goal={} dGoal={})", pick, goal, dGoal); - waitUntilIdleAfterSceneWalk(goal, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, goal, finishTh); - routeState.lastMovedTimeMs = System.currentTimeMillis(); - routeState.stuckCount = 0; - return true; - } - return false; + Rs2WalkerRuntimeAwaits.awaitCondition( + () -> Microbot.isLoggedIn() || currentTarget == null || Thread.currentThread().isInterrupted(), + 100, 750); + boolean cancelled = currentTarget == null || Thread.currentThread().isInterrupted(); + return LoginStabilityPolicy.shouldExit(false, Microbot.isLoggedIn(), cancelled); } private static void traceProcessWalkExit(String reason, WorldPoint target, int processWalkTail) { @@ -931,20 +809,11 @@ public static long getLastRouteClearAtMs() { return routeState.lastRouteClearAtMs; } - private static void logRouteClear(String reason) { - routeState.lastRouteClearReason = reason == null ? "" : reason; - routeState.lastRouteClearAtMs = System.currentTimeMillis(); - if (reason == null || reason.isBlank()) { - WebWalkLog.routeClearMissingReason(Thread.currentThread().getName()); - } else { - WebWalkLog.routeClear(reason); - } - } /** Substrings for game-object names treated like doors (pathing heuristics). */ /** Max age for {@link Rs2LeaguesTransport#isLeaguesAreaTeleportPending(long)} in stall / stuck gates. */ - private static final long LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS = 60_000L; + static final long LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS = 60_000L; @Named("disableWalkerUpdate") static boolean disableWalkerUpdate; @@ -967,6 +836,51 @@ private static void logRouteClear(String reason) { * context and therefore retain their exact behaviour.

*/ private static final ThreadLocal walkCompletionContext = new ThreadLocal<>(); + private static final ThreadLocal walkEvidenceContext = new ThreadLocal<>(); + private static final AtomicInteger testRecoveryReplanRequests = new AtomicInteger(); + + private static final class WalkEvidenceContext + { + private boolean started; + private boolean comparisonEligible; + private boolean recoveryTriggered; + } + + private static void captureActiveRouteComparisonEligibility(long routeGeneration) + { + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null && !evidence.comparisonEligible + && Rs2PathApi.isActiveRouteComparisonEligible(routeGeneration)) + { + evidence.comparisonEligible = true; + } + } + + private static WalkerState withShadowExecutionEvidence(Supplier action) + { + WalkEvidenceContext existing = walkEvidenceContext.get(); + if (existing != null) + { + return action.get(); + } + WalkEvidenceContext evidence = new WalkEvidenceContext(); + walkEvidenceContext.set(evidence); + try + { + WalkerState result = Objects.requireNonNull(action.get(), "walker result"); + if (evidence.started) + { + Rs2PathApi.recordShadowWalkerOutcome( + result, evidence.recoveryTriggered, evidence.comparisonEligible); + } + return result; + } + finally + { + testRecoveryReplanRequests.set(0); + walkEvidenceContext.remove(); + } + } private static final class WalkCompletionContext { private final WorldPoint target; @@ -985,9 +899,11 @@ private WalkCompletionContext(WorldPoint target, BooleanSupplier condition) { * then truncated {@code displayInfo} plus {@code |h} + hex {@link String#hashCode()} so long-prefix collisions split by dest. * At most {@link #SEASONAL_HANDLER_MISS_LOG_CAP} distinct keys ever log — then new misses are silent until JVM restart. */ - private static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); - private static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); - private static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; + static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); + static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); + static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; + /** Terminal NPC edges already clicked during the current top-level walk invocation. */ + static final Set TERMINAL_TRAVEL_ATTEMPTED_EDGES = ConcurrentHashMap.newKeySet(); /** * One-shot DEBUG when {@link WorldMapPointManager} is null during route clear (shutdown race). * Later races same JVM stay silent — intentional noise cap. @@ -1000,12 +916,14 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED.clear(); SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); + testRecoveryReplanRequests.set(0); recentCurrentTileTransportByEdge.clear(); + TERMINAL_TRAVEL_ATTEMPTED_EDGES.clear(); clearRecentTransportContext(); resetRouteProgress(); } - private static volatile List seasonalTransportHandlers = + static volatile List seasonalTransportHandlers = SeasonalTransportHandlers.defaultHandlerList(); /** @@ -1036,6 +954,24 @@ public static List getSeasonalTransportHandlers() * without a stall-triggered or off-path-triggered recalculation mid-walk. */ public static final class Telemetry { + /** + * Rate-limited debug summary of {@link #doorRejectByCause} tallies (noise control on tight door clusters). + */ + public static void recordDoorReject(String cause) { + if (cause == null || cause.isEmpty()) { + cause = "unknown"; + } + doorRejectByCause.computeIfAbsent(cause, k -> new AtomicInteger()).incrementAndGet(); + if (Rs2LogRateLimit.everyN(doorRejectSummaryLogSeq, DOOR_REJECT_SUMMARY_LOG_INTERVAL) + && log.isDebugEnabled()) { + log.debug("[WalkerTelemetry] DOOR_REJECT summary={}", doorRejectByCause); + } + } + + public static void incrementSeasonalHandlerMiss() { + seasonalHandlerMissCount.incrementAndGet(); + } + public static final AtomicInteger offPathRecalcCount = new AtomicInteger(); public static final AtomicInteger offPathRecalcDeferredCount = new AtomicInteger(); public static final AtomicInteger stallRecalcCount = new AtomicInteger(); @@ -1052,26 +988,13 @@ public static final class Telemetry { public static final AtomicLong lastEventAtMs = new AtomicLong(); public static volatile String lastReason = ""; - 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; + static final ConcurrentHashMap doorRejectByCause = new ConcurrentHashMap<>(); + static final AtomicInteger doorRejectSummaryLogSeq = new AtomicInteger(0); + 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). - */ - public static void recordDoorReject(String cause) { - if (cause == null || cause.isEmpty()) { - cause = "unknown"; - } - doorRejectByCause.computeIfAbsent(cause, k -> new AtomicInteger()).incrementAndGet(); - if (Rs2LogRateLimit.everyN(doorRejectSummaryLogSeq, DOOR_REJECT_SUMMARY_LOG_INTERVAL) - && log.isDebugEnabled()) { - log.debug("[WalkerTelemetry] DOOR_REJECT summary={}", doorRejectByCause); - } - } public static void incrementLeaguesLockAttributed() { leaguesLockAttributedCount.incrementAndGet(); @@ -1085,9 +1008,6 @@ public static void incrementLeaguesLockParseMiss() { leaguesLockParseMissCount.incrementAndGet(); } - public static void incrementSeasonalHandlerMiss() { - seasonalHandlerMissCount.incrementAndGet(); - } public static void recordOffPathRecalc(WorldPoint playerPos, int pathSize) { offPathRecalcCount.incrementAndGet(); @@ -1131,14 +1051,18 @@ public static void recordPartialRetry(int attempt, int finalDist) { public static void recordUnreachable(String cause, WorldPoint player, WorldPoint target, WorldPoint pathEndpoint, int pathSize, int distanceThreshold, - Pathfinder pathfinder) { + Rs2RouteMetrics routeMetrics) { unreachableCount.incrementAndGet(); lastReason = "unreachable:" + cause; lastEventAtMs.set(System.currentTimeMillis()); int distToTarget = (pathEndpoint != null && target != null) ? pathEndpoint.distanceTo(target) : -1; - Pathfinder.PathfinderStats pfStats = (pathfinder != null) ? pathfinder.getStats() : null; - String stats = (pfStats != null) ? pfStats.toString() : "null"; - log.warn("[WalkerTelemetry] UNREACHABLE cause={} player={} target={} pathEndpoint={} pathSize={} endpointToTarget={} threshold={} pathfinderStats={} totalUnreachable={}", + String stats = routeMetrics == null ? "null" : String.format( + "RouteMetrics(nodes=%d,transports=%d,time=%dms,cost=%d)", + routeMetrics.getNodesChecked(), + routeMetrics.getTransportsChecked(), + routeMetrics.hasSearchNanos() ? routeMetrics.getSearchNanos() / 1_000_000L : -1L, + routeMetrics.getPathCost()); + log.warn("[WalkerTelemetry] UNREACHABLE cause={} player={} target={} pathEndpoint={} pathSize={} endpointToTarget={} threshold={} routeMetrics={} totalUnreachable={}", cause, player, target, pathEndpoint, pathSize, distToTarget, distanceThreshold, stats, unreachableCount.get()); } @@ -1167,7 +1091,7 @@ public static int totalRecalcs() { } // Trapdoor and manhole mappings for open/closed states - private static final Map OPEN_TO_CLOSED_MAPPINGS = Map.of( + static final Map OPEN_TO_CLOSED_MAPPINGS = Map.of( 1581, 1579, // open trapdoor -> closed trapdoor 882, 881 // open manhole -> closed manhole ); @@ -1310,11 +1234,9 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } try { - if (config.walkWithBankedTransports()) { - return walkWithBankedTransportsAndState(target, distance, false); - } else { - return walkWithStateInternal(target, distance); - } + return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() + ? walkWithBankedTransportsAndStateLocked(target, distance, false) + : walkWithStateInternal(target, distance)); } finally { walkerLock.unlock(); } @@ -1373,11 +1295,9 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long } try { - if (config.walkWithBankedTransports()) - { - return walkWithBankedTransportsAndStateLocked(target, distance, false); - } - return walkWithStateInternal(target, distance); + return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() + ? walkWithBankedTransportsAndStateLocked(target, distance, false) + : walkWithStateInternal(target, distance)); } finally { @@ -1393,10 +1313,17 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long */ private static WalkerState walkWithStateInternal(WorldPoint target, int distance) { Objects.requireNonNull(target, "walk target"); + currentWalkDistance = Math.max(0, distance); if (isClientThread()) { log.warn("Please do not call the walker from the main thread"); return WalkerState.EXIT; } + // BEFORE any planning. The first version withdrew these inside markWalkSessionStart, which + // runs after setTarget has already kicked the pathfinder off — measured live at the Tithe + // door: the retry's plan ran against the previous walk's blocks (SEARCH_EXHAUSTED against a + // sealed goal), collapsed to a 1-tile path, and the retry burned itself on it while the + // unlearn arrived two lines later. + withdrawWalkScopedDoorBlocks(); WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -1404,19 +1331,38 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance int distToTarget = playerLocWalk.distanceTo(target); LocalPoint localTarget = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target); boolean walkableCheck = Rs2Tile.isWalkable(localTarget); - boolean reachableTileCheck = distToTarget <= distance && Rs2Tile.getReachableTilesFromTile(playerLocWalk, distance).containsKey(target); + Map reachableWithinDistance = distToTarget <= distance + ? Rs2Tile.getReachableTilesFromTile(playerLocWalk, distance) + : Collections.emptyMap(); + boolean reachableTileCheck = distToTarget <= distance && reachableWithinDistance.containsKey(target); + + // An unwalkable target is normal — you cannot stand ON a door, chest or bank booth, so the + // walk has to finish beside it. But distanceTo is straight-line and knows nothing about walls, + // so "within distance of an object" was reported as ARRIVED even with a wall between: the + // caller then tried to interact from the wrong side of it and the script failed with the + // walker claiming success. Require somewhere we can actually STAND next to the target. + // + // Falls back to the old distance-only answer when the BFS is unavailable, so a reachability + // hiccup cannot turn arrival into a walk that never terminates. + boolean unwalkableTargetReached = !walkableCheck && distToTarget <= distance + && (reachableWithinDistance.isEmpty() + || hasReachableNeighbour(target, reachableWithinDistance)); - if (reachableTileCheck || (!walkableCheck && distToTarget <= distance)) { + if (reachableTileCheck || unwalkableTargetReached) { return WalkerState.ARRIVED; } + if (!walkableCheck && distToTarget <= distance && !reachableWithinDistance.isEmpty()) { + WebWalkLog.spInfo("arrival_declined_unreachable | target={} player={} dist={} — within distance " + + "but no reachable tile beside it; continuing", + compactWorldPoint(target), compactWorldPoint(playerLocWalk), distToTarget); + } - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null && !pathfinder.isDone()) { + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (routeStatus.isCalculating()) { return WalkerState.MOVING; } - boolean hasCurrentPath = pathfinder != null - && pathfinder.isDone() - && pathfinder.getTargets().contains(target); + boolean hasCurrentPath = routeStatus.isReady() + && routeStatus.getTargets().contains(target); if (!hasCurrentPath) { setTarget(target); } else { @@ -1510,8 +1456,8 @@ public static WalkerState walkStep(WorldPoint target, int distance) { setTarget(target); return WalkerState.MOVING; } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null || !pathfinder.isDone()) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isReady()) { return WalkerState.MOVING; // path still computing — wait, don't reset it } @@ -1520,8 +1466,8 @@ public static WalkerState walkStep(WorldPoint target, int distance) { return WalkerState.MOVING; } - final List rawPath = pathfinder.getPath(); - final List path = pathfinder.getWalkablePath(); + final List rawPath = routeStatus.getRawPath(); + final List path = routeStatus.getWalkablePath(); if (!walkStepPathReachesTarget(path, target, distance)) { setTarget(null, "rs2walker:walkStep:no-walkable-path"); return WalkerState.UNREACHABLE; @@ -1534,8 +1480,9 @@ public static WalkerState walkStep(WorldPoint target, int distance) { // target nor a planned-path point is clickable (e.g. the route needs a transport walkStep can't // cross), no click is issued and we hold on the line rather than wander off it — walkStep is not // built for transport routes; use the blocking walkTo/walkUntil for those. - boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= NORMAL_MINIMAP_REACH_EUCLIDEAN; - clickMiniMapOrFallback(rawPath, target, playerLoc, NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, allowDirectionalFallback, -1); + int walkStepReach = normalMinimapReach(); + boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= walkStepReach; + clickMiniMapOrFallback(rawPath, target, playerLoc, walkStepReach - 1, allowDirectionalFallback, -1); return WalkerState.MOVING; } @@ -1556,8 +1503,168 @@ public static WalkerState walkStep(WorldPoint target, int distance) { * lines appear across the gap the loop is spinning without acting and the state here says why, and * if they stop the thread is blocked inside a wait and the last line says which pass entered it. */ + /** + * How long a walk may go WITHOUT CLOSING DISTANCE on its goal before it is reported as a + * probable livelock. + * + *

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 path) { + long now = System.currentTimeMillis(); + TailDecision.StagnationAction action = TailDecision.decideRouteStagnation( + routeState.routeProgressAdvancedAtMs, now, ROUTE_STAGNATION_BUDGET_MS, + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS); + if (action == TailDecision.StagnationAction.NONE) { + return null; + } + if (action == TailDecision.StagnationAction.REPLAN) { + routeState.stagnationReplansSpent++; + // Restart the clock by hand: a replan that returns the identical route never trips the + // route-changed re-stamp, and each replan is owed a full budget of its own. + routeState.routeProgressAdvancedAtMs = now; + WebWalkLog.spInfo("route_stagnation_replan | spent={}/{} idx={} at={} goal={}", + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS, + routeState.routeProgressIdx, compactWorldPoint(Rs2Player.getWorldLocation()), + compactWorldPoint(target)); + recalculatePath(); + return null; + } + WorldPoint endpoint = path == null || path.isEmpty() ? null : path.get(path.size() - 1); + WebWalkLog.spInfo("route_stagnation_exhausted | idx={} replans={} at={} goal={} — route index " + + "never advanced; movement without progress is not progress", + routeState.routeProgressIdx, routeState.stagnationReplansSpent, + compactWorldPoint(Rs2Player.getWorldLocation()), compactWorldPoint(target)); + Telemetry.recordUnreachable("route-stagnation-exhausted", Rs2Player.getWorldLocation(), + target, endpoint, path == null ? 0 : path.size(), distance, + Rs2PathApi.getActiveRouteStatus().getMetrics().orElse(null)); + setTarget(null, "rs2walker:processWalk:route-stagnation-exhausted"); + return WalkerState.UNREACHABLE; + } + + /** Player tile at the last tail-exempt iteration; a change means the run was making progress. */ + private static volatile WorldPoint lastExemptRunLocation = null; + + /** + * Counts consecutive tail-exempt iterations THAT DID NOT MOVE THE PLAYER. + * + *

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 path, WorldPoint targe * @param target * @param distance */ + /** + * Whether any tile orthogonally or diagonally adjacent to {@code target} is in the player-origin + * reachable set — i.e. there is somewhere we can actually stand to interact with it. + *

+ * 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 reachable) { + if (target == null || reachable == null || reachable.isEmpty()) { + return false; + } + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) { + continue; + } + if (reachable.containsKey( + new WorldPoint(target.getX() + dx, target.getY() + dy, target.getPlane()))) { + return true; + } + } + } + return false; + } + private static WalkerState processWalk(WorldPoint target, int distance) { // Solve the Draynor basement lever puzzle first if walking to a basement tile, so the // door-transports are unlocked before pathfinding. No-op outside the basement. The @@ -1615,16 +1748,46 @@ private static WalkerState processWalk(WorldPoint target, int distance) { return processWalk(target, distance, 0); } - private static WalkerState processWalk(WorldPoint target, int distance, int partialRetries) { - if (debug) { - return WalkerState.EXIT; + /** + * Logs the partial segment and applies the partial-regression guard: a fresh partial whose + * endpoint sits farther from the goal than this walk's best accepted one by more than + * max({@link #PARTIAL_REGRESS_MIN_SLACK_TILES}, best/4) tiles is a budget/tiebreak artifact of + * an exhausted search, not a road — walking it flips the travel direction. Returns true when a + * replan was issued instead of accepting the segment; the caller skips the pass. + */ + private static boolean replanRegressedPartialSegment(WorldPoint segEnd, WorldPoint target, int waypointCount) { + final int partialDGoal = segEnd.distanceTo(target); + WebWalkLog.partialSegment(segEnd, partialDGoal, target, waypointCount); + final int bestDGoal = routeState.bestPartialDGoal; + final int worstAcceptableDGoal = bestDGoal == Integer.MAX_VALUE + ? Integer.MAX_VALUE + : bestDGoal + Math.max(PARTIAL_REGRESS_MIN_SLACK_TILES, bestDGoal / 4); + if (partialDGoal > worstAcceptableDGoal + && routeState.partialRegressReplans < MAX_PARTIAL_REGRESS_REPLANS) { + routeState.partialRegressReplans++; + WebWalkLog.spInfo("partial_regress | replan={}/{} dGoal={} best={} segEnd={} goal={}", + routeState.partialRegressReplans, MAX_PARTIAL_REGRESS_REPLANS, + partialDGoal, bestDGoal, segEnd, target); + recalculatePath(); + return true; + } + // A better endpoint tightens the baseline; a regressed endpoint that survived the bounded + // replans becomes the baseline so the same route does not re-trigger the guard every pass. + if (partialDGoal < bestDGoal || partialDGoal > worstAcceptableDGoal) { + routeState.bestPartialDGoal = partialDGoal; + } + routeState.partialRegressReplans = 0; + return false; + } + + private static WalkerState processWalk(WorldPoint target, int distance, int partialRetries) { + if (debug) { + return WalkerState.EXIT; } // Pre-flight: a destination with no walkable tile within the arrival distance can never be // reached, so reject it here rather than after a full route ending at the nearest wall. - PathfinderConfig preflightConfig = Rs2PathApi.getPathfinderConfig(); - CollisionMap preflightMap = preflightConfig != null ? preflightConfig.getMap() : null; - if (!hasWalkableTileWithin(preflightMap, target, distance)) { - WorldPoint nearestWalkable = nearestWalkableTile(preflightMap, target, 48); + if (!Rs2PathApi.hasWalkableTileWithin(target, distance)) { + WorldPoint nearestWalkable = Rs2PathApi.nearestWalkableTile(target, 48); log.warn("[Walker] walk rejected: target {} has no walkable tile within {} in the collision map" + " (nearest walkable {}); check the destination coordinate", target, distance, @@ -1635,10 +1798,12 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.UNREACHABLE; } int partialRetriesWorking = partialRetries; + int clientThreadTimeoutRetries = 0; // When the last partial retry was spent, so route progress made after it can refill the // budget. Without this the counter is monotonic for the entire walk. long lastPartialRetryAtMs = 0L; WorldPoint lastPartialRetryAtLoc = null; + int consecutiveExemptIterations = 0; WorldPoint lastAttemptedMinimapClick = null; boolean lastAttemptedMinimapClickOk = false; long lastAttemptedMinimapClickAtMs = 0L; @@ -1656,7 +1821,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part currentTarget, routeState.interimTargetWp, partialRetriesWorking); - if (!Microbot.isLoggedIn()) { + if (isStableLoggedOut()) { traceProcessWalkExit("not-logged-in", target, processWalkTail); setTarget(null, "rs2walker:processWalk:not-logged-in"); return WalkerState.EXIT; @@ -1665,15 +1830,19 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.EXIT; } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) { markStartupPhase("pf_wait_enter", target, "reason=pathfinder_null"); walkerDiag("pathfinder null; waiting up to %dms", PATHFINDER_NULL_WAIT_MS); - pathfinder = sleepUntilNotNull(Rs2PathApi::getPathfinder, PATHFINDER_NULL_WAIT_MS); + Rs2WalkerRuntimeAwaits.awaitCondition( + () -> Rs2PathApi.getActiveRouteStatus().isPresent(), + 100, + PATHFINDER_NULL_WAIT_MS); + routeStatus = Rs2PathApi.getActiveRouteStatus(); if (walkCancelledDiag(target, "processWalk:after-wait-pathfinder", processWalkTail)) { return WalkerState.EXIT; } - if (pathfinder == null) { + if (!routeStatus.isPresent()) { if (currentTarget != null && currentTarget.equals(target)) { walkerDiag("pathfinder null but target still set; recalculating"); recalculatePath(); @@ -1686,17 +1855,24 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part markStartupPhase("pf_ready", target, "source=pathfinder_not_null"); } - if (!pathfinder.isDone()) { + if (routeStatus.isCalculating()) { + long observedGeneration = routeStatus.getGeneration(); markStartupPhase("pf_wait_retry", target, "slice=" + PATHFINDER_DONE_POLL_WAIT_MS); if (pathfinderPendingSinceMs == 0L) { pathfinderPendingSinceMs = System.currentTimeMillis(); } walkerDiag("pathfinder not done; short-poll max %dms", PATHFINDER_DONE_POLL_WAIT_MS); - boolean isDone = Rs2WalkerRuntimeAwaits.awaitPathfinderDone(pathfinder, PATHFINDER_DONE_POLL_WAIT_MS); + Rs2WalkerRuntimeAwaits.awaitCondition(() -> { + Rs2ActiveRouteStatus current = Rs2PathApi.getActiveRouteStatus(); + return !current.isPresent() + || current.getGeneration() != observedGeneration + || current.isReady(); + }, 100, PATHFINDER_DONE_POLL_WAIT_MS); + routeStatus = Rs2PathApi.getActiveRouteStatus(); if (walkCancelledDiag(target, "processWalk:after-wait-done", processWalkTail)) { return WalkerState.EXIT; } - if (!isDone) { + if (!routeStatus.isReady()) { if (System.currentTimeMillis() - pathfinderPendingSinceMs > 10_000L) { traceProcessWalkExit("pathfinder-timeout-not-done", target, processWalkTail); setTarget(null, "rs2walker:processWalk:pathfinder-timeout-not-done"); @@ -1711,18 +1887,26 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part markStartupPhase("pf_ready", target, "source=pathfinder_done"); } pathfinderPendingSinceMs = 0L; + captureActiveRouteComparisonEligibility(routeStatus.getGeneration()); + + if (consumeRecoveryReplanForTest()) + { + WebWalkLog.spDebug("test_recovery_replan | target={}", target); + recalculatePathForRecovery(); + continue; + } if (Rs2PathApi.getMarker() == null) { restoreTargetMarker(target); } - final List rawPath = pathfinder.getPath(); - final List path = pathfinder.getWalkablePath(); + final List rawPath = routeStatus.getRawPath(); + final List path = routeStatus.getWalkablePath(); final int[] smoothedToRaw = mapSmoothedToRaw(path, rawPath); 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(); + WalkLoopSnapshot walkLoop = WalkLoopSnapshot.capture(); final WorldPoint dst; if (path == null || path.isEmpty()) { dst = walkLoop.playerLoc; @@ -1732,12 +1916,15 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part boolean partialPath = false; if (dst == null || dst.distanceTo(target) > distance) { + final WorldPoint sealedRim = consumeSealedRimRetarget(target, dst); + if (sealedRim != null) { return processWalk(sealedRim, distance, partialRetries); } if (path != null && path.size() > 1) { - WebWalkLog.partialSegment(dst, dst.distanceTo(target), target, path.size()); + if (replanRegressedPartialSegment(dst, target, path.size())) { continue; } partialPath = true; } else { Telemetry.recordUnreachable("no-walkable-path", walkLoop.playerLoc, - target, dst, path == null ? 0 : path.size(), distance, pathfinder); + target, dst, path == null ? 0 : path.size(), distance, + routeStatus.getMetrics().orElse(null)); setTarget(null, "rs2walker:processWalk:no-walkable-path"); return WalkerState.UNREACHABLE; } @@ -1747,8 +1934,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.ARRIVED; } - // 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). + // Partial segment: refresh routing before the endpoint so the continuation is ready. if (partialPath) { WorldPoint playerPt = walkLoop.playerLoc; if (playerPt != null && dst != null) { @@ -1777,25 +1963,18 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } - int earlyRouteStartIdx = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); + int earlyRouteStartIdx = stabilizeRouteProgressWithRawWatermark(rawPath, 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}); // clearing here would drop currentTarget before the partial-path retry/recalc branch. - if (!partialPath && isNear(dst) && routeState.interimTargetWp == null) { + if (!partialPath && isNear(dst, walkLoop.playerLoc) && routeState.interimTargetWp == null) { setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); } boolean shouldIssueActiveRouteIdleNudge = shouldIssueActiveRouteIdleNudge(); - long nowTickGraceMs = System.currentTimeMillis(); - if (lastAttemptedMinimapClickOk && lastAttemptedMinimapClickAtMs > 0L - && !shouldIssueActiveRouteIdleNudge - && nowTickGraceMs - lastAttemptedMinimapClickAtMs < MINIMAP_CLICK_STALL_GRACE_MS) { - routeState.lastMovedTimeMs = nowTickGraceMs; - } - checkIfStuck(); if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; @@ -1810,9 +1989,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } long sinceMoved = System.currentTimeMillis() - routeState.lastMovedTimeMs; long threshold = stallThresholdMs(); - Telemetry.recordStallRecalc(sinceMoved, Rs2Player.getWorldLocation()); + Telemetry.recordStallRecalc(sinceMoved, walkLoop.playerLoc); WebWalkLog.stallRecalc(sinceMoved, threshold, - Rs2Player.isInCombat(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + Rs2Player.isInCombat(), walkLoop.animating, walkLoop.interacting); if (lastAttemptedMinimapClick != null) { WebWalkLog.stallContextDebug( lastAttemptedMinimapClick, @@ -1825,12 +2004,12 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part clearInterimTarget("stall-recalc"); if (immediateRouteTransportPending) { WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); - } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { - setTarget(target); + } else if (walkLoop.idle()) { + recalculatePathForRecovery(); tryIssueRouteRecoveryClick(rawPath, path, target, distance, "stall recovery click"); continue; } else { - setTarget(target); + recalculatePathForRecovery(); continue; } } @@ -1844,7 +2023,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part routeState.lastActiveRouteIdleNudgeAtMs = System.currentTimeMillis(); } if (routeState.stuckCount > 10) { - var reachable = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 5).keySet(); + var reachable = Rs2Tile.getReachableTilesFromTile(walkLoop.playerLoc, 5).keySet(); if (!reachable.isEmpty()) { // Rank sidestep candidates by distance-toward-target so recovery // biases toward the goal instead of wandering. Keep a top-K pool @@ -1854,10 +2033,13 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int poolSize = Math.min(3, ranked.size()); WorldPoint sidestep = ranked.get(Rs2Random.between(0, poolSize)); log.info("[Walker] stuck sidestep: clicked to={} player={} routeState.stuckCount={}", - sidestep, Rs2Player.getWorldLocation(), routeState.stuckCount); + sidestep, walkLoop.playerLoc, routeState.stuckCount); walkMiniMap(sidestep); sleepGaussian(1000, 300); routeState.stuckCount = 0; + // The sleep above made the pass-start snapshot a lie; every read below this + // point (playerLocForIndex first among them) must see the post-sidestep world. + walkLoop = WalkLoopSnapshot.capture(); } } @@ -1865,10 +2047,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part 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(), - playerLocForIndex, - path.isEmpty() ? null : path.get(0), + walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", path.size(), + playerLocForIndex, path.isEmpty() ? null : path.get(0), path.isEmpty() ? null : path.get(path.size() - 1)); traceProcessWalkExit("closest-index-none", target, processWalkTail); setTarget(null, "rs2walker:processWalk:closest-index-none"); @@ -1897,9 +2077,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // walker can run minutes in the wrong corridor without ever replanning. Off-path, do nothing // here: the player stops, the "moving" deferral ends, and OFFPATH_RECALC replans properly. if (clearedInterimTarget - && isNearPath() - && !Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + && isNearPath(walkLoop.playerLoc) + && !walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { @@ -1948,31 +2128,31 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { boolean doorOrTransportResult = false; boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); - String exitReason = "end-of-path"; - Map doorEdgesAttemptedThisTail = new HashMap<>(); + WalkExit exit = WalkExit.END_OF_PATH; + String offPathDeferDetail = ""; + doorAttemptLedger.beginTailPass(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); - WorldPoint activeInterimPlayer = Rs2Player.getWorldLocation(); + // Re-capture: the widget dialogs above sleep for seconds when they fire. + walkLoop = WalkLoopSnapshot.capture(); long activeInterimNowMs = System.currentTimeMillis(); - if (!Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + if (!walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && (target == null - || activeInterimPlayer == null - || activeInterimPlayer.distanceTo(target) > immediateFinishTh) - && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { - exitReason = "interim-in-flight"; - WebWalkLog.earlyExit(exitReason, - activeInterimPlayer, + || walkLoop.playerLoc == null + || walkLoop.playerLoc.distanceTo(target) > immediateFinishTh) + && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs)) { + exit = WalkExit.INTERIM_IN_FLIGHT_ROUTE; + WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), + walkLoop.playerLoc, target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("tail exempt exitReason=%s tailBefore=%d early=true interim=%s", - exitReason, - processWalkTail, - routeState.interimTargetWp); + exit.wireName(offPathDeferDetail), processWalkTail, routeState.interimTargetWp); processWalkTail--; continue; } @@ -1991,7 +2171,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM "reason=transport_settling"); } if (allowRawSceneScan && postTransportWindow - && !hasUpcomingNearbyTransportStep(path, rawScanTransportLookaheadStartIdx, Rs2Player.getWorldLocation(), + && !hasUpcomingNearbyTransportStep(path, rawScanTransportLookaheadStartIdx, walkLoop.playerLoc, POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { allowRawSceneScan = false; tmarkPostTransport("post_transport_raw_scene_scan_skip", target, @@ -2015,13 +2195,12 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM : (startupPolicy.allowBroadRawHandlers() ? "gated-outer" : "policy-startup"); boolean rawSceneHandled = allowRawSceneScan && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target, true); - tmarkPostTransport("post_transport_raw_scene_scan_why", target, - "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); + tmarkPostTransport("post_transport_raw_scene_scan_why", target, "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); tmarkPostTransport("post_transport_raw_scene_scan", target, "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); if (rawSceneHandled) { doorOrTransportResult = true; - exitReason = "raw-path-scene-object-handled"; + exit = WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED; } long currentTileTransportStartAt = System.currentTimeMillis(); @@ -2032,7 +2211,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM "handled=" + currentTileTransportHandled + " ms=" + (System.currentTimeMillis() - currentTileTransportStartAt)); if (currentTileTransportHandled) { doorOrTransportResult = true; - exitReason = "current-tile-transport-handled"; + exit = WalkExit.CURRENT_TILE_TRANSPORT_HANDLED; } if (!doorOrTransportResult) { @@ -2042,7 +2221,9 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } } - WorldPoint currentPlayerLoc = Rs2Player.getWorldLocation(); + // Re-capture: the raw scan, current-tile transport and direct-short-walk above block. + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint currentPlayerLoc = walkLoop.playerLoc; reachableTilesCache = Rs2Tile.getReachableTilesFromTile(currentPlayerLoc, HANDLER_RANGE * 3); reachableTilesCacheOrigin = currentPlayerLoc; final int currentPlayerPlane = currentPlayerLoc != null ? currentPlayerLoc.getPlane() : -1; @@ -2075,21 +2256,20 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean recentTransportWindow = routeState.lastTransportHandledAtMs > 0 && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; - WorldPoint playerForPathCheck = Rs2Player.getWorldLocation(); + // One world per segment iteration: the previous iteration's handlers may have blocked. + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerForPathCheck = walkLoop.playerLoc; if (isTransportInteractionSettling()) { - tmarkPostTransport("post_transport_settling_yield", target, - "at=" + compactWorldPoint(playerForPathCheck)); - exitReason = "transport-settling-yield"; + tmarkPostTransport("post_transport_settling_yield", target, "at=" + compactWorldPoint(playerForPathCheck)); + exit = WalkExit.TRANSPORT_SETTLING_YIELD; break; } - boolean nearPath = isNearPath(); + boolean nearPath = isNearPath(walkLoop.playerLoc); boolean nearPathByVariance = !nearPath && isNearPathByVariance(path, playerForPathCheck); if (recentTransportWindow && !nearPath) { WebWalkLog.tmark("post_transport_nearpath_gate", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "nearPath=false variance=" + nearPathByVariance); + target, playerForPathCheck, "nearPath=false variance=" + nearPathByVariance); } if (!nearPath && !recentTransportWindow && !nearPathByVariance) { // Avoid mid-walk recalculation while recent clicks, route progress, or busy state @@ -2102,24 +2282,22 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { WebWalkLog.tmark("post_transport_offpath_moving_yield", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "defer=" + deferReason); + target, playerForPathCheck, "defer=" + deferReason); } - exitReason = "off-path-deferred:" + deferReason; + exit = WalkExit.OFF_PATH_DEFERRED; + offPathDeferDetail = deferReason; break; } - Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); + Telemetry.recordOffPathRecalc(walkLoop.playerLoc, path.size()); // Distinguish the drift signature in logs: off-path while still moving with no // walker action in flight = something external is steering the player. - WebWalkLog.recalc(Rs2Player.isMoving() - ? "off_path_unowned_movement" : "no_longer_near_path"); + WebWalkLog.recalc(walkLoop.moving ? "off_path_unowned_movement" : "no_longer_near_path"); if (config.cancelInstead()) { setTarget(null, "rs2walker:processWalk:off-path-cancel-instead"); } else { - recalculatePath(); + recalculatePathForRecovery(); } - exitReason = "not-near-path"; + exit = WalkExit.NOT_NEAR_PATH; break; } if (!nearPath && recentTransportWindow) { @@ -2132,9 +2310,9 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // Gate scene-object handlers to segments near the player. Doors/rockfalls/transports // can only be interacted with when the object is in the loaded scene (near the player), // and these calls do scene-object scans that add up across 100+ segment paths. - WorldPoint playerNearSeg = Rs2Player.getWorldLocation(); + WorldPoint playerNearSeg = walkLoop.playerLoc; if (playerNearSeg == null) { - exitReason = "player-location-null"; + exit = WalkExit.PLAYER_LOCATION_NULL; break; } int segDistance = currentWorldPoint.distanceTo2D(playerNearSeg); @@ -2145,31 +2323,19 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean startupBeforeFirstClick = currentWalkerPhase() == WalkerPhase.STARTUP; boolean immediateSegmentTransportStep = hasImmediatePlannedTransportStep(path, i, playerNearSeg); boolean recentDoorAttemptNearSegment = hasRecentDoorAttemptNearIndex(path, i); - boolean skipPostTransportSegmentHandlers = recentTransportWindow - && !upcomingNearbyTransport - && !recentDoorAttemptNearSegment - && !isDoorInteractionSettling() - && !isRecoveryMovementInFlight() - && reachableTilesCache.containsKey(currentWorldPoint); - boolean skipStartupPreclickSegmentHandlers = !immediateSegmentTransportStep - && shouldSkipStartupPreclickSegmentHandlers( - startupBeforeFirstClick, - i, - indexOfStartPoint, - recentDoorAttemptNearSegment, - isDoorInteractionSettling(), - isRecoveryMovementInFlight()); - if (skipPostTransportSegmentHandlers || skipStartupPreclickSegmentHandlers) { + SegmentGate.SegmentAction segmentAction = SegmentGate.decide( + recentTransportWindow, upcomingNearbyTransport, recentDoorAttemptNearSegment, + isDoorInteractionSettling(), isRecoveryMovementInFlight(), + reachableTilesCache.containsKey(currentWorldPoint), + startupBeforeFirstClick, immediateSegmentTransportStep, i, indexOfStartPoint); + if (segmentAction.isSkip()) { segmentSkippedThisPass = true; - if (skipStartupPreclickSegmentHandlers) { + if (segmentAction == SegmentGate.SegmentAction.SKIP_STARTUP_PRECLICK) { markStartupPhase("preclick_segment_handler_skip", target, - "i=" + i + " reason=startup_before_first_click"); + "i=" + i + " reason=" + segmentAction.wireReason()); } tmarkPostTransport("post_transport_segment_handler_skip", - target, - "i=" + i + " reason=" + (skipPostTransportSegmentHandlers - ? "no_nearby_planned_transport" - : "startup_before_first_click")); + target, "i=" + i + " reason=" + segmentAction.wireReason()); } else { long segmentHandlerStartAt = System.currentTimeMillis(); int rawI = (i < smoothedToRaw.length) ? smoothedToRaw[i] : 0; @@ -2195,20 +2361,21 @@ && shouldSkipStartupPreclickSegmentHandlers( // // With an earlier segment skipped, doors fall back to the stationary requirement, // which is the behaviour from before ranged door dispatch existed. - boolean nearestSegmentDoor = !segmentHandlersRanThisPass && !segmentSkippedThisPass; + boolean nearestSegmentDoor = SegmentGate.mayDispatchDoorAtRange( + segmentHandlersRanThisPass, segmentSkippedThisPass); segmentHandlersRanThisPass = true; boolean doorMovementGateOk = !Rs2Player.isMoving() || (nearestSegmentDoor && doorInteractionWhileApproachingEnabled()); if (!startupImmediateTransportOnly && doorMovementGateOk && !isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { doorOrTransportResult = handleDoorsInRawSegment(rawPath, rawI, rawEnd, - obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.segmentDoorTimeoutMs(), reachableTilesCache); } if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=door handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "door-handled"; + exit = WalkExit.DOOR_HANDLED; break; } @@ -2220,10 +2387,10 @@ && shouldSkipStartupPreclickSegmentHandlers( && !Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() && allowPathAdjacentProbe) { if (tryHandleBlockingPathObjectsWithTimeout(rawPath, rawI, 5, 10, - obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { + obstaclePolicy.pathAdjacentProbeTimeoutMs())) { tmarkPostTransport("post_transport_segment_handler", target, "stage=path_adj handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "path-blocker-handled"; + exit = WalkExit.PATH_BLOCKER_HANDLED; break; } } @@ -2241,7 +2408,7 @@ && shouldSkipStartupPreclickSegmentHandlers( if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=rockfall handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "rockfall-handled"; + exit = WalkExit.ROCKFALL_HANDLED; break; } @@ -2258,7 +2425,7 @@ && shouldSkipStartupPreclickSegmentHandlers( if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=transport handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "transport-handled"; + exit = WalkExit.TRANSPORT_HANDLED; break; } tmarkPostTransport("post_transport_segment_handler", target, @@ -2267,71 +2434,70 @@ && shouldSkipStartupPreclickSegmentHandlers( } boolean tileReachable = reachableTilesCache.containsKey(currentWorldPoint); + // The handlers above block for seconds, so re-capture the snapshot — but ONLY for + // tiles the recovery gate below can consume (far hops were the pre-obstacle stall). if (!tileReachable && !inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc != null) { - int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); - if (unreachableDist <= HANDLER_RANGE + 2) { - reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE + 5); - reachableTilesCacheOrigin = playerLoc; - tileReachable = reachableTilesCache.containsKey(currentWorldPoint); - if (tileReachable) { - log.debug("[Walker] tile {} reachable after cache refresh from {}", currentWorldPoint, playerLoc); - } - } + if (FrontierDecision.shouldSkipFarUnreachableTile(currentWorldPoint, + walkLoop.playerLoc, HANDLER_RANGE + 2, FAR_UNREACHABLE_STALENESS_MARGIN)) { + continue; + } + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerLoc = walkLoop.playerLoc; + if (playerLoc != null && !playerLoc.equals(reachableTilesCacheOrigin)) { + reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE * 3); + reachableTilesCacheOrigin = playerLoc; + tileReachable = reachableTilesCache.containsKey(currentWorldPoint); + WebWalkLog.spDebug("reachable_recapture | from={} tile={} reachableNow={}", compactWorldPoint(playerLoc), compactWorldPoint(currentWorldPoint), tileReachable); } } if (!tileReachable && !inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); + WorldPoint playerLoc = walkLoop.playerLoc; if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { + int recoveryScanStart = forwardRecoveryScanStart(rawPath, smoothedToRaw, indexOfStartPoint, playerLoc); boolean candidateOnCurrentRouteFrontier = RouteRecovery.isLocalRecoveryCandidateOnForwardRoute( rawPath, smoothedToRaw, - indexOfStartPoint, + recoveryScanStart, i, LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS); if (!candidateOnCurrentRouteFrontier) { - log.info("[Walker] spatially-near future route branch ignored for local recovery: " - + "tile={} idx={}/{} routeStart={} player={}", - currentWorldPoint, i, path.size(), indexOfStartPoint, playerLoc); + log.info("[Walker] spatially-near future route branch ignored for local recovery: tile={} idx={}/{} routeStart={} player={}", currentWorldPoint, i, path.size(), recoveryScanStart, playerLoc); if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { - exitReason = "route-fold-continuation-click"; - } else { - exitReason = "route-fold-continuation-pending"; + exit = WalkExit.ROUTE_FOLD_CONTINUATION_CLICK; + break; } - break; + // Fold stall fix: ending the pass at a behind/branch tile left the NEXT gate unhandled (4-26s pending per corridor); keep scanning forward. + continue; } - log.debug("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", - currentWorldPoint, i, path.size(), playerLoc, target); + log.debug("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", currentWorldPoint, i, path.size(), playerLoc, target); + routeState.recoveryGateEnteredAtMs = System.currentTimeMillis(); // Anti-end-camping frontier rewind. The near-player reachability check skips // far-away route tiles, so on a route whose tail folds back beside the player - // (Clock Tower) the miss can fire on the GOAL (Euclidean-near, idx end) while the - // REAL blocked frontier — the door tiles at mid-route — was silently skipped. - // Recovery then camps on the end: door scans probe the wrong raw segment and the - // recovery target anchors at the goal. Rewind to the EARLIEST unreachable route - // tile: that is the first edge the walk actually cannot cross, which is where the - // door (or other obstacle) really is. Every recovery path below exits the loop, - // so rebinding i/currentWorldPoint here is contained. - for (int fi = Math.max(0, indexOfStartPoint); fi < i; fi++) { - WorldPoint ft = path.get(fi); - if (ft != null && ft.getPlane() == currentPlayerPlane - && reachableTilesCache != null && !reachableTilesCache.containsKey(ft)) { - log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", - fi, ft, i); - i = fi; - currentWorldPoint = ft; - break; - } + // (Clock Tower) the miss can fire on the GOAL (Euclidean-near, idx end) while + // the REAL blocked frontier — the door tiles at mid-route — was silently + // skipped, camping recovery on the end. Rewind to the EARLIEST unreachable + // route tile: the first uncrossable edge is where the obstacle really is. + // Every recovery path below exits the loop, so rebinding i/currentWorldPoint + // here is contained. + int rewoundIdx = FrontierDecision.earliestBlockedIndex( + path, recoveryScanStart, i, currentPlayerPlane, reachableTilesCache); + if (rewoundIdx != FrontierDecision.NO_EARLIER_BLOCKED_INDEX) { + log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", + rewoundIdx, path.get(rewoundIdx), i); + i = rewoundIdx; + currentWorldPoint = path.get(rewoundIdx); } - int edgeIdx = Math.max(indexOfStartPoint, i - 1); - int rawEdgeStart = (edgeIdx < smoothedToRaw.length) ? smoothedToRaw[edgeIdx] : 0; - int rawEdgeEnd = (i < smoothedToRaw.length) ? smoothedToRaw[i] + 1 : rawPath.size(); - WorldPoint edgeFrom = rawEdgeStart >= 0 && rawEdgeStart < rawPath.size() ? rawPath.get(rawEdgeStart) : null; - WorldPoint edgeTo = rawEdgeEnd - 1 >= 0 && rawEdgeEnd - 1 < rawPath.size() ? rawPath.get(rawEdgeEnd - 1) : null; + FrontierDecision.FrontierEdge frontier = + FrontierDecision.frontierEdge(rawPath, smoothedToRaw, recoveryScanStart, i); + int edgeIdx = frontier.edgeIndex(); + int rawEdgeStart = frontier.rawStart(); + int rawEdgeEnd = frontier.rawEndExclusive(); + WorldPoint edgeFrom = frontier.from(); + WorldPoint edgeTo = frontier.to(); // Unified obstacle dispatch for the blocked frontier (P2). One call resolves both // a rockfall to mine here and a reachable transport/agility-shortcut origin to step @@ -2345,7 +2511,7 @@ && shouldSkipStartupPreclickSegmentHandlers( inInstance); if (frontierObstacle.kind() == ObstacleResolution.Kind.INTERACTED) { // A rockfall was mined or an on-origin transport/shortcut was taken. - exitReason = "frontier-obstacle-handled"; + exit = WalkExit.FRONTIER_OBSTACLE_HANDLED; break; } if (frontierObstacle.kind() == ObstacleResolution.Kind.ABORT) { @@ -2355,72 +2521,59 @@ && shouldSkipStartupPreclickSegmentHandlers( } if (hasRecentDoorAttemptOnEdge(edgeFrom, edgeTo)) { - boolean resolvedAfterWait = waitForDoorEdgeResolution(edgeFrom, edgeTo, + boolean edgeResolved = waitForDoorEdgeResolution(edgeFrom, edgeTo, obstaclePolicy.edgeResolutionWaitTimeoutMs()); - if (resolvedAfterWait && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target)) { - exitReason = "door-edge-resolved-fast-click"; - } else { - exitReason = resolvedAfterWait ? "door-edge-resolved-after-wait" : "door-edge-waiting-retry"; - } + boolean clicked = FrontierDecision.shouldFastClickAfterEdgeWait(edgeResolved) + && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target); + exit = FrontierDecision.afterEdgeWait(edgeResolved, clicked).exit(); break; } if (hasRecentDoorAttemptNearIndex(rawPath, rawEdgeStart)) { - boolean resolvedAfterNearbyWait = waitForRecentDoorEdgeResolutionNearIndex(rawPath, rawEdgeStart, + boolean nearbyResolved = waitForRecentDoorEdgeResolutionNearIndex(rawPath, rawEdgeStart, obstaclePolicy.edgeResolutionWaitTimeoutMs()); WorldPoint afterNearbyWait = Rs2Player.getWorldLocation(); - boolean progressedAfterNearbyWait = afterNearbyWait != null + boolean playerMoved = afterNearbyWait != null && !afterNearbyWait.equals(playerLoc); - if (resolvedAfterNearbyWait && progressedAfterNearbyWait) { - if (tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target)) { - exitReason = "door-edge-resolved-fast-click"; - } else { - exitReason = "door-edge-resolved-after-nearby-wait"; - } - break; - } - if (!resolvedAfterNearbyWait) { - exitReason = "door-edge-nearby-waiting-retry"; + boolean clicked = FrontierDecision.shouldFastClickAfterNearbyWait(nearbyResolved, playerMoved) + && tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target); + FrontierDecision.DoorWaitOutcome nearbyOutcome = + FrontierDecision.afterNearbyWait(nearbyResolved, playerMoved, clicked); + if (nearbyOutcome.endsPass()) { + exit = nearbyOutcome.exit(); break; } + // FALL_THROUGH: a nearby door opened but we did not move, so nothing was + // learned about THIS frontier — carry on to the settle checks below. } - boolean gateDoorInteraction = isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown(); - long recentDoorAgeMs = recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart); - boolean pendingDoorTraversal = recentDoorAgeMs >= 0 - && recentDoorAgeMs <= DOOR_TRAVERSAL_RECOVERY_BLOCK_MS - && !Rs2Player.isMoving(); - if (gateDoorInteraction) { - // Avoid any follow-up door probing right after an interaction; - // resolver is still settling and re-probes can loop. - exitReason = "door-settling-yield"; + FrontierDecision.FrontierYield frontierYield = + FrontierDecision.yieldBeforeDoorActions( + isDoorInteractionSettling(), + isDoorEdgePassSkipCoolingDown(), + recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart), + DOOR_TRAVERSAL_RECOVERY_BLOCK_MS, + Rs2Player.isMoving(), + shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())); + if (frontierYield.yields()) { + exit = frontierYield.exit(); break; } - if (pendingDoorTraversal) { - // Keep one-shot behavior after door open: let traversal finish - // before issuing fallback path-adj/recovery actions. - 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"; + if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { + exit = WalkExit.RECENT_DOOR_EDGE_NUDGE; break; } if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), - doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { - exitReason = "door-handled-local-reachability-raw-scan"; + playerLoc, 2, 14)) { + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN; break; } if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), null)) { - exitReason = "door-handled-local-reachability"; + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY; break; } if (isRecoveryMovementInFlight()) { - exitReason = "recovery-move-in-flight"; + exit = WalkExit.RECOVERY_MOVE_IN_FLIGHT; break; } boolean unresolvedDoorNearRawPath = hasUnresolvedDoorLikeObjectNearRawPath(rawPath, @@ -2428,30 +2581,47 @@ && shouldSkipStartupPreclickSegmentHandlers( UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + // No !gateDoorInteraction re-check: reaching here means the yield above + // returned NONE, which already proved the door-settling window closed. + if (unresolvedDoorNearRawPath && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), playerLoc, UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE)) { - exitReason = "door-handled-nearby-route-door"; + exit = WalkExit.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(); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + if (unresolvedDoorNearRawPath && obstaclePolicy.allowNearbyFallback() && nowMs - routeState.lastDoorPathAdjAttemptAtMs > 1200) { routeState.lastDoorPathAdjAttemptAtMs = nowMs; if (tryResolvePathAdjacentBlocker(playerLoc, rawPath, rawEdgeStart, 3, 10)) { - exitReason = "door-handled-path-adj-scan"; + exit = WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN; break; } } + // A shortcut / transport on the blocked frontier is TAKEN here rather than + // routed around: the minimap fallback below would pick the tile on the FAR + // side and send the server the long way around the gap. Recovery acts on the + // edge blocking us right now, so it is the nearest obstacle by construction + // and may dispatch from range. + // Ordered BEFORE door suppression, which breaks out of recovery and so never + // let the transport have its turn. Measured near Draynor: a catalog transport + // at (3064,3282) was refused as walled, declined by the door handlers, then + // suppressed as a "nearby route door" that was this very transport — four + // seconds before the raw scan dispatched it. Suppression still guards the + // generic recovery click below; it just no longer outranks this. + if ((PohTeleports.isInHouse() || !inInstance) + && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { + exit = WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY; + break; + } + if (unresolvedDoorNearRawPath) { // An unresolved door sits on/near the blocked edge but every door handler above // declined (settling / recent-attempt cooldowns). Do NOT fall through to the @@ -2480,27 +2650,12 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, routeState.lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_suppressed_approach | to={} idx={} tile={}", compactWorldPoint(doorApproach), rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-suppressed-approach-click"; + exit = WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK; break; } WebWalkLog.spInfo("door_recovery_suppressed | reason=nearby-route-door idx={} tile={}", rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-recovery-suppressed"; - break; - } - - // An agility shortcut / transport sitting on the blocked frontier is TAKEN - // here rather than routed around. The minimap-click fallback below picks the - // furthest path tile within Euclidean minimap reach, which for a stepping-stone - // (or any gap/wall shortcut) is the tile on the FAR side -- clicking it makes the - // server walk the long way around the gap it should have crossed. Taking the - // transport first mirrors the segment-handler transport scan (which can be - // skipped in the post-transport window) and the door/rockfall handling above. - // Recovery acts on the edge blocking us RIGHT NOW, so it is the nearest - // obstacle by construction and may dispatch from range. - if ((PohTeleports.isInHouse() || !inInstance) - && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { - exitReason = "transport-handled-local-reachability"; + exit = WalkExit.DOOR_RECOVERY_SUPPRESSED; break; } @@ -2529,22 +2684,19 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { if (playerLocNow != null && !playerLocNow.equals(playerLoc)) { WebWalkLog.spInfo("recovery_position_stale | was={} now={} idx={} re-evaluating", compactWorldPoint(playerLoc), compactWorldPoint(playerLocNow), i); - exitReason = "recovery-position-stale"; + exit = WalkExit.RECOVERY_POSITION_STALE; break; } final int recoveryMinimapReach = STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN; int recoverIdx = findForwardReachableRecoveryIndex(path, i, playerLoc, recoveryMinimapReach); if (recoverIdx < 0) { - recoverIdx = RouteRecovery.findFurthestClickableIndex(path, i, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + recoverIdx = RouteRecovery.findFurthestClickableIndex(path, i, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, recoveryMinimapReach); } int minRecoveryIdx = Math.max(indexOfStartPoint, i); - recoverIdx = Math.min(Math.max(recoverIdx, minRecoveryIdx), path.size() - 1); + recoverIdx = FrontierDecision.clampRecoveryIndex(recoverIdx, indexOfStartPoint, i, path.size()); WorldPoint recoverTarget = path.get(recoverIdx); if (euclideanSq(recoverTarget, playerLoc) > recoveryMinimapReach * recoveryMinimapReach) { @@ -2559,16 +2711,10 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { // (e.g. an undead tree). The planner avoids those via avoidDangerousNpcs, // but this runtime fallback would otherwise strand us in melee. Step the // target back along the path to the nearest non-hazard tile. - PathfinderConfig dangerCfg = Rs2PathApi.getPathfinderConfig(); - if (dangerCfg != null && dangerCfg.isAvoidDangerousNpcs() && recoverTarget != null - && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(recoverTarget))) { - int safeIdx = recoverIdx; - while (safeIdx > minRecoveryIdx - && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(path.get(safeIdx)))) { - safeIdx--; - } - recoverIdx = safeIdx; - recoverTarget = path.get(safeIdx); + if (Rs2PathApi.shouldAvoidDangerousTile(recoverTarget)) { + recoverIdx = FrontierDecision.stepBackFromDanger(path, recoverIdx, minRecoveryIdx, + Rs2PathApi::shouldAvoidDangerousTile); + recoverTarget = path.get(recoverIdx); } int rawAnchorIndex = rawIndexForSmoothedIndex(recoverIdx, smoothedToRaw, rawPath); WorldPoint rawRecoveryTarget = inInstance ? null : findFurthestRawPathPointMatchingGated( @@ -2577,23 +2723,21 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { recoveryMinimapReach - 1, rawAnchorIndex, Rs2Walker::isKnownWalkableOrUnloaded); - if (rawRecoveryTarget != null - && !rawRecoveryTarget.equals(playerLoc) - && (dangerCfg == null - || !dangerCfg.isAvoidDangerousNpcs() - || !dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(rawRecoveryTarget)))) { - recoverTarget = rawRecoveryTarget; - } - // Prefer walking onto the reachable transport / agility-shortcut origin the unified - // dispatch resolved above (e.g. a stepping stone) over the furthest-walkable target. - // The transport only dispatches while the player stands on its origin, so clicking - // the far side of the shortcut just loops on the near bank; stepping onto the origin - // lets the normal transport handler cross next tick. - if (frontierObstacle.kind() == ObstacleResolution.Kind.WALK_TO_ORIGIN - && frontierObstacle.walkTarget() != null - && !frontierObstacle.walkTarget().equals(playerLoc)) { - recoverTarget = frontierObstacle.walkTarget(); + WalkExit claimedDoorExit = resolveWalledDoorClaim(playerLoc, + obstaclePolicy.unreachableDoorTimeoutMs()); + if (claimedDoorExit != null) { + doorOrTransportResult = claimedDoorExit.isDoorLike(); + exit = claimedDoorExit; break; } + // Precedence (base < raw-gated < shortcut origin) and the hazard asymmetry + // between them live with the decision, pinned by its table. + WorldPoint shortcutOrigin = + frontierObstacle.kind() == ObstacleResolution.Kind.WALK_TO_ORIGIN + ? frontierObstacle.walkTarget() + : null; + recoverTarget = FrontierDecision.chooseRecoveryTarget(recoverTarget, + rawRecoveryTarget, shortcutOrigin, playerLoc, + Rs2PathApi::shouldAvoidDangerousTile); // The click decision (preemption vs walled vs cooldown vs click) is PURE and // decision-table-tested in RouteRecovery — this shell only carries out the // chosen action. Guard rationale (long recovery pass, walled end-snap, cooldown @@ -2613,19 +2757,18 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt System.currentTimeMillis(), routeState.lastWalledRecoveryReplanAtMs, WALLED_RECOVERY_REPLAN_COOLDOWN_MS); if (clickAction == RouteRecovery.RecoveryClickAction.YIELD_ACTION_IN_FLIGHT) { - exitReason = "recovery-click-preempted-by-action"; + exit = WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION; break; } if (clickAction == RouteRecovery.RecoveryClickAction.REPLAN_WALLED) { routeState.lastWalledRecoveryReplanAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("recovery_target_walled | to={} player={} replanning", compactWorldPoint(recoverTarget), compactWorldPoint(playerLoc)); - recalculatePath(); - exitReason = "recovery-target-walled-replan"; - break; + recalculatePathForRecovery(); } - if (clickAction == RouteRecovery.RecoveryClickAction.WAIT_WALLED) { - exitReason = "recovery-target-walled-waiting"; + WalkExit recoveryClickExit = FrontierDecision.exitForRecoveryClick(clickAction); + if (recoveryClickExit != null) { + exit = recoveryClickExit; break; } WorldPoint clickedRecoveryTarget = null; @@ -2636,13 +2779,12 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt 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. - if (!clicked && recoverTarget != null - && target != null - && playerLoc.distanceTo2D(target) <= Math.max(2, distance + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) - && playerLoc.distanceTo2D(recoverTarget) <= DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER + // Scene-click fallback only on final-adjacent approach (minimap can miss the + // clip very close); reachability-gated — a last resort, not the primary path. + if (!clicked + && FrontierDecision.shouldTrySceneClickFallback(playerLoc, target, recoverTarget, + distance, FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV, + DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER) && Rs2Tile.isTileReachable(recoverTarget) && walkFastCanvas(recoverTarget)) { clicked = true; @@ -2676,10 +2818,10 @@ && walkFastCanvas(recoverTarget)) { // spurious stall-recalc right after issuing recovery movement. routeState.lastMovedTimeMs = System.currentTimeMillis(); routeState.stuckCount = 0; - exitReason = "local-recovery-click"; + exit = WalkExit.LOCAL_RECOVERY_CLICK; break; } - exitReason = "local-reachability-miss-no-click"; + exit = WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK; break; } } @@ -2692,7 +2834,7 @@ && walkFastCanvas(recoverTarget)) { // unreachable / door-edge-resolution branch above is intentionally left alone — it // waits on the door edge itself and issues its own resolution-aware fast click. if (isDoorInteractionSettling()) { - exitReason = "door-settling-yield"; + exit = WalkExit.DOOR_SETTLING_YIELD; break; } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); @@ -2710,7 +2852,7 @@ && walkFastCanvas(recoverTarget)) { // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). WorldPoint playerLoc = Rs2Player.getWorldLocation(); - final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; + final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); // 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. @@ -2724,7 +2866,7 @@ && walkFastCanvas(recoverTarget)) { // rather than spinning without issuing movement commands. if (Rs2Player.isMoving()) { if (!inInstance && handlePendingDoorDuringInterim(rawPath, - obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.segmentDoorTimeoutMs(), playerLoc)) { routeState.interimTargetWp = null; routeState.interimTargetIdx = -1; @@ -2734,7 +2876,7 @@ && walkFastCanvas(recoverTarget)) { routeState.interimLastDistanceToTarget = Integer.MAX_VALUE; routeState.interimLastRetargetAtMs = 0L; doorOrTransportResult = true; - exitReason = "door-handled-during-interim"; + exit = WalkExit.DOOR_HANDLED_DURING_INTERIM; break; } final WorldPoint posBeforeWait = playerLoc; @@ -2752,7 +2894,7 @@ && walkFastCanvas(recoverTarget)) { boolean closeEnoughForNextClick = posAfterWait != null && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_CLOSE_TILES; if (!closeEnoughForNextClick && Rs2Player.isMoving()) { - exitReason = "interim-in-flight"; + exit = WalkExit.INTERIM_IN_FLIGHT_CLICK; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", interimFinal, posAfterWait == null ? interimDist : interimFinal.distanceTo2D(posAfterWait), @@ -2782,11 +2924,8 @@ && walkFastCanvas(recoverTarget)) { routeState.interimLastRetargetAtMs = 0L; } - int targetIdx = RouteRecovery.findFurthestForwardClickableIndex(path, i, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + int targetIdx = RouteRecovery.findFurthestForwardClickableIndex(path, i, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, MINIMAP_REACH_EUCLIDEAN); WorldPoint targetWp = path.get(targetIdx); // If the forward waypoint is outside minimap reach, interpolate a @@ -2842,15 +2981,9 @@ && walkFastCanvas(recoverTarget)) { targetIdx = Math.max(targetIdx, i); } } - } - + } WorldPoint posBefore = playerLoc; int rawAnchorIndex = rawIndexForSmoothedIndex(i, smoothedToRaw, rawPath); - // Prefer a collision-REACHABLE raw-route point. "Walkable" (tile not fully - // blocked) is not the same as "reachable from the player": a tile flush on the - // far side of a castle wall is walkable yet only reachable via a long detour, so - // a Euclidean-close click there sends the player into the wall. Reachability - // gating excludes the wrong side outright. See movement.md #19. WorldPoint rawRouteTarget = inInstance ? null : selectRouteClickTarget(rawPath, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); WorldPoint clickTarget; @@ -2860,12 +2993,6 @@ && walkFastCanvas(recoverTarget)) { clickTarget = rawRouteTarget; } else { clickTarget = inInstance ? targetWp : getPointWithWallDistance(targetWp, playerLoc); - // getPointWithWallDistance computes tiles reachable FROM THE TARGET, so its - // wall nudge can land on the far side of a wall or inside a building; a stale - // smoothed waypoint right after a teleport can also be unreachable. If the - // resulting click is not reachable, rejoin the route via the nearest reachable - // raw point on EITHER side of the anchor rather than clicking a wrong-side / - // random-far tile. fallbackTag = "wallnudge"; if (!inInstance && !Rs2Tile.isTileReachable(clickTarget)) { WorldPoint rejoin = findReachableRejoinRawPathPoint(rawPath, playerLoc, @@ -2878,10 +3005,17 @@ && walkFastCanvas(recoverTarget)) { } } if (!inInstance && handlePendingDoorBeforeRouteClick(rawPath, path, i, targetIdx, - smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), playerLoc)) { doorOrTransportResult = true; - exitReason = "door-handled-before-minimap-click"; + exit = WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK; + break; + } + WalkExit claimedDoorExit = resolveWalledDoorClaim(playerLoc, + obstaclePolicy.segmentDoorTimeoutMs()); + if (claimedDoorExit != null) { + doorOrTransportResult = claimedDoorExit.isDoorLike(); + exit = claimedDoorExit; break; } clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, MINIMAP_REACH_EUCLIDEAN - 1); @@ -2973,12 +3107,12 @@ && walkFastCanvas(recoverTarget)) { if (!Rs2Player.isMoving()) { if (handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target)) { doorOrTransportResult = true; - exitReason = "post-click-raw-path-scene-object-handled"; + exit = WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED; break; } if (handleCurrentTileTransportTowardPath(rawPath, path, target)) { doorOrTransportResult = true; - exitReason = "post-click-current-tile-transport-handled"; + exit = WalkExit.POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED; break; } } @@ -2994,7 +3128,7 @@ && walkFastCanvas(recoverTarget)) { // path tiles are further away and will also fail — break and let the outer // loop wait for the player to walk closer before re-evaluating. if (!clicked) { - exitReason = "click-failed-off-minimap"; + exit = WalkExit.CLICK_FAILED_OFF_MINIMAP; routeState.interimTargetWp = null; routeState.interimTargetIdx = -1; routeState.interimSetAtMs = 0L; @@ -3008,14 +3142,15 @@ && walkFastCanvas(recoverTarget)) { } break; } - // Advance past intermediate tiles we've effectively walked over so the - // outer loop doesn't re-run door/rockfall/transport handlers for indices - // now behind the player. - i = targetIdx; + // ONE CLICK PER PASS (task #25): advancing i meant 2-3 clicks per pass, each + // with its own post-click waits — 8-15s passes while the player stood at the + // first click's interim. End the pass; the next re-clicks ~1s after arrival. + exit = WalkExit.ROUTE_MOVE_IN_FLIGHT; + break; } } - if (doorOrTransportResult && shouldCanvasNudgeAfterDoorLikeExit(exitReason)) { + if (doorOrTransportResult && exit.isDoorLike()) { boolean canvasNudged = maybeCanvasNudgeAfterDoor(target, distance, path); // Arm after nudge returns so the window does not expire during in-nudge waits. The long // window exists to stop a minimap click landing on the heels of a CANVAS click, so it is @@ -3038,15 +3173,16 @@ && walkFastCanvas(recoverTarget)) { } } - if (!"end-of-path".equals(exitReason)) { - WebWalkLog.earlyExit(exitReason, + logRecoveryGateDuration(target, exit); + if (exit != WalkExit.END_OF_PATH) { + WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), Rs2Player.getWorldLocation(), target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("early-exit detail reason=%s interim=%s doorOrTransport=%s partialPath=%s", - exitReason, + exit.wireName(offPathDeferDetail), routeState.interimTargetWp, doorOrTransportResult, partialPath); @@ -3055,7 +3191,7 @@ && walkFastCanvas(recoverTarget)) { // Only do the final-tile canvas click if we iterated the whole path cleanly. // 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 (!doorOrTransportResult && exit == WalkExit.END_OF_PATH) { if (walkCancelledDiag(target, "processWalk:before-final-canvas", processWalkTail)) { return WalkerState.EXIT; } @@ -3082,7 +3218,7 @@ && walkFastCanvas(recoverTarget)) { if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, - NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); + normalMinimapReach() - 1, rawAnchorIndex); } else { finalClick = Rs2Walker.walkFastCanvas(canvasClickWp); } @@ -3099,9 +3235,9 @@ && walkFastCanvas(recoverTarget)) { // the previous movement command. Charging those passes as failures can exhaust the // bounded tail loop before the player reaches a nearby transport origin. if (!doorOrTransportResult - && "end-of-path".equals(exitReason) + && exit == WalkExit.END_OF_PATH && Rs2Player.isMoving()) { - exitReason = "route-move-in-flight"; + exit = WalkExit.ROUTE_MOVE_IN_FLIGHT; } WorldPoint pathLastForFinish = path.get(path.size() - 1); int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); @@ -3113,38 +3249,26 @@ && walkFastCanvas(recoverTarget)) { if (walkCancelledDiag(target, "processWalk:partial-path-branch", processWalkTail)) { return WalkerState.EXIT; } - // Route progress since the last retry means the walk is working — refill the budget. - // It otherwise only ever increments, so "3 retries" meant three outer-loop iterations - // for the whole journey rather than three consecutive failures to advance. - // - // Standing somewhere new is required as well as the progress timestamp: - // routeState.routeProgressAdvancedAtMs is also bumped whenever the route is merely REPLACED, and - // each retry calls recalculatePath(), so the timestamp alone would let a retry refill - // the budget it just spent. When the target is genuinely unreachable the player stops - // moving, so requiring movement is what still lets the budget drain and terminate. WorldPoint retryLoc = Rs2Player.getWorldLocation(); boolean movedSinceLastRetry = lastPartialRetryAtLoc == null || (retryLoc != null && !retryLoc.equals(lastPartialRetryAtLoc)); - if (partialRetriesWorking > 0 - && movedSinceLastRetry - && routeState.routeProgressAdvancedAtMs > lastPartialRetryAtMs) { - walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", - routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); + if (TailDecision.shouldRefillPartialRetryBudget(partialRetriesWorking, movedSinceLastRetry, + routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs)) { + walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); partialRetriesWorking = 0; } - // A handled door/transport/blocker ended the iteration because work was done, not - // because the walker is stuck. Still re-route, but do not charge the budget for it. - if (isRouteProgressExit(exitReason)) { - walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", - exitReason, processWalkTail, partialRetriesWorking); + TailDecision.TailAction partialAction = TailDecision.decide(false, true, exit, + partialRetriesWorking, TailDecision.MAX_PARTIAL_RETRIES); + if (partialAction == TailDecision.TailAction.PARTIAL_PROGRESS_REPLAN) { + walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); recalculatePath(); continue; } - if (partialRetriesWorking < 3) { + if (partialAction == TailDecision.TailAction.PARTIAL_RETRY_REPLAN) { lastPartialRetryAtMs = System.currentTimeMillis(); lastPartialRetryAtLoc = retryLoc; Telemetry.recordPartialRetry(partialRetriesWorking + 1, finalDist); - WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, 3); + WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, TailDecision.MAX_PARTIAL_RETRIES); recalculatePath(); partialRetriesWorking++; continue; @@ -3156,14 +3280,19 @@ && walkFastCanvas(recoverTarget)) { // than the actual shortfall. WorldPoint unreachableEndpoint = path.isEmpty() ? null : path.get(path.size() - 1); Telemetry.recordUnreachable("partial-retries-exhausted", Rs2Player.getWorldLocation(), - target, unreachableEndpoint, path.size(), distance, Rs2PathApi.getPathfinder()); + target, unreachableEndpoint, path.size(), distance, + Rs2PathApi.getActiveRouteStatus().getMetrics().orElse(null)); setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { - if (isOffPathRecalcDeferredExit(exitReason)) { + WalkerState stagnated = handleRouteStagnation(target, distance, path); + if (stagnated != null) { + return stagnated; + } + if (exit == WalkExit.OFF_PATH_DEFERRED) { // 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); + String deferReason = offPathDeferDetail; long offPathWaitMs = offPathRecalcDeferredWaitMs(deferReason, System.currentTimeMillis(), routeState.lastMovedTimeMs, @@ -3202,19 +3331,16 @@ && walkFastCanvas(recoverTarget)) { } // 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) - || "recovery-move-in-flight".equals(exitReason) - || "route-move-in-flight".equals(exitReason) - || "route-fold-continuation-click".equals(exitReason) - || isOffPathRecalcDeferredExit(exitReason)) { - walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); + if (exit.isTailExempt()) { + consecutiveExemptIterations = trackExemptRun(consecutiveExemptIterations, target, exit, offPathDeferDetail); + walkerDiag("tail exempt exitReason=%s tailBefore=%d", exit.wireName(offPathDeferDetail), processWalkTail); processWalkTail--; + } else { + consecutiveExemptIterations = 0; } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", - processWalkTail + 1, - exitReason, - Rs2Player.getWorldLocation().distanceTo(target), - partialPath); + processWalkTail + 1, exit.wireName(offPathDeferDetail), + Rs2Player.getWorldLocation().distanceTo(target), partialPath); continue; } } catch (Exception ex) { @@ -3224,6 +3350,16 @@ && walkFastCanvas(recoverTarget)) { setTarget(null, "rs2walker:processWalk:interrupted-exception"); return WalkerState.EXIT; } + if (isClientThreadReadTimeout(ex) + && clientThreadTimeoutRetries < CLIENT_THREAD_TIMEOUT_RETRIES + && Objects.equals(currentTarget, target) + && !Thread.currentThread().isInterrupted()) { + int nextRetry = ++clientThreadTimeoutRetries; + WebWalkLog.spInfo("client_thread_timeout_retry | attempt={}/{} target={}", + nextRetry, CLIENT_THREAD_TIMEOUT_RETRIES, target); + processWalkTail--; + continue; + } log.error("Exception in Rs2Walker:", ex); WebWalkLog.interruptedExit("walker exception exit (403)"); traceProcessWalkExit("exception-" + ex.getClass().getSimpleName(), target, MAX_PROCESS_WALK_TAIL_ITERATIONS - 1); @@ -3309,7 +3445,7 @@ public static WorldPoint getPointWithWallDistance(WorldPoint target, WorldPoint Set reachableFromPlayer = playerLoc == null ? Collections.emptySet() : Rs2Tile.getReachableTilesFromTile(playerLoc, - Math.max(2, NORMAL_MINIMAP_REACH_EUCLIDEAN)).keySet(); + Math.max(2, normalMinimapReach())).keySet(); if (hasMinimapRelevantMovementFlag(localPoint, flags)) { WorldPoint best = bestWallDistanceNeighbor(tiles.keySet(), playerLoc, reachableFromPlayer, @@ -3380,7 +3516,7 @@ private static WorldPoint bestWallDistanceNeighbor(Collection candid return best; } - private static boolean isKnownWalkableOrUnloaded(WorldPoint target) { + static boolean isKnownWalkableOrUnloaded(WorldPoint target) { if (target == null) { return false; } @@ -3389,7 +3525,8 @@ private static boolean isKnownWalkableOrUnloaded(WorldPoint target) { return localTarget == null || Rs2Tile.isWalkable(localTarget); } - private static boolean isWalkCancelled(WorldPoint target) { + + static boolean isWalkCancelled(WorldPoint target) { // The single choke point for stopping a walk: processWalk already consults it at every // checkpoint and inside the movement-wait predicates. if (InputArbiter.isHuman()) { @@ -3430,25 +3567,6 @@ private static boolean evaluateWalkCompletion(WalkCompletionContext context) { return context.met; } - static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) { - int data = flagMap[point.getSceneX()][point.getSceneY()]; - Set movementFlags = MovementFlag.getSetFlags(data); - - if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_EAST) - && Rs2Tile.isWalkable(point.dx(1))) - return true; - - if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_WEST) - && Rs2Tile.isWalkable(point.dx(-1))) - return true; - - if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_NORTH) - && Rs2Tile.isWalkable(point.dy(1))) - return true; - - return movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_SOUTH) - && Rs2Tile.isWalkable(point.dy(-1)); - } // Enable run (if energy permits) and drink a stamina/restore-energy potion when // energy drops below a threshold on a long walk. Short hops don't justify a dose. @@ -3460,12 +3578,12 @@ static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) static final int STAMINA_HARDCORE_MIN = 12; static final int STAMINA_HARDCORE_MAX = 24; static final double STAMINA_HARDCORE_PROBABILITY = 0.3; - private static final int STAMINA_THRESHOLD_FALLBACK = 35; + static final int STAMINA_THRESHOLD_FALLBACK = 35; private static final int STAMINA_MIN_PATH_TILES = 20; private static final long STAMINA_MIN_INTERVAL_MS = 10_000; - private static volatile String staminaSeedName = null; - private static volatile int staminaThresholdCached = STAMINA_THRESHOLD_FALLBACK; + static volatile String staminaSeedName = null; + static volatile int staminaThresholdCached = STAMINA_THRESHOLD_FALLBACK; // Door-scan cooldown state migrated to WalkerRouteState; the fallback/LOS timestamps that // used to sit here were dead (written by nothing, read by nothing) and are simply gone. @@ -3480,46 +3598,8 @@ static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) private static final int WALLED_RECOVERY_TARGET_EUCLIDEAN = 9; private static final long WALLED_RECOVERY_REPLAN_COOLDOWN_MS = 5_000L; - static int computeStaminaThreshold(String playerName, long installSeed) { - if (playerName == null || playerName.isEmpty()) { - return STAMINA_THRESHOLD_FALLBACK; - } - long nameHash = mix64(playerName.toLowerCase()); - long seed = nameHash ^ installSeed; - java.util.Random rng = new java.util.Random(seed); - if (rng.nextDouble() < STAMINA_HARDCORE_PROBABILITY) { - int span = STAMINA_HARDCORE_MAX - STAMINA_HARDCORE_MIN + 1; - return STAMINA_HARDCORE_MIN + rng.nextInt(span); - } - int span = STAMINA_CASUAL_MAX - STAMINA_CASUAL_MIN + 1; - return STAMINA_CASUAL_MIN + rng.nextInt(span); - } - private static long mix64(String s) { - long h = 0xcbf29ce484222325L; - for (int i = 0; i < s.length(); i++) { - h ^= s.charAt(i); - h *= 0x100000001b3L; - } - return h; - } - private static int staminaThreshold() { - String name = null; - try { - var player = Microbot.getClient().getLocalPlayer(); - if (player != null) name = player.getName(); - } catch (Exception ignored) { - } - if (name == null || name.isEmpty()) { - return staminaThresholdCached; - } - if (!name.equals(staminaSeedName)) { - staminaSeedName = name; - staminaThresholdCached = computeStaminaThreshold(name, Microbot.getInstallSeed()); - } - return staminaThresholdCached; - } private static void manageRunEnergy(int pathRemaining) { try { @@ -3542,10 +3622,27 @@ private static void manageRunEnergy(int pathRemaining) { } } + /** + * Explicit-zoom variant, kept for external callers that genuinely want a particular zoom. The + * walker itself never uses it: {@code Perspective.localToMinimap} reads the LIVE zoom, so the + * click math is correct at any setting, and pinning the minimap at max zoom on every click both + * looked bot-like and fought the user's own zoom the moment they changed it. + */ public static boolean walkMiniMap(WorldPoint worldPoint, double zoomDistance) { if (Microbot.getClient().getMinimapZoom() != zoomDistance) Microbot.getClient().setMinimapZoom(zoomDistance); + return walkMiniMap(worldPoint); + } + /** + * Clicks {@code worldPoint} on the minimap at whatever zoom the user has. Zoom only moves the + * trade-off between reach and pixel precision — zoomed IN shrinks clickable range (~16 tiles at + * zoom 5, ~40 zoomed out), zoomed out shrinks pixels-per-tile — and every caller already has a + * fallback for an unclickable point (nearer route point, canvas click), which is exactly what a + * human at that zoom would do. Tile-exact clicks near walls use the canvas path, which is + * pixel-precise at any zoom. + */ + public static boolean walkMiniMap(WorldPoint worldPoint) { Point point = Rs2MiniMap.worldToMinimap(worldPoint); if (point == null) return false; @@ -3556,157 +3653,12 @@ public static boolean walkMiniMap(WorldPoint worldPoint, double zoomDistance) { } - public static boolean walkMiniMap(WorldPoint worldPoint) { - return walkMiniMap(worldPoint, 5); - } - - private static boolean isMiniMapClickable(WorldPoint worldPoint, double zoomDistance) { - if (worldPoint == null) { - return false; - } - if (Microbot.getClient().getMinimapZoom() != zoomDistance) { - Microbot.getClient().setMinimapZoom(zoomDistance); - } - Point point = Rs2MiniMap.worldToMinimap(worldPoint); - return point != null && (disableWalkerUpdate || Rs2MiniMap.isPointInsideMinimap(point)); - } - - private static boolean walkRawPathMiniMapToward(List rawPath, - WorldPoint target, - WorldPoint playerLoc, - int maxEuclidean) { - 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 null; - } - if (walkMiniMap(fallback)) { - log.info("[Walker] Minimap click target {} was outside clip; used route fallback {}", target, fallback); - return fallback; - } - return null; - } - - static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { - if (target == null || playerLoc == null || target.getPlane() != playerLoc.getPlane()) { - return false; - } - - int dx = target.getX() - playerLoc.getX(); - int dy = target.getY() - playerLoc.getY(); - double distance = Math.sqrt(dx * dx + dy * dy); - if (distance <= 1) { - return false; - } - if (walkReachableMiniMapToward(target, playerLoc, maxEuclidean)) { - return true; - } - int cappedRadius = Math.max(2, maxEuclidean); - // The scaled-radius points below are geometric guesses toward an off-clip target. Right - // after a teleport (or when the target sits behind a wall) that guess can be an unreachable - // tile far off the route, producing the "random click far from the path" behaviour. Only - // click a guess that is actually reachable from the player. - Set reachable = Rs2Tile - .getReachableTilesFromTile(playerLoc, Math.max(2, cappedRadius)).keySet(); - int[] radii = new int[] {cappedRadius, 10, 8, 6, 4}; - for (int radius : radii) { - if (radius >= distance) { - continue; - } - double scale = radius / distance; - WorldPoint fallback = new WorldPoint( - playerLoc.getX() + (int) Math.round(dx * scale), - playerLoc.getY() + (int) Math.round(dy * scale), - playerLoc.getPlane()); - if (fallback.equals(playerLoc)) { - continue; - } - if (!reachable.contains(fallback)) { - continue; - } - if (Rs2Walker.walkMiniMap(fallback)) { - log.info("[Walker] Minimap click target {} was outside clip; used fallback {}", target, fallback); - return true; - } - } - return false; - } - private static boolean walkReachableMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { - int currentDistance = euclideanSq(playerLoc, target); - return Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet().stream() - .filter(tile -> tile != null - && tile.getPlane() == playerLoc.getPlane() - && !tile.equals(playerLoc) - && euclideanSq(playerLoc, tile) <= maxEuclidean * maxEuclidean - && euclideanSq(tile, target) < currentDistance) - .sorted(Comparator - .comparingInt((WorldPoint tile) -> euclideanSq(tile, target)) - .thenComparing(Comparator.comparingInt((WorldPoint tile) -> euclideanSq(playerLoc, tile)).reversed())) - .filter(Rs2Walker::walkMiniMap) - .findFirst() - .map(tile -> { - log.info("[Walker] Minimap click target {} was outside clip; used reachable fallback {}", target, tile); - return true; - }) - .orElse(false); - } - // findFurthestRawPathPointMatching (pure) moved to geometry/WalkerPathGeometry (P1); this game-coupled - // wrapper supplies the constant forward-search window and the lazy reachable-closest fallback. UNGATED — - // it is the pure-selection unit the tests exercise; live click paths use the gated variant below. - static WorldPoint findFurthestRawPathPointMatching(List rawPath, - WorldPoint playerLoc, - int maxEuclidean, - int rawAnchorIndex, - Predicate isCandidate) { - return WalkerPathGeometry.findFurthestRawPathPointMatching(rawPath, playerLoc, maxEuclidean, - rawAnchorIndex, isCandidate, ROUTE_PROGRESS_FORWARD_SEARCH_TILES, - () -> getClosestTileIndex(rawPath, playerLoc)); - } /** * Live-click variant of {@link #findFurthestRawPathPointMatching} with the route-blocked scan gate: it @@ -3716,7 +3668,7 @@ static WorldPoint findFurthestRawPathPointMatching(List rawPath, * separate from the ungated wrapper because the gate reads live game state (the BFS), which the * pure-selection unit tests must not depend on. */ - private static WorldPoint findFurthestRawPathPointMatchingGated(List rawPath, + static WorldPoint findFurthestRawPathPointMatchingGated(List rawPath, WorldPoint playerLoc, int maxEuclidean, int rawAnchorIndex, @@ -3739,106 +3691,18 @@ private static WorldPoint findFurthestRawPathPointMatchingGated(List && playerLoc.distanceTo2D(selected) <= CLOSEST_INDEX_REACHABLE_STEP_BUDGET - 2) { WebWalkLog.spInfo("route_click_walled | to={} player={} anchorIdx={} — refused, falling back", compactWorldPoint(selected), compactWorldPoint(playerLoc), rawAnchorIndex); + learnWalledRouteEdge(rawPath, playerLoc, reachable); return null; } return selected; } - /** - * Selects the next minimap click target from the raw route, gated on collision reachability. - *

- * Preference order: - *

    - *
  1. Furthest-forward raw point that is collision-reachable from the player. A point on the - * far side of a wall is Euclidean-close but not reachable within the sampled area, so it is - * excluded — this is what stops the walker clicking through castle walls / into buildings.
  2. - *
  3. Furthest-forward raw point that is off the loaded scene. Collision cannot be verified for - * unloaded tiles, but a minimap click toward a distant route point is still correct, so long - * outdoor routes keep flowing.
  4. - *
- * 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 rawPath, WorldPoint playerLoc, - int maxEuclidean, int rawAnchorIndex) { - if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { - routeState.lastRouteClickTier = "norawpath"; - return null; - } - // Anti-ban: vary HOW FAR ALONG the route we click. Selection otherwise always returns the - // furthest candidate inside a fixed radius, so every click covers the same tile span — a - // deterministic signature. Varying the reach is the safe axis: it only changes how far - // forward we pick, never sideways, so the target stays on the planned route (#20). Lateral - // tile offsets are the wrong axis and were removed for exactly that reason (#15); lateral - // randomness belongs inside the tile (click-point jitter), not in tile selection. - int jitteredReach = routeClickReach(maxEuclidean); - WorldPoint selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, rawAnchorIndex); - if (selected == null && jitteredReach < maxEuclidean) { - // A shortened reach must never be the reason selection fails — that would drop the click - // onto the caller's off-route wall-nudge clamp. Retry at full reach before giving up. - selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); - } - if (selected == null && rawAnchorIndex >= 0) { - // The smoothed->raw anchor can point past the player's vicinity (stale mapping, sparse - // smoothing, or a replanned route). The anchored forward scan then breaks immediately on - // the Euclidean bound and yields nothing for EVERY predicate — which is exactly the - // sel=none case that dropped route clicks onto the off-route wall-nudge clamp. Retry - // anchored at the player's own closest raw tile before giving up. - // Keep the jitter on this path too. The player-anchored retry fires on most first clicks - // of a route, so using full reach here bypassed the reach variation exactly where it is - // most visible — measured click distances clustered at 9.0-10.0 instead of spreading. - selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, -1); - if (selected == null && jitteredReach < maxEuclidean) { - selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, -1); - } - if (selected != null) { - routeState.lastRouteClickTier = routeState.lastRouteClickTier + "@player"; - } - } - return selected; - } - /** - * Per-click route reach, jittered below {@code maxEuclidean} so consecutive clicks do not all - * cover the same tile span. - *

- * 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 rawPath, WorldPoint playerLoc, - int maxEuclidean, int rawAnchorIndex) { - // Click the furthest forward point ON THE RAW ROUTE that is within minimap reach. - // - // A minimap click is resolved by the GAME's own pathing, so line of sight is irrelevant to - // walking: a player clicks past a corner, through a doorway, or around a building and the - // server routes them there. Requiring straight LOS made the walker advance corner-to-corner, - // stopping at each one to re-aim — a visible tell, and it bought no correctness. The - // invariant that actually matters is that the target sits ON the planned route, so wherever - // the server routes us we still arrive on that route. - // - // The off-route click (3176,3428) that started this came from the caller's - // smoothed-waypoint Euclidean clamp after selection returned null on a stale anchor — not - // from a lack of line of sight. Pending doors/gates are handled by - // handlePendingDoorBeforeRouteClick, not by shortening the click. - WorldPoint forward = findFurthestRawPathPointMatchingGated(rawPath, playerLoc, maxEuclidean, - rawAnchorIndex, Rs2Walker::isKnownWalkableOrUnloaded); - if (forward != null && !forward.equals(playerLoc)) { - routeState.lastRouteClickTier = "route"; - return forward; - } - routeState.lastRouteClickTier = "none"; - return null; - } + + + + /** * Which tier of {@link #selectRouteClickTarget} produced the most recent click target @@ -3884,25 +3748,7 @@ static WorldPoint findReachableRejoinRawPathPoint(List rawPath, Worl () -> getClosestTileIndex(rawPath, playerLoc)); } - 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; - } - return findFurthestRawPathPointMatchingGated(rawPath, playerLoc, maxEuclidean, rawAnchorIndex, - candidate -> !candidate.equals(playerLoc) - && isKnownWalkableOrUnloaded(candidate) - && isMiniMapClickable(candidate, 5)); - } // rawPathStepDistance (pure) moved to geometry/WalkerPathGeometry (P1) alongside its only caller, // findFurthestRawPathPointMatching; no remaining Rs2Walker callers. @@ -3916,78 +3762,27 @@ static int rawPathForwardAnchorIndex(List rawPath, WorldPoint player ROUTE_PROGRESS_FORWARD_SEARCH_TILES, () -> getClosestTileIndex(rawPath, playerLoc)); } - private static boolean shouldIssueActiveRouteIdleNudge() { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - long now = System.currentTimeMillis(); - if (playerLoc == null || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Player.isInteracting() - || Rs2LeaguesTransport.isTeleportInProgress() - || Rs2LeaguesTransport.isLeaguesAreaTeleportPending(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { - routeState.idleNudgeLastObservedLocation = playerLoc; - routeState.idleNudgeStationarySinceMs = now; - return false; - } - // While door recovery is actively suppressed (unresolved door on the blocked edge, handlers cooling - // down), the nudge MUST NOT fire: its forward click is not door-aware and can select a tile on the - // far side of the closed door, which routes the player around the building and off the route. The - // suppress branch itself walks the player to the door's near side; standing there waiting for the - // cooldown is the correct behavior, not idleness to nudge out of. - if (now - routeState.doorRecoverySuppressedAtMs < DOOR_SUPPRESS_NUDGE_HOLDOFF_MS) { - routeState.idleNudgeLastObservedLocation = playerLoc; - routeState.idleNudgeStationarySinceMs = now; - return false; - } - if (!playerLoc.equals(routeState.idleNudgeLastObservedLocation)) { - routeState.idleNudgeLastObservedLocation = playerLoc; - routeState.idleNudgeStationarySinceMs = now; - return false; - } - if (routeState.idleNudgeStationarySinceMs <= 0L) { - routeState.idleNudgeStationarySinceMs = now; - return false; + /** + * The local-recovery scan anchor, forward-corrected past route tiles the player has already + * passed (FrontierDecision.forwardScanStartIndex). The player's raw position is found with the + * forward-window search, not plain-nearest, so a route tail folding back beside the player + * (Clock Tower) cannot yank the anchor to the end of the route. + */ + private static int forwardRecoveryScanStart(List rawPath, int[] smoothedToRaw, + int indexOfStartPoint, WorldPoint playerLoc) { + if (rawPath == null || rawPath.isEmpty() || smoothedToRaw == null || playerLoc == null + || indexOfStartPoint < 0 || indexOfStartPoint >= smoothedToRaw.length + || smoothedToRaw[indexOfStartPoint] < 0) { + return indexOfStartPoint; } - return now - routeState.idleNudgeStationarySinceMs >= ACTIVE_ROUTE_IDLE_NUDGE_MS - && now - routeState.lastActiveRouteIdleNudgeAtMs >= ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS; + int playerRawIdx = rawPathForwardAnchorIndex(rawPath, playerLoc, smoothedToRaw[indexOfStartPoint]); + return FrontierDecision.forwardScanStartIndex(smoothedToRaw, indexOfStartPoint, playerRawIdx); } - private static boolean tryIssueRouteRecoveryClick(List rawPath, - List path, - WorldPoint target, - int configuredDistance, - String logLabel) { - return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, logLabel, - STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN, true); - } - private static boolean tryIssueRouteContinuationClick(List rawPath, - List path, - WorldPoint target, - int configuredDistance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null || path == null || path.isEmpty()) { - return false; - } - if (rawPath != null && !rawPath.isEmpty()) { - int rawIdx = getClosestTileIndex(rawPath, playerLoc); - if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, - rawIdx, - playerLoc, - UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, - UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, - HANDLER_RANGE)) { - return false; - } - } - int pathIdx = Math.max(0, getClosestTileIndex(path, playerLoc)); - if (hasUpcomingNearbyTransportStep(path, pathIdx, playerLoc, - POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, - POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { - return false; - } - return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", - NORMAL_MINIMAP_REACH_EUCLIDEAN, false); - } - private static boolean tryIssueRouteMovementClick(List rawPath, + + static boolean tryIssueRouteMovementClick(List rawPath, List path, WorldPoint target, int configuredDistance, @@ -4012,11 +3807,8 @@ private static boolean tryIssueRouteMovementClick(List rawPath, // pathfinder makes the idle nudge issue the first click instead of the main loop). WorldPoint clickTarget = selectRouteClickTarget(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); if (clickTarget == null) { - int clickableIdx = RouteRecovery.findFurthestForwardClickableIndex(path, startIdx, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + int clickableIdx = RouteRecovery.findFurthestForwardClickableIndex(path, startIdx, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, maxEuclidean); clickableIdx = Math.max(startIdx, Math.min(clickableIdx, path.size() - 1)); clickTarget = path.get(clickableIdx); @@ -4031,15 +3823,38 @@ private static boolean tryIssueRouteMovementClick(List rawPath, maxEuclidean - 1, Rs2Walker::isKnownWalkableOrUnloaded); } + // The primary selector reaches here only after refusing every route point (e.g. the + // walled net saw a shut door between), and this fallback vets candidates by + // WALKABILITY, not reachability. Clicking a walkable-but-unreachable tile moves the + // player nowhere while still arming an interim — at the Stronghold's chained gates the + // idle nudge did exactly that every ~2s beyond the shut second gate, and the dead + // interim's in-flight yields starved the pass that would have opened it. + if (clickTarget != null && !Rs2Tile.isTileReachable(clickTarget)) { + WebWalkLog.spDebug("route_click_fallback_unreachable | to={} player={}", + compactWorldPoint(clickTarget), compactWorldPoint(playerLoc)); + return false; + } } boolean clicked = false; WorldPoint clickedTarget = null; if (clickTarget != null && !clickTarget.equals(playerLoc)) { clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, maxEuclidean - 1); - clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, - maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); - clicked = clickedTarget != null; + // The finish needs scene precision, not minimap reach. A minimap tile is a few pixels + // wide, so a click at the goal from 1-2 tiles out routinely quantizes onto a neighbour — + // measured as the last-tile dance (1784,3559 -> 1786,3559 -> 1784,3560 around a + // 1785,3560 goal). Inside the final band, click the exact tile on screen instead. + if (target != null && playerLoc.distanceTo2D(target) <= INTERIM_CLOSE_TILES + && clickTarget.getPlane() == target.getPlane() + && clickTarget.distanceTo2D(target) <= 1 + && walkFastCanvas(clickTarget)) { + clickedTarget = clickTarget; + clicked = true; + } else { + clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, + maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + clicked = clickedTarget != null; + } } // EVERY movement click logs at info. The interim-continuation label used to log at debug only, // which made its clicks invisible: the walker appeared to "randomly click far from the path" @@ -4092,18 +3907,6 @@ static boolean routeArrivalSatisfied(WorldPoint playerLoc, return playerLoc.distanceTo2D(target) <= finishThreshold; } - 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 @@ -4132,45 +3935,7 @@ 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) { @@ -4247,12 +4012,8 @@ public static WorldPoint walkCanvas(WorldPoint worldPoint) { * @return total amount of tiles */ public static int getTotalTiles(WorldPoint start, WorldPoint destination) { - if (Rs2PathApi.getPathfinderConfig().getTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, destination); - pathfinder.run(); - List path = pathfinder.getPath(); + Rs2RouteResult route = Rs2PathApi.plan(Rs2RouteRequest.to(start, destination)); + List path = route.getPath(); if (path.isEmpty() || path.get(path.size() - 1).getPlane() != destination.getPlane()) return Integer.MAX_VALUE; // Create a WorldArea centered on the worldPoint by calculating the south-west corner WorldPoint pathPoint_SW = new WorldPoint( @@ -4322,7 +4083,6 @@ public static int getTotalTiles(WorldPoint destination) { // takes an avg 200-300 ms // Used mainly for agility, might have to tweak this for other stuff public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int pathSizeX, int pathSizeY,boolean useBankedItems) { - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); WorldArea pathArea = null; // Create centered WorldArea for the object instead of corner-based @@ -4334,16 +4094,14 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int WorldArea objectArea = new WorldArea(objectSouthWest, sizeX + 2, sizeY + 2); try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankedItems); - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); - if (Rs2PathApi.getPathfinderConfig().getTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); - } - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), Rs2Player.getWorldLocation(), worldPoint); - pathfinder.run(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(Rs2Player.getWorldLocation(), worldPoint) + .withRefreshTarget(worldPoint) + .withBankItems(useBankedItems)); // Create centered WorldArea for the path endpoint instead of corner-based - WorldPoint pathEndpoint = pathfinder.getPath().get(pathfinder.getPath().size() - 1); + WorldPoint pathEndpoint = route.getEndpoint().orElseThrow( + () -> new IllegalStateException("planner returned no endpoint")); WorldPoint pathSouthWest = new WorldPoint( pathEndpoint.getX() - pathSizeX / 2, pathEndpoint.getY() - pathSizeY / 2, @@ -4353,9 +4111,6 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int } catch (Exception e) { log.trace("Exception in canReach: {} - ", e.getMessage(), e); return false; - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); } return pathArea != null ? pathArea.intersectsWith2D(objectArea) : false; } @@ -4397,16 +4152,17 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, bool */ public static List getWalkPath(WorldPoint start, WorldPoint target) { long startTime = System.nanoTime(); - Rs2PathApi.getPathfinderConfig().refresh(target); - long pathfinderStartTime = System.nanoTime(); - Pathfinder pathfinderLocal = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, target); - pathfinderLocal.run(); - List path = pathfinderLocal.getPath(); - long pathfinderEndTime = System.nanoTime(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, target) + .withRefreshTarget(target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.ALWAYS)); + List path = route.getPath(); long totalEndTime = System.nanoTime(); - double configTimeMs = (pathfinderStartTime - startTime) / 1_000_000.0; - double pathfinderTimeMs = (pathfinderEndTime - pathfinderStartTime) / 1_000_000.0; + double pathfinderTimeMs = route.hasSearchNanos() + ? route.getSearchNanos() / 1_000_000.0 + : 0.0; double totalTimeMs = (totalEndTime - startTime) / 1_000_000.0; + double configTimeMs = Math.max(0.0, totalTimeMs - pathfinderTimeMs); StringBuilder performanceLog = new StringBuilder(); performanceLog.append("getWalkPath Performance: ") @@ -4606,40 +4362,7 @@ private static Map buildPathFirstIndex(List pat * @return The filtered and processed list of transports */ private static List applyTransportFiltering(List transports) { - return transports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM || t.getType() == TransportType.FAIRY_RING || - t.getType() == TransportType.TELEPORTATION_SPELL || t.getType() == TransportType.CANOE || - 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 - && t.getDisplayInfo().toLowerCase().startsWith("leagues area:"))) - .peek(t -> { - // Set fairy ring requirements if not already set - if (t.getType() == TransportType.FAIRY_RING && - ((t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) ) && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) { - t.setItemIdRequirements(Set.of(Set.of( - ItemID.DRAMEN_STAFF, - ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF - ))); - } - - // Set currency requirements for currency-based transports - if (isCurrencyBasedTransport(t.getType()) && - (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) && - t.getCurrencyName() != null && !t.getCurrencyName().isEmpty() && t.getCurrencyAmount() > 0) { - int currencyItemId = getCurrencyItemId(t.getCurrencyName()); - if (currencyItemId != -1) { - t.setItemIdRequirements(Set.of(Set.of(currencyItemId))); - log.debug("Set currency requirement for {}: {} x{} (ID: {})", - t.getType(), t.getCurrencyName(), t.getCurrencyAmount(), currencyItemId); - } - } - }) - .collect(Collectors.toList()); + return Rs2WalkerBankingPlanner.applyTransportFiltering(transports); } @@ -4772,9 +4495,9 @@ && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH } // (3) Reachable transport / agility-shortcut origin ahead: wide forward-window scan. - WorldPoint shortcutOrigin = RouteRecovery.findReachableTransportOriginAhead( - rawPath, playerRawIdx, playerLoc, - reachableTilesCache.keySet(), Rs2PathApi.getTransports(), + WorldPoint shortcutOrigin = RouteRecovery.findReachableTransportOriginAhead( + rawPath, playerRawIdx, playerLoc, + reachableTilesCache.keySet(), Rs2PathApi::hasCatalogTransportOrigin, recoveryMinimapReach - 1, ROUTE_PROGRESS_FORWARD_SEARCH_TILES); if (shortcutOrigin != null && !shortcutOrigin.equals(playerLoc)) { return ObstacleResolution.walkToOrigin(shortcutOrigin); @@ -4783,282 +4506,21 @@ && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH return ObstacleResolution.notApplicable(); } - private static WalkerState tryDirectShortWalk(WorldPoint target, - int distance, - List rawPath, - List path, - boolean inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (target == null || playerLoc == null || path == null || path.isEmpty()) { - return WalkerState.MOVING; - } - WorldPoint end = path.get(path.size() - 1); - int finishTh = tightFinishThreshold(target, end, distance); + 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); + } - int initialDist = playerLoc.distanceTo(target); - if (initialDist <= finishTh) { - setTarget(null, "rs2walker:tryDirectShortWalk:already-within-distance"); - return WalkerState.ARRIVED; - } - - final int directClickMaxDistance = 13; - if (playerLoc.getPlane() != target.getPlane() || initialDist > directClickMaxDistance) { - return WalkerState.MOVING; - } - - if (end == null || end.getPlane() != target.getPlane() || end.distanceTo(target) > distance) { - return WalkerState.MOVING; - } - - if (hasPendingExplicitTransportStepBeforeArrival(rawPath, target, distance) - || hasPendingExplicitTransportStepBeforeArrival(path, target, distance)) { - return WalkerState.MOVING; - } - if (!inInstance && hasPendingDoorLikeSceneObjectBeforeDirectClick(rawPath, path, playerLoc, - directClickMaxDistance)) { - log.debug("[Walker] defer tryDirectShortWalk minimap: route has pending door/gate scene object"); - return WalkerState.MOVING; - } - - if (!inInstance && !Rs2Tile.isWalkable(end)) { - return WalkerState.MOVING; - } - if (!inInstance && !Rs2Tile.isTileReachable(end)) { - return WalkerState.MOVING; - } - if (!inInstance && localRouteDetoursFromComputedRoute(rawPath, end, directClickMaxDistance)) { - return WalkerState.MOVING; - } - long suppressUntil = routeState.suppressTryDirectShortWalkUntilMs; - if (suppressUntil != 0L && System.currentTimeMillis() < suppressUntil) { - log.debug("[Walker] defer tryDirectShortWalk minimap (post door canvas nudge, {}ms window)", - POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS); - return WalkerState.MOVING; - } - - 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; - } - - final WorldPoint before = playerLoc; - boolean moved = sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); - }, 800); - - if (!moved) { - 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; - } - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); - }, 800); - } - - WorldPoint afterClick = Rs2Player.getWorldLocation(); - if (afterClick != null && afterClick.distanceTo(target) <= finishTh) { - setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-click"); - return WalkerState.ARRIVED; - } - - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= finishTh || !Rs2Player.isMoving()); - }, 4000); - - WorldPoint afterWalk = Rs2Player.getWorldLocation(); - if (afterWalk != null && afterWalk.distanceTo(target) <= finishTh) { - setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-walk"); - return WalkerState.ARRIVED; - } - - 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) { - boolean directTargetInRange = shouldAttemptDirectMinimapTarget(end, playerLoc, maxEuclidean); - if (directTargetInRange && walkMiniMap(end)) { - return true; - } - - // distanceTo() is Chebyshev distance, while the minimap clip is effectively circular. - // A diagonal endpoint can therefore pass the short-walk gate while being well outside the - // clip. In that case select a normal forward raw-route point immediately instead of first - // issuing a predictably rejected endpoint click and reporting the continuation as a fallback. - WorldPoint routeTarget = findFurthestVisibleKnownRawPathPoint( - rawPath, playerLoc, maxEuclidean, rawAnchorIndex); - if (routeTarget != null - && !routeTarget.equals(playerLoc) - && !routeTarget.equals(end) - && walkMiniMap(routeTarget)) { - if (directTargetInRange) { - log.debug("[Walker] Direct short-walk target {} was outside the minimap clip; continuing via route {}", - end, routeTarget); - } - return true; - } - return walkFastCanvasOnScreenOnly(end, true); - } - - static boolean shouldAttemptDirectMinimapTarget(WorldPoint target, - WorldPoint playerLoc, - int maxEuclidean) { - if (target == null || playerLoc == null || maxEuclidean < 0 - || target.getPlane() != playerLoc.getPlane()) { - return false; - } - long dx = (long) target.getX() - playerLoc.getX(); - long dy = (long) target.getY() - playerLoc.getY(); - long radius = maxEuclidean; - return dx * dx + dy * dy <= radius * radius; - } - - private static boolean hasPendingExplicitTransportStepBeforeArrival(List path, - WorldPoint target, - int distance) { - return hasPendingRouteStepBeforeArrival(path, target, distance, i -> isCatalogBackedTransportSegment(path, i)); - } - - static boolean hasPendingRouteStepBeforeArrival(List path, - WorldPoint target, - int distance, - java.util.function.IntPredicate routeStepAtIndex) { - if (path == null || path.size() < 2 || routeStepAtIndex == null) { - return false; - } - - for (int i = 0; i < path.size() - 1; i++) { - WorldPoint point = path.get(i); - if (target != null && point != null && point.distanceTo(target) <= distance) { - return false; - } - if (routeStepAtIndex.test(i)) { - return true; - } - } - return false; - } - - private static boolean localRouteDetoursFromComputedRoute(List rawPath, - WorldPoint end, - int directClickMaxDistance) { - if (rawPath == null || rawPath.size() < 2 || end == null) { - return false; - } - - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null || playerLoc.getPlane() != end.getPlane()) { - return false; - } - - int rawStart = getClosestTileIndex(rawPath, playerLoc); - if (rawStart < 0 || rawStart >= rawPath.size() - 1) { - return false; - } - - int rawEnd = -1; - for (int i = rawStart; i < rawPath.size(); i++) { - WorldPoint point = rawPath.get(i); - if (point == null || point.getPlane() != end.getPlane()) { - break; - } - if (point.equals(end)) { - rawEnd = i; - break; - } - } - if (rawEnd < 0) { - return false; - } - int computedSteps = rawEnd - rawStart; - if (computedSteps <= 0) { - return false; - } - final int detourSlackTiles = 4; - int searchDistance = Math.max(directClickMaxDistance * 3, computedSteps + detourSlackTiles + 1); - Integer localSteps = Rs2Tile.getReachableTilesFromTile(playerLoc, searchDistance).get(end); - return localSteps == null || localSteps > computedSteps + detourSlackTiles; - } - private static boolean hasPendingDoorLikeSceneObjectBeforeDirectClick(List rawPath, - List path, - WorldPoint playerLoc, - int directClickMaxDistance) { - List route = rawPath != null && rawPath.size() >= 2 ? rawPath : path; - if (route == null || route.size() < 2 || playerLoc == null) { - return false; - } - int closest = getClosestTileIndex(route, playerLoc); - if (closest < 0 || closest >= route.size()) { - return false; - } - int maxEdges = 12; - int radius = Math.max(3, directClickMaxDistance + 2); - int start = Math.max(0, closest - 2); - int endExclusive = Math.min(route.size() - 1, start + maxEdges); - for (int i = start; i < endExclusive; i++) { - WorldPoint from = route.get(i); - WorldPoint to = route.get(i + 1); - if (from == null || to == null) { - continue; - } - if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { - break; - } - if (from.distanceTo2D(playerLoc) > radius && to.distanceTo2D(playerLoc) > radius) { - break; - } - if (isCatalogBackedTransportSegment(route, i) && !isDoorLikeCatalogTransportSegment(route, i)) { - continue; - } - if (hasDoorLikeSceneObjectOnSegment(from, to, playerLoc, radius)) { - return true; - } - } - return false; - } private static boolean handlePendingDoorBeforeRouteClick(List rawPath, List path, @@ -5066,7 +4528,6 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat int targetPathIdx, int[] smoothedToRaw, long timeoutMs, - Map attempted, WorldPoint playerLoc) { if (rawPath == null || rawPath.size() < 2 || path == null || path.isEmpty() || playerLoc == null || targetPathIdx < fromPathIdx) { @@ -5096,81 +4557,24 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { return true; } } return false; } - private static boolean handlePendingDoorDuringInterim(List rawPath, - long timeoutMs, - Map attempted, - WorldPoint playerLoc) { - if (rawPath == null || rawPath.size() < 2 || playerLoc == null - || isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown() - || isRecoveryMovementInFlight() || Rs2Player.isMoving()) { - return false; - } - - return handlePendingDoorNearRawPath(rawPath, timeoutMs, attempted, playerLoc, 2, 14); - } - - private static boolean handlePendingDoorNearRawPath(List rawPath, - long timeoutMs, - Map attempted, - WorldPoint playerLoc, - int backtrackEdges, - int lookaheadEdges) { - if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { - return false; - } - if (Rs2Player.isMoving()) { - return false; - } - - int rawStart = getClosestTileIndex(rawPath, playerLoc); - if (rawStart < 0) { - return false; - } - int start = Math.max(0, rawStart - Math.max(0, backtrackEdges)); - int endExclusive = Math.min(rawPath.size() - 1, rawStart + Math.max(1, lookaheadEdges)); - for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { - WorldPoint a = rawPath.get(ri); - WorldPoint b = rawPath.get(ri + 1); - if (a == null || b == null) { - continue; - } - if (a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { - break; - } - if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { - continue; - } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { - continue; - } - if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { - continue; - } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { - return true; - } - } - return false; - } private static boolean handleUnresolvedDoorNearRawPath(List rawPath, int rawEdgeStart, long timeoutMs, - Map attempted, WorldPoint playerLoc, int backtrackEdges, int lookaheadEdges, @@ -5193,13 +4597,13 @@ private static boolean handleUnresolvedDoorNearRawPath(List rawPath, if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (!hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { return true; } } @@ -5229,6 +4633,18 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange, WorldPoint target, boolean allowTransportHandlers) { + long passT0 = System.currentTimeMillis(); + try { + return handleNearbyRawPathSceneObjectsInner(rawPath, handlerRange, target, allowTransportHandlers); + } finally { + WalkPassStats.rawSceneScanMs.addAndGet(System.currentTimeMillis() - passT0); + } + } + + private static boolean handleNearbyRawPathSceneObjectsInner(List rawPath, + int handlerRange, + WorldPoint target, + boolean allowTransportHandlers) { if (rawPath == null || rawPath.size() < 2) { return false; } @@ -5263,8 +4679,8 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } if (shouldUseFocusedRawDoorIndex(rawPath, rawStart)) { - int idx = routeState.rawScanFocusedDoorIdx; - routeState.rawScanFocusedDoorAttempts++; + int idx = doorAttemptLedger.rawScanFocusDoorIdx(); + doorAttemptLedger.recordRawScanFocusAttempt(); if (handleDoors(rawPath, idx, true)) { log.info("[Walker] Raw path focused door handler resolved obstacle near {}", playerLoc); return true; @@ -5316,6 +4732,8 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, rawScanDoorInteractionWaitMs = 0L; rawScanDoorEdgeWaitMs = 0L; rawScanDoorFindMs = 0L; + rawScanDoorInteractMs = 0L; + rawScanDoorVerifyMs = 0L; // Route order guard for ranged transport dispatch: set once a transport step is passed over, // so nothing further along the route can be actioned ahead of the obstacle in front of us. boolean sawUndispatchedTransportStep = false; @@ -5438,14 +4856,19 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, long doorFindMs = rawScanDoorFindMs; // What is left after the probe and both waits: the menu interaction and the // post-interaction verification. Previously all of this was reported as "doorProbe". - long doorOtherMs = Math.max(0L, doorMs - doorWaitMs - doorEdgeWaitMs - doorFindMs); - log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorFind={}ms doorEdgeWait={}ms doorOther={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", - totalMs, scannedIdx, snapshotMs, doorFindMs, doorEdgeWaitMs, doorOtherMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, + long doorInteractMs = rawScanDoorInteractMs; + long doorVerifyMs = rawScanDoorVerifyMs; + long doorOtherMs = Math.max(0L, doorMs - doorWaitMs - doorEdgeWaitMs - doorFindMs + - doorInteractMs - doorVerifyMs); + log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorFind={}ms doorInteract={}ms doorVerify={}ms doorEdgeWait={}ms doorOther={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", + totalMs, scannedIdx, snapshotMs, doorFindMs, doorInteractMs, doorVerifyMs, doorEdgeWaitMs, doorOtherMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, resolved, allowTransportHandlers); } rawScanDoorInteractionWaitMs = 0L; rawScanDoorEdgeWaitMs = 0L; rawScanDoorFindMs = 0L; + rawScanDoorInteractMs = 0L; + rawScanDoorVerifyMs = 0L; } } @@ -5468,188 +4891,60 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, * Null outside a raw scan, so every other {@code handleDoors} caller keeps the original * query-per-probe behaviour. */ - private static volatile List rawScanWallSnapshot = null; - private static volatile List rawScanGameObjectSnapshot = null; + static volatile List rawScanWallSnapshot = null; + static volatile List rawScanGameObjectSnapshot = null; /** Immutable locations copied on the client thread for off-thread snapshot filtering. */ - private static Map rawScanDoorLocationSnapshot = null; + static Map rawScanDoorLocationSnapshot = null; /** Object definitions and segment matches are stable for one immutable scene snapshot. */ - private static Map> rawScanDoorCompositionCache = null; - private static Map> rawScanDoorSegmentCache = null; + static Map> rawScanDoorCompositionCache = null; + static Map> rawScanDoorSegmentCache = null; /** Scan-scoped memo for the segment-independent door-candidate test (see DoorProbeContext). */ - private static Map rawScanDoorEligibilityCache = null; + static Map rawScanDoorEligibilityCache = null; - /** Wraps the current scan-scoped probe caches for the extracted door-probe logic. */ - private static DoorProbeContext doorProbeContext() { - return new DoorProbeContext(rawScanWallSnapshot, rawScanGameObjectSnapshot, - rawScanDoorLocationSnapshot, rawScanDoorCompositionCache, rawScanDoorSegmentCache, - rawScanDoorEligibilityCache); - } /** Interaction/edge-resolution wait contained inside {@link #handleDoors}; excluded from probe cost. */ - private static volatile long rawScanDoorInteractionWaitMs = 0L; + static volatile long rawScanDoorInteractionWaitMs = 0L; /** Time inside {@link #waitForDoorEdgeResolution} during a raw scan (a wait, not probe work). */ - private static volatile long rawScanDoorEdgeWaitMs = 0L; + static volatile long rawScanDoorEdgeWaitMs = 0L; /** Time inside the door segment probe during a raw scan (the actual geometry/snapshot work). */ - private static volatile long rawScanDoorFindMs = 0L; + static volatile long rawScanDoorFindMs = 0L; + /** Time spent issuing the door menu click itself (composition resolve + menu entry + mouse). */ + static volatile long rawScanDoorInteractMs = 0L; + /** Time spent verifying the outcome: traversal check, and the re-scan that asks if it is still shut. */ + static volatile long rawScanDoorVerifyMs = 0L; - /** - * The door segment probe, timed. "doorProbe" in the slow-scan line is a RESIDUAL — the whole - * handleDoors call minus the interaction wait — so it silently absorbed the edge-resolution wait, - * the menu interaction and the post-interaction verification too. Attributing the probe itself is - * the only way to tell an expensive scan from an expensive wait, and they want opposite fixes. - */ - private static TileObject findDoorNearSegmentTimed(WorldPoint fromWp, WorldPoint toWp, List doorActions) { - long startedAt = System.currentTimeMillis(); - try { - return Rs2DoorProbe.findDoorNearSegment(doorProbeContext(), sessionBlacklistedDoors, - recentlyOpenedStationaryDoors, STATIONARY_DOOR_SUPPRESS_MS, fromWp, toWp, doorActions); - } finally { - if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorFindMs += System.currentTimeMillis() - startedAt; - } - } - } - private static Map captureRawScanDoorLocationsOnClientThread() { - Map locations = new IdentityHashMap<>(); - if (rawScanWallSnapshot != null) { - for (WallObject wall : rawScanWallSnapshot) { - if (wall != null) { - locations.put(wall, ((TileObject) wall).getWorldLocation()); - } - } - } - if (rawScanGameObjectSnapshot != null) { - for (GameObject object : rawScanGameObjectSnapshot) { - if (object != null) { - locations.put(object, ((TileObject) object).getWorldLocation()); - } - } - } - return locations; - } + // ---- Per-leg door stage accumulators (every door path, not just raw scans). The eleven-gate + // Stronghold run produced a suspiciously CONSTANT ~5.4s per door_interaction_done with zero + // slow-await lines, so the time lives outside the await, and the raw-scan breakdown only covers + // one of the three entry paths. Reset at handleDoorsWithTimeout entry; printed on its tmark. + static volatile long doorLegFindMs; + static volatile long doorLegInteractMs; + static volatile long doorLegAwaitMs; + static volatile long doorLegVerifyMs; + static volatile long doorLegNudgeMs; + static volatile long doorLegExceptionMs; + - /** - * Exact-tile match first, then a one-tile adjacency fallback — the same preference order the - * previous pair of bounded queries produced. - */ - private static WallObject resolveProbeWallObject(WorldPoint probe) { - List snapshot = rawScanWallSnapshot; - if (snapshot != null) { - WallObject adjacent = null; - for (WallObject candidate : snapshot) { - if (candidate == null) { - continue; - } - WorldPoint loc = candidate.getWorldLocation(); - if (loc == null) { - continue; - } - if (loc.equals(probe)) { - return candidate; - } - if (adjacent == null && loc.distanceTo2D(probe) <= 1) { - adjacent = candidate; - } - } - return adjacent; - } - WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); - if (wall == null) { - wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); - } - return wall; - } - /** @see #resolveProbeWallObject(WorldPoint) */ - private static TileObject resolveProbeGameObject(WorldPoint probe) { - List snapshot = rawScanGameObjectSnapshot; - if (snapshot != null) { - GameObject adjacent = null; - for (GameObject candidate : snapshot) { - if (candidate == null) { - continue; - } - WorldPoint loc = candidate.getWorldLocation(); - if (loc == null) { - continue; - } - if (loc.equals(probe)) { - return candidate; - } - if (adjacent == null && loc.distanceTo2D(probe) <= 1) { - adjacent = candidate; - } - } - return adjacent; - } - TileObject object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); - if (object == null) { - object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); - } - return object; - } - private static boolean hasDoorCandidateOnRawSegment(List rawPath, int index) { - if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { - return false; - } - if (isCatalogBackedTransportSegment(rawPath, index) && !isDoorLikeCatalogTransportSegment(rawPath, index)) { - return false; - } - boolean isInstance = Microbot.getClient() - .getTopLevelWorldView() - .getScene() - .isInstance(); - WorldPoint rawFrom = rawPath.get(index); - WorldPoint rawTo = rawPath.get(index + 1); - WorldPoint fromWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawFrom) : rawFrom; - WorldPoint toWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawTo) : rawTo; - if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { - return false; - } - List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); - return findDoorNearSegmentTimed(fromWp, toWp, doorActions) != null; - } - private static void setRawScanDoorFocus(int index) { - routeState.rawScanFocusedDoorIdx = index; - routeState.rawScanFocusedDoorSetAtMs = System.currentTimeMillis(); - routeState.rawScanFocusedDoorAttempts = 0; - } - private static boolean shouldUseFocusedRawDoorIndex(List rawPath, int rawStartIdx) { - Integer idx = routeState.rawScanFocusedDoorIdx; - if (idx == null) { - return false; - } - if (routeState.interimTargetWp != null) { - return false; - } - if (System.currentTimeMillis() - routeState.rawScanFocusedDoorSetAtMs > RAW_SCAN_DOOR_FOCUS_MAX_MS) { - return false; - } - if (routeState.rawScanFocusedDoorAttempts >= RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS) { - return false; - } - if (idx < 0 || idx >= rawPath.size() - 1) { - return false; - } - if (rawStartIdx > idx + 1) { - return false; - } - return Math.abs(rawStartIdx - idx) <= 2; - } - private static void clearRawScanDoorFocus(String reason) { - if (routeState.rawScanFocusedDoorIdx != null && debug) { - walkerDiag("clear raw door focus: %s", reason); + + + + + + private static boolean handleCurrentTileTransportTowardPath(List rawPath, List path, WorldPoint target) { + long passT0 = System.currentTimeMillis(); + try { + return handleCurrentTileTransportTowardPathInner(rawPath, path, target); + } finally { + WalkPassStats.currentTileMs.addAndGet(System.currentTimeMillis() - passT0); } - routeState.rawScanFocusedDoorIdx = null; - routeState.rawScanFocusedDoorSetAtMs = 0L; - routeState.rawScanFocusedDoorAttempts = 0; } - private static boolean handleCurrentTileTransportTowardPath(List rawPath, List path, WorldPoint target) { + private static boolean handleCurrentTileTransportTowardPathInner(List rawPath, List path, WorldPoint target) { if (Rs2Player.isMoving()) { return false; } @@ -5662,32 +4957,26 @@ private static boolean handleCurrentTileTransportTowardPath(List raw return false; } - // Snappy proximity: consider usable transports whose origin is reachable within a few tiles - // of the player, not just the one on the exact player tile. NPC/"Follow" transports (e.g. Elkoy - // in the Tree Gnome Village maze) roam and sit a tile off the planned path, so exact-tile - // matching never sees them. The destination-on-forward-route gate below keeps this safe against - // off-path loops, and getTransports() is already the usable (config/quest/level-filtered) set, - // so we never grab a transport the pathfinder excluded. + // Snappy proximity: consider exact planned transports whose origin is reachable within a few + // tiles of the player, not just one on the exact player tile. NPC/"Follow" transports (e.g. + // Elkoy in the Tree Gnome Village maze) roam and sit a tile off the planned path. The old code + // rescanned every usable catalog row and inferred selection from destination membership; that is + // ambiguous when multiple transports share an edge. The completed route now supplies both order + // and exact identity. final int NEARBY_TRANSPORT_REACH = 5; - Map> transportsByOrigin = Rs2PathApi.getTransports(); - Set transports = new HashSet<>(); - Set transportsOnPlayerTile = transportsByOrigin.get(playerLoc); - if (transportsOnPlayerTile != null) { - transports.addAll(transportsOnPlayerTile); - } - for (WorldPoint reachableTile : Rs2Tile.getReachableTilesFromTile(playerLoc, NEARBY_TRANSPORT_REACH).keySet()) { - Set ts = transportsByOrigin.get(reachableTile); - if (ts != null) { - transports.addAll(ts); - } - } - if (transports.isEmpty()) { + Set reachableOrigins = new HashSet<>( + Rs2Tile.getReachableTilesFromTile(playerLoc, NEARBY_TRANSPORT_REACH).keySet()); + reachableOrigins.add(playerLoc); + List plannedSelections = + Rs2PathApi.getActiveTransportSelections(rawPath); + if (plannedSelections.isEmpty()) { return false; } Map forwardIndex = new HashMap<>(); addForwardPathIndices(forwardIndex, rawPath, playerLoc); addForwardPathIndices(forwardIndex, path, playerLoc); + int rawClosestIndex = Math.max(0, getClosestTileIndex(rawPath, playerLoc)); WorldPoint priorOrigin = routeState.lastTransportOriginLocation; // Trust the pathfinder: only take a nearby transport whose destination is on the @@ -5698,21 +4987,29 @@ private static boolean handleCurrentTileTransportTowardPath(List raw // the pathfinder never chose: it looped forever on the Mor Ul Rek cave entrance/exit and // stalled clicking the Fossil Island rowboat. The pathfinder already routed every transport // it wants onto the path, so on-route membership is the correct, region-safe admission test. - List candidates = transports.stream() - .filter(t -> t.getDestination() != null) + List candidates = plannedSelections.stream() + // One-edge backtrack permits standing just past an interaction origin while preventing + // a repeated destination later in the route from reviving an already-passed transport. + .filter(selection -> selection.getPathIndex() >= Math.max(0, rawClosestIndex - 1)) + .filter(selection -> { + Transport transport = selection.getLocalExecutionTransport(); + WorldPoint origin = transport.getOrigin(); + return origin == null || reachableOrigins.contains(origin); + }) // Local adjacent same-plane edges (doors/gates) are handled by segment door/object // logic; current-tile transport probing can bounce on these and create loops. - .filter(t -> !isAdjacentSamePlaneTransport(t)) - .filter(t -> priorOrigin == null - || !t.getDestination().equals(priorOrigin)) - .filter(t -> target == null + .filter(selection -> !isAdjacentSamePlaneTransport(selection.getLocalExecutionTransport())) + .filter(selection -> priorOrigin == null + || !selection.getEdge().getDestination().equals(priorOrigin)) + .filter(selection -> target == null || playerLoc.getPlane() != target.getPlane() - || t.getDestination().getPlane() == target.getPlane()) - .filter(t -> forwardIndex.containsKey(t.getDestination())) - .sorted(Comparator.comparingInt(t -> forwardIndex.get(t.getDestination()))) + || selection.getEdge().getDestination().getPlane() == target.getPlane()) + .filter(selection -> forwardIndex.containsKey(selection.getEdge().getDestination())) + .sorted(Comparator.comparingInt(Rs2PathApi.ActiveTransportSelection::getPathIndex)) .collect(Collectors.toList()); - for (Transport transport : candidates) { + for (Rs2PathApi.ActiveTransportSelection selection : candidates) { + Transport transport = selection.getLocalExecutionTransport(); WorldPoint origin = transport.getOrigin() != null ? transport.getOrigin() : playerLoc; if (shouldThrottleCurrentTileTransportAttempt(origin, transport.getDestination())) { continue; @@ -5722,7 +5019,7 @@ private static boolean handleCurrentTileTransportTowardPath(List raw // Pass the transport's own origin so handleTransports walks the short hop to it before // interacting (NPC dispatch already auto-walks via canWalkTo + interact); object/door // interactions that can't be reached from here simply return false and we fall through. - if (handleTransports(Arrays.asList(origin, transport.getDestination()), 0)) { + if (Rs2WalkerTransports.handleSelectedTransport(Arrays.asList(origin, transport.getDestination()), 0, selection)) { if (didCurrentTileTransportProgress(before, transport.getDestination(), target)) { log.info("[Walker] Nearby transport handler resolved obstacle: origin={} dest={} (player {})", origin, transport.getDestination(), playerLoc); @@ -5771,31 +5068,21 @@ private static void addForwardPathIndices(Map forwardIndex, } } - // Session-local set of door tiles the walker detected as quest/stat-locked after a - // failed interact. Cleared when the client restarts. Prevents infinite retry loops - // through the same restricted door when the restriction isn't in restrictions.tsv. - static final Set sessionBlacklistedDoors = ConcurrentHashMap.newKeySet(); - private static final Map recentlyOpenedStationaryDoors = new ConcurrentHashMap<>(); - 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; + // D3 slice 3: the session blacklist (quest/stat-locked doors) and the recently-opened + // suppression map live in the ledger as tile-keyed facets. + static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; + // D3 slice 1: ATTEMPTED lives in the ledger — one owner for the per-edge cooldown facts AND + // the latest-claim fact that used to sit in routeState.lastDoorAttempt* and disagree with them. + static final DoorAttemptLedger doorAttemptLedger = new DoorAttemptLedger(); + static final long DOOR_ATTEMPT_EDGE_COOLDOWN_MS = 2_500; + // D3 slice 2: cross-failure strikes and walk-scoped blocks live in the ledger (REFUSED facet). + static final long DOOR_CROSS_FAILURE_DECAY_MS = 300_000; + static final int DOOR_CROSS_FAILURE_STRIKE_LIMIT = 3; 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; + static final long DOOR_INTERACTION_GLOBAL_COOLDOWN_MS = 1_800; - static boolean hasQuestLockKeywords(String text) { - if (text == null || text.isEmpty()) return false; - String lc = text.toLowerCase(); - // Phrases that consistently appear on quest/stat-gated doors and gates. - return lc.contains("quest") || lc.contains("you need to") || lc.contains("you must") - || lc.contains("you have not") || lc.contains("cannot enter") - || lc.contains("can't enter") || lc.contains("requires you"); - } - private static boolean isQuestLockedDoorDialogue() { - if (!Rs2Dialogue.isInDialogue()) return false; - return hasQuestLockKeywords(Rs2Dialogue.getDialogueText()); - } /** * Rank sidestep-recovery candidate tiles by Chebyshev distance to the walk target so @@ -5849,15 +5136,12 @@ private static int findForwardReachableRecoveryIndex(List path, // findForwardRecoveryIndex extracted to recovery/RouteRecovery (P1 walker decomposition) - private static boolean isMiniMapRecoveryClickable(WorldPoint worldPoint) { - return isMiniMapClickable(worldPoint, 5); - } // interpolateClickableTarget extracted to recovery/RouteRecovery (P1) // clampToEuclideanRadius extracted to recovery/RouteRecovery (P1) - private static int euclideanSq(WorldPoint a, WorldPoint b) { + static int euclideanSq(WorldPoint a, WorldPoint b) { int dx = a.getX() - b.getX(); int dy = a.getY() - b.getY(); return dx * dx + dy * dy; @@ -5866,6054 +5150,2084 @@ private static int euclideanSq(WorldPoint a, WorldPoint b) { // findReachableTransportOriginAhead extracted to recovery/RouteRecovery as a pure, unit-tested function (P1) - private static boolean handleDoors(List path, int index) { - return handleDoors(path, index, false); - } - private static boolean handleDoors(List path, int index, boolean allowSegmentProbe) { - if (Rs2PathApi.getPathfinder() == null || index >= path.size() - 1) return false; - // Skip any door whose tile was blacklisted after a prior quest-lock detection — - // avoid re-triggering the same failed interact loop this session. - WorldPoint skipFrom = path.get(index); - WorldPoint skipTo = index + 1 < path.size() ? path.get(index + 1) : null; - if (sessionBlacklistedDoors.contains(skipFrom) - || (skipTo != null && sessionBlacklistedDoors.contains(skipTo))) { - return false; - } - List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); - boolean isInstance = Microbot.getClient() - .getTopLevelWorldView() - .getScene() - .isInstance(); - - WorldPoint rawFrom = path.get(index); - WorldPoint rawTo = path.get(index + 1); - WorldPoint fromWp = isInstance - ? Rs2WorldPoint.convertInstancedWorldPoint(rawFrom) - : rawFrom; - WorldPoint toWp = isInstance - ? Rs2WorldPoint.convertInstancedWorldPoint(rawTo) - : rawTo; - - if (isInstance && (toWp == null || fromWp == null)) { - // Expected inside the PoH when the next tile is a teleport destination - // (convertInstancedWorldPoint -> fromWorldInstance returns null for tiles - // that aren't in the current instance chunk). Log path context so - // unexpected occurrences outside that case can be diagnosed. - log.debug("[Walker] handleDoors: POH/instance conversion returned null (rawFrom={} fromWp={} rawTo={} toWp={}) idx={}/{} — skipping door check", - rawFrom, fromWp, rawTo, toWp, index, path.size()); - return false; - } - // Cross-plane path steps are always transports (stairs, ladders, trapdoors) — - // door probes on mismatched planes would emit wrong-plane corner coordinates - // and the plane-guard below would reject them anyway. Let handleTransports - // take it. - if (fromWp.getPlane() != toWp.getPlane()) { - return false; - } - if (isCatalogBackedTransportSegment(path, index) && !isDoorLikeCatalogTransportSegment(path, index)) { - return false; - } - if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { - return false; - } - // A broad raw scan already owns immutable wall/game-object snapshots. Resolve the - // segment directly from them instead of running the probe loop, which repeatedly - // requested the same object definitions on the client thread for adjacent raw edges. - if (allowSegmentProbe - && (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null)) { - TileObject snapshotDoor = findDoorNearSegmentTimed(fromWp, toWp, doorActions); - if (snapshotDoor == null) { - return false; - } - if (snapshotDoor instanceof WallObject) { - return tryHandleDoorObject(snapshotDoor, snapshotDoor.getWorldLocation(), - fromWp, toWp, doorActions, true); - } - } - for (int offset = 0; offset <= 1; offset++) { - int doorIdx = index + offset; - if (doorIdx >= path.size()) continue; - WorldPoint rawDoorWp = path.get(doorIdx); - WorldPoint doorWp = isInstance - ? Rs2WorldPoint.convertInstancedWorldPoint(rawDoorWp) - : rawDoorWp; - List probes = Rs2DoorAheadResolver.buildSegmentProbes(fromWp, toWp, doorWp); - for (WorldPoint probe : probes) { - if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { - return false; - } - boolean adjacentToPath = probe.distanceTo(fromWp) <= 1 || probe.distanceTo(toWp) <= 1; - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (!adjacentToPath || playerLoc == null || !Objects.equals(probe.getPlane(), playerLoc.getPlane())) continue; - - // WallObjects can report their world location as an adjacent tile depending on - // orientation / scene representation. Use exact match first, then allow a small - // adjacency fallback so door handling triggers reliably. - WallObject wall = resolveProbeWallObject(probe); - - TileObject object = (wall != null) ? wall : resolveProbeGameObject(probe); - if (object == null) continue; - if (!Rs2DoorGeometry.isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { - Telemetry.recordDoorReject("door-out-of-range"); - continue; - } - if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { - Telemetry.recordDoorReject("catalog-transport-object"); - continue; - } - ObjectComposition baseComp = Rs2GameObject.convertToObjectComposition(object); - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - if (comp == null) { - Telemetry.recordDoorReject("composition-null"); - continue; - } - if (baseComp != null && baseComp.getImpostorIds() != null - && !Rs2DoorClassifier.isNullOrPlaceholderObjectName(baseComp.getName()) - && Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) { - Telemetry.recordDoorReject("impostor-rejected"); - continue; - } - if (Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) { - Telemetry.recordDoorReject("name-not-door"); - continue; - } - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { - Telemetry.recordDoorReject("skip-close-only-open"); - continue; - } - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - if (action == null) { - Telemetry.recordDoorReject("no-walk-action"); - continue; - } - if (Rs2DoorClassifier.doorActionPriorityIndex(action) == Integer.MAX_VALUE) { - Telemetry.recordDoorReject("non-standard-door-action"); - continue; - } - boolean found = false; - final String name = comp.getName(); - if (object instanceof WallObject) { - // Validate the door's ACTUAL blocked edge against the segment, not the probe - // tile. The probe can sit a tile off the wall (adjacency fallback above), and the - // old probe-orientation check plus the pathTouchesBothEnds shortcut opened doors - // merely beside the path. isDoorOnSegment walks the segment against the wall's - // real edge, matching the GameObject branch and findDoorNearSegment. - if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { - log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); - found = true; - } else { - Telemetry.recordDoorReject("orient-mismatch"); - } - } else { - if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { - log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); - found = true; - } else { - Telemetry.recordDoorReject("gameobject-segment-mismatch"); - } - } - if (found) { - if (!handleDoorException(object, action)) { - if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { - WebWalkLog.spInfo("door_attempt_throttled | mode=segment-door probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - return false; - } - if (shouldThrottleGlobalDoorInteraction()) { - WebWalkLog.spInfo("door_global_await | mode=segment-door probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - return false; - } - if (doorInteractionDeferredForMovement(probe)) { - 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(); - boolean interacted; - try { - interacted = Rs2GameObject.interact(object, action); - } catch (Exception ex) { - WebWalkLog.spInfo("door_interact_exception | mode=segment-door probe={} from={} to={} ex={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); - return false; - } - if (!interacted) { - WebWalkLog.spInfo("door_interact_failed | mode=segment-door probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - return false; - } - markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); - WorldPoint posAfter = Rs2Player.getWorldLocation(); - boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); - if (!traversed && isQuestLockedDoorDialogue()) { - String dialogue = Rs2Dialogue.getDialogueText(); - log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", - probe, name, action, dialogue); - sessionBlacklistedDoors.add(probe); - Rs2Dialogue.clickContinue(); - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - recalculatePath(); - // Resolved by rerouting; return before the wrong-traversal branch so a - // quest/skill-locked door is never learned as a blocked edge (it unlocks when the - // requirement is met). Matches the tryHandleDoorObject quest-locked path. - return true; - } - if (!traversed) { - if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { - sessionBlacklistedDoors.add(probe); - log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", - probe, fromWp, toWp, posBefore, posAfter); - // Wrong-traversal is a stable map property (one-way / mis-encoded door geometry), - // so persist it as a learned block that survives restarts and reroutes future paths. - // (Quest/skill-locked doors take the isQuestLockedDoorDialogue() branch above and are - // deliberately NOT learned — they unlock when the requirement is met.) - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().learnBlockedEdge(fromWp, toWp, - "wrong-traversal door @ " + compactWorldPoint(probe)); - } - } - if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { - log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", - probe, fromWp, toWp); - } else { - markStationaryDoorOpened(probe); - if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { - markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); - return true; - } - } - return false; - } - markStationaryDoorOpened(probe); - markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); - } - return true; - } - } - } - TileObject nearbyDoor = allowSegmentProbe ? findDoorNearSegmentTimed(fromWp, toWp, doorActions) : null; - if (nearbyDoor != null && tryHandleDoorObject(nearbyDoor, nearbyDoor.getWorldLocation(), fromWp, toWp, doorActions, true)) { - return true; - } - return false; - } + + + + /** How long a door attempt claims its edge against outside interference (route revalidation). */ + private static final long ACTIVE_DOOR_EDGE_CLAIM_MS = 10_000L; + - private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, boolean allowSegmentProbe) { - if (object == null || probe == null) return false; - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (!Rs2DoorGeometry.isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { - return false; - } - if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { - return false; - } - ObjectComposition comp = Rs2DoorProbe.resolveDoorComposition(doorProbeContext(), object); - if (!Rs2DoorClassifier.isDoorComposition(comp, doorActions)) return false; - String action = Rs2DoorClassifier.getDoorAction(comp, doorActions); - if (action == null) return false; - boolean found = false; - final String name = comp.getName(); - if (object instanceof WallObject) { - int orientation = ((WallObject) object).getOrientationA(); - if (searchNeighborPoint(orientation, probe, fromWp) - || searchNeighborPoint(orientation, probe, toWp) - || (allowSegmentProbe && Rs2DoorGeometry.wallDoorTouchesSegment((WallObject) object, fromWp, toWp))) { - log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); - found = true; + + + + 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; } - } else if (name != null && name.toLowerCase().contains("door")) { - if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { - log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); - found = true; + 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); + } - if (!found) return false; - if (handleDoorException(object, action)) { - return true; - } - if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { - WebWalkLog.spInfo("door_attempt_throttled | mode=segment-probe probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); - return false; - } - if (shouldThrottleGlobalDoorInteraction()) { - WebWalkLog.spInfo("door_global_await | mode=segment-probe probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + + + static boolean shouldDeferRouteWorkForActiveInterim(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs, + int bestDistanceSeen, + long lastMovedAtMs, + boolean playerMoving, + int handoffTiles) { + if (interim == null) { return false; } - if (doorInteractionDeferredForMovement(probe)) { - WebWalkLog.spInfo("door_interact_deferred | reason=moving mode=segment-probe probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + if (shouldClearInterimTarget( + interim, playerLoc, setAtMs, lastProgressAtMs, nowMs, bestDistanceSeen)) { return false; } - markDoorAttempt(probe, fromWp, toWp); - markGlobalDoorInteractionCooldown(); - WorldPoint posBefore = Rs2Player.getWorldLocation(); - boolean interacted; - try { - interacted = Rs2GameObject.interact(object, action); - } catch (Exception ex) { - WebWalkLog.spInfo("door_interact_exception | mode=segment-probe probe={} from={} to={} ex={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); + if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { return false; } - if (!interacted) { - WebWalkLog.spInfo("door_interact_failed | mode=segment-probe probe={} from={} to={}", - compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + if (playerLoc.distanceTo2D(interim) <= Math.max(0, handoffTiles)) { return false; } - markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); - WorldPoint posAfter = Rs2Player.getWorldLocation(); - boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); - if (traversed) { - markStationaryDoorOpened(probe); - markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + if (playerMoving) { return true; } - if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { - sessionBlacklistedDoors.add(probe); - log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", - probe, fromWp, toWp, posBefore, posAfter); - } - if (isQuestLockedDoorDialogue()) { - String dialogue = Rs2Dialogue.getDialogueText(); - log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", - probe, name, action, dialogue); - sessionBlacklistedDoors.add(probe); - Rs2Dialogue.clickContinue(); - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - recalculatePath(); + if (isRecentEvent(nowMs, lastProgressAtMs, INTERIM_PROGRESS_TIMEOUT_MS)) { return true; } - - if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { - log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", - probe, fromWp, toWp); - } else { - markStationaryDoorOpened(probe); - if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { - markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); - return true; - } - } - return false; + return isRecentEvent(nowMs, lastMovedAtMs, RECOVERY_MOVEMENT_IN_FLIGHT_MS); } - private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, String action) { - if (probe == null || action == null) { + + /** One game tick: the floor a DIFFERENT door still owes after any door click. */ + static final long DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS = 600L; + + + + + + private static boolean isTransportInteractionSettling() { + long handledAt = routeState.lastTransportHandledAtMs; + if (handledAt <= 0L) { return false; } + return transportSettlePending(System.currentTimeMillis() - handledAt, + Rs2Player.getWorldLocation(), + routeState.lastTransportDestinationLocation, + Rs2Player.isMoving(), + Rs2Player.isAnimating()); + } - WorldPoint anchor = Rs2Player.getWorldLocation(); - if (anchor == null || anchor.getPlane() != probe.getPlane()) { - anchor = probe; - } - TileObject object = Rs2GameObject.getAll(o -> doorObjectStillHasAction(o, probe, fromWp, toWp, doorActions, action), - anchor, Math.max(3, HANDLER_RANGE)) - .stream() - .findFirst() - .orElse(null); - return object != null; + + static boolean isRecoveryMovementInFlight() { + return System.currentTimeMillis() - routeState.lastUnreachableRecoveryClickAtMs < RECOVERY_MOVEMENT_IN_FLIGHT_MS; } - private static boolean doorObjectStillHasAction(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, String action) { - if (object == null || object.getWorldLocation() == null || action == null) { - return false; - } - if (!(object instanceof WallObject) && !(object instanceof GameObject)) { - return false; - } - WorldPoint loc = object.getWorldLocation(); - if (probe != null && loc.getPlane() != probe.getPlane()) { - return false; - } - if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { - return false; - } - boolean nearProbe = probe != null && loc.distanceTo2D(probe) <= 2; - boolean onSegment = fromWp != null && toWp != null && Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp); - if (!nearProbe && !onSegment) { + + + + + + + + private static boolean shouldThrottleCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp == null || toWp == null) { return false; } - ObjectComposition composition = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - String currentAction = Rs2DoorClassifier.getDoorAction(composition, doorActions); - return currentAction != null && currentAction.equalsIgnoreCase(action); + String edgeKey = doorAttemptKey(null, fromWp, toWp); + long now = System.currentTimeMillis(); + recentCurrentTileTransportByEdge.entrySet() + .removeIf(entry -> now - entry.getValue() > CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS); + Long last = recentCurrentTileTransportByEdge.get(edgeKey); + return last != null && now - last < CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS; } - private static void markStationaryDoorOpened(WorldPoint doorTile) { - Rs2DoorHandler.markStationaryDoorOpened(recentlyOpenedStationaryDoors, doorTile); + private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp == null || toWp == null) { + return; + } + recentCurrentTileTransportByEdge.put( + doorAttemptKey(null, fromWp, toWp), + System.currentTimeMillis()); } - private static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp); - } - private static boolean shouldThrottleDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.shouldThrottleDoorAttempt( - recentDoorAttemptByEdge, - DOOR_ATTEMPT_EDGE_COOLDOWN_MS, - doorTile, - fromWp, - toWp); - } - private static boolean hasRecentDoorAttemptOnEdge(WorldPoint fromWp, WorldPoint toWp) { - return shouldThrottleDoorAttempt(null, fromWp, toWp); + /** Exact selected transport step retained by the completed active route. */ + private static boolean hasExplicitTransportStep(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return false; + } + return Rs2PathApi.getActiveTransportEdge(path.get(index), path.get(index + 1)).isPresent(); } - private static boolean hasRecentDoorAttemptNearIndex(List path, int edgeIdx) { - if (path == null || path.size() < 2 || edgeIdx < 0) { + /** + * Whether a planned transport origin sits essentially under the player's feet on the RAW path. + *

+ * The startup phase suppresses broad raw handlers until the first movement click — but the first + * transport of a walk is routinely taken with no click at all (the player already stands on its + * origin), so the phase stays STARTUP straight through the NEXT transport. With the raw scan + * disabled, nothing dispatches it: the walker idles until the idle nudge minimap-clicks onto the + * origin, which is the "runs the four tiles instead of clicking the stairs" report. The segment + * loop already carves out exactly this case; this is the raw scan's equivalent, and it is + * deliberately as narrow — an origin within the near band, nothing else. + */ + private static boolean hasImmediateRawTransportStepNearPlayer(List rawPath) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { return false; } - int start = Math.max(0, edgeIdx - 1); - int end = Math.min(path.size() - 2, edgeIdx + 1); - for (int i = start; i <= end; i++) { - WorldPoint from = path.get(i); - WorldPoint to = path.get(i + 1); - if (!isLikelyDoorEdgeTransition(from, to)) { - continue; - } - if (hasRecentDoorAttemptOnEdge(from, to)) { + int rawIdx = getClosestTileIndex(rawPath, playerLoc); + if (rawIdx < 0) { + return false; + } + int lastIdx = Math.min(rawPath.size() - 2, rawIdx + RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); + for (int ri = Math.max(0, rawIdx); ri <= lastIdx; ri++) { + if (hasImmediatePlannedTransportStep(rawPath, ri, playerLoc)) { return true; } } return false; } - private static boolean waitForRecentDoorEdgeResolutionNearIndex(List path, int edgeIdx, int timeoutMs) { - if (path == null || path.size() < 2 || edgeIdx < 0) { + private static boolean hasImmediatePlannedTransportStep(List path, + int routeStartIdx, + WorldPoint playerLoc) { + if (path == null || routeStartIdx < 0 || routeStartIdx >= path.size() - 1 || playerLoc == null) { return false; } - int start = Math.max(0, edgeIdx - 1); - int end = Math.min(path.size() - 2, edgeIdx + 1); - for (int i = start; i <= end; i++) { - WorldPoint from = path.get(i); - WorldPoint to = path.get(i + 1); - if (!isLikelyDoorEdgeTransition(from, to)) { - continue; - } - if (hasRecentDoorAttemptOnEdge(from, to)) { - return waitForDoorEdgeResolution(from, to, timeoutMs); - } - } - return false; + WorldPoint origin = path.get(routeStartIdx); + return hasExplicitTransportStep(path, routeStartIdx) + && isTransportOriginNearPlayer( + origin, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); } - private static long recentDoorAttemptAgeNearIndex(List path, int edgeIdx) { - if (path == null || path.size() < 2 || edgeIdx < 0) { - return -1L; - } - long now = System.currentTimeMillis(); - long newestAttemptAt = -1L; - int start = Math.max(0, edgeIdx - 1); - int end = Math.min(path.size() - 2, edgeIdx + 1); - for (int i = start; i <= end; i++) { - WorldPoint from = path.get(i); - WorldPoint to = path.get(i + 1); - if (!isLikelyDoorEdgeTransition(from, to)) { - continue; - } - Long attemptedAt = recentDoorAttemptByEdge.get(doorAttemptKey(null, from, to)); - if (attemptedAt != null) { - newestAttemptAt = Math.max(newestAttemptAt, attemptedAt); - } - } - return newestAttemptAt < 0 ? -1L : Math.max(0L, now - newestAttemptAt); + static boolean shouldApproachPlannedTransportOrigin(boolean explicitTransportStep, + WorldPoint routeOrigin, + WorldPoint playerLoc, + int dispatchMaxDistance) { + return explicitTransportStep + && routeOrigin != null + && playerLoc != null + && routeOrigin.getPlane() == playerLoc.getPlane() + && routeOrigin.distanceTo2D(playerLoc) > Math.max(0, dispatchMaxDistance); } - private static boolean isLikelyDoorEdgeTransition(WorldPoint from, WorldPoint to) { - if (from == null || to == null || from.getPlane() != to.getPlane()) { + /** + * Whether this path edge is covered by a transport catalog row (same coordinates loaded from TSV into + * {@link Rs2PathApi#getTransports()}). Includes strict origin-destination steps (including + * cross-plane rows such as ladders) and same-plane hops where the path starts on a tile Chebyshev-adjacent + * to the catalog origin but still targets that row's destination, so door probing does not fight + * {@code handleTransports}. + */ + static boolean isCatalogBackedTransportSegment(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { return false; } - // Door crossings are local transitions. Ignore long smoothed hops that can - // accidentally reuse old door attempt keys and stall nearby-wait logic. - return from.distanceTo2D(to) >= 1 && from.distanceTo2D(to) <= 2; + return isCatalogBackedTransportSegment(path.get(index), path.get(index + 1)); } - private static boolean tryPostDoorFastMinimapClick(List path, int edgeIdx, WorldPoint playerLoc, WorldPoint target) { - if (path == null || path.size() < 2 || playerLoc == null) { + static boolean isCatalogBackedTransportSegment(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { return false; } - int from = Math.max(0, edgeIdx + 1); - int to = Math.min(path.size() - 1, from + 8); - WorldPoint candidate = null; - int bestDistToTarget = Integer.MAX_VALUE; - for (int i = from; i <= to; i++) { - WorldPoint wp = path.get(i); - if (wp == null || wp.getPlane() != playerLoc.getPlane()) { - break; - } - if (euclideanSq(wp, playerLoc) > POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN * POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN) { - break; - } - if (!Rs2Tile.isTileReachable(wp)) { - continue; - } - int d = target == null ? 0 : wp.distanceTo2D(target); - if (candidate == null || d < bestDistToTarget) { - candidate = wp; - bestDistToTarget = d; - } - } - if (candidate == null || candidate.equals(playerLoc)) { - return false; + if (matchesDirectedTransportCatalogEdge(from, to)) { + return true; } - // Do not issue an immediate fast click while the player is still traversing - // (moving/animation in flight) from the just-handled door edge. - if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { - return false; + if (matchesDirectedTransportCatalogEdge(to, from)) { + return true; } - boolean clicked = walkMiniMap(candidate); - if (!clicked) { - clicked = walkMiniMapToward(candidate, playerLoc, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + if (matchesAdjacentOriginShortTransportHop(from, to)) { + return true; } - if (clicked) { - markFirstMovementClick("post_door_fast_click", target, playerLoc, - "to=" + compactWorldPoint(candidate)); + if (matchesAdjacentOriginShortTransportHop(to, from)) { + return true; } - return clicked; + return false; } - private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target) { - if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { - return false; - } - WorldPoint before = Rs2Player.getWorldLocation(); - if (before == null || before.getPlane() != toWp.getPlane()) { - return false; - } - if (before.equals(toWp)) { - return true; - } - if (before.distanceTo2D(toWp) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { - return false; - } - if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { - return false; - } - boolean clicked = walkFastCanvas(toWp); - if (!clicked) { - clicked = walkMiniMapToward(toWp, before, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); - } - if (!clicked) { - return false; - } - markFirstMovementClick("first_door_edge_nudge", target, before, "to=" + compactWorldPoint(toWp)); - sleepUntil(() -> { - if (isWalkCancelled(target)) { - return true; - } - WorldPoint now = Rs2Player.getWorldLocation(); - return isDoorEdgeNudgeResolved(before, now, fromWp, toWp); - }, POST_DOOR_EDGE_NUDGE_WAIT_MS); - WorldPoint after = Rs2Player.getWorldLocation(); - boolean progressed = isDoorEdgeNudgeResolved(before, after, fromWp, toWp); - if (progressed) { - WebWalkLog.tmark("door_edge_nudge", System.currentTimeMillis() - routeState.walkSessionStartedAtMs, - target, before, "from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); - routeState.lastMovedTimeMs = System.currentTimeMillis(); - routeState.stuckCount = 0; - } else { - WebWalkLog.spInfo("door_edge_nudge_unresolved | from={} to={} before={} after={}", - compactWorldPoint(fromWp), compactWorldPoint(toWp), compactWorldPoint(before), compactWorldPoint(after)); - } - return progressed; - } - private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { - WorldPoint from = routeState.lastDoorAttemptFrom; - WorldPoint to = routeState.lastDoorAttemptTo; - long attemptedAt = routeState.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() - routeState.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; - } - if (before.equals(after)) { - return false; - } - if (before.getPlane() != after.getPlane() - || after.getPlane() != fromWp.getPlane() - || after.getPlane() != toWp.getPlane()) { - return false; - } - int beforeTo = before.distanceTo2D(toWp); - int afterTo = after.distanceTo2D(toWp); - if (after.equals(toWp) || afterTo == 0) { - return true; - } - return afterTo <= 1 && afterTo < beforeTo; - } - private static int interimPreclickTiles() { - try { - return interimPreclickTiles(Rs2Player.isRunEnabled()); - } catch (Exception e) { - return INTERIM_PRECLICK_TILES; - } - } - static int interimPreclickTiles(boolean runEnabled) { - return runEnabled ? INTERIM_RUN_PRECLICK_TILES : INTERIM_PRECLICK_TILES; - } - static boolean shouldClearInterimTarget(WorldPoint interim, - WorldPoint playerLoc, - long setAtMs, - long lastProgressAtMs, - long nowMs) { - return shouldClearInterimTarget(interim, playerLoc, setAtMs, lastProgressAtMs, nowMs, Integer.MAX_VALUE); - } + /** - * @param bestDistanceSeen closest the player has been to {@code interim} while holding it, or - * {@link Integer#MAX_VALUE} when unknown (then the abandon check is inert). + * True when this scene object is the interactable listed on a transport catalog row (same + * coordinates and object ids as TSV loaded into {@link Rs2PathApi#getTransports()}). + * Door-ahead / fallback / LOS scans must treat it as non-door so {@link #handleTransports} owns it. */ - static boolean shouldClearInterimTarget(WorldPoint interim, - WorldPoint playerLoc, - long setAtMs, - long lastProgressAtMs, - long nowMs, - int bestDistanceSeen) { - if (interim == null) { - return false; - } - if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { - return true; - } - if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { - return true; - } - // An interim the player is walking AWAY from is dead, and nothing else here notices. - // interimLastProgressAtMs is renewed whenever the ROUTE INDEX advances, so a player making - // honest progress along the route — in the opposite direction to a checkpoint the route has - // since moved past — renews the interim every pass and the stale-progress escape can never - // fire. Measured: interim held at (2973,3350) while the player walked 2961,3349 -> 2960,3343, - // moving=true throughout, renewed until interimAgeMs=9999 and only then "expired" — with a - // transport dispatch waiting behind it the whole time. - if (bestDistanceSeen != Integer.MAX_VALUE - && playerLoc.distanceTo2D(interim) > bestDistanceSeen + INTERIM_ABANDON_MARGIN_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 < routeState.interimLastDistanceToTarget) { - routeState.interimLastDistanceToTarget = distance; - routeState.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 = routeState.interimTargetWp; - recordInterimDistanceProgress(interim, playerLoc, nowMs); - if (interim != null && path != null && !path.isEmpty()) { - int bestIdxNow = getClosestTileIndex(path, playerLoc); - if (bestIdxNow > routeState.interimLastBestPathIdx) { - routeState.interimLastBestPathIdx = bestIdxNow; - routeState.interimLastProgressAtMs = nowMs; - } - } - if (!shouldClearInterimTarget(interim, playerLoc, routeState.interimSetAtMs, - routeState.interimLastProgressAtMs, nowMs, routeState.interimLastDistanceToTarget)) { - 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 (routeState.interimLastDistanceToTarget != Integer.MAX_VALUE - && playerLoc.distanceTo2D(interim) - > routeState.interimLastDistanceToTarget + INTERIM_ABANDON_MARGIN_TILES) { - reason = "moving-away"; - } else if (routeState.interimLastProgressAtMs > 0L && nowMs - routeState.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 = routeState.interimTargetWp; - if (interim == null) { - return false; - } - recordInterimDistanceProgress(interim, playerLoc, nowMs); - if (playerLoc != null && path != null && !path.isEmpty()) { - int bestIdxNow = getClosestTileIndex(path, playerLoc); - if (bestIdxNow > routeState.interimLastBestPathIdx) { - routeState.interimLastBestPathIdx = bestIdxNow; - routeState.interimLastProgressAtMs = nowMs; - } - } - return shouldYieldForActiveRecoveryInterim(interim, - playerLoc, - routeState.interimSetAtMs, - routeState.interimLastProgressAtMs, - nowMs, - routeState.lastMovedTimeMs, - routeState.lastUnreachableRecoveryClickAtMs, - Rs2Player.isMoving()); - } - private static boolean shouldYieldForActiveRouteInterim(WorldPoint playerLoc, - List path, - long nowMs) { - WorldPoint interim = routeState.interimTargetWp; - if (interim == null) { - return false; - } - recordInterimDistanceProgress(interim, playerLoc, nowMs); - if (playerLoc != null && path != null && !path.isEmpty()) { - int bestIdxNow = getClosestTileIndex(path, playerLoc); - if (bestIdxNow > routeState.interimLastBestPathIdx) { - routeState.interimLastBestPathIdx = bestIdxNow; - routeState.interimLastProgressAtMs = nowMs; - } - } - return shouldDeferRouteWorkForActiveInterim(interim, - playerLoc, - routeState.interimSetAtMs, - routeState.interimLastProgressAtMs, - nowMs, - routeState.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 (shouldDeferRouteWorkForActiveInterim(interim, - playerLoc, - setAtMs, - lastProgressAtMs, - nowMs, - lastMovedAtMs, - playerMoving, - INTERIM_CLOSE_TILES)) { - return true; - } - return isRecentEvent(nowMs, lastRecoveryClickAtMs, RECOVERY_MOVEMENT_IN_FLIGHT_MS); - } - 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, setAtMs, lastProgressAtMs, nowMs)) { - return false; - } - if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { - return false; - } - 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 = routeState.interimTargetWp; - if (old != null) { - if ("close".equals(reason)) { - WebWalkLog.spDebug("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); - } else { - WebWalkLog.spInfo("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); - } - } - routeState.interimTargetWp = null; - routeState.interimTargetIdx = -1; - routeState.interimSetAtMs = 0L; - routeState.interimLastProgressAtMs = 0L; - routeState.interimLastBestPathIdx = -1; - routeState.interimLastDistanceToTarget = Integer.MAX_VALUE; - routeState.interimLastRetargetAtMs = 0L; - } - private static boolean shouldThrottleGlobalDoorInteraction() { - return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(routeState.nextDoorInteractionAllowedAtMs) - || shouldDeferDoorInteractionForDialogue(); - } - /** - * A guarded door answers with a conversation instead of opening ("you can't go in there"). The - * walker reads the lack of movement as "no progress, retry" and clicks again — and that click - * CANCELS the menu the previous click just opened, destroying the only thing that can get us - * through. Whatever answers dialogue (the questing layer) then never sees a menu that survives - * long enough to act on, so the walk livelocks at the door. - * - *

Deferring is BOUNDED: if nothing answers within {@link #DOOR_DIALOGUE_DEFER_MAX_MS} the - * walker resumes clicking, so a stray conversation with no handler cannot stall a plain walk - * that has no dialogue logic behind it. - */ - private static boolean shouldDeferDoorInteractionForDialogue() { - if (!Rs2Dialogue.hasSelectAnOption()) { - routeState.doorDialogueDeferSinceMs = 0L; - return false; - } - long now = System.currentTimeMillis(); - if (routeState.doorDialogueDeferSinceMs == 0L) { - routeState.doorDialogueDeferSinceMs = now; - WebWalkLog.spInfo("door_dialogue_defer | an option menu is open — not re-clicking the door"); - } - return doorDialogueDeferActive(routeState.doorDialogueDeferSinceMs, now, DOOR_DIALOGUE_DEFER_MAX_MS); - } - /** - * Pure half of the dialogue hold-off: defer only while the menu has been up for less than - * {@code maxDeferMs}. Split out because an unbounded version of this gate would trade a livelock - * at a guarded door for a permanent stall at any unanswered conversation. - */ - static boolean doorDialogueDeferActive(long deferSinceMs, long nowMs, long maxDeferMs) { - return deferSinceMs > 0L && nowMs - deferSinceMs < maxDeferMs; - } - private static boolean isDoorInteractionSettling() { - long now = System.currentTimeMillis(); - if (now >= routeState.doorInteractionSettleUntilMs) { - return false; - } - // Early exit: the interaction's purpose was opening the door — once its far side is reachable, - // the edge is open and there is nothing left to settle (previously this was a flat 900ms freeze - // after every door). One-tick floor for object-state flux; the window is cleared on success so - // repeated checks this tick don't re-run the reachability probe. - WorldPoint farSide = routeState.doorSettleFarSideWp; - if (farSide != null - && now - routeState.doorInteractionSettleStartedAtMs >= POST_INTERACT_SETTLE_MIN_MS - && Rs2Tile.isTileReachable(farSide)) { - routeState.doorInteractionSettleUntilMs = 0L; - routeState.doorSettleFarSideWp = null; - return false; - } - return true; - } - private static boolean isTransportInteractionSettling() { - long handledAt = routeState.lastTransportHandledAtMs; - if (handledAt <= 0L) { - return false; - } - return transportSettlePending(System.currentTimeMillis() - handledAt, - Rs2Player.getWorldLocation(), - routeState.lastTransportDestinationLocation, - Rs2Player.isMoving(), - Rs2Player.isAnimating()); - } - /** - * Pure settle decision after a handled transport. Settling ends as soon as the player is confirmed - * ARRIVED — standing at/next to the transport's planned destination, neither moving nor animating — - * after a one-tick floor for post-action state flux; {@link #TRANSPORT_POST_INTERACT_SETTLE_MS} is - * only the ceiling for when arrival never confirms (unknown destination, drawn-out travel). The old - * check compared against where the player stood when the transport was MARKED handled, which after - * landing is always true while standing still — so the settle could only ever end by timeout, a fixed - * ~900ms freeze after every single transport. - */ - static boolean transportSettlePending(long ageMs, WorldPoint now, WorldPoint plannedDestination, - boolean moving, boolean animating) { - if (ageMs < 0L || ageMs > TRANSPORT_POST_INTERACT_SETTLE_MS) { - return false; - } - if (ageMs < POST_INTERACT_SETTLE_MIN_MS) { - return true; - } - if (now == null || plannedDestination == null) { - return ageMs <= TRANSPORT_POST_INTERACT_SETTLE_MS / 2; - } - boolean arrivedIdle = now.getPlane() == plannedDestination.getPlane() - && now.distanceTo2D(plannedDestination) <= 1 - && !moving && !animating; - return !arrivedIdle; - } - private static boolean isDoorEdgePassSkipCoolingDown() { - return System.currentTimeMillis() - routeState.lastDoorEdgePassSkipAtMs < DOOR_EDGE_SKIP_COOLDOWN_MS; - } - private static boolean isRecoveryMovementInFlight() { - return System.currentTimeMillis() - routeState.lastUnreachableRecoveryClickAtMs < RECOVERY_MOVEMENT_IN_FLIGHT_MS; - } - /** Starts the door settle window, remembering the far-side tile so it can end when the edge opens. */ - private static void markDoorInteractionSettling(WorldPoint farSideWp) { - long now = System.currentTimeMillis(); - routeState.doorInteractionSettleStartedAtMs = now; - routeState.doorInteractionSettleUntilMs = now + DOOR_POST_INTERACT_SETTLE_MS; - routeState.doorSettleFarSideWp = farSideWp; - } - private static void markGlobalDoorInteractionCooldown() { - routeState.nextDoorInteractionAllowedAtMs = Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS); - } - private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { - Rs2DoorHandler.markDoorAttempt(recentDoorAttemptByEdge, doorTile, fromWp, toWp); - if (fromWp != null && toWp != null) { - routeState.lastDoorAttemptFrom = fromWp; - routeState.lastDoorAttemptTo = toWp; - routeState.lastDoorAttemptAtMs = System.currentTimeMillis(); - } - } - private static boolean shouldThrottleCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { - if (fromWp == null || toWp == null) { - return false; - } - String edgeKey = doorAttemptKey(null, fromWp, toWp); - long now = System.currentTimeMillis(); - recentCurrentTileTransportByEdge.entrySet() - .removeIf(entry -> now - entry.getValue() > CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS); - Long last = recentCurrentTileTransportByEdge.get(edgeKey); - return last != null && now - last < CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS; - } - private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { - if (fromWp == null || toWp == null) { - return; - } - recentCurrentTileTransportByEdge.put( - doorAttemptKey(null, fromWp, toWp), - System.currentTimeMillis()); - } - private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( - recentlyOpenedStationaryDoors, - STATIONARY_DOOR_SUPPRESS_MS, - fromWp, - toWp); - } + private static boolean hasLineOfSightBetween(WorldPoint a, WorldPoint b) { + if (a == null || b == null) return false; + return a.toWorldArea().hasLineOfSightTo( + Microbot.getClient().getTopLevelWorldView(), + b.toWorldArea()); + } - private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { - if (doorTile == null) { - return false; - } - Long openedAt = recentlyOpenedStationaryDoors.get(doorTile); - if (openedAt == null) { - return false; - } - long ageMs = System.currentTimeMillis() - openedAt; - if (ageMs > STATIONARY_DOOR_SUPPRESS_MS) { - recentlyOpenedStationaryDoors.remove(doorTile); - return false; - } - return true; - } + /** + * Path-adjacent door resolver: only interact with objects that are on/adjacent to + * a blocked path edge near the player. Prevents clicking random "door-like" junk + * that isn't the blocker. + */ + private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List path, int startIdx, int scanAheadEdges, int radiusTiles) { + if (playerLoc == null || path == null || path.size() < 2) return false; + if (startIdx < 0) startIdx = 0; + if (startIdx >= path.size() - 1) return false; - /** - * Strict catalog step: path tile equals transport origin in {@link Rs2PathApi#getTransports()} - * and next tile equals that row's destination. Used where the walker must dispatch {@code handleTransports} - * from the path index (origin keyed in the TSV-fed multimap). - */ - private static boolean hasExplicitTransportStep(List path, int index) { - if (path == null || index < 0 || index >= path.size() - 1) { - return false; - } - return matchesDirectedTransportCatalogEdge(path.get(index), path.get(index + 1)); - } + int endEdgeIdx = Math.min(path.size() - 2, startIdx + Math.max(0, scanAheadEdges)); + final int pathEdgeDoorMaxDist = 4; + Map byIdentity = new LinkedHashMap<>(); - /** - * Whether a planned transport origin sits essentially under the player's feet on the RAW path. - *

- * The startup phase suppresses broad raw handlers until the first movement click — but the first - * transport of a walk is routinely taken with no click at all (the player already stands on its - * origin), so the phase stays STARTUP straight through the NEXT transport. With the raw scan - * disabled, nothing dispatches it: the walker idles until the idle nudge minimap-clicks onto the - * origin, which is the "runs the four tiles instead of clicking the stairs" report. The segment - * loop already carves out exactly this case; this is the raw scan's equivalent, and it is - * deliberately as narrow — an origin within the near band, nothing else. - */ - private static boolean hasImmediateRawTransportStepNearPlayer(List rawPath) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { - return false; - } - int rawIdx = getClosestTileIndex(rawPath, playerLoc); - if (rawIdx < 0) { - return false; - } - int lastIdx = Math.min(rawPath.size() - 2, rawIdx + RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); - for (int ri = Math.max(0, rawIdx); ri <= lastIdx; ri++) { - if (hasImmediatePlannedTransportStep(rawPath, ri, playerLoc)) { - return true; - } - } - return false; - } + for (int edgeIdx = startIdx; edgeIdx <= endEdgeIdx; edgeIdx++) { + WorldPoint from = path.get(edgeIdx); + WorldPoint to = path.get(edgeIdx + 1); + if (from == null || to == null) continue; - 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); - return hasExplicitTransportStep(path, routeStartIdx) - && isTransportOriginNearPlayer( - origin, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); - } + // Only edges "near enough" to matter. + int dFrom = from.distanceTo2D(playerLoc); + int dTo = to.distanceTo2D(playerLoc); + if (Math.min(dFrom, dTo) > radiusTiles) continue; - static boolean shouldApproachPlannedTransportOrigin(boolean explicitTransportStep, - WorldPoint routeOrigin, - WorldPoint playerLoc, - int dispatchMaxDistance) { - return explicitTransportStep - && routeOrigin != null - && playerLoc != null - && routeOrigin.getPlane() == playerLoc.getPlane() - && routeOrigin.distanceTo2D(playerLoc) > Math.max(0, dispatchMaxDistance); - } + // Only treat as blocker if the next tile is unreachable OR the edge has no LOS. + boolean blocked = Rs2DoorAheadResolver.isPathEdgeBlocked(from, to); + if (!blocked) continue; - /** - * Whether this path edge is covered by a transport catalog row (same coordinates loaded from TSV into - * {@link Rs2PathApi#getTransports()}). Includes strict origin-destination steps (including - * cross-plane rows such as ladders) and same-plane hops where the path starts on a tile Chebyshev-adjacent - * to the catalog origin but still targets that row's destination, so door probing does not fight - * {@code handleTransports}. - */ - private static boolean isCatalogBackedTransportSegment(List path, int index) { - if (path == null || index < 0 || index >= path.size() - 1) { - return false; - } - return isCatalogBackedTransportSegment(path.get(index), path.get(index + 1)); - } + // Scan candidates in loaded scene radius around player. + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + WorldPoint objWp = w.getWorldLocation(); + if (objWp == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; + if (wasStationaryDoorOpenedRecently(objWp)) continue; - private static boolean isCatalogBackedTransportSegment(WorldPoint from, WorldPoint to) { - if (from == null || to == null) { - return false; - } - if (matchesDirectedTransportCatalogEdge(from, to)) { - return true; - } - if (matchesDirectedTransportCatalogEdge(to, from)) { - return true; - } - if (matchesAdjacentOriginShortTransportHop(from, to)) { - return true; - } - if (matchesAdjacentOriginShortTransportHop(to, from)) { - return true; - } - return false; - } + if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { + continue; + } + if (!Rs2DoorGeometry.isDoorOnSegment(w, from, to)) { + continue; + } - private static boolean isDoorLikeCatalogTransportSegment(List path, int index) { - if (path == null || index < 0 || index >= path.size() - 1) { - return false; - } - return isDoorLikeCatalogTransportSegment(path.get(index), path.get(index + 1)); - } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - private static boolean isDoorLikeCatalogTransportSegment(WorldPoint from, WorldPoint to) { - if (from == null || to == null) { - return false; - } - return hasDoorLikeDirectedCatalogTransport(from, to) - || hasDoorLikeDirectedCatalogTransport(to, from) - || hasDoorLikeAdjacentOriginShortTransportHop(from, to) - || hasDoorLikeAdjacentOriginShortTransportHop(to, from); - } + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; - private static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, WorldPoint dest) { - if (origin == null || dest == null) { - return false; - } - Set transports = Rs2PathApi.getTransports().get(origin); - if (transports == null || transports.isEmpty()) { - return false; - } - return transports.stream().anyMatch(t -> Objects.equals(t.getDestination(), dest)); - } + String actionFinal = action == null ? "" : action; + + int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); + mergePathAdjCandidate( + byIdentity, + w, + objWp, + actionFinal, + pri, + edgeIdx, + from, + to, + edgeDist); + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + WorldPoint objWp = g.getWorldLocation(); + if (objWp == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; + if (wasStationaryDoorOpenedRecently(objWp)) continue; + if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { + continue; + } + if (!Rs2DoorGeometry.isDoorOnSegment(g, from, to)) { + continue; + } + + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; + + String actionFinal = action == null ? "" : action; + + int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); + mergePathAdjCandidate( + byIdentity, + g, + objWp, + actionFinal, + pri, + edgeIdx, + from, + to, + edgeDist); + } + } + if (byIdentity.isEmpty()) { + log.debug("[Walker] path-adj blocker-scan: no candidates (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); + return false; + } - private static boolean hasDoorLikeDirectedCatalogTransport(WorldPoint origin, WorldPoint dest) { - if (origin == null || dest == null) { + List components = buildPathAdjDoorComponents(byIdentity.values(), startIdx, playerLoc); + if (components.isEmpty()) { + log.debug("[Walker] path-adj blocker-scan: no components (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); return false; } - Set transports = Rs2PathApi.getTransports().get(origin); - if (transports == null || transports.isEmpty()) { + PathAdjDoorComponent bestComponent = components.stream() + .min(Comparator.comparingInt(c -> c.score)) + .orElse(null); + if (bestComponent == null || bestComponent.best == null) { + log.debug("[Walker] path-adj blocker-scan: no component winner (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); return false; } - return transports.stream() - .anyMatch(t -> Objects.equals(t.getDestination(), dest) && Rs2DoorProbe.isDoorLikeCatalogTransport(t)); - } - - /** - * True when some catalog origin one step from {@code from} has a same-plane adjacent transport to {@code to}. - * Restricted to {@link #isAdjacentSamePlaneTransport} rows so long-distance transports do not suppress doors. - */ - private static boolean matchesAdjacentOriginShortTransportHop(WorldPoint from, WorldPoint to) { - if (from == null || to == null || from.getPlane() != to.getPlane()) { + PathAdjDoorCandidate chosen = bestComponent.best; + TileObject best = chosen.object; + String bestAction = chosen.action; + int bestScore = bestComponent.score; + WorldPoint bestFrom = chosen.from; + WorldPoint bestTo = chosen.to; + if (isRecentTransportEdgeCandidate(chosen.location, bestFrom, bestTo)) { + WebWalkLog.spInfo("path_adj_recent_transport_skip | probe={} from={} to={} origin={} dest={}", + compactWorldPoint(chosen.location), + compactWorldPoint(bestFrom), + compactWorldPoint(bestTo), + compactWorldPoint(routeState.lastTransportOriginLocation), + compactWorldPoint(routeState.lastTransportDestinationLocation)); return false; } - for (int dx = -1; dx <= 1; dx++) { - for (int dy = -1; dy <= 1; dy++) { - if (dx == 0 && dy == 0) { - continue; - } - WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); - Set transports = Rs2PathApi.getTransports().get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { - if (Objects.equals(t.getDestination(), to) && isAdjacentSamePlaneTransport(t)) { - return true; - } - } - } - } - return false; - } - - private static boolean hasDoorLikeAdjacentOriginShortTransportHop(WorldPoint from, WorldPoint to) { - if (from == null || to == null || from.getPlane() != to.getPlane()) { - return false; - } - for (int dx = -1; dx <= 1; dx++) { - for (int dy = -1; dy <= 1; dy++) { - if (dx == 0 && dy == 0) { - continue; - } - WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); - Set transports = Rs2PathApi.getTransports().get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { - if (Objects.equals(t.getDestination(), to) - && isAdjacentSamePlaneTransport(t) - && Rs2DoorProbe.isDoorLikeCatalogTransport(t)) { - return true; - } + log.info("[Walker] path-adj blocker-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, chosen.location); + WorldPoint bestLoc = chosen.location; + if (shouldThrottleDoorAttempt(bestLoc, bestFrom, bestTo)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); } } - } - return false; - } - - - - /** - * True when this scene object is the interactable listed on a transport catalog row (same - * coordinates and object ids as TSV loaded into {@link Rs2PathApi#getTransports()}). - * Door-ahead / fallback / LOS scans must treat it as non-door so {@link #handleTransports} owns it. - */ - - private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { - long startedAt = System.currentTimeMillis(); - AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); - try { - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); - } finally { - if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; - } - } - } - - private static boolean waitForDoorEdgeResolution(WorldPoint fromWp, WorldPoint toWp, int timeoutMs) { - long startedAt = System.currentTimeMillis(); - DoorResolution resolution = Rs2WalkerAwaits.awaitDoorEdgeResolution(fromWp, toWp, timeoutMs); - long elapsed = System.currentTimeMillis() - startedAt; - // Counted separately or it lands in the scan's doorProbe residual and reads as probe cost — - // this wait alone has been measured at 1897ms (FAILED_TIMEOUT). - if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorEdgeWaitMs += elapsed; - } - WebWalkLog.tmark("door_edge_wait_done", elapsed, currentTarget, - Rs2Player.getWorldLocation(), - "result=" + resolution + " from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); - return resolution == DoorResolution.RESOLVED; - } - - private static boolean isDoorEdgeResolved(WorldPoint fromWp, WorldPoint toWp) { - return Rs2WalkerAwaits.isDoorEdgeResolved(fromWp, toWp); - } - - static boolean didTraverseInteractedDoor(WorldPoint start, WorldPoint end, WorldPoint objectLoc, - WorldPoint fromWp, WorldPoint toWp) { - if (start == null || end == null || objectLoc == null || toWp == null) { - return false; - } - if (start.getPlane() != end.getPlane() || end.getPlane() != objectLoc.getPlane() || end.getPlane() != toWp.getPlane()) { - return false; - } - if (start.equals(end)) { - return false; - } - if (!movedAcrossInteractedObject(start, end, objectLoc)) { - return false; - } - int beforeTo = start.distanceTo2D(toWp); - int afterTo = end.distanceTo2D(toWp); - if (afterTo >= beforeTo) { - return false; - } - // Keep the traversal check anchored to the active segment. - return fromWp == null || fromWp.getPlane() == end.getPlane(); - } - - static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoint end, WorldPoint fromWp, WorldPoint toWp) { - return shouldBlacklistDoorAfterWrongTraversal(start, end, fromWp, toWp, false); - } - - /** - * As {@link #shouldBlacklistDoorAfterWrongTraversal(WorldPoint, WorldPoint, WorldPoint, WorldPoint)} - * but aware of whether the {@code end} position was sampled while the player was STILL WALKING. The - * interact walks the player to the door first and the progress wait can time out en route, so a - * moving sample is just a point along the path — not a traversal verdict. Deciding from one poisoned - * Wydin's shop door: before=3008,3207 (en route), after=3012,3211 (seven tiles from the edge, mid - * walk) was blacklisted AND learn-persisted as a blocked edge. A same-plane moving sample must never - * blacklist; a plane change is still trusted (the door acted — walking cannot change plane). - */ - static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoint end, WorldPoint fromWp, - WorldPoint toWp, boolean sampledWhileMoving) { - if (start == null || end == null || toWp == null) { - return false; - } - if (start.equals(end)) { - return false; - } - if (start.getPlane() != end.getPlane()) { - return true; - } - if (sampledWhileMoving) { - return false; - } - if (!startedNearDoorEdge(start, fromWp, toWp)) { - return false; - } - int moved = start.distanceTo2D(end); - if (moved < 3) { - return false; - } - int startTo = start.distanceTo2D(toWp); - int endTo = end.distanceTo2D(toWp); - if (endTo <= startTo + 1) { - return false; - } - if (fromWp == null || fromWp.getPlane() != end.getPlane()) { - return true; - } - int startFrom = start.distanceTo2D(fromWp); - int endFrom = end.distanceTo2D(fromWp); - 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()); - int startRelY = Integer.compare(start.getY(), objectLoc.getY()); - int endRelY = Integer.compare(end.getY(), objectLoc.getY()); - return startRelX != endRelX || startRelY != endRelY; - } - - private static boolean hasDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, - WorldPoint playerLoc, int radiusTiles) { - if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { - return false; - } - if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { - return false; - } - if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { - return false; - } - - for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { - if (isPendingRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { - return true; - } - } - for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { - if (isPendingRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { - return true; - } - } - return false; - } - - private static boolean hasUnresolvedDoorLikeObjectNearRawPath(List rawPath, - int rawEdgeStart, - WorldPoint playerLoc, - int backtrackEdges, - int lookaheadEdges, - int radiusTiles) { - if (rawPath == null || rawPath.size() < 2 || playerLoc == null || rawEdgeStart < 0) { - return false; - } - - int start = Math.max(0, rawEdgeStart - Math.max(0, backtrackEdges)); - int endExclusive = Math.min(rawPath.size() - 1, rawEdgeStart + Math.max(1, lookaheadEdges)); - for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { - WorldPoint from = rawPath.get(ri); - WorldPoint to = rawPath.get(ri + 1); - if (from == null || to == null) { - continue; - } - if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { - break; - } - if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { - continue; - } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { - continue; - } - if (hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { - return true; - } - } - return false; - } - - private static boolean hasUnresolvedDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, - WorldPoint playerLoc, int radiusTiles) { - if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { - return false; - } - if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { - return false; - } - - for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { - if (isUnresolvedRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { - return true; - } - } - for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { - if (isUnresolvedRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { - return true; - } - } - return false; - } - - private static boolean isUnresolvedRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, - WorldPoint playerLoc, int radiusTiles) { - if (object == null || object.getWorldLocation() == null) { - return false; - } - WorldPoint location = object.getWorldLocation(); - if (location.getPlane() != playerLoc.getPlane() - || location.distanceTo2D(playerLoc) > radiusTiles - || (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) - || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { - return false; - } - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - if (comp == null - || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName()) - || Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { - return false; - } - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - } - - private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, - WorldPoint playerLoc, int radiusTiles) { - if (object == null || object.getWorldLocation() == null) { - return false; - } - WorldPoint location = object.getWorldLocation(); - if (location.getPlane() != playerLoc.getPlane() - || location.distanceTo2D(playerLoc) > radiusTiles - || sessionBlacklistedDoors.contains(location) - || (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) - || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { - return false; - } - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - if (comp == null - || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName()) - || Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { - return false; - } - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - } - - - /** - * Door handling can include dialogue and waits; bound it so the walker cannot hang - * indefinitely on a bad interact. If the timeout elapses, return false so the main - * loop can continue (stall detection / replans). - */ - private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs) { - return handleDoorsWithTimeout(path, index, timeoutMs, null); - } - - private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, - Map attemptedDoorEdgesThisPass) { - return handleDoorsWithTimeout(path, index, timeoutMs, attemptedDoorEdgesThisPass, false); - } - - private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, - Map attemptedDoorEdgesThisPass, - boolean allowSegmentProbe) { - long start = System.currentTimeMillis(); - WorldPoint[] segment = resolveDoorSegment(path, index); - String edgeKey = segment != null && segment.length >= 2 && segment[0] != null && segment[1] != null - ? doorAttemptKey(null, segment[0], segment[1]) - : null; - WorldPoint playerBeforeAttempt = Rs2Player.getWorldLocation(); - if (!markDoorEdgeAttemptThisPass(attemptedDoorEdgesThisPass, segment, playerBeforeAttempt)) { - routeState.lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); - WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); - return false; - } - boolean handled = handleDoors(path, index, allowSegmentProbe); - if (!handled) { - // Do not consume one-shot budget when no interaction happened; allow - // a later resolver in the same pass to attempt this edge. - if (attemptedDoorEdgesThisPass != null && edgeKey != null) { - attemptedDoorEdgesThisPass.remove(edgeKey); - } - return false; - } - WebWalkLog.tmark("door_interaction_done", System.currentTimeMillis() - start, currentTarget, playerBeforeAttempt, - "idx=" + index); - long remaining = timeoutMs - (System.currentTimeMillis() - start); - if (remaining <= 0) { - return true; - } - WorldPoint before = Rs2Player.getWorldLocation(); - int remainingInt = (int) Math.min(Integer.MAX_VALUE, remaining); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - if (before != null && now != null && !before.equals(now)) return true; - return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); - }, remainingInt); - - if (segment != null && !isDoorEdgeResolved(segment[0], segment[1])) { - WebWalkLog.spInfo("door_edge_post_unresolved | idx={} from={} to={}", - index, compactWorldPoint(segment[0]), compactWorldPoint(segment[1])); - } else if (segment != null) { - WebWalkLog.tmark("door_edge_resolved", System.currentTimeMillis() - start, currentTarget, - Rs2Player.getWorldLocation(), - "from=" + compactWorldPoint(segment[0]) + " to=" + compactWorldPoint(segment[1])); - } - return true; - } - - private static WorldPoint[] resolveDoorSegment(List path, int index) { - if (path == null || index < 0 || index >= path.size() - 1) { - return null; - } - WorldPoint fromWp = path.get(index); - WorldPoint toWp = path.get(index + 1); - if (fromWp == null || toWp == null) { - return null; - } - boolean isInstance = Microbot.getClient() - .getTopLevelWorldView() - .getScene() - .isInstance(); - if (!isInstance) { - return new WorldPoint[] {fromWp, toWp}; - } - WorldPoint convertedFrom = Rs2WorldPoint.convertInstancedWorldPoint(fromWp); - WorldPoint convertedTo = Rs2WorldPoint.convertInstancedWorldPoint(toWp); - if (convertedFrom == null || convertedTo == null) { - return null; - } - return new WorldPoint[] {convertedFrom, convertedTo}; - } - - static boolean markDoorEdgeAttemptThisPass(Map attemptedDoorEdgesThisPass, - WorldPoint[] segment, - WorldPoint playerBeforeAttempt) { - if (attemptedDoorEdgesThisPass == null || segment == null || segment.length < 2 - || segment[0] == null || segment[1] == null) { - return true; - } - String edgeKey = doorAttemptKey(null, segment[0], segment[1]); - WorldPoint previousAttemptPos = attemptedDoorEdgesThisPass.get(edgeKey); - if (previousAttemptPos != null && playerBeforeAttempt != null - && previousAttemptPos.getPlane() == playerBeforeAttempt.getPlane() - && previousAttemptPos.distanceTo2D(playerBeforeAttempt) <= 1) { - return false; - } - attemptedDoorEdgesThisPass.put(edgeKey, playerBeforeAttempt); - return true; - } - - /** - * Last-resort door resolver for "tile unreachable near player" stalls. - * Scans a very small radius around the player for door-like wall/game objects - * and interacts with the best candidate action. - */ - private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int radiusTiles) { - if (playerLoc == null || radiusTiles <= 0) return false; - - TileObject best = null; - String bestAction = null; - int bestActionPri = Integer.MAX_VALUE; - int bestDist = Integer.MAX_VALUE; - int scannedWalls = 0; - int scannedGames = 0; - int candidates = 0; - - for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { - if (w == null) continue; - scannedWalls++; - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; - candidates++; - - // Allow empty-action doors: use default interact. - String actionFinal = action == null ? "" : action; - int dist = w.getWorldLocation() == null ? Integer.MAX_VALUE : w.getWorldLocation().distanceTo2D(playerLoc); - int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); - if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { - best = w; - bestAction = actionFinal; - bestActionPri = pri; - bestDist = dist; - } - } - - for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { - if (g == null) continue; - scannedGames++; - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; - candidates++; - - String actionFinal = action == null ? "" : action; - int dist = g.getWorldLocation() == null ? Integer.MAX_VALUE : g.getWorldLocation().distanceTo2D(playerLoc); - int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); - if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { - best = g; - bestAction = actionFinal; - bestActionPri = pri; - bestDist = dist; - } - } - - if (best == null || bestAction == null) { - log.info("[Walker] fallback door-scan: no candidates (radius={} player={} scannedWalls={} scannedGames={} candidates={})", - radiusTiles, playerLoc, scannedWalls, scannedGames, candidates); - return false; - } - - WorldPoint before = Rs2Player.getWorldLocation(); - log.info("[Walker] fallback door-scan: action={} at {}", bestAction.isEmpty() ? "" : bestAction, best.getWorldLocation()); - if (bestAction.isEmpty()) { - Rs2GameObject.interact(best); - } else { - Rs2GameObject.interact(best, bestAction); - } - Rs2Player.waitForWalking(); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - if (before != null && now != null && !before.equals(now)) return true; - return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); - }, 1500); - return true; - } - - /** - * LOS-based door resolution: when a path says "go through that door" but local reachability - * says "unreachable", we may be a few tiles away from the actual door object. Scan door-like - * objects in a wider radius and require line-of-sight from the player, then interact with the - * best candidate (closest to the upcoming path tiles). - */ - private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, List path, int startIdx, int radiusTiles) { - if (playerLoc == null || path == null || path.size() < 2) return false; - if (startIdx < 0 || startIdx >= path.size()) return false; - - TileObject best = null; - String bestAction = null; - int bestScore = Integer.MAX_VALUE; - - // Look a little ahead along the path to bias toward the intended door edge. - int endIdx = Math.min(path.size() - 1, startIdx + 10); - - for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { - if (w == null) continue; - if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; - - String actionFinal = action == null ? "" : action; - - // Score by proximity to upcoming path tiles (lower is better). - int score = Integer.MAX_VALUE; - WorldPoint objWp = w.getWorldLocation(); - if (objWp != null) { - for (int j = startIdx; j <= endIdx; j++) { - WorldPoint pj = path.get(j); - if (pj == null) continue; - score = Math.min(score, objWp.distanceTo2D(pj)); - } - // Tie-break toward closer objects. - score = score * 10 + objWp.distanceTo2D(playerLoc); - } - - if (best == null || score < bestScore) { - best = w; - bestAction = actionFinal; - bestScore = score; - } - } - - for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { - if (g == null) continue; - if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; - - String actionFinal = action == null ? "" : action; - - int score = Integer.MAX_VALUE; - WorldPoint objWp = g.getWorldLocation(); - if (objWp != null) { - for (int j = startIdx; j <= endIdx; j++) { - WorldPoint pj = path.get(j); - if (pj == null) continue; - score = Math.min(score, objWp.distanceTo2D(pj)); - } - score = score * 10 + objWp.distanceTo2D(playerLoc); - } - - if (best == null || score < bestScore) { - best = g; - bestAction = actionFinal; - bestScore = score; - } - } - - if (best == null) { - log.info("[Walker] LOS door-scan: no candidates (radius={} player={} idx={}/{})", radiusTiles, playerLoc, startIdx, path.size()); return false; - } - - log.info("[Walker] LOS door-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, best.getWorldLocation()); - if (bestAction == null || bestAction.isEmpty()) { - Rs2GameObject.interact(best); - } else { - Rs2GameObject.interact(best, bestAction); - } - Rs2Player.waitForWalking(); - return true; - } - - private static boolean hasLineOfSightBetween(WorldPoint a, WorldPoint b) { - if (a == null || b == null) return false; - return a.toWorldArea().hasLineOfSightTo( - Microbot.getClient().getTopLevelWorldView(), - b.toWorldArea()); - } - - /** - * Path-adjacent door resolver: only interact with objects that are on/adjacent to - * a blocked path edge near the player. Prevents clicking random "door-like" junk - * that isn't the blocker. - */ - private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List path, int startIdx, int scanAheadEdges, int radiusTiles) { - if (playerLoc == null || path == null || path.size() < 2) return false; - if (startIdx < 0) startIdx = 0; - if (startIdx >= path.size() - 1) return false; - - int endEdgeIdx = Math.min(path.size() - 2, startIdx + Math.max(0, scanAheadEdges)); - final int pathEdgeDoorMaxDist = 4; - Map byIdentity = new LinkedHashMap<>(); - - for (int edgeIdx = startIdx; edgeIdx <= endEdgeIdx; edgeIdx++) { - WorldPoint from = path.get(edgeIdx); - WorldPoint to = path.get(edgeIdx + 1); - if (from == null || to == null) continue; - - // Only edges "near enough" to matter. - int dFrom = from.distanceTo2D(playerLoc); - int dTo = to.distanceTo2D(playerLoc); - if (Math.min(dFrom, dTo) > radiusTiles) continue; - - // Only treat as blocker if the next tile is unreachable OR the edge has no LOS. - boolean blocked = Rs2DoorAheadResolver.isPathEdgeBlocked(from, to); - if (!blocked) continue; - - // Scan candidates in loaded scene radius around player. - for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { - if (w == null) continue; - WorldPoint objWp = w.getWorldLocation(); - if (objWp == null) continue; - if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; - if (wasStationaryDoorOpenedRecently(objWp)) continue; - - if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { - continue; - } - if (!Rs2DoorGeometry.isDoorOnSegment(w, from, to)) { - continue; - } - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; - - String actionFinal = action == null ? "" : action; - - int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); - int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); - mergePathAdjCandidate( - byIdentity, - w, - objWp, - actionFinal, - pri, - edgeIdx, - from, - to, - edgeDist); - } - - for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { - if (g == null) continue; - WorldPoint objWp = g.getWorldLocation(); - if (objWp == null) continue; - if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; - if (wasStationaryDoorOpenedRecently(objWp)) continue; - if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { - continue; - } - if (!Rs2DoorGeometry.isDoorOnSegment(g, from, to)) { - continue; - } - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; - - String actionFinal = action == null ? "" : action; - - int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); - int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); - mergePathAdjCandidate( - byIdentity, - g, - objWp, - actionFinal, - pri, - edgeIdx, - from, - to, - edgeDist); - } - } - if (byIdentity.isEmpty()) { - log.debug("[Walker] path-adj blocker-scan: no candidates (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); - return false; - } - - List components = buildPathAdjDoorComponents(byIdentity.values(), startIdx, playerLoc); - if (components.isEmpty()) { - log.debug("[Walker] path-adj blocker-scan: no components (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); - return false; - } - PathAdjDoorComponent bestComponent = components.stream() - .min(Comparator.comparingInt(c -> c.score)) - .orElse(null); - if (bestComponent == null || bestComponent.best == null) { - log.debug("[Walker] path-adj blocker-scan: no component winner (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); - return false; - } - PathAdjDoorCandidate chosen = bestComponent.best; - TileObject best = chosen.object; - String bestAction = chosen.action; - int bestScore = bestComponent.score; - WorldPoint bestFrom = chosen.from; - WorldPoint bestTo = chosen.to; - if (isRecentTransportEdgeCandidate(chosen.location, bestFrom, bestTo)) { - WebWalkLog.spInfo("path_adj_recent_transport_skip | probe={} from={} to={} origin={} dest={}", - compactWorldPoint(chosen.location), - compactWorldPoint(bestFrom), - compactWorldPoint(bestTo), - compactWorldPoint(routeState.lastTransportOriginLocation), - compactWorldPoint(routeState.lastTransportDestinationLocation)); - return false; - } - log.info("[Walker] path-adj blocker-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, chosen.location); - WorldPoint bestLoc = chosen.location; - if (shouldThrottleDoorAttempt(bestLoc, bestFrom, bestTo)) { - WebWalkLog.spInfo("door_attempt_throttled | mode=path-adj probe={} from={} to={}", - compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); - } - } - return false; - } - if (shouldThrottleGlobalDoorInteraction()) { - WebWalkLog.spInfo("door_global_await | mode=path-adj probe={} from={} to={}", - compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); - return false; - } - markDoorAttempt(bestLoc, bestFrom, bestTo); - markGlobalDoorInteractionCooldown(); - WorldPoint posBefore = Rs2Player.getWorldLocation(); - boolean interacted; - try { - if (bestAction == null || bestAction.isEmpty()) { - interacted = Rs2GameObject.interact(best); - } else { - interacted = Rs2GameObject.interact(best, bestAction); - } - } catch (Exception ex) { - WebWalkLog.spInfo("door_interact_exception | mode=path-adj probe={} from={} to={} ex={}", - compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo), ex.getClass().getSimpleName()); - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); - } - } - return false; - } - if (!interacted) { - WebWalkLog.spInfo("door_interact_failed | mode=path-adj probe={} from={} to={}", - compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); - } - } - return false; - } - markDoorInteractionSettling(bestTo); - waitForDoorInteractionProgress(bestFrom, bestTo); - WorldPoint posAfter = Rs2Player.getWorldLocation(); - boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, bestLoc, bestFrom, bestTo); - if (traversed) { - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); - } - } - return true; - } - boolean wrongTraversal = bestLoc != null && shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, bestFrom, bestTo, Rs2Player.isMoving()); - if (wrongTraversal) { - log.warn("[Walker] Path-adj door traversed wrong way; not session-blacklisting fallback candidate: door={} from={} to={} before={} after={}", - bestLoc, bestFrom, bestTo, posBefore, posAfter); - } else { - for (WorldPoint loc : bestComponent.locations) { - if (loc != null) { - markStationaryDoorOpened(loc); - } - } - } - log.debug("[Walker] path-adj blocker-scan interact did not traverse (at={} from={} to={} before={} after={})", - bestLoc, bestFrom, bestTo, posBefore, posAfter); - // Interaction was sent and awaited; yield this pass so unreachable recovery - // does not immediately fire a minimap click while door traversal settles. - return true; - } - - private static void mergePathAdjCandidate( - Map byIdentity, - TileObject object, - WorldPoint location, - String action, - int actionPriority, - int edgeIdx, - WorldPoint from, - WorldPoint to, - int edgeDist) { - if (object == null || location == null) { - return; - } - if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { - return; - } - String identity = object.getClass().getSimpleName() + "|" + object.getId() + "|" - + location.getX() + "," + location.getY() + "," + location.getPlane(); - String familyKey = normalizePathAdjFamilyKey(object, action); - PathAdjDoorCandidate incoming = new PathAdjDoorCandidate( - object, - location, - action == null ? "" : action, - actionPriority, - edgeIdx, - from, - to, - edgeDist, - familyKey); - PathAdjDoorCandidate existing = byIdentity.get(identity); - if (existing == null) { - byIdentity.put(identity, incoming); - return; - } - if (incoming.edgeIdx < existing.edgeIdx - || (incoming.edgeIdx == existing.edgeIdx && incoming.edgeDist < existing.edgeDist)) { - byIdentity.put(identity, incoming); - } - } - - private static String normalizePathAdjFamilyKey(TileObject object, String action) { - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - String name = comp != null && comp.getName() != null ? comp.getName().toLowerCase(Locale.ROOT).trim() : "unknown"; - String act = action == null ? "" : action.toLowerCase(Locale.ROOT).trim(); - WorldPoint loc = object != null ? object.getWorldLocation() : null; - int plane = loc != null ? loc.getPlane() : -1; - int objectId = object != null ? object.getId() : -1; - int idRangeLow = objectId >= 0 ? objectId - 1 : -1; - int idRangeHigh = objectId >= 0 ? objectId + 1 : -1; - return name + "|" + act + "|p" + plane + "|id=" + idRangeLow + "-" + idRangeHigh; - } - - private static boolean arePathAdjFamiliesCompatible(String a, String b) { - if (Objects.equals(a, b)) { - return true; - } - if (a == null || b == null) { - return false; - } - int aIdTag = a.indexOf("|id="); - int bIdTag = b.indexOf("|id="); - if (aIdTag <= 0 || bIdTag <= 0) { - return false; - } - String aBase = a.substring(0, aIdTag); - String bBase = b.substring(0, bIdTag); - if (!Objects.equals(aBase, bBase)) { - return false; - } - int[] aRange = parsePathAdjIdRange(a.substring(aIdTag + 4)); - int[] bRange = parsePathAdjIdRange(b.substring(bIdTag + 4)); - if (aRange == null || bRange == null) { - return false; - } - return Math.max(aRange[0], bRange[0]) <= Math.min(aRange[1], bRange[1]); - } - - private static int[] parsePathAdjIdRange(String range) { - if (range == null || range.isEmpty()) { - return null; - } - int sep = range.indexOf('-'); - if (sep <= 0 || sep >= range.length() - 1) { - return null; - } - try { - int low = Integer.parseInt(range.substring(0, sep)); - int high = Integer.parseInt(range.substring(sep + 1)); - if (high < low) { - return null; - } - return new int[] {low, high}; - } catch (NumberFormatException ignored) { - return null; - } - } - - private static void markNearbyDoorFamilyOpened(TileObject originObject, WorldPoint originLocation, String action, int radiusTiles) { - if (originObject == null || originLocation == null || radiusTiles <= 0) { - return; - } - String familyKey = normalizePathAdjFamilyKey(originObject, action); - if (familyKey == null || familyKey.isEmpty()) { - markStationaryDoorOpened(originLocation); - return; - } - markStationaryDoorOpened(originLocation); - for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, originLocation, radiusTiles)) { - if (wall == null || wall.getWorldLocation() == null) { - continue; - } - if (wall.getWorldLocation().getPlane() != originLocation.getPlane()) { - continue; - } - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(wall); - String neighborFamily = normalizePathAdjFamilyKey(wall, comp == null ? null : Rs2DoorClassifier.pickWalkDoorAction(comp)); - if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { - markStationaryDoorOpened(wall.getWorldLocation()); - } - } - for (GameObject game : Rs2GameObject.getGameObjects(o -> true, originLocation, radiusTiles)) { - if (game == null || game.getWorldLocation() == null) { - continue; - } - if (game.getWorldLocation().getPlane() != originLocation.getPlane()) { - continue; - } - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(game); - String neighborFamily = normalizePathAdjFamilyKey(game, comp == null ? null : Rs2DoorClassifier.pickWalkDoorAction(comp)); - if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { - markStationaryDoorOpened(game.getWorldLocation()); - } - } - } - - private static List buildPathAdjDoorComponents( - Collection candidates, - int startIdx, - WorldPoint playerLoc) { - if (candidates == null || candidates.isEmpty()) { - return Collections.emptyList(); - } - List list = new ArrayList<>(candidates); - boolean[] visited = new boolean[list.size()]; - List components = new ArrayList<>(); - for (int i = 0; i < list.size(); i++) { - if (visited[i]) { - continue; - } - PathAdjDoorCandidate seed = list.get(i); - visited[i] = true; - java.util.Deque queue = new ArrayDeque<>(); - queue.add(i); - List members = new ArrayList<>(); - members.add(seed); - while (!queue.isEmpty()) { - int idx = queue.removeFirst(); - PathAdjDoorCandidate a = list.get(idx); - for (int j = 0; j < list.size(); j++) { - if (visited[j]) { - continue; - } - PathAdjDoorCandidate b = list.get(j); - if (!arePathAdjFamiliesCompatible(a.familyKey, b.familyKey)) { - continue; - } - if (a.location == null || b.location == null) { - continue; - } - int tileGap = a.location.distanceTo2D(b.location); - int edgeGap = Math.abs(a.edgeIdx - b.edgeIdx); - if (tileGap > PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP - && edgeGap > PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP) { - continue; - } - visited[j] = true; - queue.addLast(j); - members.add(b); - } - } - PathAdjDoorCandidate best = null; - int earliestEdge = Integer.MAX_VALUE; - int bestLocalScore = Integer.MAX_VALUE; - Set locs = new LinkedHashSet<>(); - for (PathAdjDoorCandidate c : members) { - locs.add(c.location); - earliestEdge = Math.min(earliestEdge, c.edgeIdx); - int pri = c.actionPriority == Integer.MAX_VALUE ? 100 : c.actionPriority; - int localScore = c.edgeDist * 100 + pri * 10 - + (playerLoc != null && c.location != null ? c.location.distanceTo2D(playerLoc) : 0); - if (best == null || localScore < bestLocalScore) { - best = c; - bestLocalScore = localScore; - } - } - int edgeOffset = Math.max(0, earliestEdge - startIdx); - int componentScore = edgeOffset * 1000 + bestLocalScore; - components.add(new PathAdjDoorComponent(best, componentScore, locs)); - } - return components; - } - - private static final class PathAdjDoorCandidate { - private final TileObject object; - private final WorldPoint location; - private final String action; - private final int actionPriority; - private final int edgeIdx; - private final WorldPoint from; - private final WorldPoint to; - private final int edgeDist; - private final String familyKey; - - private PathAdjDoorCandidate(TileObject object, WorldPoint location, String action, int actionPriority, - int edgeIdx, WorldPoint from, WorldPoint to, int edgeDist, String familyKey) { - this.object = object; - this.location = location; - this.action = action; - this.actionPriority = actionPriority; - this.edgeIdx = edgeIdx; - this.from = from; - this.to = to; - this.edgeDist = edgeDist; - this.familyKey = familyKey; - } - } - - private static final class PathAdjDoorComponent { - private final PathAdjDoorCandidate best; - private final int score; - private final Set locations; - - private PathAdjDoorComponent(PathAdjDoorCandidate best, int score, Set locations) { - this.best = best; - this.score = score; - this.locations = locations; - } - } - - /** - * Scan a few path indices near the player (<= radius tiles) and attempt to resolve - * any door/gate blocks before issuing further minimap clicks. - */ - private static boolean tryHandleNearbyDoorsWithTimeout(List path, int startIdx, int radiusTiles, long timeoutMs) { - if (path == null || path.isEmpty() || startIdx < 0) return false; - final WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null) return false; - - int start = Math.min(startIdx, path.size() - 2); - for (int j = start; j < path.size() - 1; j++) { - WorldPoint wp = path.get(j); - if (wp == null) continue; - if (wp.getPlane() != playerLoc.getPlane()) break; - if (wp.distanceTo2D(playerLoc) > radiusTiles) { - // Path is ordered; once we're beyond radius, later indices will likely be further. - break; - } - if (handleDoorsWithTimeout(path, j, timeoutMs)) { - return true; - } - } - return false; - } - - /** - * Predict blockers on the path by probing the next few path edges for door/gate-like - * objects (including diagonal corners). If any probe tile contains a door-like object - * within {@code radiusTiles} of the player, run door handling with a bounded wait. - */ - private static boolean tryHandleBlockingPathObjectsWithTimeout( - List path, - int startIdx, - int radiusTiles, - int maxEdges, - long timeoutMs, - Map attemptedDoorEdgesThisPass) - { - if (path == null || path.size() < 2) return false; - if (startIdx < 0) return false; - final WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null) return false; - - int start = Math.min(startIdx, path.size() - 2); - int edgesChecked = 0; - for (int j = start; j < path.size() - 1 && edgesChecked < maxEdges; j++, edgesChecked++) { - WorldPoint from = path.get(j); - WorldPoint to = path.get(j + 1); - if (from == null || to == null) continue; - if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) break; - - // Only bother probing edges near the player; far edges are not loaded in scene. - if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { - break; - } - - boolean diagonal = from.getX() != to.getX() && from.getY() != to.getY(); - List probes = new ArrayList<>(); - probes.add(from); - probes.add(to); - if (diagonal) { - probes.add(new WorldPoint(to.getX(), from.getY(), from.getPlane())); - probes.add(new WorldPoint(from.getX(), to.getY(), from.getPlane())); - } - - for (WorldPoint probe : probes) { - if (probe == null) continue; - if (probe.getPlane() != playerLoc.getPlane()) continue; - if (probe.distanceTo2D(playerLoc) > radiusTiles) continue; - - WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); - TileObject object = (wall != null) - ? wall - : Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); - if (object == null) continue; - - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; - if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - - // Gate by "door-like" name or by having a known door-like action. - String action = Arrays.stream(comp.getActions()) - .filter(Objects::nonNull) - .filter(act -> !Rs2DoorClassifier.isDoorCloseOrShutAction(act)) - .filter(act -> Rs2DoorClassifier.doorActionPriorityIndex(act) < Integer.MAX_VALUE) - .min(Comparator.comparingInt(Rs2DoorClassifier::doorActionPriorityIndex)) - .orElse(null); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) || action != null; - if (!doorLike) continue; - if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) continue; - - // Found a likely blocker on-path: hand off to existing door handler (which - // includes quest-lock detection, blacklisting, and recalculation). - if (handleDoorsWithTimeout(path, j, timeoutMs, attemptedDoorEdgesThisPass)) { - return true; - } - } - } - return false; - } - - private static boolean handleDoorException(TileObject object, String action) { - if (isInStrongholdOfSecurity()) { - return handleStrongholdOfSecurityAnswer(object, action); - } - return false; - } - - private static boolean isInStrongholdOfSecurity() { - List mapRegionIds = List.of(7505, 7504, 7760, 7503, 7759, 7758, 7757, 8013, 7756, 8012, 8017, 8530, 9297); - return mapRegionIds.contains(Rs2Player.getWorldLocation().getRegionID()); - } - - private static boolean handleStrongholdOfSecurityAnswer(TileObject object, String action) { - Rs2GameObject.interact(object, action); - boolean isInDialogue = Rs2Dialogue.sleepUntilInDialogue(); - - // Not all the doors ask questions, so only if dialogue is shown we will attempt to get the answer - if (!isInDialogue) return true; - - // Skip over first door dialogue & don't forget to set up two-factor warning - if (Rs2Dialogue.getDialogueText().toLowerCase().contains("two-factor authentication options") || Rs2Dialogue.getDialogueText().toLowerCase().contains("hopefully you will learn
much from us.")) { - Rs2Dialogue.sleepUntilHasContinue(); - sleepUntil(() -> !Rs2Dialogue.hasContinue() || Rs2Dialogue.getDialogueText().toLowerCase().contains("to pass you must answer me"), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - if (!Rs2Dialogue.isInDialogue()) return true; - } - - String dialogueAnswer = null; - int attempts = 0; - final int maxAttempts = 5; - - // We attempt to find the answer multiple times in-case there is dialogue that appears before the question - while (dialogueAnswer == null && attempts < maxAttempts) { - if (currentTarget == null) break; - dialogueAnswer = StrongholdAnswer.findAnswer(Rs2Dialogue.getDialogueText()); - if (dialogueAnswer == null) { - Rs2Dialogue.clickContinue(); - Rs2Random.waitEx(800, 100); - } - attempts++; - } - - if (dialogueAnswer != null) { - Rs2Dialogue.clickContinue(); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(dialogueAnswer); - Rs2Dialogue.sleepUntilHasContinue(); - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Player.waitForAnimation(1200); - return true; - } - - return false; - } - - /** - * Determines whether a given neighbor tile lies immediately adjacent to - * a reference tile, in the direction specified by a wall orientation code. - * - * @param orientation the wall orientation code: - *

    - *
  • 1 = west
  • - *
  • 2 = north
  • - *
  • 4 = east
  • - *
  • 8 = south
  • - *
  • 16 = northwest
  • - *
  • 32 = northeast
  • - *
  • 64 = southeast
  • - *
  • 128 = southwest
  • - *
- * @param point the reference {@link WorldPoint} representing the tile at the wall’s base - * @param neighbor the {@link WorldPoint} to test for adjacency - * @return {@code true} if {@code neighbor} is exactly one tile away from {@code point} - * in the direction indicated by {@code orientation}, {@code false} otherwise - */ - private static boolean searchNeighborPoint(int orientation, WorldPoint point, WorldPoint neighbor) { - int dx = neighbor.getX() - point.getX(); - int dy = neighbor.getY() - point.getY(); - - switch (orientation) { - case 1: // west - return dx == -1 && dy == 0; - case 2: // north - return dx == 0 && dy == 1; - case 4: // east - return dx == 1 && dy == 0; - case 8: // south - return dx == 0 && dy == -1; - case 16: // northwest - return dx == -1 && dy == 1; - case 32: // northeast - return dx == 1 && dy == 1; - case 64: // southeast - return dx == 1 && dy == -1; - case 128: // southwest - return dx == -1 && dy == -1; - default: - return false; - } - } - - /** - * @param path list of worldpoints - * @return closest tile index - */ - public static int getClosestTileIndex(List path) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); - } - - static int getClosestTileIndex(List path, WorldPoint playerLoc) { - return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); - } - - // 3-arg getClosestTileIndex (pure) moved to geometry/WalkerPathGeometry (P1) - - /** Step budget of {@link #getClosestIndexReachableTiles}'s BFS; also the route-blocked scan gate's bound. */ - private static final int CLOSEST_INDEX_REACHABLE_STEP_BUDGET = 20; - - private static HashMap getClosestIndexReachableTiles(WorldPoint playerLoc) { - if (playerLoc == null) { - return new HashMap<>(); - } - HashMap tiles = Rs2Tile.getReachableTilesFromTile(playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); - - // 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, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); - } - return tiles; - } - - static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { - if (path == null || path.isEmpty() || closestIdx < 0 || closestIdx >= path.size()) { - return closestIdx; - } - - WorldPoint pathStart = path.get(0); - WorldPoint pathEnd = path.get(path.size() - 1); - boolean routeChanged = routeState.routeProgressTarget == null - || !routeState.routeProgressTarget.equals(target) - || routeState.routeProgressPathSize != path.size() - || !Objects.equals(routeState.routeProgressPathStart, pathStart) - || !Objects.equals(routeState.routeProgressPathEnd, pathEnd) - || routeState.routeProgressIdx >= path.size(); - if (routeChanged) { - routeState.routeProgressTarget = target; - routeState.routeProgressPathStart = pathStart; - routeState.routeProgressPathEnd = pathEnd; - routeState.routeProgressPathSize = path.size(); - routeState.routeProgressIdx = closestIdx; - routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); - return closestIdx; - } - - if (routeState.routeProgressIdx < 0 || closestIdx >= routeState.routeProgressIdx) { - if (closestIdx > routeState.routeProgressIdx) { - recordRouteProgressAdvanced(); - } - routeState.routeProgressIdx = closestIdx; - return closestIdx; - } - - int forwardIdx = closestForwardPathIndex(path, routeState.routeProgressIdx, playerLoc); - if (forwardIdx >= routeState.routeProgressIdx) { - if (forwardIdx > routeState.routeProgressIdx) { - routeState.routeProgressIdx = forwardIdx; - recordRouteProgressAdvanced(); - } - return routeState.routeProgressIdx; - } - return routeState.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 = routeState.routeProgressTarget == null - || !routeState.routeProgressTarget.equals(target) - || routeState.routeProgressPathSize != path.size() - || !Objects.equals(routeState.routeProgressPathStart, pathStart) - || !Objects.equals(routeState.routeProgressPathEnd, pathEnd) - || routeState.routeProgressIdx >= path.size(); - if (routeChanged) { - routeState.routeProgressTarget = target; - routeState.routeProgressPathStart = pathStart; - routeState.routeProgressPathEnd = pathEnd; - routeState.routeProgressPathSize = path.size(); - routeState.routeProgressIdx = hintedIdx; - recordRouteProgressAdvanced(); - return; - } - - if (hintedIdx > routeState.routeProgressIdx) { - routeState.routeProgressIdx = hintedIdx; - recordRouteProgressAdvanced(); - } - } - - static int advanceIndexPastRecentTransportEdge(List path, int index, WorldPoint playerLoc) { - if (path == null || path.isEmpty() || index < 0 || index >= path.size() - || !isRecentTransportEdgeWindow()) { - return index; - } - WorldPoint origin = routeState.lastTransportOriginLocation; - WorldPoint destination = routeState.lastTransportDestinationLocation; - if (origin == null || destination == null || playerLoc == null - || playerLoc.getPlane() != destination.getPlane() - || playerLoc.distanceTo2D(destination) > 3) { - return index; - } - - int scanEndExclusive = Math.min(path.size(), index + 8); - int lastTransportEdgeIdx = -1; - for (int i = index; i < scanEndExclusive; i++) { - WorldPoint point = path.get(i); - if (isNearSamePlane(point, origin, 2) || isNearSamePlane(point, destination, 2)) { - lastTransportEdgeIdx = i; - } - } - if (lastTransportEdgeIdx >= index && lastTransportEdgeIdx + 1 < path.size()) { - return lastTransportEdgeIdx + 1; - } - return index; - } - - private static int closestForwardPathIndex(List path, int fromIdx, WorldPoint playerLoc) { - if (path == null || path.isEmpty() || playerLoc == null || fromIdx < 0 || fromIdx >= path.size()) { - return -1; - } - int bestIdx = -1; - int bestDist = Integer.MAX_VALUE; - int toIdxExclusive = Math.min(path.size(), fromIdx + ROUTE_PROGRESS_FORWARD_SEARCH_TILES + 1); - for (int i = fromIdx; i < toIdxExclusive; i++) { - WorldPoint point = path.get(i); - if (point == null || point.getPlane() != playerLoc.getPlane()) { - continue; - } - int dist = playerLoc.distanceTo2D(point); - if (dist < bestDist) { - bestIdx = i; - bestDist = dist; - } - } - return bestIdx; - } - - private static void resetRouteProgress() { - routeState.routeProgressIdx = -1; - routeState.routeProgressTarget = null; - routeState.routeProgressPathStart = null; - routeState.routeProgressPathEnd = null; - routeState.routeProgressPathSize = -1; - routeState.routeProgressAdvancedAtMs = 0L; - } - - private static void recordRouteProgressAdvanced() { - long now = System.currentTimeMillis(); - routeState.routeProgressAdvancedAtMs = now; - routeState.lastMovedTimeMs = now; - routeState.stuckCount = 0; - } - - private static boolean isRecentTransportEdgeWindow() { - long handledAt = routeState.lastTransportHandledAtMs; - if (handledAt <= 0L) { - return false; - } - long ageMs = System.currentTimeMillis() - handledAt; - return ageMs >= 0L && ageMs <= RECENT_TRANSPORT_EDGE_SUPPRESS_MS; - } - - private static boolean isNearSamePlane(WorldPoint a, WorldPoint b, int distance) { - return a != null - && b != null - && a.getPlane() == b.getPlane() - && a.distanceTo2D(b) <= distance; - } - - private static boolean isRecentTransportEdgeCandidate(WorldPoint objectLoc, WorldPoint from, WorldPoint to) { - if (!isRecentTransportEdgeWindow()) { - return false; - } - WorldPoint origin = routeState.lastTransportOriginLocation; - WorldPoint destination = routeState.lastTransportDestinationLocation; - if (origin == null || destination == null) { - return false; - } - boolean objectNearTransport = isNearSamePlane(objectLoc, origin, 2) - || isNearSamePlane(objectLoc, destination, 2); - boolean edgeMatchesTransport = (isNearSamePlane(from, origin, 2) && isNearSamePlane(to, destination, 2)) - || (isNearSamePlane(from, destination, 2) && isNearSamePlane(to, origin, 2)) - || (objectNearTransport - && (isNearSamePlane(from, origin, 2) - || isNearSamePlane(from, destination, 2) - || isNearSamePlane(to, origin, 2) - || isNearSamePlane(to, destination, 2))); - return objectNearTransport && edgeMatchesTransport; - } - - /** - * Force the walker to recalculate path - */ - public static void recalculatePath() { - WorldPoint goal = currentTarget; - if (goal == null) { - return; - } - // Must not call setTarget(null)+setTarget(goal): that briefly clears {@link #currentTarget}, - // and processWalk on another thread treats null as cancel (isWalkCancelled). - Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal); - } - - /** - * Updates world-map marker and restarts pathfinding for {@code target}. Does not assign - * {@link #currentTarget}; callers set it when appropriate. - */ - private static void applyWalkerDestination(WorldPoint target) { - Rs2WalkerLifecycleRuntime.applyWalkerDestination(target); - } - - /** - * @param target destination, or {@code null} to clear (prefer {@link #clearWalkingRoute(String)} for observability) - */ - public static void setTarget(WorldPoint target) { - setTarget(target, null); - } - - /** - * @param clearReasonWhenNull logged when {@code target} is {@code null}; omit only from tests or legacy paths. - * Clearing ({@code target == null}) runs without a {@link net.runelite.client.Client} - * (teardown-safe). Non-null destinations still require a live client and login/player checks. - */ - public static void setTarget(WorldPoint target, String clearReasonWhenNull) { - if (target != null && !Microbot.isLoggedIn()) { - log.warn("Unable to set target: not logged in"); - return; - } - if (target != null) { - Client client = Microbot.getClient(); - if (client == null) { - log.warn("Unable to set target: client unavailable"); - return; - } - Player localPlayer = client.getLocalPlayer(); - if (!Rs2PathApi.isStartPointSet() && localPlayer == null) { - log.warn("Start point is not set and player is null"); - return; - } - } - - currentTarget = target; - - if (target == null) { - // A completed/cancelled route owns its transport handoff context. Keeping the - // timestamp alive made an unrelated walk started within 15 seconds inherit - // post-transport handler suppression and misleading elapsed-time markers. - clearRecentTransportContext(); - resetRouteProgress(); - logRouteClear(clearReasonWhenNull); - synchronized (Rs2PathApi.getPathfinderMutex()) { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null) { - pathfinder.cancel(); - } - Future pathfinderFuture = Rs2PathApi.getPathfinderFuture(); - if (pathfinderFuture != null && !pathfinderFuture.isDone()) { - pathfinderFuture.cancel(true); - } - Rs2PathApi.setPathfinderFuture(null); - Rs2PathApi.setPathfinder(null); - } - - WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); - if (wmm != null) { - wmm.remove(Rs2PathApi.getMarker()); - } else if (Rs2LogRateLimit.once(WORLD_MAP_REMOVE_NULL_LOGGED)) { - log.debug("[Walker] WorldMapPointManager null during route clear — marker may linger until teardown"); - } - Rs2PathApi.setMarker(null); - Rs2PathApi.setStartPointSet(false); - } else { - applyWalkerDestination(target); - } - } - - private static void restoreTargetMarker(WorldPoint target) { - if (target == null || Rs2PathApi.getMarker() != null) { - return; - } - - try { - WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); - if (wmm == null) { - log.debug("[Walker] Cannot restore marker: WorldMapPointManager unavailable"); - return; - } - Rs2PathApi.setMarker(new WorldMapPoint(target, Rs2PathApi.MARKER_IMAGE)); - Rs2PathApi.getMarker().setName("Target"); - Rs2PathApi.getMarker().setTarget(Rs2PathApi.getMarker().getWorldPoint()); - Rs2PathApi.getMarker().setJumpOnClick(true); - wmm.add(Rs2PathApi.getMarker()); - log.info("[Walker] Restored missing path target marker at {}", target); - } catch (Exception ex) { - log.debug("[Walker] Failed to restore target marker at {}", target, ex); - } - } - - /** - * @param start - * @param end - */ - public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { - return Rs2WalkerLifecycleRuntime.restartPathfinding(start, end); - } - - public static boolean restartPathfinding(WorldPoint start, Set ends) { - return Rs2WalkerLifecycleRuntime.restartPathfinding(start, ends); - } - - /** - * @param point - * @return - */ - public static Tile getTile(WorldPoint point) { - LocalPoint a; - if (Microbot.getClient().getTopLevelWorldView().isInstance()) { - WorldPoint instancedWorldPoint = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), point).stream().findFirst().orElse(null); - if (instancedWorldPoint == null) { - log.error("getTile instancedWorldPoint is null"); - return null; - } - a = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), instancedWorldPoint); - } else { - a = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), point); - } - if (a == null) { - return null; - } - return Microbot.getClient().getTopLevelWorldView().getScene().getTiles()[point.getPlane()][a.getSceneX()][a.getSceneY()]; - } - - /** - * @param path - * @param indexOfStartPoint - * @return - */ - private static boolean handleTransports(List path, int indexOfStartPoint) { - if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 - && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { - return false; - } - Set transports = Rs2PathApi.getTransports().get(path.get(indexOfStartPoint)); - if (transports == null || transports.isEmpty()) { - return false; - } - if (log.isDebugEnabled()) { - log.debug("[Walker] handleTransports at {}: {} candidates — {}", path.get(indexOfStartPoint), - transports.size(), - transports.stream().map(Transport::getDisplayInfo).collect(Collectors.joining(", "))); - } - // When the player is inside a POH instance, the player's raw world-location plane is - // the instance-template plane and has no relationship to the POH-transport origin plane. - // Skip the plane guard in that case so POH transports can actually be considered. - boolean inPohInstance = Microbot.getClient().getTopLevelWorldView().getScene().isInstance() - && net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile() != null; - - // Pre-compute path point index map for O(1) lookups instead of repeated O(n) scans - Map pathFirstIndex = new HashMap<>(path.size()); - for (int idx = 0; idx < path.size(); idx++) { - pathFirstIndex.putIfAbsent(path.get(idx), idx); - } - - 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) { - worldPointCollections = Collections.singleton(null); - } else if (inPohInstance && transport.getType() == TransportType.POH) { - // POH fix: when the player is inside a POH instance, the transport's exit-portal - // origin is an overworld tile that doesn't map into the player's instance chunks, - // so toLocalInstance() returns an empty collection and the inner loop never runs. - // Pass the origin through directly so the per-i dispatch below can execute. - worldPointCollections = Collections.singleton(transport.getOrigin()); - } else { - worldPointCollections = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), transport.getOrigin()); - } - log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", - transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); - for (WorldPoint origin : worldPointCollections) { - WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); - if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null - && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { - continue; - } - - // Hoist path-constant checks out of the inner loop: destination must exist in path - if (!pathFirstIndex.containsKey(transport.getDestination())) { - log.debug("[Walker] skip {}: destination {} not in path", transport.getDisplayInfo(), transport.getDestination()); - continue; - } - // QUETZAL is not {@link TransportType#isTeleport} — without this, stall/off-path recalc can re-open the map and - // click the same landing repeatedly while already there (no movement → infinite stall loop). - if (transport.getType() == TransportType.QUETZAL) { - if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { - log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", - transport.getDisplayInfo(), OFFSET, transport.getDestination()); - continue; - } - } - if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { - if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { - log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); - continue; - } - } - - // Pre-compute origin/destination indices once per transport (not per inner iteration) - int precomputedIndexOfOrigin = -1; - int precomputedIndexOfDest = -1; - if (!TransportType.isTeleport(transport.getType(), transport.getOrigin())) { - Integer originIdx = pathFirstIndex.get(transport.getOrigin()); - Integer destIdx = pathFirstIndex.get(transport.getDestination()); - precomputedIndexOfOrigin = originIdx != null ? originIdx : -1; - precomputedIndexOfDest = destIdx != null ? destIdx : -1; - if (log.isDebugEnabled()) { - log.debug("[Walker] filter4 {}: indexOfOrigin={}, indexOfDestination={}, pathSize={}, originInPath={}, destInPath={}", - transport.getDisplayInfo(), precomputedIndexOfOrigin, precomputedIndexOfDest, path.size(), - precomputedIndexOfOrigin != -1, precomputedIndexOfDest != -1); - } - if (precomputedIndexOfDest == -1) continue; - if (precomputedIndexOfOrigin == -1) continue; - if (precomputedIndexOfDest < precomputedIndexOfOrigin) continue; - } - - for (int i = indexOfStartPoint; i < path.size(); i++) { - WorldPoint plPathLoop = Rs2Player.getWorldLocation(); - if (plPathLoop == null) { - // Cannot verify plane / dispatch — do not burn remaining path indices this tick. - break; - } - if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { - log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); - break; // plane won't change across iterations, so break instead of continue - } - - if (i == indexOfStartPoint) { - log.debug("[Walker] reached pre-dispatch for {}: i={}, path[i]={}, origin={}, equalsOrigin={}", - transport.getDisplayInfo(), i, path.get(i), origin, path.get(i).equals(origin)); - } - - if (path.get(i).equals(origin)) { - if (transport.getType() == TransportType.SHIP || transport.getType() == TransportType.NPC || transport.getType() == TransportType.BOAT) { - - Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); - - // Wrap with observation so Leagues blocked-region chat can attribute this attempt. - if (attemptObserved(transport, () -> npc != null && Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction()))) { - Rs2Player.waitForWalking(); - sleepUntil(Rs2Dialogue::isInDialogue,600*2); - - if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption("Can you take me somewhere?"); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - } - - if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - } - - if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")){ - sleepTickJitter(2); - Rs2Dialogue.clickContinue(); - } else if (Objects.equals(transport.getName(), "Mountain Guide")) { - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - } - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean shipNearDest = sleepUntil( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - SHIP_NPC_BOAT_LANDING_WAIT_MS); - if (!shipNearDest) { - WebWalkLog.spWarn( - "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", - SHIP_NPC_BOAT_LANDING_WAIT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - boolean reachedDestination = shipNearDest; - sleepTickJitter(6); - if (reachedDestination) { - return finishHandledTransport(transport); - } - } else { - WorldPoint originTile = path.get(i); - boolean clicked = Rs2Walker.walkFastCanvas(originTile); - if (!clicked) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc != null) { - clicked = walkMiniMapToward(originTile, playerLoc, 13); - } - } - if (!clicked) { - clicked = Rs2Walker.walkMiniMap(originTile); - } - if (!clicked) { - log.debug("[Walker] ship/npc/boat fallback click failed for {}", originTile); - } - sleep(1200, 1600); - } - } - - if (transport.getType() == TransportType.CHARTER_SHIP) { - if (attemptObserved(transport, () -> handleCharterShip(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean charterLanded = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!charterLanded) { - WebWalkLog.spWarn( - "charter ship post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - sleepTickJitter(4); // wait 4 extra ticks before walking - return finishHandledTransport(transport); - } - } - } - - log.debug("[Walker] Handling {} transport: {} (i={}, path[i]={}, origin={})", - transport.getType(), transport.getDisplayInfo(), i, path.get(i), origin); - if (transport.getType() == TransportType.POH) { - boolean pohResult = attemptObserved(transport, () -> handlePohTransport(transport)); - log.debug("[Walker] handlePohTransport({}) returned {}", transport.getDisplayInfo(), pohResult); - if (pohResult) { - // Shares ship/NPC/boat 10s landing budget — intentional single timeout constant. - boolean pohNearDest = sleepUntil( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - SHIP_NPC_BOAT_LANDING_WAIT_MS); - if (!pohNearDest) { - WebWalkLog.spWarn( - "POH post-travel wait timed out ({}ms) dest={} at={}", - SHIP_NPC_BOAT_LANDING_WAIT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - if (pohNearDest) { - return finishHandledTransport(transport); - } - } - } - - if (transport.getType() == TransportType.CANOE) { - if (attemptObserved(transport, () -> handleCanoe(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.SPIRIT_TREE) { - if (!Rs2PathApi.getPathfinderConfig().isUseSpiritTrees()) { - log.debug("[Walker] skip spirit tree transport — setting is off"); - continue; - } - if (attemptObserved(transport, () -> handleSpiritTree(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean spiritLanded = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!spiritLanded) { - WebWalkLog.spWarn( - "spirit tree post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - if (spiritLanded) { - return finishHandledTransport(transport); - } - } - } - - if (transport.getType() == TransportType.QUETZAL) { - if (attemptObserved(transport, () -> handleQuetzal(transport))) { - boolean landedNearDest = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!landedNearDest) { - WebWalkLog.spWarn( - "quetzal post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.MAGIC_CARPET) { - if (attemptObserved(transport, () -> handleMagicCarpet(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.WILDERNESS_OBELISK) { - if (attemptObserved(transport, () -> handleWildernessObelisk(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.GNOME_GLIDER) { - if (attemptObserved(transport, () -> handleGlider(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), - TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - sleepTickJitter(3); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.FAIRY_RING) { - WorldPoint plFairy = Rs2Player.getWorldLocation(); - WorldPoint tdFairy = transport.getDestination(); - boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); - if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_MINIGAME) { - if (attemptObserved(transport, () -> handleMinigameTeleport(transport))) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_ITEM) { - if (attemptObserved(transport, () -> handleTeleportItem(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_SPELL) { - if (attemptObserved(transport, () -> handleTeleportSpell(transport))) { - if (isLumbridgeHomeTeleport(transport)) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 600, 35000); - } else { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - } - Rs2Tab.switchTo(InterfaceTab.INVENTORY); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.SEASONAL_TRANSPORT) { - if (attemptObservedWithoutAttemptRecord(transport, () -> handleSeasonalTransport(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (isBarrowsDigTransport(transport)) { - return handleBarrowsDigTransport(transport); - } - - if (transport.getObjectId() <= 0) break; - - final int transportObjectId = transport.getObjectId(); - final String transportAction = transport.getAction(); - final List transportActions = getTransportActionOptions(transportAction); - // Climb-down transports have a closed-variant (trapdoor/manhole/grate/hatch) - // that shares the same tile but a different object ID. Infer the closed - // variant from ObjectComposition (any nearby object with an "Open" action - // and a matching name) rather than a hardcoded ID pair, so new variants - // work without a code change. - final boolean allowClosedVariant = "Climb-down".equalsIgnoreCase(transportAction) - || "Climb down".equalsIgnoreCase(transportAction); - - final boolean allowAlKharidTollGateVariant = isAlKharidTollGateObjectId(transportObjectId); - // The FIRST transport of a walk costs ~12.7s in the segment handler while the same - // transport mid-route costs ~1.8s, and the plane-change waits account for only - // ~1.5s of it (measured over three Falador castle runs). This scan runs once per - // CANDIDATE transport at the tile, and a staircase tile carries several rows, so - // the suspicion is N scans rather than one. Time it and say how many candidates - // were queued, so the next run distinguishes "one slow scan" from "many scans". - long objectScanStartedAt = System.currentTimeMillis(); - final Integer legacyClosedId = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); - // Id-only first: these are plain field reads, no composition resolution. - List matched = Rs2GameObject.getAll(o -> { - int id = o.getId(); - if (id == transportObjectId) return true; - if (allowAlKharidTollGateVariant && isAlKharidTollGateObjectId(id)) return true; - return legacyClosedId != null && id == legacyClosedId; - }, transport.getOrigin(), 10); - if (matched.isEmpty() && allowClosedVariant) { - // Only now pay for compositions, and only on the transport's own tile: a closed - // variant (trapdoor/manhole/grate/hatch) sits where the transport is, never ten - // tiles away. Previously this ran for EVERY object within 10 tiles whenever the - // action was Climb-down, one client-thread hop each — measured at 5.5-10.9 - // SECONDS for a single scan inside Falador castle, and the reason descending - // stairs was slow while ascending was not. - matched = Rs2GameObject.getAll(o -> { - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); - if (comp == null || comp.getActions() == null) return false; - String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); - boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") - || nm.contains("grate") || nm.contains("hatch"); - if (!nameMatches) return false; - return Arrays.stream(comp.getActions()).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase("Open")); - }, transport.getOrigin(), 2); - } - List objects = matched.stream() - .sorted(Comparator - .comparingInt((TileObject o) -> resolveTransportObjectAction(o, transportActions).isPresent() ? 0 : 1) - .thenComparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) - .collect(Collectors.toList()); - - long objectScanMs = System.currentTimeMillis() - objectScanStartedAt; - if (objectScanMs >= TRANSPORT_OBJECT_SCAN_SLOW_MS) { - WebWalkLog.spInfo("transport_object_scan | slow scanMs={} objectId={} candidatesAtTile={} matches={} origin={}", - objectScanMs, transportObjectId, orderedTransports.size(), objects.size(), - compactWorldPoint(transport.getOrigin())); - } - TileObject object = objects.stream().findFirst().orElse(null); - if (object instanceof GroundObject) { - object = objects.stream() - .filter(o -> !Objects.equals(o.getWorldLocation(), Rs2Player.getWorldLocation())) - .min(Comparator.comparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getOrigin())) - .thenComparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getDestination()))).orElse(null); - } - - if (object != null) { - // Skip reachability check for GroundObjects and Magic Mushtrees - if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { - if (!Rs2Tile.isTileReachable(transport.getOrigin())) { - break; - } - } - - // Closed variant detection: if the found object doesn't advertise the - // transport action but does advertise "Open", open it first and re-find - // the now-open object before invoking handleObject. - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); - if (comp != null && comp.getActions() != null) { - String[] actions = comp.getActions(); - boolean hasTransportAction = resolveTransportObjectAction(actions, transportActions).isPresent(); - boolean hasOpen = Arrays.stream(actions).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase("Open")); - if (!hasTransportAction && hasOpen) { - log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", - transport.getOrigin(), object.getId(), comp.getName(), transportAction); - final int closedId = object.getId(); - Rs2GameObject.interact(object, "Open"); - Rs2Player.waitForAnimation(2000); - TileObject reopened = Rs2GameObject.getAll(o -> { - if (o.getId() == closedId) return false; - ObjectComposition c = Rs2GameObject.convertToObjectComposition(o); - if (c == null || c.getActions() == null) return false; - return resolveTransportObjectAction(c.getActions(), transportActions).isPresent(); - }, transport.getOrigin(), 3).stream() - .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) - .orElse(null); - if (reopened != null) object = reopened; - } - } - - String interactionAction = resolveTransportObjectAction(object, transportActions) - .orElse(transportAction); - if (!Objects.equals(interactionAction, transportAction)) { - log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", - interactionAction, transportAction, object.getWorldLocation(), object.getId()); - } - prepareTransportObjectForInteraction(object); - if (!handleObject(transport, object, interactionAction)) { - return false; - } - sleepUntil(() -> !Rs2Player.isAnimating()); - WorldPoint destWait = transport.getDestination(); - int maxInclusive = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; - if (destWait == null) { - return false; - } - boolean landedAfterObject = waitForPostHandleObjectLanding(transport, destWait, maxInclusive); - if (!landedAfterObject) { - WorldPoint afterInteraction = Rs2Player.getWorldLocation(); - // Adjacent same-plane transports demand landing on the EXACT destination - // tile (maxInclusive == 0), and agility shortcuts routinely deposit the - // player a tile off it — so a crossing can physically succeed while this - // check still fails. Suppression previously ran only on the success path, - // which left the inverse transport immediately eligible: the walker - // crossed, took the same shortcut straight back, and stranded itself. If - // we are no longer on the origin we did cross, so suppress both tiles - // regardless of the landing verdict. The landing result itself is - // unchanged — this still returns false and replans. - if (isAdjacentSamePlaneTransport(transport) - && afterInteraction != null - && !afterInteraction.equals(transport.getOrigin())) { - markAdjacentSamePlaneTransportHandled(transport, object); - } - WebWalkLog.spWarn( - "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", - POST_HANDLE_OBJECT_LANDING_WAIT_MS, - compactWorldPoint(destWait), - compactWorldPoint(afterInteraction)); - } - if (landedAfterObject) { - markAdjacentSamePlaneTransportHandled(transport, object); - return finishHandledTransport(transport); - } - return false; - } - } - } - } - return false; - } - - private static boolean waitForPostHandleObjectLanding(Transport transport, - WorldPoint destWait, - 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; - } - if (!isAdjacentSamePlaneTransport(transport) - || System.currentTimeMillis() - waitStartedAt < POST_HANDLE_OBJECT_FAILED_SETTLE_MS) { - return false; - } - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null || destWait == null || playerLoc.getPlane() != destWait.getPlane() - || Rs2Player.isMoving() || Rs2Player.isAnimating()) { - return false; - } - 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) - && settledAwayFromOrigin) { - settledAwayFromAdjacentDestination.set(true); - return true; - } - 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())); - return false; - } - 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); - if (destinationDistance <= Math.max(1, maxInclusive) - && playerLoc.distanceTo2D(origin) > 0) { - return true; - } - if (transport.getType() != TransportType.AGILITY_SHORTCUT) { - return false; - } - - // Some adjacent shortcut catalogues describe a multi-object animation as one-tile - // hops. The Falador stepping stones, for example, can carry 3154 -> 3149 while the - // selected edge says 3154 -> 3153. Accept only a tightly bounded forward, collinear - // overshoot; sideways movement, reverse movement, and arbitrary teleports still fail. - int edgeX = destWait.getX() - origin.getX(); - int edgeY = destWait.getY() - origin.getY(); - int movedX = playerLoc.getX() - origin.getX(); - int movedY = playerLoc.getY() - origin.getY(); - int forwardProgress = movedX * edgeX + movedY * edgeY; - int lateralOffset = Math.abs(movedX * edgeY - movedY * edgeX); - return forwardProgress > 0 - && forwardProgress <= 6 - && lateralOffset <= 1; - } - - /** - * Handles the transportation process specifically for instances of PohTransport. - * Any Transport param that reaches this is assumed to be a PohTransport. - * - * @param transport the transport object to be checked and processed - * @return true if the transport is an instance of PohTransport and its transport method executes successfully, false otherwise - */ - private static boolean handlePohTransport(Transport transport) { - if(!(transport instanceof PohTransport)) { - throw new IllegalStateException("handlePohTransport should not be called for non-PohTransports"); - } - return ((PohTransport)transport).execute(); - } - - private static List getTransportActionOptions(String action) { - if (action == null || action.isBlank()) { - return Collections.emptyList(); - } - - List actions = new ArrayList<>(); - actions.add(action); - if ("Bottom-floor".equalsIgnoreCase(action)) { - actions.add("Climb-down"); - actions.add("Climb down"); - } else if ("Top-floor".equalsIgnoreCase(action)) { - actions.add("Climb-up"); - actions.add("Climb up"); - } - return actions; - } - - private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); - if (comp == null || comp.getActions() == null) { - return Optional.empty(); - } - return resolveTransportObjectAction(comp.getActions(), actionOptions); - } - - private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { - if (objectActions == null || actionOptions == null || actionOptions.isEmpty()) { - return Optional.empty(); - } - - for (String desired : actionOptions) { - for (String actual : objectActions) { - if (actual != null && desired.equalsIgnoreCase(Rs2UiHelper.stripColTags(actual))) { - return Optional.of(actual); - } - } - } - return Optional.empty(); - } - - private static void prepareTransportObjectForInteraction(TileObject tileObject) { - if (tileObject == null || tileObject.getLocalLocation() == null) { - return; - } - if (!Rs2Camera.isTileOnScreen(tileObject)) { - Rs2Camera.turnTo(tileObject); - sleepUntil(() -> Rs2Camera.isTileOnScreen(tileObject), 1200); - } - } - - private static boolean handleObject(Transport transport, TileObject tileObject) { - return handleObject(transport, tileObject, transport.getAction()); - } - - /** - * A transport may be gated on an item that its own vendor sells on the spot (the Shantay pass - * pattern: the gate wants a ticket, Shantay sells tickets two tiles away). The catalog rows in - * {@code purchasable_items.tsv} say which item, which vendor, and how close the vendor must be - * to the transport origin; the transports.tsv duplicate-row OR (item row + currency-twin row) - * already made the planner route through such transports for players holding only the coins. - * This pre-step completes the currency variant: buy the item before interacting. Free rows - * (e.g. a gate's exit direction) carry neither item nor currency requirements and never match. - * - *

Vendor interaction is by NPC id — a name lookup once partial-matched the nearer - * "Shantay Guard" (Actions=[Talk-to, null, Pass]) and the buy silently failed. - */ - private static void ensureRequiredItemBeforeTransport(Transport transport) { - PurchasableItemCatalog.PurchasableItem purchasable = PurchasableItemCatalog.forTransport(transport); - if (purchasable == null || Rs2Inventory.hasItem(purchasable.itemId)) { - return; - } - WebWalkLog.spInfo("purchasable_buy | item={} vendor={} action={} at={}", - purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction, - compactWorldPoint(Rs2Player.getWorldLocation())); - if (Rs2Npc.interact(purchasable.vendorNpcId, purchasable.vendorAction)) { - sleepUntil(() -> Rs2Inventory.hasItem(purchasable.itemId), 4000); - } - if (!Rs2Inventory.hasItem(purchasable.itemId)) { - WebWalkLog.spWarn("purchasable_buy failed | item={} vendor={} action={} — no item acquired", - purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction); - } - } - - private static boolean handleObject(Transport transport, TileObject tileObject, String action) { - ensureRequiredItemBeforeTransport(transport); - WorldPoint before = Rs2Player.getWorldLocation(); - Rs2GameObject.interact(tileObject, action); - if (handleObjectExceptions(transport, tileObject)) return true; - WorldPoint tdObj = transport.getDestination(); - WorldPoint plObj = Rs2Player.getWorldLocation(); - if (tdObj == null || plObj == null) { - return false; - } - if (tdObj.getPlane() == plObj.getPlane()) { - if (transport.getType() == TransportType.AGILITY_SHORTCUT) { - Rs2Player.waitForAnimation(); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return isPlayerWithinChebyshevInclusive(tdObj, 2) - || isSettledNearAdjacentSamePlaneLanding(transport, now, tdObj, 0); - }, 10000); - } else if (transport.getType() == TransportType.MINECART) { - if (interactWithAdventureLog(transport)) { - sleepTickJitter(2); // wait extra 2 game ticks before moving - } else { - sleepUntil(() -> Rs2Player.getPoseAnimation() == 2148, 5000); - sleepUntil(() -> Rs2Player.getPoseAnimation() != 2148, 10000); - } - } else if (transport.getType() == TransportType.TELEPORTATION_PORTAL) { - sleepTickJitter(2); // wait extra 2 game ticks before moving - } else { - Rs2Player.waitForWalking(); - Rs2Dialogue.clickOption("Yes please"); //shillo village cart - if (isAdjacentSamePlaneTransport(transport)) { - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.equals(transport.getDestination()) - || !now.equals(before) - || !Rs2Player.isMoving()); - }, 2000); - WorldPoint afterOpen = Rs2Player.getWorldLocation(); - if (afterOpen != null && !afterOpen.equals(transport.getDestination())) { - boolean clicked = walkMiniMap(transport.getDestination()); - if (!clicked) { - clicked = walkFastCanvas(transport.getDestination()); - } - if (clicked) { - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return now != null && td != null && now.equals(td); - }, 3000); - } - } - } - } - return true; - } else { - WorldPoint plZ = Rs2Player.getWorldLocation(); - if (plZ == null) { - return false; - } - int z = plZ.getPlane(); - // Instrumentation: the FIRST plane-change transport of a walk consistently costs ~9.5s - // while the same kind mid-route costs ~2.2s (measured across two Falador castle runs). - // The waits below bound at 1800 + 5000 + jitter, and a failed start returns false and is - // retried, so two attempts would explain it — but that is inference. These timings say - // which of start-detection, plane-detection or retry actually burns the seconds. - long planeChangeStartedAt = System.currentTimeMillis(); - boolean started = sleepUntil(() -> { - WorldPoint p = Rs2Player.getWorldLocation(); - return p != null && (p.getPlane() != z || Rs2Player.isMoving() || Rs2Player.isAnimating()); - }, 1800); - long startWaitMs = System.currentTimeMillis() - planeChangeStartedAt; - if (!started) { - WebWalkLog.spInfo("transport_plane_change | no_start startWaitMs={} obj={} action={} — returning for retry", - startWaitMs, tileObject.getId(), transport.getAction()); - return false; - } - WorldPoint plAfterStart = Rs2Player.getWorldLocation(); - boolean planeChanged = plAfterStart != null && plAfterStart.getPlane() != z - || sleepUntil(() -> { - WorldPoint p = Rs2Player.getWorldLocation(); - return p != null && p.getPlane() != z; - }, 5000); - long planeWaitMs = System.currentTimeMillis() - planeChangeStartedAt - startWaitMs; - if (planeChanged) { - // gaussRand is an unbounded Box-Muller draw, so mean 300 / dev 120 goes negative past - // ~2.5 sigma (about one call in 160) and Thread.sleep throws IllegalArgumentException, - // killing the whole walk. Seen live: "timeout value is negative" here aborted a - // Falador castle run into ShortestPathScript auto-retry 1/3. Clamping only removes the - // impossible tail — the jitter this sleep exists to provide is untouched. - sleep(Math.max(MIN_PLANE_CHANGE_SETTLE_MS, (int) Rs2Random.gaussRand(300.0, 120.0))); - } - WebWalkLog.spInfo("transport_plane_change | changed={} startWaitMs={} planeWaitMs={} totalMs={} obj={}", - planeChanged, startWaitMs, planeWaitMs, - System.currentTimeMillis() - planeChangeStartedAt, tileObject.getId()); - return planeChanged; - } - } - - private static boolean isAdjacentSamePlaneTransport(Transport transport) { - return transport != null - && transport.getOrigin() != null - && transport.getDestination() != null - && transport.getOrigin().getPlane() == transport.getDestination().getPlane() - && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; - } - - private static int[] mapSmoothedToRaw(List smoothed, List raw) { - if (smoothed == null || raw == null || smoothed.isEmpty() || raw.isEmpty()) { - return new int[0]; - } - int[] mapping = new int[smoothed.size()]; - int rawIdx = 0; - for (int si = 0; si < smoothed.size(); si++) { - WorldPoint sp = smoothed.get(si); - while (rawIdx < raw.size() && !raw.get(rawIdx).equals(sp)) { - rawIdx++; - } - mapping[si] = Math.min(rawIdx, raw.size() - 1); - } - return mapping; - } - - private static int rawEndForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, - List rawPath, List path) { - if (smoothedIdx + 1 < path.size() && smoothedIdx + 1 < smoothedToRaw.length) { - return smoothedToRaw[smoothedIdx + 1]; - } - return rawPath.size(); - } - - private static boolean handleDoorsInRawSegment(List rawPath, int rawFrom, int rawTo, - long timeoutMs, Map attempted, - Map reachableCache) { - WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; - long startedAt = System.currentTimeMillis(); - for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { - long elapsed = System.currentTimeMillis() - startedAt; - if (elapsed >= timeoutMs) { - return false; - } - if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) - && reachableCache.containsKey(rawPath.get(ri + 1)) - && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), - playerLoc, HANDLER_RANGE)) { - continue; - } - long remainingTimeoutMs = Math.max(1L, timeoutMs - elapsed); - if (handleDoorsWithTimeout(rawPath, ri, remainingTimeoutMs, attempted)) { - return true; - } - if (isDoorInteractionSettling()) { - return false; - } - } - return false; - } - - - private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { - return handleTransportsInRawSegment(rawPath, rawFrom, rawTo, false); - } - - /** - * Dispatches a planned transport on this raw segment. - *

- * This is the path that actually takes stairs and ladders on a normal walk — the raw scene scan's - * ranged branch rarely gets there first, because the route click puts the player on the origin - * before the scan runs. So gating only the scan left the walker still walking its four tiles to - * the foot of the stairs before clicking, which is exactly what interact-at-range was meant to - * stop. {@code allowRangedDispatch} lets the caller say "this is the nearest obstacle", and route - * order is then held inside the loop: a transport passed over denies the ranged branch to - * everything behind it. - */ - private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo, - boolean allowRangedDispatch) { - Boolean inInstance = null; - boolean sawUndispatchedTransportStep = false; - for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (isRawTransportOriginNearPlayer( - rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { - if (handleTransports(rawPath, ri)) { - return true; - } - if (hasExplicitTransportStep(rawPath, ri)) { - sawUndispatchedTransportStep = true; - } - continue; - } - if (!hasExplicitTransportStep(rawPath, ri)) { - continue; - } - if (!allowRangedDispatch || sawUndispatchedTransportStep) { - sawUndispatchedTransportStep = true; - continue; - } - WorldPoint origin = rawPath.get(ri); - WorldPoint dest = rawPath.get(ri + 1); - int originDistance = playerLoc != null && origin != null - && origin.getPlane() == playerLoc.getPlane() - ? origin.distanceTo2D(playerLoc) - : -1; - if (inInstance == null) { - inInstance = Microbot.getClientThread() - .runOnClientThreadOptional(() -> Microbot.getClient().getTopLevelWorldView().isInstance()) - .orElse(Boolean.TRUE); - } - boolean allowed = shouldDispatchTransportAtRange( - originDistance, - RAW_TRANSPORT_DISPATCH_MAX_DISTANCE, - HANDLER_RANGE, - true, - isObjectInteractionTransportStep(rawPath, ri), - inInstance, - isDoorInteractionSettling() || isTransportInteractionSettling(), - rangedTransportEdgeFailedRecently(origin, dest), - rangedTransportDispatchEnabled()); - if (!allowed) { - sawUndispatchedTransportStep = true; - continue; - } - WebWalkLog.spInfo("ranged_transport_dispatch | origin={} dist={} — clicking from range, server walks us", - compactWorldPoint(origin), originDistance); - WorldPoint before = Rs2Player.getWorldLocation(); - if (handleTransports(rawPath, ri)) { - if (didCurrentTileTransportProgress(before, dest, currentTarget)) { - return true; - } - markRangedTransportEdgeFailed(origin, dest); - } - sawUndispatchedTransportStep = true; - } - return false; - } - - /** - * Whether a planned transport may be interacted with from RANGE instead of stepping onto its - * origin tile first. - *

- * Clicking an object makes the SERVER path the player to a valid interaction tile and perform the - * action; it owns the collision data, so it is strictly better at choosing that tile than any - * approach heuristic of ours. The walker already spots obstacles {@code HANDLER_RANGE} tiles out - * but would only act within {@link #RAW_TRANSPORT_DISPATCH_MAX_DISTANCE}, so it walked to a tile - * it had guessed at and only then clicked — and the guess is what failed at the Black Knights' - * ladder, the Falador castle staircase and the guarded door, never the interaction itself. - *

- * ROUTE ORDER is the one thing this must not break: clicking a door twelve tiles ahead when a - * closed gate sits between walks the player into the gate. Only the FIRST unresolved obstacle on - * the route may be actioned at range, which {@code firstObstacleOnRoute} carries. - * - * @param originDistance tiles from the player to the transport origin - * @param maxNearDistance the legacy on-the-origin band; always dispatchable, unchanged - * @param maxRangedDistance furthest the ranged branch may reach (the scan's handler range) - * @param firstObstacleOnRoute no earlier unresolved obstacle sits between player and origin - * @param objectInteractionTransport the row is handled by the generic object click, not a - * dialogue/widget flow that gains nothing from this - * @param inInstance instances keep the legacy band: raw coords make "on route" unreliable - * @param settling a door/transport settle window is still open - * @param rangedAttemptFailedRecently a previous ranged attempt on this edge produced no movement - * @param enabled config kill switch - */ - static boolean shouldDispatchTransportAtRange(int originDistance, - int maxNearDistance, - int maxRangedDistance, - boolean firstObstacleOnRoute, - boolean objectInteractionTransport, - boolean inInstance, - boolean settling, - boolean rangedAttemptFailedRecently, - boolean enabled) { - if (originDistance < 0) { - return false; - } - if (originDistance <= maxNearDistance) { - return true; // legacy behaviour, untouched - } - return enabled - && !inInstance - && !settling - && !rangedAttemptFailedRecently - && objectInteractionTransport - && firstObstacleOnRoute - && originDistance <= maxRangedDistance; - } - - static boolean isRawTransportOriginNearPlayer(List rawPath, - int transportIndex, - WorldPoint playerLoc, - int maxDistance) { - if (rawPath == null || playerLoc == null - || transportIndex < 0 || transportIndex >= rawPath.size() - 1) { - return false; - } - WorldPoint routeOrigin = rawPath.get(transportIndex); - return isTransportOriginNearPlayer(routeOrigin, playerLoc, maxDistance); - } - - /** - * True when every transport planned at {@code rawPath[index]} is one the generic object click - * handles (doors, stairs, ladders, gates). Dialogue and widget flows — boats, canoes, gliders, - * fairy rings, minecarts, teleports — are excluded: the server will not walk the player into a - * conversation, so ranged dispatch buys them nothing and risks firing them early. Agility - * shortcuts are excluded too; they need the exact origin tile (the stepping-stone case). - */ - private static boolean isObjectInteractionTransportStep(List rawPath, int index) { - if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { - return false; - } - Set transports = Rs2PathApi.getTransports().get(rawPath.get(index)); - if (transports == null || transports.isEmpty()) { - return false; - } - WorldPoint next = rawPath.get(index + 1); - return transports.stream() - .filter(t -> t != null && Objects.equals(t.getDestination(), next)) - .anyMatch(t -> t.getType() == TransportType.TRANSPORT); - } - - /** Config kill switch for ranged transport dispatch; on when the config is unavailable. */ - private static boolean rangedTransportDispatchEnabled() { - return config == null || config.interactWithRouteObstaclesAtRange(); - } - - /** Same switch, for opening the nearest route door without waiting out the approach walk. */ - private static boolean doorInteractionWhileApproachingEnabled() { - return rangedTransportDispatchEnabled(); - } - - /** - * Whether a door interaction must wait because the player is moving. - *

- * Relaxing only the caller-side gate was not enough: the interaction sites carry their own - * {@code isMoving()} checks, so the handler ran during the approach and then declined anyway. - *

- * Scoping the permission to the segment loop was ALSO not enough — door handling is reached from - * the recovery path and the raw scene scan as well, and a Falador castle run on the fixed build - * still logged {@code door_interact_deferred | reason=moving mode=segment-door} from the - * reachability-miss recovery. Those entry points each act on the door blocking the route RIGHT - * NOW, so there is no ordering left to protect at this level: the only question here is whether - * the walker is allowed to interrupt its own walk, which is exactly what the feature is for. - * Route ordering is enforced where it belongs — the segment loop, which iterates many segments - * and still only lets the nearest one act while moving. - */ - private static boolean doorInteractionDeferredForMovement(WorldPoint doorTile) { - if (!Rs2Player.isMoving()) { - return false; - } - if (!doorInteractionWhileApproachingEnabled()) { - return true; - } - // While MOVING, only act on a door we are practically standing at. The probe searches ten - // tiles, which was harmless while interaction required standing still — arriving implied - // proximity. Acting mid-walk removed that implication, and the walker opened the door at - // (2985,3341) from nine tiles out while (2981,3340) was still shut in front of it: the - // interaction timed out against the closed near door, the player drifted backwards, and the - // walk lost ~15s to recovery clicks before the real blocker was handled. - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - return playerLoc == null - || doorTile == null - || doorTile.getPlane() != playerLoc.getPlane() - || doorTile.distanceTo2D(playerLoc) > DOOR_APPROACH_INTERACT_MAX_TILES; - } - - /** Ranged dispatch attempts that produced no movement, keyed by origin→destination edge. */ - private static final Map failedRangedTransportEdges = new ConcurrentHashMap<>(); - private static final long RANGED_TRANSPORT_RETRY_COOLDOWN_MS = 30_000L; - - private static String rangedTransportEdgeKey(WorldPoint from, WorldPoint to) { - return compactWorldPoint(from) + ">" + compactWorldPoint(to); - } - - private static boolean rangedTransportEdgeFailedRecently(WorldPoint from, WorldPoint to) { - Long at = failedRangedTransportEdges.get(rangedTransportEdgeKey(from, to)); - return at != null && System.currentTimeMillis() - at < RANGED_TRANSPORT_RETRY_COOLDOWN_MS; - } - - /** - * Records that a ranged attempt on this edge produced nothing, so the walker falls back to - * walking onto the origin for it. That is the unreachable case — the server declined to path — - * and it must degrade to the legacy behaviour rather than re-click from range forever. - */ - private static void markRangedTransportEdgeFailed(WorldPoint from, WorldPoint to) { - failedRangedTransportEdges.put(rangedTransportEdgeKey(from, to), System.currentTimeMillis()); - WebWalkLog.spInfo("ranged_transport_no_progress | {} -> {} — falling back to walking onto the origin", - compactWorldPoint(from), compactWorldPoint(to)); - } - - private static boolean isTransportOriginNearPlayer(WorldPoint routeOrigin, - WorldPoint playerLoc, - int maxDistance) { - return routeOrigin != null - && playerLoc != null - && routeOrigin.getPlane() == playerLoc.getPlane() - && routeOrigin.distanceTo2D(playerLoc) <= Math.max(0, maxDistance); - } - - private static boolean finishHandledTransport(Transport transport) { - long handoffStartedAt = System.currentTimeMillis(); - routeState.lastTransportHandledAtMs = handoffStartedAt; - routeState.lastTransportHandledAtLocation = Rs2Player.getWorldLocation(); - routeState.lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; - routeState.lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; - WorldPoint goal = currentTarget; - WorldPoint transportDest = transport != null ? transport.getDestination() : null; - boolean expectedTransport = consumeExpectedTransportDestination(transportDest); - boolean hasPrecomputedContinuation = hasPrecomputedContinuationFromTransport(transport); - if (goal != null) { - WebWalkLog.tmark("transport_handoff_enter", - 0L, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest) - + " expected=" + expectedTransport - + " precomputed=" + hasPrecomputedContinuation - + " type=" + (transport != null ? transport.getType() : "null")); - } - if ((expectedTransport || hasPrecomputedContinuation) && goal != null) { - WebWalkLog.tmark(expectedTransport ? "transport_handoff_expected_hit" : "transport_handoff_precomputed_hit", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - return true; - } - if (goal != null && transportDest != null) { - // Destination-aware handoff: prepare next path from known landing tile. - boolean queued = restartPathfinding(transportDest, goal); - WebWalkLog.tmark("transport_handoff_restart", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "queued=" + queued + " dest=" + compactWorldPoint(transportDest)); - if (!queued && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_fallback", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - } else if (goal != null && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_goal_only", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - return true; - } - - private static void primeExpectedTransportDestinations(List path, int startIdx) { - if (path == null || path.size() < 2) { - synchronized (expectedTransportDestinations) { - expectedTransportDestinations.clear(); - } - return; - } - int start = Math.max(0, startIdx); - java.util.Deque next = new ArrayDeque<>(); - WorldPoint lastAdded = null; - for (int i = start; i < path.size() - 1; i++) { - if (!isCatalogBackedTransportSegment(path, i)) { - continue; - } - WorldPoint destination = path.get(i + 1); - if (destination == null) { - continue; + } + if (shouldThrottleGlobalDoorInteraction(bestFrom, bestTo)) { + WebWalkLog.spInfo("door_global_await | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + return false; + } + markDoorAttempt(bestLoc, bestFrom, bestTo); + markGlobalDoorInteractionCooldown(); + WorldPoint posBefore = Rs2Player.getWorldLocation(); + boolean interacted; + try { + if (bestAction == null || bestAction.isEmpty()) { + interacted = Rs2GameObject.interact(best); + } else { + interacted = Rs2GameObject.interact(best, bestAction); + } + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=path-adj probe={} from={} to={} ex={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo), ex.getClass().getSimpleName()); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } } - if (lastAdded == null || !lastAdded.equals(destination)) { - next.addLast(destination); - lastAdded = destination; + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } } - } - synchronized (expectedTransportDestinations) { - expectedTransportDestinations.clear(); - expectedTransportDestinations.addAll(next); - } - } - - private static boolean consumeExpectedTransportDestination(WorldPoint destination) { - if (destination == null) { - return false; - } - synchronized (expectedTransportDestinations) { - while (!expectedTransportDestinations.isEmpty()) { - WorldPoint expected = expectedTransportDestinations.peekFirst(); - if (expected == null) { - expectedTransportDestinations.pollFirst(); - continue; + return false; + } + markDoorInteractionSettling(bestTo); + waitForDoorInteractionProgress(bestFrom, bestTo); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, bestLoc, bestFrom, bestTo); + if (traversed) { + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); } - if (sameOrNearTransportDestination(expected, destination)) { - expectedTransportDestinations.pollFirst(); - return true; + } + return true; + } + boolean wrongTraversal = bestLoc != null && shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, bestFrom, bestTo, Rs2Player.isMoving()); + if (wrongTraversal) { + log.warn("[Walker] Path-adj door traversed wrong way; not session-blacklisting fallback candidate: door={} from={} to={} before={} after={}", + bestLoc, bestFrom, bestTo, posBefore, posAfter); + } else { + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); } - break; } - return false; } - } - - private static boolean sameOrNearTransportDestination(WorldPoint a, WorldPoint b) { - return a != null - && b != null - && a.getPlane() == b.getPlane() - && a.distanceTo2D(b) <= TRANSPORT_DEST_MATCH_CHEBYSHEV; - } + log.debug("[Walker] path-adj blocker-scan interact did not traverse (at={} from={} to={} before={} after={})", + bestLoc, bestFrom, bestTo, posBefore, posAfter); + // Interaction was sent and awaited; yield this pass so unreachable recovery + // does not immediately fire a minimap click while door traversal settles. + return true; + } - private static boolean hasPrecomputedContinuationFromTransport(Transport transport) { - if (transport == null || transport.getDestination() == null) { - return false; - } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null || !pathfinder.isDone()) { - return false; - } - List walkPath = pathfinder.getWalkablePath(); - if (walkPath == null || walkPath.size() < 2) { - return false; - } - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - int closest = getClosestTileIndex(walkPath, playerLoc); - if (closest < 0) { - return false; - } - WorldPoint destination = transport.getDestination(); - for (int i = Math.max(0, closest - 2); i < walkPath.size(); i++) { - WorldPoint point = walkPath.get(i); - if (sameOrNearTransportDestination(point, destination)) { - return i < walkPath.size() - 1; - } + private static void mergePathAdjCandidate( + Map byIdentity, + TileObject object, + WorldPoint location, + String action, + int actionPriority, + int edgeIdx, + WorldPoint from, + WorldPoint to, + int edgeDist) { + if (object == null || location == null) { + return; } - return false; - } - - static boolean shouldRecalculatePathAfterTransport(Transport transport) { - if (transport == null || transport.getDestination() == null) { - return false; + if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { + return; } - if (TransportType.isTeleport(transport.getType())) { - return true; + String identity = object.getClass().getSimpleName() + "|" + object.getId() + "|" + + location.getX() + "," + location.getY() + "," + location.getPlane(); + String familyKey = normalizePathAdjFamilyKey(object, action); + PathAdjDoorCandidate incoming = new PathAdjDoorCandidate( + object, + location, + action == null ? "" : action, + actionPriority, + edgeIdx, + from, + to, + edgeDist, + familyKey); + PathAdjDoorCandidate existing = byIdentity.get(identity); + if (existing == null) { + byIdentity.put(identity, incoming); + return; } - if (transport.getOrigin() == null) { - return false; + if (incoming.edgeIdx < existing.edgeIdx + || (incoming.edgeIdx == existing.edgeIdx && incoming.edgeDist < existing.edgeDist)) { + byIdentity.put(identity, incoming); } - return transport.getOrigin().getPlane() != transport.getDestination().getPlane() - || transport.getOrigin().distanceTo2D(transport.getDestination()) > OFFSET; } - private static void markAdjacentSamePlaneTransportHandled(Transport transport, TileObject tileObject) { - for (WorldPoint point : adjacentSamePlaneTransportSuppressionPoints(transport, tileObject)) { - markStationaryDoorOpened(point); - } - } - static Set adjacentSamePlaneTransportSuppressionPoints(Transport transport, TileObject tileObject) { - if (!isAdjacentSamePlaneTransport(transport)) { - return Collections.emptySet(); - } - Set points = new LinkedHashSet<>(); - points.add(transport.getOrigin()); - points.add(transport.getDestination()); - if (tileObject != null && tileObject.getWorldLocation() != null) { - points.add(tileObject.getWorldLocation()); - } - 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); - } + static final class PathAdjDoorCandidate { + PathAdjDoorCandidate(TileObject object, WorldPoint location, String action, int actionPriority, + int edgeIdx, WorldPoint from, WorldPoint to, int edgeDist, String familyKey) { + this.object = object; + this.location = location; + this.action = action; + this.actionPriority = actionPriority; + this.edgeIdx = edgeIdx; + this.from = from; + this.to = to; + this.edgeDist = edgeDist; + this.familyKey = familyKey; + } + final TileObject object; + final WorldPoint location; + final String action; + final int actionPriority; + final int edgeIdx; + final WorldPoint from; + final WorldPoint to; + final int edgeDist; + final String familyKey; - 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"); + static final class PathAdjDoorComponent { + PathAdjDoorComponent(PathAdjDoorCandidate best, int score, Set locations) { + this.best = best; + this.score = score; + this.locations = locations; } + final PathAdjDoorCandidate best; + final int score; + final Set locations; - 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(); - final int openTrapdoorId = entry.getValue(); - if (transport.getObjectId() == openTrapdoorId) { - if (tileObject.getId() == closedTrapdoorId) { - Rs2GameObject.interact(tileObject, "Open"); - sleepUntil(() -> Rs2GameObject.exists(openTrapdoorId)); - TileObject openTrapdoor = Rs2GameObject.getAll(o -> o.getId() == openTrapdoorId, tileObject.getWorldLocation(), 10).stream().findFirst().orElse(null); - if (openTrapdoor != null) { - Rs2GameObject.interact(openTrapdoor, transport.getAction()); - } - } else if (tileObject.getId() == openTrapdoorId) { - Rs2GameObject.interact(tileObject, transport.getAction()); - } - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean trapdoorLanded = sleepUntilTrue( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!trapdoorLanded) { - WebWalkLog.spWarn( - "trapdoor post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - return true; - } - } + /** + * Predict blockers on the path by probing the next few path edges for door/gate-like + * objects (including diagonal corners). If any probe tile contains a door-like object + * within {@code radiusTiles} of the player, run door handling with a bounded wait. + */ + private static boolean tryHandleBlockingPathObjectsWithTimeout( + List path, + int startIdx, + int radiusTiles, + int maxEdges, + long timeoutMs) + { + if (path == null || path.size() < 2) return false; + if (startIdx < 0) return false; + final WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) return false; - if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { - return handleAlKharidTollGate(transport); - } + int start = Math.min(startIdx, path.size() - 2); + int edgesChecked = 0; + for (int j = start; j < path.size() - 1 && edgesChecked < maxEdges; j++, edgesChecked++) { + WorldPoint from = path.get(j); + WorldPoint to = path.get(j + 1); + if (from == null || to == null) continue; + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) break; - //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(); - Rs2Player.waitForAnimation(); - return true; - } - // Handle Leaves Traps in Isafdar Forest - if (tileObject.getId() == ObjectID.REGICIDE_PITFALL_SIDE) { - Rs2Player.waitForAnimation(1200); - if (Rs2Player.getWorldLocation().getY() > 6400) { - Rs2GameObject.interact(ObjectID.REGICIDE_TRAP_HAND_HOLDS); - sleepUntil(() -> Rs2Player.getWorldLocation().getY() < 6400); - } else { - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating()); - } - return true; - } - // Handle Ferox Encalve Barrier - if (tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER || tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER_M) { - if (Rs2Dialogue.isInDialogue()) { - if (Rs2Dialogue.getDialogueText().toLowerCase().contains("when returning to the enclave")) { - Rs2Dialogue.clickContinue(); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.keyPressForDialogueOption("Yes, and don't ask again."); - Rs2Dialogue.sleepUntilNotInDialogue(); - return true; - } - } - } - // Handle Cobwebs blocking path - if (tileObject.getId() == ObjectID.BIGWEB_SLASHABLE && !Rs2Equipment.isWearing(ItemID.ARANEA_BOOTS)) { - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating(1200)); - final WorldPoint webLocation = tileObject.getWorldLocation(); - final WorldPoint currentPlayerPoint = Rs2Player.getWorldLocation(); - boolean doesWebStillExist = Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isPresent(); - if (doesWebStillExist) { - sleepUntil(() -> Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isEmpty(), - () -> { - Rs2GameObject.interact(tileObject, "slash"); - Rs2Player.waitForAnimation(); - }, 8000, 1200); - } - Rs2Walker.walkFastCanvas(transport.getDestination()); - return sleepUntil(() -> !Objects.equals(currentPlayerPoint, Rs2Player.getWorldLocation())); - } - - // Handle Brimhaven Dungeon Entrance - if (tileObject.getId() == 20877) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Rs2Dialogue.sleepUntilHasQuestion("Pay 875 coins to enter?"); - Rs2Dialogue.clickOption("Yes"); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return now != null && td != null && now.equals(td); - }); - return true; - } - // Handle Brimhaven Dungeon Stepping Stones - if (tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE1 || tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE2) { - Rs2Player.waitForAnimation(600 * 7); - return true; - } + // Only bother probing edges near the player; far edges are not loaded in scene. + if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { + break; + } - // Handle Morte Myre Cave Agility Shortcut - if (tileObject.getId() == ObjectID.FAIRY2_ROUTE_CAVEWALLTUNNEL) { - Rs2Player.waitForAnimation((600 * 4 ) + 300); - return true; - } + boolean diagonal = from.getX() != to.getX() && from.getY() != to.getY(); + List probes = new ArrayList<>(); + probes.add(from); + probes.add(to); + if (diagonal) { + probes.add(new WorldPoint(to.getX(), from.getY(), from.getPlane())); + probes.add(new WorldPoint(from.getX(), to.getY(), from.getPlane())); + } - // Handle Crash Site Cavern Gate - if (tileObject.getId() == 28807 && transport.getOrigin().equals(new WorldPoint(2435,3519, 0))) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("yes"); - return true; - } + for (WorldPoint probe : probes) { + if (probe == null) continue; + if (probe.getPlane() != playerLoc.getPlane()) continue; + if (probe.distanceTo2D(playerLoc) > radiusTiles) continue; - // Handle Cave Entrance inside of Asgarnia Ice Caves - if (tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_EAST || tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_WEST) { - Rs2Player.waitForAnimation(); - } + WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); + TileObject object = (wall != null) + ? wall + : Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); + if (object == null) continue; - // Handle Rev Cave Dialogue - if (tileObject.getId() == ObjectID.WILD_CAVE_ENTRANCE_LOW) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Widget dialogueSprite = Rs2Dialogue.getDialogueSprite(); - if (dialogueSprite != null && dialogueSprite.getItemId() == 1004) { - Rs2Dialogue.clickContinue(); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption("Yes, don't ask again"); - Rs2Dialogue.sleepUntilNotInDialogue(); - } - return true; - } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; - if (tileObject.getId() == ObjectID.HEROROCKSLIDE) { - Rs2Player.waitForAnimation(600 * 4); - return true; - } + // Gate by "door-like" name or by having a known door-like action. + String action = Arrays.stream(comp.getActions()) + .filter(Objects::nonNull) + .filter(act -> !Rs2DoorClassifier.isDoorCloseOrShutAction(act)) + .filter(act -> Rs2DoorClassifier.doorActionPriorityIndex(act) < Integer.MAX_VALUE) + .min(Comparator.comparingInt(Rs2DoorClassifier::doorActionPriorityIndex)) + .orElse(null); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) continue; + + // Found a likely blocker on-path: hand off to existing door handler (which + // includes quest-lock detection, blacklisting, and recalculation). + if (handleDoorsWithTimeoutBudgeted(path, j, timeoutMs, false)) { + return true; + } + } + } + return false; + } - if (Rs2GameObject.getObjectIdsByName("Fossil_Rowboat").contains(tileObject.getId())) { - if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; - char option = transport.getDisplayInfo().charAt(0); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Keyboard.keyPress(option); - sleepUntil(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 10000); - return true; - } - // Handle door/gate near wilderness agility course - if (tileObject.getId() == ObjectID.BALANCEGATE52A || tileObject.getId() == ObjectID.BALANCEGATE52B_RIGHT || tileObject.getId() == ObjectID.BALANCEGATE52B_LEFT) { - Rs2Player.waitForAnimation(600 * 4); - return true; - } - if (tileObject.getId() == ObjectID.AERIAL_FISHING_BOAT) { - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(transport.getDisplayInfo(), true); - sleepUntil(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 10000); - return true; - } - // Handle Magic Mushtree (Fossil Island Mycelium Transportation System) - if (MagicMushtree.isMagicMushtree(tileObject)) { - return MagicMushtree.handleTransport(transport); - } - return false; + /** + * @param path list of worldpoints + * @return closest tile index + */ + public static int getClosestTileIndex(List path) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); } - private static boolean handleWildernessObelisk(Transport transport) { - GameObject obelisk = Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()); - - if (obelisk != null) { - Rs2GameObject.interact(obelisk, transport.getAction()); - sleepUntil(() -> Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()) != null); - walkFastCanvas(transport.getOrigin()); - return sleepUntilTrue(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 100, 10000); - } - return false; + static int getClosestTileIndex(List path, WorldPoint playerLoc) { + return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, getClosestIndexReachableTiles(playerLoc)); } - private static boolean handleTeleportSpell(Transport transport) { - if (Rs2Pvp.isInWilderness() && (Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()) > (transport.getMaxWildernessLevel() + 1))) return false; - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - - String spellName = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo().toLowerCase(); - - String option = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[1].trim().toLowerCase() - : "cast"; + // 3-arg getClosestTileIndex (pure) moved to geometry/WalkerPathGeometry (P1) - int identifier = hasMultipleDestination - ? 2 - : 1; + /** Step budget of {@link #getClosestIndexReachableTiles}'s BFS; also the route-blocked scan gate's bound. */ + static final int CLOSEST_INDEX_REACHABLE_STEP_BUDGET = 20; - MagicAction magicSpell = Arrays.stream(MagicAction.values()).filter(x -> x.getName().toLowerCase().contains(spellName)).findFirst().orElse(null); - if (magicSpell != null) { - if (magicSpell == MagicAction.LUMBRIDGE_HOME_TELEPORT) { - return Rs2Magic.quickCast(magicSpell); - } - return Rs2Magic.cast(magicSpell, option, identifier); + /** + * Calls and milliseconds spent in the player-origin BFS since the current walk started. + * + *

Every {@code getClosestTileIndex} runs one of these, and the walk loop asks for a route + * index many times per iteration — route progress, interim tracking, near-path checks, click + * selection, each recovery probe. Each one is a fresh breadth-first search executed on the CLIENT + * thread, so the cost is a round trip, not arithmetic, and it does not show up in any existing + * timing line. A walk that goes silent for seconds with no heartbeat is blocked inside something, + * and this is the leading candidate; these two numbers ride on the heartbeat so the next log + * settles it instead of another round of inference. + */ + private static final AtomicInteger reachableBfsCalls = new AtomicInteger(); + private static final AtomicLong reachableBfsMillis = new AtomicLong(); + + static HashMap getClosestIndexReachableTiles(WorldPoint playerLoc) { + if (playerLoc == null) { + return new HashMap<>(); } - return false; - } - - private static boolean isLumbridgeHomeTeleport(Transport transport) { - return transport.getDisplayInfo() != null - && transport.getDisplayInfo().toLowerCase().startsWith("lumbridge home teleport"); - } - - private static boolean handleTeleportItem(Transport transport) { - WorldPoint plWild = Rs2Player.getWorldLocation(); - if (Rs2Pvp.isInWilderness() && plWild != null - && Rs2Pvp.getWildernessLevelFrom(plWild) > (transport.getMaxWildernessLevel() + 1)) { - return false; + HashMap tiles; + long bfsStartedAt = System.currentTimeMillis(); + reachableBfsCalls.incrementAndGet(); + try { + tiles = Rs2Tile.getReachableTilesFromTile( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + } catch (RuntimeException failure) { + if (!isClientThreadReadTimeout(failure)) { + throw failure; + } + reachableBfsMillis.addAndGet(System.currentTimeMillis() - bfsStartedAt); + WebWalkLog.spInfo("client_thread_timeout_fallback | op=closest_route_index"); + return nearbyTilesIgnoringCollision( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); } - boolean succesfullAction = false; - for (Set itemIds : transport.getItemIdRequirements()) { - if (succesfullAction) - break; - 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 = reachedDistanceOrDefault(); - if (reachRd > 0 && isPlayerWithinChebyshevOf(transport.getDestination(), reachRd)) { - break; - } - if (succesfullAction) break; + reachableBfsMillis.addAndGet(System.currentTimeMillis() - bfsStartedAt); - //If an action is succesfully we break out of the loop - succesfullAction = handleWearableTeleports(transport, itemId) || handleInventoryTeleports(transport, itemId); - } + // 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 = nearbyTilesIgnoringCollision( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); } - return succesfullAction; + return tiles; } - private static boolean handleInventoryTeleports(Transport transport, int itemId) { - Rs2ItemModel rs2Item = Rs2Inventory.get(itemId); - if (rs2Item == null) return false; - - // A list of generic teleports that can be used if no parsable destination action is found - List genericKeyWords = Arrays.asList( - "invoke", "empty", "consume", "open", "teleport", "rub", "break", "reminisce", "signal", "play", "commune", "squash", "blow" - ); - - // Return true when the item does not use a generic keyword to teleport to its destination - boolean hasParsableDestination = transport.getDisplayInfo().contains(":"); - String destination = hasParsableDestination - ? transport.getDisplayInfo().split(":")[1].trim().toLowerCase() - : transport.getDisplayInfo().trim().toLowerCase(); - - boolean wildernessTransport = PathfinderConfig.isInWilderness(WorldPointUtil.packWorldPoint(transport.getDestination())); - log.debug("Trying to find action for destination={}", destination); - // Check if item has destination as direct action - String itemAction = rs2Item.getAction(destination); - // Check if item has destination as sub-menu action - Map.Entry sub = rs2Item.getIndexOfSubAction(destination); - if (itemAction == null && sub != null && sub.getKey() != null) { - itemAction = destination; + static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || closestIdx < 0 || closestIdx >= path.size()) { + return closestIdx; } - // If there's only one destination with the item possible, a generic action will also work - if (itemAction == null && !hasParsableDestination) { - itemAction = rs2Item.getActionFromList(genericKeyWords); + WorldPoint pathStart = path.get(0); + WorldPoint pathEnd = path.get(path.size() - 1); + boolean routeChanged = routeState.routeProgressTarget == null + || !routeState.routeProgressTarget.equals(target) + || routeState.routeProgressPathSize != path.size() + || !Objects.equals(routeState.routeProgressPathStart, pathStart) + || !Objects.equals(routeState.routeProgressPathEnd, pathEnd) + || routeState.routeProgressIdx >= path.size(); + if (routeChanged) { + routeState.routeProgressTarget = target; + routeState.routeProgressPathStart = pathStart; + routeState.routeProgressPathEnd = pathEnd; + routeState.routeProgressPathSize = path.size(); + routeState.routeProgressIdx = closestIdx; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + // A new route means new raw indices; a stale high-water mark from the old route would + // silently disable the raw watermark for the rest of the walk. + routeState.rawProgressHighIdx = -1; + return closestIdx; } - if (itemAction != null) { - boolean interaction = Rs2Inventory.interact(rs2Item, itemAction); - if (!interaction) { - return false; - } else if (wildernessTransport) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes", "Okay"); - } else if (isQuetzalWhistleItemId(itemId)) { - return finishQuetzalWhistleTransport(transport); + if (routeState.routeProgressIdx < 0 || closestIdx >= routeState.routeProgressIdx) { + if (closestIdx > routeState.routeProgressIdx) { + recordRouteProgressAdvanced(); } - return true; + routeState.routeProgressIdx = closestIdx; + return closestIdx; } - // If no location-based action found, try generic actions - itemAction = rs2Item.getActionFromList(genericKeyWords); + int forwardIdx = closestForwardPathIndex(path, routeState.routeProgressIdx, playerLoc); + if (forwardIdx >= routeState.routeProgressIdx) { + if (forwardIdx > routeState.routeProgressIdx) { + routeState.routeProgressIdx = forwardIdx; + recordRouteProgressAdvanced(); + } + return routeState.routeProgressIdx; + } + return routeState.routeProgressIdx; + } - if (itemAction == null) { - log.debug("No generic keyword found for={}, genericKeywords={}", itemAction, String.join(",", genericKeyWords)); - return false; + static void hintRouteProgressIndex(List path, int hintedIdx, WorldPoint target) { + if (path == null || path.isEmpty() || hintedIdx < 0 || hintedIdx >= path.size()) { + return; } - if (Rs2Inventory.interact(itemId, itemAction)) { - log.debug("Traveling with genericAction={}, to {} - ({})", itemAction, transport.getDisplayInfo(), transport.getDestination()); + WorldPoint pathStart = path.get(0); + WorldPoint pathEnd = path.get(path.size() - 1); + boolean routeChanged = routeState.routeProgressTarget == null + || !routeState.routeProgressTarget.equals(target) + || routeState.routeProgressPathSize != path.size() + || !Objects.equals(routeState.routeProgressPathStart, pathStart) + || !Objects.equals(routeState.routeProgressPathEnd, pathEnd) + || routeState.routeProgressIdx >= path.size(); + if (routeChanged) { + routeState.routeProgressTarget = target; + routeState.routeProgressPathStart = pathStart; + routeState.routeProgressPathEnd = pathEnd; + routeState.routeProgressPathSize = path.size(); + routeState.routeProgressIdx = hintedIdx; + recordRouteProgressAdvanced(); + return; + } - if (itemAction.equalsIgnoreCase("open") && itemId == ItemID.BOOKOFSCROLLS_CHARGED) { - return handleMasterScrollBook(destination); - } else if (isQuetzalWhistleItemId(itemId)) { - return finishQuetzalWhistleTransport(transport); - } else if (isDialogueBasedTeleportItem(transport.getDisplayInfo())) { - // Multi-destination teleport items: wait for destination selection dialogue - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(destination); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; - } else if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { - // Burning amulet in inventory: confirm wilderness teleport - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("Okay, teleport to level"); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; - } else if (wildernessTransport) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes", "Okay"); - } else { - Rs2Player.waitForAnimation(); - log.info("Unsure how to handle this itemTransport={} action={}", transport, itemAction); - } + if (hintedIdx > routeState.routeProgressIdx) { + routeState.routeProgressIdx = hintedIdx; + recordRouteProgressAdvanced(); } - return false; } - private static boolean handleWearableTeleports(Transport transport, int itemId) { - Rs2ItemModel rs2Item = Rs2Equipment.get(itemId); - if (rs2Item == null) return false; - if (transport.getDisplayInfo().contains(":")) { - String[] values = transport.getDisplayInfo().split(":"); - String destination = values[1].trim().toLowerCase(); + static int advanceIndexPastRecentTransportEdge(List path, int index, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || index < 0 || index >= path.size() + || !isRecentTransportEdgeWindow()) { + return index; + } + WorldPoint origin = routeState.lastTransportOriginLocation; + WorldPoint destination = routeState.lastTransportDestinationLocation; + if (origin == null || destination == null || playerLoc == null + || playerLoc.getPlane() != destination.getPlane() + || playerLoc.distanceTo2D(destination) > 3) { + return index; + } - if (transport.getDisplayInfo().toLowerCase().contains("slayer ring")) { - Rs2Equipment.invokeMenu(rs2Item, "teleport"); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(destination); - } else { - Rs2Equipment.invokeMenu(rs2Item, destination); - if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("Okay, teleport to level"); - } + int scanEndExclusive = Math.min(path.size(), index + 8); + int lastTransportEdgeIdx = -1; + for (int i = index; i < scanEndExclusive; i++) { + WorldPoint point = path.get(i); + if (isNearSamePlane(point, origin, 2) || isNearSamePlane(point, destination, 2)) { + lastTransportEdgeIdx = i; } - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; } - return false; - } - - /** - * Checks if the teleport item requires dialogue-based destination selection. - * These are items that, when rubbed/activated, show a dialogue menu to choose destination. - * - * @param displayInfo the displayInfo from the transport - * @return true if the item requires dialogue handling - */ - private static boolean isDialogueBasedTeleportItem(String displayInfo) { - if (displayInfo == null) return false; - String lowerDisplayInfo = displayInfo.toLowerCase(); - return lowerDisplayInfo.contains("slayer ring") - || lowerDisplayInfo.contains("games necklace") - || lowerDisplayInfo.contains("skills necklace") - || lowerDisplayInfo.contains("ring of dueling") - || lowerDisplayInfo.contains("ring of wealth") - || lowerDisplayInfo.contains("amulet of glory") - || lowerDisplayInfo.contains("combat bracelet") - || lowerDisplayInfo.contains("digsite pendant") - || lowerDisplayInfo.contains("necklace of passage") - || lowerDisplayInfo.contains("giantsoul amulet"); + if (lastTransportEdgeIdx >= index && lastTransportEdgeIdx + 1 < path.size()) { + return lastTransportEdgeIdx + 1; + } + return index; } - /** - * Checks if the player's current location is within the specified area defined by the given world points. - * - * @param worldPoints an array of two world points of the NW and SE corners of the area - * @return true if the player's current location is within the specified area, false otherwise - */ - public static boolean isInArea(WorldPoint... worldPoints) { - if (worldPoints == null || worldPoints.length < 2 || worldPoints[0] == null || worldPoints[1] == null) { - throw new IllegalArgumentException("isInArea requires two WorldPoints."); + private static int closestForwardPathIndex(List path, int fromIdx, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || playerLoc == null || fromIdx < 0 || fromIdx >= path.size()) { + return -1; } - WorldPoint a = worldPoints[0]; - WorldPoint b = worldPoints[1]; - final int aX = a.getX(), aY = a.getY(); - final int bX = b.getX(), bY = b.getY(); - - final int minX = Math.min(aX, bX); - final int maxX = Math.max(aX, bX); - final int minY = Math.min(aY, bY); - final int maxY = Math.max(aY, bY); - - final WorldPoint playerLocation = Rs2Player.getWorldLocation(); - final int playerX = playerLocation.getX(); - final int playerY = playerLocation.getY(); + int bestIdx = -1; + int bestDist = Integer.MAX_VALUE; + int toIdxExclusive = Math.min(path.size(), fromIdx + ROUTE_PROGRESS_FORWARD_SEARCH_TILES + 1); + for (int i = fromIdx; i < toIdxExclusive; i++) { + WorldPoint point = path.get(i); + if (point == null || point.getPlane() != playerLoc.getPlane()) { + continue; + } + int dist = playerLoc.distanceTo2D(point); + if (dist < bestDist) { + bestIdx = i; + bestDist = dist; + } + } + return bestIdx; + } - // draws box from 2 points to check against all variations of player X,Y from said points. - return (playerX >= minX && playerX <= maxX && playerY >= minY && playerY <= maxY); + private static void resetRouteProgress() { + routeState.routeProgressIdx = -1; + routeState.routeProgressTarget = null; + routeState.routeProgressPathStart = null; + routeState.routeProgressPathEnd = null; + routeState.routeProgressPathSize = -1; + routeState.routeProgressAdvancedAtMs = 0L; + routeState.stagnationReplansSpent = 0; + routeState.rawProgressHighIdx = -1; } /** - * Checks if the player's current location is within the specified range from the given center point. - * - * @param centerOfArea a WorldPoint which is the center of the desired area, - * @param range an int of range to which the boundaries will be drawn in a square, - * @return true if the player's current location is within the specified area, false otherwise + * Per-pass progress update with RAW granularity. The smoothed index alone starves the stagnation + * clock on healthy walks: the entire Varrock west approach — fifty tiles and three doors — sits + * inside the final smoothed segment, so the index held one value through ~50s of honest walking + * (measured 2026-08-12) against a 60s budget. The player's furthest-yet raw index advances tile + * by tile on exactly that walk, and still refuses to advance during the Tithe ping-pong: two + * tiles oscillating can set a high-water mark once, never repeatedly. */ - public static boolean isInArea(WorldPoint centerOfArea, int range) { - WorldPoint seCorner = new WorldPoint(centerOfArea.getX() + range, centerOfArea.getY() - range, centerOfArea.getPlane()); - WorldPoint nwCorner = new WorldPoint(centerOfArea.getX() - range, centerOfArea.getY() + range, centerOfArea.getPlane()); - return isInArea(seCorner, nwCorner); // call to our sibling - } - - public static boolean isNear() { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) return false; // idk are we near if we don't have a path? - final List path = pathfinder.getPath(); - if (path == null) return false; - - WorldPoint playerLocation = Rs2Player.getWorldLocation(); - if (playerLocation == null) { - return false; + static int stabilizeRouteProgressWithRawWatermark(List rawPath, List path, + int closestIdx, WorldPoint target, WorldPoint playerLoc) { + int stabilized = stabilizeRouteProgressIndex(path, closestIdx, target, playerLoc); + if (rawPath != null && !rawPath.isEmpty() && playerLoc != null) { + // Plain nearest-by-distance (no reachability BFS): a monotone high-water mark only needs + // consistency with itself, and this runs once per loop pass. + int rawIdx = WalkerPathGeometry.getClosestTileIndex(rawPath, playerLoc, null); + if (rawIdx > routeState.rawProgressHighIdx) { + routeState.rawProgressHighIdx = rawIdx; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + } } - int index = IntStream.range(0, path.size()) - .filter(f -> { - WorldPoint wp = path.get(f); - return wp.getPlane() == playerLocation.getPlane() - && wp.distanceTo2D(playerLocation) < 3; - }) - .findFirst().orElse(-1); - return index >= Math.max(path.size() - 10, 0); + return stabilized; } - /** - * @param target - * @return - */ - public static boolean isNear(WorldPoint target) { - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.equals(target); + private static void recordRouteProgressAdvanced() { + long now = System.currentTimeMillis(); + routeState.routeProgressAdvancedAtMs = now; + routeState.lastMovedTimeMs = now; + routeState.stuckCount = 0; } - public static boolean isNearPath() { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) return true; - - final List path = pathfinder.getWalkablePath(); - if (path == null || path.isEmpty()) return true; - - final WorldPoint loc = Rs2Player.getWorldLocation(); - if (loc == null) return true; - - if (config.recalculateDistance() < 0 || routeState.lastPosition.equals(routeState.lastPosition = loc)) { - return true; - } - - if (config.usePoh() && PohTeleports.isInHouse()) { - //Would be nice to have access to current node here and check if the current Node is a POH transport node. - return true; - } - - var reachableTiles = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), config.recalculateDistance() - 1); - for (WorldPoint point : path) { - if (reachableTiles.containsKey(point)) { - return true; - } + private static boolean isRecentTransportEdgeWindow() { + long handledAt = routeState.lastTransportHandledAtMs; + if (handledAt <= 0L) { + return false; } + long ageMs = System.currentTimeMillis() - handledAt; + return ageMs >= 0L && ageMs <= RECENT_TRANSPORT_EDGE_SUPPRESS_MS; + } - return false; + static boolean isNearSamePlane(WorldPoint a, WorldPoint b, int distance) { + return a != null + && b != null + && a.getPlane() == b.getPlane() + && a.distanceTo2D(b) <= distance; } - private static boolean isNearPathByVariance(List path, WorldPoint playerLoc) { - if (path == null || path.isEmpty() || playerLoc == null) { + private static boolean isRecentTransportEdgeCandidate(WorldPoint objectLoc, WorldPoint from, WorldPoint to) { + if (!isRecentTransportEdgeWindow()) { return false; } - int closestIdx = getClosestTileIndex(path, playerLoc); - if (closestIdx < 0 || closestIdx >= path.size()) { + WorldPoint origin = routeState.lastTransportOriginLocation; + WorldPoint destination = routeState.lastTransportDestinationLocation; + if (origin == null || destination == null) { return false; } - WorldPoint closest = path.get(closestIdx); - return closest != null - && closest.getPlane() == playerLoc.getPlane() - && closest.distanceTo2D(playerLoc) <= PATH_VARIANCE_TOLERANCE_CHEBYSHEV; + boolean objectNearTransport = isNearSamePlane(objectLoc, origin, 2) + || isNearSamePlane(objectLoc, destination, 2); + boolean edgeMatchesTransport = (isNearSamePlane(from, origin, 2) && isNearSamePlane(to, destination, 2)) + || (isNearSamePlane(from, destination, 2) && isNearSamePlane(to, origin, 2)) + || (objectNearTransport + && (isNearSamePlane(from, origin, 2) + || isNearSamePlane(from, destination, 2) + || isNearSamePlane(to, origin, 2) + || isNearSamePlane(to, destination, 2))); + return objectNearTransport && edgeMatchesTransport; } - static String offPathRecalcDeferralReason(boolean playerMoving, - boolean playerAnimating, - boolean playerInteracting, - boolean movementOwned, - 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"; - } - // Busy state defers only while the walker owns the movement. Combat retaliation and - // aggro pathing keep moving/animating/interacting true indefinitely, and an unbounded - // defer here paralyzes the walker while something else drags the player off the route. - if (movementOwned) { - 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"; + /** + * Force the walker to recalculate path + */ + public static void recalculatePath() { + recalculatePath(Rs2PlannerShadowContext.Invocation.ACTIVE_REPLAN); + } + + /** + * Queue one deterministic recovery replan for the active live-test walk. + * + *

The request is consumed by {@code processWalk} on the walker thread so the normal recovery evidence + * context is updated. Production callers cannot enable this hook: it is inert unless the test runner set + * {@code microbot.test.mode=true} and an active target exists.

+ */ + public static boolean requestRecoveryReplanForTest() + { + if (!Boolean.getBoolean("microbot.test.mode") || currentTarget == null) + { + return false; + } + testRecoveryReplanRequests.incrementAndGet(); + return true; + } + + static boolean consumeRecoveryReplanForTest() + { + if (!Boolean.getBoolean("microbot.test.mode")) + { + testRecoveryReplanRequests.set(0); + return false; + } + return testRecoveryReplanRequests.getAndUpdate(value -> Math.max(0, value - 1)) > 0; + } + + private static void recalculatePathForRecovery() { + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null) + { + evidence.recoveryTriggered = true; + } + recalculatePath(Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN); + } + + private static void recalculatePath(Rs2PlannerShadowContext.Invocation invocation) { + WorldPoint goal = currentTarget; + if (goal == null) { + return; } - if (movementOwned && isRecentEvent(nowMs, lastMovedAtMs, OFF_PATH_RECALC_RECENT_MOVEMENT_MS)) { - return "recent-movement"; + // Startup marks are deduped per phase per walk, so a startup that REPLANS goes silent for its + // whole second pass — pf_wait_retry, pf_ready and path_snapshot have all been logged already. + // That is exactly the window a walled-click replan lands in, which is why the slowest starts + // are the least visible ones: a four-second gap with nothing in it but the replan itself. + // Re-arm them so each startup attempt narrates its own. + if (!routeState.firstMovementClickMarked) { + startupPhasesLogged.clear(); } - return null; + // Must not call setTarget(null)+setTarget(goal): that briefly clears {@link #currentTarget}, + // and processWalk on another thread treats null as cancel (isWalkCancelled). + Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal, invocation); } /** - * Whether current player movement is plausibly the result of a walker-issued action — - * a route/recovery click, a door interaction, or a transport handoff — rather than an - * external force (combat retaliation, aggro, another script). Only owned movement may - * defer the off-path recalc or preempt a recovery click. + * The walled-click net just refused a click because a scene door sits on the route edge. A + * replan cannot help — the planner's graph crosses that door, so it returns the same route and + * the refusal loops (three identical replans over 24s at the Rogues' Den pub door, broken only + * when the stall recalc happened to click the door). Close the distance instead: walk to the + * edge's near side so the door pipeline engages on arrival. Declines when already beside the + * door (the pipeline's turn), an approach is in flight, or the near side is unreachable. */ - private static boolean isMovementWalkerOwned(long nowMs, long minimapClickAtMs) { - long lastOwnedActionAtMs = Math.max( - Math.max(minimapClickAtMs, routeState.doorInteractionSettleStartedAtMs), - Math.max(routeState.lastTransportHandledAtMs, - Math.max(routeState.lastUnreachableRecoveryClickAtMs, routeState.interimSetAtMs))); - return isRecentEvent(nowMs, lastOwnedActionAtMs, WALKER_MOVEMENT_OWNERSHIP_WINDOW_MS); + private static WalkExit resolveWalledDoorClaim(WorldPoint playerLoc, long timeoutMs) { + WorldPoint from = routeState.walledDoorEdgeFrom; + WorldPoint to = routeState.walledDoorEdgeTo; + long now = System.currentTimeMillis(); + WalledDoorClaimPolicy.Decision decision = WalledDoorClaimPolicy.decide( + from, to, routeState.walledDoorEdgeAtMs, now, playerLoc, Rs2Player.isMoving(), + from != null && Rs2Tile.isTileReachable(from)); + switch (decision) { + case CROSSED: + clearWalledDoorClaim(); + return WalkExit.RECOVERY_POSITION_STALE; + case ACTION_IN_FLIGHT: + return WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION; + case HANDLE_AT_EDGE: + if (handleDoorsWithTimeoutBudgeted(Arrays.asList(from, to), 0, timeoutMs, true)) { + clearWalledDoorClaim(); + return WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY; + } + routeState.doorRecoverySuppressedAtMs = now; + WebWalkLog.spInfo("walled_door_owned | edge={}->{} player={} state=await-door-handler", + compactWorldPoint(from), compactWorldPoint(to), compactWorldPoint(playerLoc)); + return WalkExit.DOOR_TRAVERSAL_PENDING_YIELD; + case APPROACH: + if (!walkMiniMap(from)) { + routeState.doorRecoverySuppressedAtMs = now; + return WalkExit.DOOR_RECOVERY_SUPPRESSED; + } + routeState.lastUnreachableRecoveryClickAtMs = now; + WebWalkLog.spInfo("walled_door_approach | edge={}->{} player={} - approaching claimed door instead of replanning", + compactWorldPoint(from), compactWorldPoint(to), compactWorldPoint(playerLoc)); + return WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK; + case EXPIRED: + case INVALID: + clearWalledDoorClaim(); + return null; + default: + return null; + } } - private static String currentOffPathRecalcDeferralReason(long minimapClickAtMs) { - long nowMs = System.currentTimeMillis(); - return offPathRecalcDeferralReason( - Rs2Player.isMoving(), - Rs2Player.isAnimating(), - Rs2Player.isInteracting(), - isMovementWalkerOwned(nowMs, minimapClickAtMs), - isDoorInteractionSettling(), - isTransportInteractionSettling(), - routeState.interimTargetWp != null, - nowMs, - routeState.lastMovedTimeMs, - routeState.routeProgressAdvancedAtMs, - minimapClickAtMs, - routeState.interimLastProgressAtMs); + private static void clearWalledDoorClaim() { + routeState.walledDoorEdgeFrom = null; + routeState.walledDoorEdgeTo = null; + routeState.walledDoorEdgeAtMs = 0L; } - 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); + /** + * Emits how long the pass spent inside the local-reachability recovery gate (entry stamped at + * the "local reachability miss" log). The gate's cascade — door scans, edge waits, walled-click + * fallbacks, recovery-target probes, each paying client-thread hops — was the unattributed bulk + * of 8-12s pass_slow residuals at the Rogues' Den doorstep. The exit reason names which branch + * ended it; segDoor/segTransport time recorded within the window overlaps this figure. + */ + private static void logRecoveryGateDuration(WorldPoint target, WalkExit exit) { + long enteredAt = routeState.recoveryGateEnteredAtMs; + if (enteredAt <= 0) { + return; } - return (int) Math.max(OFF_PATH_RECALC_DEFER_WAIT_MIN_MS, - Math.min(OFF_PATH_RECALC_DEFER_WAIT_MAX_MS, remainingMs)); + routeState.recoveryGateEnteredAtMs = 0L; + WebWalkLog.tmark("recovery_gate_done", System.currentTimeMillis() - enteredAt, target, + Rs2Player.getWorldLocation(), "exit=" + (exit == null ? "none" : exit.name())); } - private static boolean isOffPathRecalcDeferredExit(String exitReason) { - return exitReason != null && exitReason.startsWith("off-path-deferred:"); - } + /** Pathfinder normalizes nested sealed shells; the walker may change effective target once. */ + private static final int MAX_SEALED_RIM_RETARGETS = 1; - private static String offPathDeferredReasonFromExit(String exitReason) { - if (!isOffPathRecalcDeferredExit(exitReason)) { - return ""; + /** Retargets a proven-sealed requested goal to one normalized, reachable approach rim. */ + private static WorldPoint consumeSealedRimRetarget(WorldPoint target, WorldPoint dst) { + var pf = Rs2PathApi.getPathfinder(); + if (pf == null) { + return null; } - 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; + WorldPoint rim = pf.getReachedSealedSubstitute(); + if (rim != null && rim.equals(dst) && !rim.equals(target)) { + WebWalkLog.spInfo("sealed_target_retarget | goal={} rim={} — goal proven sealed, walking to its rim", + target, rim); + setTarget(rim); + recalculatePath(); + return rim; + } + // Rim UNREACHED: the substitute budget (~125-tile flood radius) exhausts en route whenever + // the sealed goal is far away, and without this the whole journey is a partial crawl — one + // truncated search per pass (Falador->Burthorpe against a clicked hatch tile), ending + // beside the goal but never terminating. The rim tile is an ordinary reachable tile, so + // plan to IT with the normal full budget: one complete route, the door pipeline handles + // route doors, and arrival lands beside the sealed tile the caller actually clicked. + WorldPoint nearest = pf.getNearestSealedRimSubstitute(); + if (nearest == null || nearest.equals(target) + || routeState.sealedRimRetargets >= MAX_SEALED_RIM_RETARGETS) { + return null; + } + routeState.sealedRimRetargets++; + WebWalkLog.spInfo("sealed_target_retarget | requested={} effective={} rim={} — goal sealed; planning to normalized rim ({}/{})", + routeState.requestedGoal, target, nearest, + routeState.sealedRimRetargets, MAX_SEALED_RIM_RETARGETS); + setTarget(nearest); + recalculatePath(); + return nearest; } - 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); + public static void setTarget(WorldPoint target) { + setTarget(target, null); } - private static boolean hasUpcomingNearbyTransportStep(List path, - int startIdx, - WorldPoint playerLoc, - int lookaheadEdges, - int maxDist) { - if (path == null || path.size() < 2 || startIdx < 0 || playerLoc == null) { - return false; + /** + * @param clearReasonWhenNull logged when {@code target} is {@code null}; omit only from tests or legacy paths. + * Clearing ({@code target == null}) runs without a {@link net.runelite.client.Client} + * (teardown-safe). Non-null destinations still require a live client and login/player checks. + */ + public static void setTarget(WorldPoint target, String clearReasonWhenNull) { + if (target != null && !Microbot.isLoggedIn()) { + log.warn("Unable to set target: not logged in"); + return; } - int from = Math.max(0, startIdx); - int to = Math.min(path.size() - 2, from + Math.max(0, lookaheadEdges)); - for (int i = from; i <= to; i++) { - if (!isCatalogBackedTransportSegment(path, i)) { - continue; + if (target != null) { + Client client = Microbot.getClient(); + if (client == null) { + log.warn("Unable to set target: client unavailable"); + return; } - WorldPoint segFrom = path.get(i); - WorldPoint segTo = path.get(i + 1); - if (segFrom == null || segTo == null || segFrom.getPlane() != playerLoc.getPlane()) { - continue; + Player localPlayer = client.getLocalPlayer(); + if (!Rs2PathApi.isStartPointSet() && localPlayer == null) { + log.warn("Start point is not set and player is null"); + return; } - int d = Math.min(segFrom.distanceTo2D(playerLoc), segTo.distanceTo2D(playerLoc)); - if (d <= Math.max(1, maxDist)) { - return true; + } + + currentTarget = target; + + if (target == null) { + // A completed/cancelled route owns its transport handoff context. Keeping the + // timestamp alive made an unrelated walk started within 15 seconds inherit + // post-transport handler suppression and misleading elapsed-time markers. + clearRecentTransportContext(); + resetRouteProgress(); + logRouteClear(clearReasonWhenNull); + Rs2PathApi.cancelAndClearActiveRoute(); + + WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); + if (wmm != null) { + wmm.remove(Rs2PathApi.getMarker()); + } else if (Rs2LogRateLimit.once(WORLD_MAP_REMOVE_NULL_LOGGED)) { + log.debug("[Walker] WorldMapPointManager null during route clear — marker may linger until teardown"); } + Rs2PathApi.setMarker(null); + Rs2PathApi.setStartPointSet(false); + } else { + applyWalkerDestination(target); } - return false; } - private static void checkIfStuck() { - // Leagues pending teleports, dialogue, and fairy ring widget should not burn stall budget. - if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { - routeState.lastMovedTimeMs = System.currentTimeMillis(); - routeState.stuckCount = 0; - routeState.prevAnimatingForStuckCheck = Rs2Player.isAnimating(); + private static void restoreTargetMarker(WorldPoint target) { + if (target == null || Rs2PathApi.getMarker() != null) { return; } - WorldPoint now = Rs2Player.getWorldLocation(); - boolean anim = Rs2Player.isAnimating(); - if (now != null && now.equals(routeState.lastPosition)) { - boolean nearPath = isNearPath(); - boolean poseWalkingNearPath = Rs2Player.isMoving() && nearPath; - boolean animProgressNearPath = anim && !routeState.prevAnimatingForStuckCheck && nearPath; - if (animProgressNearPath || poseWalkingNearPath) { - routeState.lastMovedTimeMs = System.currentTimeMillis(); - routeState.stuckCount = 0; - } else { - routeState.stuckCount++; + try { + WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); + if (wmm == null) { + log.debug("[Walker] Cannot restore marker: WorldMapPointManager unavailable"); + return; } - } else { - routeState.stuckCount = 0; - routeState.lastMovedTimeMs = System.currentTimeMillis(); + Rs2PathApi.setMarker(new WorldMapPoint(target, Rs2PathApi.MARKER_IMAGE)); + Rs2PathApi.getMarker().setName("Target"); + Rs2PathApi.getMarker().setTarget(Rs2PathApi.getMarker().getWorldPoint()); + Rs2PathApi.getMarker().setJumpOnClick(true); + wmm.add(Rs2PathApi.getMarker()); + log.info("[Walker] Restored missing path target marker at {}", target); + } catch (Exception ex) { + log.debug("[Walker] Failed to restore target marker at {}", target, ex); } - routeState.prevAnimatingForStuckCheck = anim; } - // Base stall threshold. See stallThresholdMs() for activity-aware scaling. - // RuneLite exposes no real-time ping, so we skip pure latency scaling and rely on - // observable activity states that also correlate with legitimately-stuck players. - private static final long STALL_BASE_MS = 12_000; - private static final double STALL_COMBAT_MULTIPLIER = 2.0; - private static final double STALL_ANIMATING_MULTIPLIER = 1.5; - private static final double STALL_MOVING_MULTIPLIER = 1.35; - /** While a sticky minimap interim waypoint is active, path segments can exceed base stall easily. */ - private static final double STALL_INTERIM_MINIMAP_MULTIPLIER = 1.75; - private static final double STALL_INTERACTING_MULTIPLIER = 1.5; /** - * After a successful minimap walk click, refresh the stall clock this long — blocked tiles / long - * segments sometimes delay tile deltas without {@link Rs2Player#isMoving()} flipping immediately. + * @param start + * @param end */ - private static final long MINIMAP_CLICK_STALL_GRACE_MS = 12_000L; + public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { + return Rs2WalkerLifecycleRuntime.restartPathfinding(start, end); + } - private static boolean interactingActorNearWalkablePath() { - Pathfinder pf = Rs2PathApi.getPathfinder(); - if (pf == null) { - return false; + public static boolean restartPathfinding(WorldPoint start, Set ends) { + return Rs2WalkerLifecycleRuntime.restartPathfinding(start, ends); + } + + /** + * @param point + * @return + */ + public static Tile getTile(WorldPoint point) { + LocalPoint a; + if (Microbot.getClient().getTopLevelWorldView().isInstance()) { + WorldPoint instancedWorldPoint = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), point).stream().findFirst().orElse(null); + if (instancedWorldPoint == null) { + log.error("getTile instancedWorldPoint is null"); + return null; + } + a = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), instancedWorldPoint); + } else { + a = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), point); } - List path = pf.getWalkablePath(); - if (path == null || path.isEmpty()) { - return false; + if (a == null) { + return null; } - Actor actor = Rs2Player.getInteracting(); - if (actor == null) { + return Microbot.getClient().getTopLevelWorldView().getScene().getTiles()[point.getPlane()][a.getSceneX()][a.getSceneY()]; + } + + /** + * @param path + * @param indexOfStartPoint + * @return + */ + private static boolean handleTransports(List path, int indexOfStartPoint) { + Optional selection = + Rs2PathApi.getActiveTransportSelection(path, indexOfStartPoint); + if (selection.isEmpty()) { return false; } - WorldPoint loc = actor.getWorldLocation(); - if (loc == null) { - return false; + return Rs2WalkerTransports.handleSelectedTransport(path, indexOfStartPoint, selection.get()); + } + + + + + + + + + + + + + static boolean isAdjacentSamePlaneTransport(Transport transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } + + static boolean isAdjacentSamePlaneTransport(Rs2TransportEdge transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } + + private static int[] mapSmoothedToRaw(List smoothed, List raw) { + if (smoothed == null || raw == null || smoothed.isEmpty() || raw.isEmpty()) { + return new int[0]; } - for (WorldPoint p : path) { - if (p == null || p.getPlane() != loc.getPlane()) { - continue; - } - if (p.distanceTo2D(loc) <= 2) { - return true; + int[] mapping = new int[smoothed.size()]; + int rawIdx = 0; + for (int si = 0; si < smoothed.size(); si++) { + WorldPoint sp = smoothed.get(si); + while (rawIdx < raw.size() && !raw.get(rawIdx).equals(sp)) { + rawIdx++; } + mapping[si] = Math.min(rawIdx, raw.size() - 1); } - return false; + return mapping; } - private static long stallThresholdMs() { - return Rs2WalkerStallPolicy.computeThresholdMs( - STALL_BASE_MS, - STALL_COMBAT_MULTIPLIER, - STALL_ANIMATING_MULTIPLIER, - STALL_MOVING_MULTIPLIER, - STALL_INTERIM_MINIMAP_MULTIPLIER, - STALL_INTERACTING_MULTIPLIER, - Rs2Player.isInCombat(), - Rs2Player.isAnimating(), - Rs2Player.isMoving(), - routeState.interimTargetWp != null, - (Rs2Player.isMoving() || Rs2Player.isAnimating()) && interactingActorNearWalkablePath()); + private static int rawEndForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, + List rawPath, List path) { + if (smoothedIdx + 1 < path.size() && smoothedIdx + 1 < smoothedToRaw.length) { + return smoothedToRaw[smoothedIdx + 1]; + } + return rawPath.size(); } - private static boolean isStuckTooLong() { - if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { - return false; - } - long routeProgressAt = routeState.routeProgressAdvancedAtMs; - if (routeProgressAt > 0L && System.currentTimeMillis() - routeProgressAt < ROUTE_PROGRESS_STALL_GRACE_MS) { - return false; - } - return routeState.lastMovedTimeMs > 0 && System.currentTimeMillis() - routeState.lastMovedTimeMs > stallThresholdMs(); + private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { + return handleTransportsInRawSegment(rawPath, rawFrom, rawTo, false); } /** - * @param start + * Dispatches a planned transport on this raw segment. + *

+ * This is the path that actually takes stairs and ladders on a normal walk — the raw scene scan's + * ranged branch rarely gets there first, because the route click puts the player on the origin + * before the scan runs. So gating only the scan left the walker still walking its four tiles to + * the foot of the stairs before clicking, which is exactly what interact-at-range was meant to + * stop. {@code allowRangedDispatch} lets the caller say "this is the nearest obstacle", and route + * order is then held inside the loop: a transport passed over denies the ranged branch to + * everything behind it. */ - public void setStart(WorldPoint start) { - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) { - return; + private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo, + boolean allowRangedDispatch) { + long passT0 = System.currentTimeMillis(); + try { + return handleTransportsInRawSegmentInner(rawPath, rawFrom, rawTo, allowRangedDispatch); + } finally { + WalkPassStats.segTransportMs.addAndGet(System.currentTimeMillis() - passT0); } - Set targets = pathfinder.getTargets(); - Rs2PathApi.setStartPointSet(true); - if (isClientThread()) { - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); - } else { - restartPathfinding(start, targets); + } + + private static boolean handleTransportsInRawSegmentInner(List rawPath, int rawFrom, int rawTo, + boolean allowRangedDispatch) { + Boolean inInstance = null; + boolean sawUndispatchedTransportStep = false; + for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (isRawTransportOriginNearPlayer( + rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { + if (handleTransports(rawPath, ri)) { + return true; + } + if (hasExplicitTransportStep(rawPath, ri)) { + sawUndispatchedTransportStep = true; + } + continue; + } + if (!hasExplicitTransportStep(rawPath, ri)) { + continue; + } + if (!allowRangedDispatch || sawUndispatchedTransportStep) { + sawUndispatchedTransportStep = true; + continue; + } + WorldPoint origin = rawPath.get(ri); + WorldPoint dest = rawPath.get(ri + 1); + int originDistance = playerLoc != null && origin != null + && origin.getPlane() == playerLoc.getPlane() + ? origin.distanceTo2D(playerLoc) + : -1; + if (inInstance == null) { + inInstance = Microbot.getClientThread() + .runOnClientThreadOptional(() -> Microbot.getClient().getTopLevelWorldView().isInstance()) + .orElse(Boolean.TRUE); + } + boolean allowed = shouldDispatchTransportAtRange( + originDistance, + RAW_TRANSPORT_DISPATCH_MAX_DISTANCE, + HANDLER_RANGE, + true, + isObjectInteractionTransportStep(rawPath, ri), + inInstance, + isDoorInteractionSettling() || isTransportInteractionSettling(), + rangedTransportEdgeFailedRecently(origin, dest), + rangedTransportDispatchEnabled()); + if (!allowed) { + sawUndispatchedTransportStep = true; + continue; + } + WebWalkLog.spInfo("ranged_transport_dispatch | origin={} dist={} — clicking from range, server walks us", + compactWorldPoint(origin), originDistance); + WorldPoint before = Rs2Player.getWorldLocation(); + if (handleTransports(rawPath, ri)) { + if (didCurrentTileTransportProgress(before, dest, currentTarget)) { + return true; + } + markRangedTransportEdgeFailed(origin, dest); + } + sawUndispatchedTransportStep = true; } + return false; } /** - * Of these candidate tiles, the one the pathfinder can actually reach most cheaply — or null when - * none of them is reachable. - * - *

Choosing somewhere to stand by proximity is wrong whenever a wall or a closed door separates - * the nearest tile from the player. A local reachability BFS does not rescue it either: the BFS - * stops at the door, so the tile on the far side — often the only usable one — is invisible to it. - * The pathfinder is the component that knows doors and transports, and it takes a whole set of - * targets natively, so asking it once answers the question that actually matters: which of - * these can I get to? - * - *

Worked case: approaching the Black Knights' Fortress ladder from (3024,3512), the tiles beside - * it are walkable and adjacent but walled off, while the usable approach is east through a Sturdy - * door. Proximity picks a walled tile every time; this picks the one with a route. + * Whether a planned transport may be interacted with from RANGE instead of stepping onto its + * origin tile first. + *

+ * Clicking an object makes the SERVER path the player to a valid interaction tile and perform the + * action; it owns the collision data, so it is strictly better at choosing that tile than any + * approach heuristic of ours. The walker already spots obstacles {@code HANDLER_RANGE} tiles out + * but would only act within {@link #RAW_TRANSPORT_DISPATCH_MAX_DISTANCE}, so it walked to a tile + * it had guessed at and only then clicked — and the guess is what failed at the Black Knights' + * ladder, the Falador castle staircase and the guarded door, never the interaction itself. + *

+ * ROUTE ORDER is the one thing this must not break: clicking a door twelve tiles ahead when a + * closed gate sits between walks the player into the gate. Only the FIRST unresolved obstacle on + * the route may be actioned at range, which {@code firstObstacleOnRoute} carries. * - * @param start where we are pathing from - * @param candidates tiles worth standing on, in no particular order - * @return the reachable candidate, or null if the pathfinder cannot reach any of them + * @param originDistance tiles from the player to the transport origin + * @param maxNearDistance the legacy on-the-origin band; always dispatchable, unchanged + * @param maxRangedDistance furthest the ranged branch may reach (the scan's handler range) + * @param firstObstacleOnRoute no earlier unresolved obstacle sits between player and origin + * @param objectInteractionTransport the row is handled by the generic object click, not a + * dialogue/widget flow that gains nothing from this + * @param inInstance instances keep the legacy band: raw coords make "on route" unreliable + * @param settling a door/transport settle window is still open + * @param rangedAttemptFailedRecently a previous ranged attempt on this edge produced no movement + * @param enabled config kill switch */ - public static WorldPoint nearestReachable(WorldPoint start, Collection candidates) { - if (start == null || candidates == null || candidates.isEmpty()) { - return null; + static boolean shouldDispatchTransportAtRange(int originDistance, + int maxNearDistance, + int maxRangedDistance, + boolean firstObstacleOnRoute, + boolean objectInteractionTransport, + boolean inInstance, + boolean settling, + boolean rangedAttemptFailedRecently, + boolean enabled) { + if (originDistance < 0) { + return false; } - Set targets = new HashSet<>(candidates); - if (targets.contains(start)) { - return start; + if (originDistance <= maxNearDistance) { + return true; // legacy behaviour, untouched } - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, targets); - pathfinder.run(); - List path = pathfinder.getPath(); - if (path == null || path.isEmpty()) { - return null; + return enabled + && !inInstance + && !settling + && !rangedAttemptFailedRecently + && objectInteractionTransport + && firstObstacleOnRoute + && originDistance <= maxRangedDistance; + } + + static boolean isRawTransportOriginNearPlayer(List rawPath, + int transportIndex, + WorldPoint playerLoc, + int maxDistance) { + if (rawPath == null || playerLoc == null + || transportIndex < 0 || transportIndex >= rawPath.size() - 1) { + return false; } - // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. - WorldPoint endpoint = path.get(path.size() - 1); - return targets.contains(endpoint) ? endpoint : null; + WorldPoint routeOrigin = rawPath.get(transportIndex); + return isTransportOriginNearPlayer(routeOrigin, playerLoc, maxDistance); } /** - * Checks the distance between startpoint and endpoint using ShortestPath - * - * @param startpoint - * @param endpoint - * @return distance + * True when every transport planned at {@code rawPath[index]} is one the generic object click + * handles (doors, stairs, ladders, gates). Dialogue and widget flows — boats, canoes, gliders, + * fairy rings, minecarts, teleports — are excluded: the server will not walk the player into a + * conversation, so ranged dispatch buys them nothing and risks firing them early. Agility + * shortcuts are excluded too; they need the exact origin tile (the stepping-stone case). */ - public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) { - Set ends = Set.of(endpoint); - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), startpoint, ends); - pathfinder.run(); - return pathfinder.getPath().size(); + private static boolean isObjectInteractionTransportStep(List rawPath, int index) { + if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { + return false; + } + return Rs2PathApi.getActiveTransportEdge(rawPath.get(index), rawPath.get(index + 1)) + .map(edge -> edge.getType() == Rs2TransportType.TRANSPORT) + .orElse(false); } - /** - * Forwards to {@link Rs2LeaguesTransport#recordTransportAttempt} for Leagues locked-region chat correlation. - * Delegate records only teleport-like transports while Leagues is active (seasonal + spells/items, e.g. ectophial). - */ - public static void recordTransportAttempt(Transport transport) - { - Rs2LeaguesTransport.recordTransportAttempt(transport); + /** Config kill switch for ranged transport dispatch; on when the config is unavailable. */ + static boolean rangedTransportDispatchEnabled() { + return config == null || config.interactWithRouteObstaclesAtRange(); } - /** - * Writes {@code phase="result"} for {@link Rs2LeaguesTransport#appendTransportObservation} (seasonal rows only). - */ - private static void recordTransportResult(Transport transport, boolean success) - { - if (transport == null || transport.getType() != TransportType.SEASONAL_TRANSPORT) - { - return; - } - if (!Rs2LeaguesTransport.isLeaguesActive()) - { - return; - } - Rs2LeaguesTransport.appendTransportObservation("result", transport, success, success ? "ok" : "fail"); + + + /** Ranged dispatch attempts that produced no movement, keyed by origin→destination edge. */ + private static final Map failedRangedTransportEdges = new ConcurrentHashMap<>(); + private static final long RANGED_TRANSPORT_RETRY_COOLDOWN_MS = 30_000L; + + static String rangedTransportEdgeKey(WorldPoint from, WorldPoint to) { + return compactWorldPoint(from) + ">" + compactWorldPoint(to); } - /** Wraps an action with {@link #recordTransportAttempt} + {@link #recordTransportResult} (seasonal JSONL, Leagues snapshot for teleports). - * @see net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport - */ - private static boolean attemptObserved(Transport transport, BooleanSupplier action) - { - if (transport == null || action == null) - { - return false; - } - boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); - // Snapshot attempt for Leagues locked-region chat correlation (avoid churn outside leagues). - if (leaguesActive) - { - recordTransportAttempt(transport); - } - boolean ok = action.getAsBoolean(); - if (leaguesActive) - { - recordTransportResult(transport, ok); - } - return ok; + private static boolean rangedTransportEdgeFailedRecently(WorldPoint from, WorldPoint to) { + Long at = failedRangedTransportEdges.get(rangedTransportEdgeKey(from, to)); + return at != null && System.currentTimeMillis() - at < RANGED_TRANSPORT_RETRY_COOLDOWN_MS; } /** - * Like {@link #attemptObserved} but does not call {@link #recordTransportAttempt} before the action. - * Seasonal handlers record attempts at their click sites so {@link Rs2LeaguesTransport#getLastTransportAttemptSnapshot} - * matches the handler that actually ran (Leagues Area vs MoA). + * Records that a ranged attempt on this edge produced nothing, so the walker falls back to + * walking onto the origin for it. That is the unreachable case — the server declined to path — + * and it must degrade to the legacy behaviour rather than re-click from range forever. */ - private static boolean attemptObservedWithoutAttemptRecord(Transport transport, BooleanSupplier action) - { - if (transport == null || action == null) - { - return false; - } - boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); - boolean ok = action.getAsBoolean(); - if (leaguesActive) - { - recordTransportResult(transport, ok); - } - return ok; + private static void markRangedTransportEdgeFailed(WorldPoint from, WorldPoint to) { + failedRangedTransportEdges.put(rangedTransportEdgeKey(from, to), System.currentTimeMillis()); + WebWalkLog.spInfo("ranged_transport_no_progress | {} -> {} — falling back to walking onto the origin", + compactWorldPoint(from), compactWorldPoint(to)); } - /** - * Tries configured seasonal transport handlers for the same {@link Transport} row. - * Attempt recording is done inside each handler (for built-ins, {@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}) - * — use {@link #attemptObservedWithoutAttemptRecord} at the call site. - */ - private static boolean handleSeasonalTransport(Transport transport) { - if (transport == null) { - return false; - } - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null) return false; + private static boolean isTransportOriginNearPlayer(WorldPoint routeOrigin, + WorldPoint playerLoc, + int maxDistance) { + return routeOrigin != null + && playerLoc != null + && routeOrigin.getPlane() == playerLoc.getPlane() + && routeOrigin.distanceTo2D(playerLoc) <= Math.max(0, maxDistance); + } - List handlers = seasonalTransportHandlers; - for (SeasonalTransportHandler h : handlers) - { - if (h == null) - { + + private static void primeExpectedTransportDestinations(List path, int startIdx) { + if (path == null || path.size() < 2) { + synchronized (expectedTransportDestinations) { + expectedTransportDestinations.clear(); + } + return; + } + int start = Math.max(0, startIdx); + java.util.Deque next = new ArrayDeque<>(); + WorldPoint lastAdded = null; + for (int i = start; i < path.size() - 1; i++) { + if (!isCatalogBackedTransportSegment(path, i)) { continue; } - if (!h.matches(transport)) - { + WorldPoint destination = path.get(i + 1); + if (destination == null) { continue; } - if (h.tryUse(transport)) - { - return true; + if (lastAdded == null || !lastAdded.equals(destination)) { + next.addLast(destination); + lastAdded = destination; } } - Telemetry.incrementSeasonalHandlerMiss(); - if (log.isDebugEnabled() && SEASONAL_HANDLER_MISS_LOGGED_COUNT.get() < SEASONAL_HANDLER_MISS_LOG_CAP) - { - WorldPoint destWp = transport.getDestination(); - String hash = Integer.toHexString(displayInfo.hashCode()); - String tail = displayInfo.length() > 160 - ? displayInfo.substring(0, 160) + "|h" + hash - : displayInfo + "|h" + hash; - final String missKey; - Integer packedTileOrNull = null; - if (destWp != null) - { - packedTileOrNull = WorldPointUtil.packWorldPoint(destWp); - missKey = Integer.toHexString(packedTileOrNull) + "|" + tail; - } - else - { - missKey = "nodest|" + tail; - } - if (SEASONAL_HANDLER_MISS_LOGGED.add(missKey)) - { - // Best-effort cap: only increment while below cap; duplicates and races are fine for debug-only logs. - for (;;) - { - int prev = SEASONAL_HANDLER_MISS_LOGGED_COUNT.get(); - if (prev >= SEASONAL_HANDLER_MISS_LOG_CAP) - { - break; - } - if (SEASONAL_HANDLER_MISS_LOGGED_COUNT.compareAndSet(prev, prev + 1)) - { - break; - } - } - String sample = displayInfo.length() > 160 ? displayInfo.substring(0, 160) + "…" : displayInfo; - if (packedTileOrNull != null) - { - sample = sample + " destPacked=" + Integer.toHexString(packedTileOrNull); - } - log.debug("[Walker] seasonal transport unmatched by configured handlers (expect pathfinder-only matching rows); key={} sample={}", - missKey, sample); - } + synchronized (expectedTransportDestinations) { + expectedTransportDestinations.clear(); + expectedTransportDestinations.addAll(next); } - return false; } - private static boolean handleSpiritTree(Transport transport) { - // Get Transport Information - String displayInfo = transport.getDisplayInfo(); - int objectId = transport.getObjectId(); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: displayInfo={}, objectId={}", displayInfo, objectId); - } - if (displayInfo == null || displayInfo.isEmpty()) { - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: displayInfo empty, returning false"); - } - return false; - } - if (!Rs2Widget.isWidgetVisible(ComponentID.ADVENTURE_LOG_CONTAINER)) { - TileObject spiritTree = Rs2GameObject.findObjectById(objectId); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: findObjectById({}) returned {}", - objectId, spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); - } - if (spiritTree == null) { - // POH fix: handleSpiritTree's findObjectById uses the transport's objectId - // which is keyed from the TSV. Inside a POH the spirit tree is a different - // object id than the overworld TSV expects. Fall back to the PohTeleports - // helper which knows the full set of POH spirit-tree ids. - spiritTree = PohTeleports.getSpiritTree(); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: POH fallback getSpiritTree() returned {}", - spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); - } - } - boolean interactResult = Rs2GameObject.interact(spiritTree, "Travel"); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: interact(spiritTree, Travel) returned {}", interactResult); - } - if (!interactResult) { - return false; - } - } - boolean result = interactWithAdventureLog(transport); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: interactWithAdventureLog returned {}", result); - } - return result; - } - private static boolean handleMinigameTeleport(Transport transport) { - final Object[] selectedOpListener = new Object[]{489, 0, 0}; - final List teleportGraphics = List.of(800, 802, 803, 804); - @Component final int GROUPING_BUTTON_COMPONENT_ID = 46333957; // 707.5 - @Component final int DROPDOWN_BUTTON_COMPONENT_ID = 4980760; // 76.24 - final int DROPDOWN_SELECTED_SPRITE_ID = 773; - @Component final int MINIGAME_LIST = 4980758; // 76.22 - @Component final int SELECTED_MINIGAME = 4980747; // 76.11 - @Component final int TELEPORT_BUTTON = 4980768; // 76.32 - // Minigame teleports cant be used if a dialogue is open. - if (Rs2Dialogue.isInDialogue()) { - var playerLocation = Rs2Player.getLocalLocation(); - walkFastLocal(playerLocation); - } + /** + * Options that open the destination list on NPCs whose right-click menu has no per-destination + * entry. Veos answers "Can you take me somewhere?" with the Port Piscarilius / Land's End menu. + */ + static final List TERMINAL_TRAVEL_MENU_OPENERS = List.of( + "Can you take me somewhere?", + "Can you take me somewhere", + "take me somewhere", + "Travel"); - if (Rs2Tab.getCurrentTab() != InterfaceTab.CHAT) { - Rs2Tab.switchTo(InterfaceTab.CHAT); - sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.CHAT); - } - Widget groupingBtn = Rs2Widget.getWidget(GROUPING_BUTTON_COMPONENT_ID); - if (groupingBtn == null) return false; - if (!Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)) { - Rs2Widget.clickWidget(groupingBtn); - sleepUntil(() -> Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)); - } - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - String destination = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo().trim().toLowerCase(); - Widget selectedWidget = Rs2Widget.getWidget(SELECTED_MINIGAME); - if (selectedWidget == null) return false; - if (!selectedWidget.getText().equalsIgnoreCase(destination)) { - Widget dropdownBtn = Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID); - if (dropdownBtn == null) return false; - if (dropdownBtn.getSpriteId() != DROPDOWN_SELECTED_SPRITE_ID) { - Rs2Widget.clickWidget(dropdownBtn); - sleepUntil(() -> Rs2Widget.findWidget(DROPDOWN_SELECTED_SPRITE_ID, List.of(Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID))) != null); - } - Widget minigameWidgetParent = Rs2Widget.getWidget(MINIGAME_LIST); - if (minigameWidgetParent == null) return false; - List minigameWidgetList = Arrays.stream(minigameWidgetParent.getDynamicChildren()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - Widget destinationWidget = Rs2Widget.findWidget(destination, minigameWidgetList); - if (destinationWidget == null) return false; - NewMenuEntry destinationMenuEntry = new NewMenuEntry() - .option("Select") - .target("") - .identifier(1) - .type(MenuAction.CC_OP) - .param0(destinationWidget.getIndex()) - .param1(minigameWidgetParent.getId()) - .forceLeftClick(false); - Microbot.doInvoke(destinationMenuEntry, new Rectangle(1, 1)); - sleepUntil(() -> Rs2Widget.getWidget(SELECTED_MINIGAME).getText().equalsIgnoreCase(destination)); - } - Widget teleportBtn = Rs2Widget.getWidget(TELEPORT_BUTTON); - if (teleportBtn == null) return false; - Rs2Widget.clickWidget(teleportBtn); - if (transport.getDisplayInfo().toLowerCase().contains("rat pits")) { - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(transport.getDisplayInfo().split(":")[1].trim().toLowerCase()); - } - sleepUntil(Rs2Player::isAnimating); - return sleepUntilTrue(() -> !Rs2Player.isAnimating() && teleportGraphics.stream().noneMatch(Rs2Player::hasSpotAnimation), 100, 20000); - } - private static boolean handleCanoe(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null || displayInfo.isEmpty()) return false; - List validActions = List.of("chop-down", "shape-canoe", "float canoe", "paddle canoe"); - ObjectComposition CANOE_COMPOSITION = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - if (CANOE_COMPOSITION == null) return false; - String currentAction = Arrays.stream(CANOE_COMPOSITION.getActions()) - .filter(Objects::nonNull) - .filter(act -> validActions.contains(act.toLowerCase())).findFirst().orElse(null); - if (currentAction == null || currentAction.isEmpty()) { - log.error("Unable to find canoe action"); - return false; - } - switch (currentAction) { - case "Chop-down": - Rs2GameObject.interact(transport.getObjectId(), "Chop-down"); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Shape-Canoe": - @Component final int CANOE_SELECTION_PARENT = 27262976; // 416.3 - @Component final int CANOE_SHAPING_TEXT = 27262986; // 416.10 - - Rs2GameObject.interact(transport.getObjectId(), "Shape-Canoe"); - boolean isCanoeShapeTextVisible = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(CANOE_SHAPING_TEXT), 100, 10000); - if (!isCanoeShapeTextVisible) { - log.error("Canoe shape text is not visible within timeout period"); - return false; - } - final int woodcuttingLevel = Rs2Player.getRealSkillLevel(Skill.WOODCUTTING); - String canoeOption; - if (woodcuttingLevel >= 57) { - canoeOption = "Waka canoe"; - } else if (woodcuttingLevel >= 42) { - canoeOption = "Stable dugout canoe"; - } else if (woodcuttingLevel >= 27) { - canoeOption = "Dugout canoe"; - } else if (woodcuttingLevel >= 12) { - canoeOption = "Log canoe"; - } else { - // Not high enough level to make any canoe - return false; - } - Widget canoeSelectionParentWidget = Rs2Widget.getWidget(CANOE_SELECTION_PARENT); - if (canoeSelectionParentWidget == null) return false; - Widget canoeSelectionWidget = Rs2Widget.findWidget("Make " + canoeOption, List.of(canoeSelectionParentWidget)); - Rs2Widget.clickWidget(canoeSelectionWidget); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Float Canoe": - Rs2GameObject.interact(transport.getObjectId(), "Float Canoe"); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Paddle Canoe": - int canoeMapMain = canoeMapMainComponentId(transport.getObjectId()); - int canoeMapDestinations = canoeMapDestinationsComponentId(transport.getObjectId()); - if (canoeMapMain < 0 || canoeMapDestinations < 0) { - log.error("Unsupported canoe station object id: {}", transport.getObjectId()); - return false; - } - if (!Rs2GameObject.interact(transport.getObjectId(), "Paddle Canoe")) { - log.error("Failed to interact with canoe station"); - return false; - } - // Wait for the player to actually walk to the canoe station and stop moving - // before checking for the destination map widget. The interact call only - // queues the click; the player still has to walk there. - sleepUntil(Rs2Player::isMoving, 2000); - sleepUntilTrue(() -> !Rs2Player.isMoving(), 100, 30000); - - // OSRS uses separate interfaces for the River Lum and River Dougne chains. - boolean isDestinationMapVisible = sleepUntilTrue( - () -> Rs2Widget.isWidgetVisible(canoeMapMain), - 100, 10000); - if (!isDestinationMapVisible) { - log.error("Canoe destination map not visible within timeout period for station {}", - transport.getObjectId()); - return false; - } - Widget destinationListWidget = Rs2Widget.getWidget(canoeMapDestinations); - if (destinationListWidget == null) return false; - Widget destination = Rs2Widget.findWidget("Travel to " + displayInfo, List.of(destinationListWidget), false); - if (destination == null) { - log.error("Could not find canoe destination widget for: {}", displayInfo); - return false; - } - Rs2Widget.clickWidget(destination); - Rs2Dialogue.waitForCutScene(100, 15000); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), 100, 5000); - } - return false; - } - static int canoeMapMainComponentId(int stationObjectId) { - if (stationObjectId >= 60845 && stationObjectId <= 60849) { - return InterfaceID.CanoeMapDougne.MAIN_MAP; - } - if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { - return InterfaceID.CanoeMapLum.MAIN_MAP; + + + + + + + + /** + * Checks if the player's current location is within the specified area defined by the given world points. + * + * @param worldPoints an array of two world points of the NW and SE corners of the area + * @return true if the player's current location is within the specified area, false otherwise + */ + public static boolean isInArea(WorldPoint... worldPoints) { + if (worldPoints == null || worldPoints.length < 2 || worldPoints[0] == null || worldPoints[1] == null) { + throw new IllegalArgumentException("isInArea requires two WorldPoints."); } - return -1; + WorldPoint a = worldPoints[0]; + WorldPoint b = worldPoints[1]; + final int aX = a.getX(), aY = a.getY(); + final int bX = b.getX(), bY = b.getY(); + + final int minX = Math.min(aX, bX); + final int maxX = Math.max(aX, bX); + final int minY = Math.min(aY, bY); + final int maxY = Math.max(aY, bY); + + final WorldPoint playerLocation = Rs2Player.getWorldLocation(); + final int playerX = playerLocation.getX(); + final int playerY = playerLocation.getY(); + + // draws box from 2 points to check against all variations of player X,Y from said points. + return (playerX >= minX && playerX <= maxX && playerY >= minY && playerY <= maxY); } - static int canoeMapDestinationsComponentId(int stationObjectId) { - if (stationObjectId >= 60845 && stationObjectId <= 60849) { - return InterfaceID.CanoeMapDougne.DESTINATIONS; - } - if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { - return InterfaceID.CanoeMapLum.DESTINATIONS; - } - return -1; + /** + * Checks if the player's current location is within the specified range from the given center point. + * + * @param centerOfArea a WorldPoint which is the center of the desired area, + * @param range an int of range to which the boundaries will be drawn in a square, + * @return true if the player's current location is within the specified area, false otherwise + */ + public static boolean isInArea(WorldPoint centerOfArea, int range) { + WorldPoint seCorner = new WorldPoint(centerOfArea.getX() + range, centerOfArea.getY() - range, centerOfArea.getPlane()); + WorldPoint nwCorner = new WorldPoint(centerOfArea.getX() - range, centerOfArea.getY() + range, centerOfArea.getPlane()); + return isInArea(seCorner, nwCorner); // call to our sibling } - static boolean isBarrowsDigTransport(Transport transport) { - if (transport == null - || transport.getType() != TransportType.TRANSPORT - || transport.getObjectId() != 0 - || !"Dig".equalsIgnoreCase(transport.getAction()) - || !"Barrow".equalsIgnoreCase(transport.getName())) { + public static boolean isNear() { + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) return false; // idk are we near if we don't have a path? + final List path = routeStatus.getRawPath(); + + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) { return false; } + int index = IntStream.range(0, path.size()) + .filter(f -> { + WorldPoint wp = path.get(f); + return wp.getPlane() == playerLocation.getPlane() + && wp.distanceTo2D(playerLocation) < 3; + }) + .findFirst().orElse(-1); + return index >= Math.max(path.size() - 10, 0); + } + + /** + * @param target + * @return + */ + public static boolean isNear(WorldPoint target) { + return isNear(target, Rs2Player.getWorldLocation()); + } - WorldPoint expectedDestination = BARROWS_DIG_DESTINATIONS.get(transport.getOrigin()); - Set> requirements = transport.getItemIdRequirements(); - return expectedDestination != null - && expectedDestination.equals(transport.getDestination()) - && requirements != null - && requirements.size() == 1 - && requirements.iterator().next().equals(Set.of(ItemID.SPADE)); + /** Snapshot variant (B2): the walk loop passes its pass-start position instead of re-reading. */ + private static boolean isNear(WorldPoint target, WorldPoint playerLoc) { + return playerLoc != null && playerLoc.equals(target); } - private static boolean isQuetzalWhistleItemId(int itemId) { - return itemId == ItemID.HG_QUETZALWHISTLE_BASIC - || itemId == ItemID.HG_QUETZALWHISTLE_ENHANCED - || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED - || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED_INFINITE; + public static boolean isNearPath() { + return isNearPath(Rs2Player.getWorldLocation()); } /** - * Inventory menu action order for opening the Quetzal map from the whistle. - * Generic teleport keyword lists put {@code invoke} before {@code blow}; matching Invoke first often does not open the map. + * Snapshot variant (B2). The two hidden client reads become the caller's {@code loc}, so the + * walk loop's continuation gate answers from the same world as its neighbours. Note the + * deliberate side effect carried over unchanged: {@code lastPosition} updates to {@code loc} + * while comparing against its previous value. */ - private static final List QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY = Arrays.asList( - "blow", "use", "invoke", "open", "teleport", "rub", "commune", "play"); + private static boolean isNearPath(WorldPoint loc) { + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) return true; - private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { - assert rs2Item != null; - String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); - if (primary != null) { - return primary; + final List path = routeStatus.getWalkablePath(); + if (path.isEmpty()) return true; + + if (loc == null) return true; + + if (config.recalculateDistance() < 0 || routeState.lastPosition.equals(routeState.lastPosition = loc)) { + return true; } - return rs2Item.getActionFromList(Arrays.asList( - "invoke", "empty", "consume", "reminisce", "signal", "squash")); - } - /** - * Labels match {@code quetzals.tsv} destination rows (map icon text). - */ - private static String quetzalMapLabelForDestination(WorldPoint dest) { - assert dest != null; - final int[][] coords = { - {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3221, 0}, {1548, 2995, 0}, - {1437, 3171, 0}, {1779, 3111, 0}, {1700, 3037, 0}, {1670, 2933, 0}, {1446, 3108, 0}, - {1613, 3300, 0}, {1226, 3091, 0}, {1344, 3022, 0}, {1411, 3361, 0}, - }; - final String[] labels = { - "Aldarin", "Civitas illa Fortis", "Hunter Guild", "Quetzacalli Gorge", "Sunset Coast", - "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum Entrance", - "Salvager Overlook", "Tal Teklan", "Kastori", "Auburnvale", - }; - assert coords.length == labels.length; - // Bank / script targets often sit several tiles off quetzals.tsv landing coords. - final int matchTiles = 15; - for (int i = 0; i < coords.length; i++) { - WorldPoint p = new WorldPoint(coords[i][0], coords[i][1], coords[i][2]); - if (dest.distanceTo2D(p) <= matchTiles && dest.getPlane() == p.getPlane()) { - return labels[i]; - } + if (config.usePoh() && PohTeleports.isInHouse()) { + //Would be nice to have access to current node here and check if the current Node is a POH transport node. + return true; } - return null; - } - /** - * Option text on the Quetzal map — Renu uses {@link InterfaceID.QuetzalMenu}, whistle uses {@link InterfaceID.QuetzalwhistleMenu} - * (same icon labels). Prefers resolving from {@link Transport#getDestination()} so bank/custom tiles match. - */ - private static String resolveQuetzalMapOptionLabel(Transport transport) { - assert transport != null; - WorldPoint dest = transport.getDestination(); - if (dest != null) { - String byCoords = quetzalMapLabelForDestination(dest); - if (byCoords != null && !byCoords.isEmpty()) { - return byCoords; - } - } - String di = transport.getDisplayInfo(); - if (di != null && di.contains(":")) { - String[] parts = di.split(":", 2); - if (parts.length >= 2) { - String loc = parts[1].trim(); - if (!loc.isEmpty()) { - return loc; - } + var reachableTiles = Rs2Tile.getReachableTilesFromTile(loc, config.recalculateDistance() - 1); + for (WorldPoint point : path) { + if (reachableTiles.containsKey(point)) { + return true; } } - return dest != null ? quetzalMapLabelForDestination(dest) : null; - } - /** True when any Quetzal or whistle-map layer is visible (CONTENTS alone can stay hidden while MAP/ICONS show). */ - private static boolean isQuetzalMapInterfaceVisible() { - return Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.UNIVERSE) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.MAP) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.ICONS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.CONTENTS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.UNIVERSE) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.MAP) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.ICONS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.CONTENTS); + return false; } - private static boolean finishQuetzalWhistleTransport(Transport transport) { - assert transport != null; - WorldPoint dest = transport.getDestination(); - assert dest != null; - WorldPoint pl = Rs2Player.getWorldLocation(); - if (pl != null && pl.getPlane() == dest.getPlane() && pl.distanceTo2D(dest) < OFFSET) { - log.debug("Quetzal whistle: already within {} tiles of {}, skipping map", OFFSET, dest); - return true; + private static boolean isNearPathByVariance(List path, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || playerLoc == null) { + return false; } - String mapLabel = resolveQuetzalMapOptionLabel(transport); - if (mapLabel == null || mapLabel.isEmpty()) { - log.warn("Quetzal whistle: could not resolve map label (displayInfo={}, destination={})", - transport.getDisplayInfo(), dest); + int closestIdx = getClosestTileIndex(path, playerLoc); + if (closestIdx < 0 || closestIdx >= path.size()) { return false; } - Rs2Player.waitForAnimation(1800); - sleepUntil(() -> isQuetzalMapInterfaceVisible() || !Rs2Player.isAnimating(), 1400); - sleep(Rs2Random.between(120, 260)); - return clickQuetzalMapDestination(mapLabel, dest); + WorldPoint closest = path.get(closestIdx); + return closest != null + && closest.getPlane() == playerLoc.getPlane() + && closest.distanceTo2D(playerLoc) <= PATH_VARIANCE_TOLERANCE_CHEBYSHEV; } - /** - * Finds destination row/icon; map can open before icon layer is built — search full subtree from several roots, - * not only {@link Widget#getDynamicChildren()} of {@link InterfaceID.QuetzalMenu#ICONS}. - */ - private static Widget findQuetzalMapDestinationWidget(String mapOptionLabel) { - assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); - int[] roots = { - InterfaceID.QuetzalMenu.ICONS, - InterfaceID.QuetzalMenu.MAP, - InterfaceID.QuetzalMenu.SCROLL, - InterfaceID.QuetzalMenu.CONTENTS, - InterfaceID.QuetzalMenu.UNIVERSE, - InterfaceID.QuetzalwhistleMenu.ICONS, - InterfaceID.QuetzalwhistleMenu.MAP, - InterfaceID.QuetzalwhistleMenu.SCROLL, - InterfaceID.QuetzalwhistleMenu.CONTENTS, - InterfaceID.QuetzalwhistleMenu.UNIVERSE, - }; - for (int rootId : roots) { - // Widget#getDynamicChildren / isHidden must not run off the client thread — use marshalled helpers. - if (Rs2Widget.isHidden(rootId)) { - continue; + static String offPathRecalcDeferralReason(boolean playerMoving, + boolean playerAnimating, + boolean playerInteracting, + boolean movementOwned, + 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"; + } + // Busy state defers only while the walker owns the movement. Combat retaliation and + // aggro pathing keep moving/animating/interacting true indefinitely, and an unbounded + // defer here paralyzes the walker while something else drags the player off the route. + if (movementOwned) { + if (playerMoving) { + return "moving"; } - Widget root = Rs2Widget.getWidget(rootId); - if (root == null) { - continue; + if (playerAnimating) { + return "animating"; } - Widget hit = Rs2Widget.findWidget(mapOptionLabel, List.of(root), false); - if (hit != null) { - return hit; + 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 (movementOwned && isRecentEvent(nowMs, lastMovedAtMs, OFF_PATH_RECALC_RECENT_MOVEMENT_MS)) { + return "recent-movement"; + } return null; } /** - * Opens no NPC — caller must already have opened the Quetzal map (whistle or Renu). + * Whether current player movement is plausibly the result of a walker-issued action — + * a route/recovery click, a door interaction, or a transport handoff — rather than an + * external force (combat retaliation, aggro, another script). Only owned movement may + * defer the off-path recalc or preempt a recovery click. */ - private static boolean clickQuetzalMapDestination(String mapOptionLabel, WorldPoint expectedDestination) { - assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); - assert expectedDestination != null; - long quetzalStartAt = System.currentTimeMillis(); - - WorldPoint here = Rs2Player.getWorldLocation(); - if (here != null && here.getPlane() == expectedDestination.getPlane() - && here.distanceTo2D(expectedDestination) < OFFSET) { - log.debug("Quetzal map: already within {} tiles of {}, skipping map click", OFFSET, expectedDestination); - return true; - } - - boolean mapVisible = sleepUntilTrue(() -> isQuetzalMapInterfaceVisible(), 100, QUETZAL_MAP_VISIBLE_WAIT_MS); - if (!mapVisible) { - log.error("Quetzal map UI not visible within timeout (label={}, checked UNIVERSE/MAP/ICONS/CONTENTS)", - mapOptionLabel); - return false; - } - WebWalkLog.tmark("quetzal_ui_opened", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - - // ICONS subtree can attach shortly after the shell — brief pause before walking widget tree from walker thread. - sleep(Rs2Random.between(80, 160)); - - AtomicReference destRef = new AtomicReference<>(); - boolean iconReady = sleepUntilTrue(() -> { - Widget w = findQuetzalMapDestinationWidget(mapOptionLabel); - destRef.set(w); - return w != null; - }, 120, QUETZAL_ICON_READY_WAIT_MS); - Widget actionWidget = destRef.get(); - if (!iconReady || actionWidget == null) { - log.error("Could not find Quetzal map icon for: {} (waited for widget tree after map visible)", mapOptionLabel); - return false; - } - WebWalkLog.tmark("quetzal_option_found", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - - Rs2Widget.clickWidget(actionWidget); - log.info("Quetzal map: traveling to {} -> {}", mapOptionLabel, expectedDestination); - WebWalkLog.tmark("quetzal_click_sent", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(expectedDestination, OFFSET), 100, 8000); + private static boolean isMovementWalkerOwned(long nowMs, long minimapClickAtMs) { + long lastOwnedActionAtMs = Math.max( + Math.max(minimapClickAtMs, doorAttemptLedger.settleStartedAtMs()), + Math.max(routeState.lastTransportHandledAtMs, + Math.max(routeState.lastUnreachableRecoveryClickAtMs, routeState.interimSetAtMs))); + return isRecentEvent(nowMs, lastOwnedActionAtMs, WALKER_MOVEMENT_OWNERSHIP_WINDOW_MS); } - private static boolean handleQuetzal(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null || displayInfo.isEmpty()) return false; + private static String currentOffPathRecalcDeferralReason(long minimapClickAtMs) { + long nowMs = System.currentTimeMillis(); + return offPathRecalcDeferralReason( + Rs2Player.isMoving(), + Rs2Player.isAnimating(), + Rs2Player.isInteracting(), + isMovementWalkerOwned(nowMs, minimapClickAtMs), + isDoorInteractionSettling(), + isTransportInteractionSettling(), + routeState.interimTargetWp != null, + nowMs, + routeState.lastMovedTimeMs, + routeState.routeProgressAdvancedAtMs, + minimapClickAtMs, + routeState.interimLastProgressAtMs); + } - WorldPoint destCheck = transport.getDestination(); - WorldPoint plCheck = Rs2Player.getWorldLocation(); - if (destCheck != null && plCheck != null && plCheck.getPlane() == destCheck.getPlane() - && plCheck.distanceTo2D(destCheck) < OFFSET) { - log.debug("Quetzal Renu: already within {} tiles of {}, skip travel UI", OFFSET, destCheck); - return true; + 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)); + } - Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); + static boolean isRecentEvent(long nowMs, long eventAtMs, long graceMs) { + return eventAtMs > 0L && nowMs >= eventAtMs && nowMs - eventAtMs < graceMs; + } - if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { - Rs2Player.waitForWalking(); - WorldPoint dest = transport.getDestination(); - String mapLabel = resolveQuetzalMapOptionLabel(transport); - if (mapLabel == null || mapLabel.isEmpty() || dest == null) { - return false; - } - return clickQuetzalMapDestination(mapLabel, dest); + private static long remainingRecentEventMs(long nowMs, long eventAtMs, long graceMs) { + if (!isRecentEvent(nowMs, eventAtMs, graceMs)) { + return OFF_PATH_RECALC_DEFER_WAIT_MIN_MS; } - return false; + return graceMs - (nowMs - eventAtMs); } - private static boolean handleMasterScrollBook(String destination) { - boolean isMasterScrollBookOpen = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(InterfaceID.Bookofscrolls.CONTENTS), 100, 10000); - if (!isMasterScrollBookOpen) { - log.error("Master Scroll Book did not open within timeout period"); + static boolean hasUpcomingNearbyTransportStep(List path, + int startIdx, + WorldPoint playerLoc, + int lookaheadEdges, + int maxDist) { + if (path == null || path.size() < 2 || startIdx < 0 || playerLoc == null) { return false; } - - Widget bookOfScrollsWidget = Rs2Widget.getWidget(InterfaceID.Bookofscrolls.CONTENTS); - List bookOfScrollsChildren = Arrays.stream(bookOfScrollsWidget.getStaticChildren()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - Widget destinationWidget = Rs2Widget.findWidget(destination, bookOfScrollsChildren, false); - if (destinationWidget == null) return false; - boolean interaction = Rs2Widget.clickWidget(destinationWidget); - if (interaction && destination.equalsIgnoreCase("Revenant cave")) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes, teleport me now"); - } - return interaction; - } - - private static boolean handleMagicCarpet(Transport transport) { - final int flyingPoseAnimation = 6936; - var rugMerchant = Rs2Npc.getNpc(transport.getObjectId()); - if (rugMerchant == null) return false; - - Rs2Npc.interact(rugMerchant, transport.getAction()); - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> Rs2Player.getPoseAnimation() == flyingPoseAnimation, 10000); - return sleepUntilTrue(() -> Rs2Player.getPoseAnimation() != flyingPoseAnimation, 600,60000); - } - - private static boolean handleCharterShip(Transport transport) { - String npcName = transport.getName(); - - Rs2NpcModel npc = Rs2Npc.getNpc(npcName); - log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); - if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { - Rs2Player.waitForWalking(); - if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4), 5000)) { - return false; + int from = Math.max(0, startIdx); + int to = Math.min(path.size() - 2, from + Math.max(0, lookaheadEdges)); + for (int i = from; i <= to; i++) { + if (!isCatalogBackedTransportSegment(path, i)) { + continue; } - - Widget destinationWidget = findCharterDestinationWidget(transport.getDisplayInfo()); - if (!invokeCharterDestinationWidget(destinationWidget, transport.getDisplayInfo())) { - return false; + WorldPoint segFrom = path.get(i); + WorldPoint segTo = path.get(i + 1); + if (segFrom == null || segTo == null || segFrom.getPlane() != playerLoc.getPlane()) { + continue; + } + int d = Math.min(segFrom.distanceTo2D(playerLoc), segTo.distanceTo2D(playerLoc)); + if (d <= Math.max(1, maxDist)) { + return true; } - 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; - } - - Widget textMatch = findCharterDestinationTextWidget(root, destinationText); - if (textMatch == null) { - return null; - } - - Widget clickable = findClickableCharterWidget(textMatch, root); - return clickable != null ? clickable : textMatch; - }).orElse(null); - } - - private static Widget findCharterDestinationTextWidget(Widget widget, String destinationText) { - if (widget == null || widget.isHidden()) { - return null; - } - if (charterWidgetMatchesDestination(widget, destinationText)) { - return widget; - } - - Widget[] staticChildren = widget.getStaticChildren(); - Widget found = findCharterDestinationTextWidget(staticChildren, destinationText); - if (found != null) { - return found; - } - - Widget[] dynamicChildren = widget.getDynamicChildren(); - found = findCharterDestinationTextWidget(dynamicChildren, destinationText); - if (found != null) { - return found; + private static void checkIfStuck() { + // Leagues pending teleports, dialogue, and fairy ring widget should not burn stall budget. + if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { + routeState.lastMovedTimeMs = System.currentTimeMillis(); + routeState.stuckCount = 0; + routeState.prevAnimatingForStuckCheck = Rs2Player.isAnimating(); + return; } - 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; + WorldPoint now = Rs2Player.getWorldLocation(); + boolean anim = Rs2Player.isAnimating(); + if (now != null && now.equals(routeState.lastPosition)) { + boolean nearPath = isNearPath(); + long sinceTileChangeMs = routeState.lastTileChangeAtMs > 0L + ? System.currentTimeMillis() - routeState.lastTileChangeAtMs + : -1L; + boolean poseWalkingNearPath = Rs2WalkerStallPolicy.poseCountsAsProgress( + Rs2Player.isMoving(), nearPath, sinceTileChangeMs, POSE_PROGRESS_TILE_CHANGE_WINDOW_MS); + boolean animProgressNearPath = anim && !routeState.prevAnimatingForStuckCheck && nearPath; + if (animProgressNearPath || poseWalkingNearPath) { + routeState.lastMovedTimeMs = System.currentTimeMillis(); + routeState.stuckCount = 0; + } else { + routeState.stuckCount++; } + } else { + routeState.lastTileChangeAtMs = System.currentTimeMillis(); + routeState.stuckCount = 0; + routeState.lastMovedTimeMs = System.currentTimeMillis(); } - return null; + routeState.prevAnimatingForStuckCheck = anim; } - private static boolean charterWidgetMatchesDestination(Widget widget, String destinationText) { - String needle = normalizeCharterWidgetText(destinationText); - if (needle.isEmpty()) { + // Base stall threshold. See stallThresholdMs() for activity-aware scaling. + // RuneLite exposes no real-time ping, so we skip pure latency scaling and rely on + // observable activity states that also correlate with legitimately-stuck players. + // + // Held at 12s deliberately. The longest LEGITIMATE stationary stretch measured across four live + // farm runs is ~7.1s, during a transport handoff — the player is standing still while a ship or + // teleport resolves and nothing is wrong. 12s keeps roughly five seconds of margin over that. + // Cutting the base is the obvious way to make recovery snappier and the wrong one: it trades a + // slow recovery for a walker that interrupts its own transports. + static final long STALL_BASE_MS = 12_000; + static final double STALL_COMBAT_MULTIPLIER = 2.0; + static final double STALL_ANIMATING_MULTIPLIER = 1.5; + static final double STALL_MOVING_MULTIPLIER = 1.35; + /** + * A sticky interim waypoint used to buy a 1.75x threshold, on the reasoning that a long segment + * can outlast the base stall. It cannot: while the player is walking toward the interim, every + * tile change refreshes the clock. The multiplier only ever bound the case where the player is + * STATIONARY with an interim live — and the idle nudge already rescues that within ~1-2s, long + * before any stall threshold is in sight. Kept above 1.0 for the tick or two between issuing a + * click and the first step. + */ + static final double STALL_INTERIM_MINIMAP_MULTIPLIER = 1.25; + static final double STALL_INTERACTING_MULTIPLIER = 1.5; + /** + * How recently the player must have actually changed tile for the pose-based movement flag to + * count as route progress. A walking step is ~600ms and a running one ~300ms, so a healthy walk + * refreshes this many times over; a player turning on the spot never does. + */ + private static final long POSE_PROGRESS_TILE_CHANGE_WINDOW_MS = 2_500L; + + private static boolean interactingActorNearWalkablePath() { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) { return false; } - if (normalizeCharterWidgetText(widget.getText()).contains(needle) - || normalizeCharterWidgetText(widget.getName()).contains(needle)) { - return true; + List path = routeStatus.getWalkablePath(); + if (path.isEmpty()) { + return false; } - String[] actions = widget.getActions(); - if (actions == null) { + Actor actor = Rs2Player.getInteracting(); + if (actor == 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 ""; + WorldPoint loc = actor.getWorldLocation(); + if (loc == null) { + return false; } - 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; + for (WorldPoint p : path) { + if (p == null || p.getPlane() != loc.getPlane()) { + continue; } - if (current == root) { - return null; + if (p.distanceTo2D(loc) <= 2) { + return true; } - current = current.getParent(); } - return null; + return false; } - private static boolean hasWidgetActions(Widget widget) { - String[] actions = widget.getActions(); - return actions != null && Arrays.stream(actions).anyMatch(action -> action != null && !action.isEmpty()); + private static long stallThresholdMs() { + return Rs2WalkerStallPolicy.computeThresholdMs( + STALL_BASE_MS, + STALL_COMBAT_MULTIPLIER, + STALL_ANIMATING_MULTIPLIER, + STALL_MOVING_MULTIPLIER, + STALL_INTERIM_MINIMAP_MULTIPLIER, + STALL_INTERACTING_MULTIPLIER, + Rs2Player.isInCombat(), + Rs2Player.isAnimating(), + Rs2Player.isMoving(), + routeState.interimTargetWp != null, + (Rs2Player.isMoving() || Rs2Player.isAnimating()) && interactingActorNearWalkablePath()); } - private static boolean invokeCharterDestinationWidget(Widget widget, String destinationText) { - if (widget == null) { + private static boolean isStuckTooLong() { + if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { return false; } - String option = getFirstWidgetAction(widget); - if (option == null || option.isBlank()) { - option = destinationText; + long routeProgressAt = routeState.routeProgressAdvancedAtMs; + if (routeProgressAt > 0L && System.currentTimeMillis() - routeProgressAt < ROUTE_PROGRESS_STALL_GRACE_MS) { + return false; } - NewMenuEntry destinationMenuEntry = new NewMenuEntry() - .option(option) - .target("") - .identifier(1) - .type(MenuAction.CC_OP) - .param0(widget.getIndex()) - .param1(widget.getId()) - .forceLeftClick(false); + return routeState.lastMovedTimeMs > 0 && System.currentTimeMillis() - routeState.lastMovedTimeMs > stallThresholdMs(); + } - Rectangle bounds = widget.getBounds(); - Microbot.doInvoke(destinationMenuEntry, bounds != null ? bounds : Rs2UiHelper.getDefaultRectangle()); - return true; + /** + * @param start + */ + public void setStart(WorldPoint start) { + Set targets = Rs2PathApi.getActiveRouteTargets(); + if (targets.isEmpty()) { + return; + } + Rs2PathApi.setStartPointSet(true); + if (isClientThread()) { + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); + } else { + restartPathfinding(start, targets); + } } - private static String getFirstWidgetAction(Widget widget) { - String[] actions = widget.getActions(); - if (actions == null) { + /** + * Of these candidate tiles, the one the pathfinder can actually reach most cheaply — or null when + * none of them is reachable. + * + *

Choosing somewhere to stand by proximity is wrong whenever a wall or a closed door separates + * the nearest tile from the player. A local reachability BFS does not rescue it either: the BFS + * stops at the door, so the tile on the far side — often the only usable one — is invisible to it. + * The pathfinder is the component that knows doors and transports, and it takes a whole set of + * targets natively, so asking it once answers the question that actually matters: which of + * these can I get to? + * + *

Worked case: approaching the Black Knights' Fortress ladder from (3024,3512), the tiles beside + * it are walkable and adjacent but walled off, while the usable approach is east through a Sturdy + * door. Proximity picks a walled tile every time; this picks the one with a route. + * + * @param start where we are pathing from + * @param candidates tiles worth standing on, in no particular order + * @return the reachable candidate, or null if the pathfinder cannot reach any of them + */ + public static WorldPoint nearestReachable(WorldPoint start, Collection candidates) { + if (start == null || candidates == null || candidates.isEmpty()) { return null; } - return Arrays.stream(actions) - .filter(action -> action != null && !action.isEmpty()) - .findFirst() + Set targets = new HashSet<>(candidates); + if (targets.contains(start)) { + return start; + } + // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. + return Rs2PathApi.plan(Rs2RouteRequest.toAny(start, targets)) + .getReachedTarget(0) .orElse(null); } - private static void confirmCharterTravelIfPrompted() { - if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2000)) { - Rs2Dialogue.clickOption("Yes", true); - } - } /** - * interact with interfaces like spirit tree etc... + * Checks the distance between startpoint and endpoint using ShortestPath * - * @param transport + * @param startpoint + * @param endpoint + * @return distance */ - private static boolean interactWithAdventureLog(Transport transport) { - if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; + public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) { + return Rs2PathApi.plan(Rs2RouteRequest.to(startpoint, endpoint)).getPath().size(); + } - // Wait for the widget to become visible - boolean isAdventureLogVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER), Rs2Player::isMoving, 100, 10000); - if (!isAdventureLogVisible) { - log.error("Widget did not become visible within the timeout."); - return false; - } - String destinationString = transport.getDisplayInfo().replaceAll("^\\d+:\\s*", ""); - Widget destinationWidget = Rs2Widget.findWidget(destinationString, List.of(Rs2Widget.getWidget(187, 3))); - if (destinationWidget == null) return false; - Rs2Widget.clickWidget(destinationWidget); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); - } - private static boolean handleGlider(Transport transport) { - int TA_QUIR_PRIW = 9043972; - int SINDARPOS = 9043975; - int LEMANTO_ANDRA = 9043978; - int KAR_HEWO = 9043981; - int GANDIUS = 9043984; - int OOKOOKOLLY_UNDRI = 9043993; - int LEMANTOLLY_UNDRI = 9043989; - // Get Transport Information - String displayInfo = transport.getDisplayInfo(); - String npcName = transport.getName(); - String action = transport.getAction(); - final int GLIDER_PARENT_WIDGET = 138; - final int GLIDER_CHILD_WIDGET = 0; - // Check if the widget is already visible - boolean isGliderMenuVisible = Rs2Widget.getWidget(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET) != null; - if (!isGliderMenuVisible) { - // Find the glider NPC - var gnome = Rs2Npc.getNpc(npcName); // Use the NPC name to find the NPC - if (gnome == null) { - return false; - } - // Interact with the gnome glider NPC - if (Rs2Npc.interact(gnome, action)) { - sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); - } - } - // Wait for the widget to become visible - boolean widgetVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET), Rs2Player::isMoving, 100, 10000); - if (!widgetVisible) { - log.error("Widget did not become visible within the timeout."); - return false; - } + /** + * Inventory menu action order for opening the Quetzal map from the whistle. + * Generic teleport keyword lists put {@code invoke} before {@code blow}; matching Invoke first often does not open the map. + */ + private static final List QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY = Arrays.asList( + "blow", "use", "invoke", "open", "teleport", "rub", "commune", "play"); - if (displayInfo.isEmpty()) return false; - - switch (displayInfo) { - case "Kar-Hewo": - return Rs2Widget.clickWidget(KAR_HEWO); - case "Ta Quir Priw": - return Rs2Widget.clickWidget(TA_QUIR_PRIW); - case "Sindarpos": - return Rs2Widget.clickWidget(SINDARPOS); - case "Lemanto Andra": - return Rs2Widget.clickWidget(LEMANTO_ANDRA); - case "Gandius": - return Rs2Widget.clickWidget(GANDIUS); - case "Ookookolly Undri": - return Rs2Widget.clickWidget(OOKOOKOLLY_UNDRI); - case "Lemantolly Undri": - return Rs2Widget.clickWidget(LEMANTOLLY_UNDRI); - default: - log.error("{} not found on the interface.", displayInfo); - return false; + private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { + assert rs2Item != null; + String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); + if (primary != null) { + return primary; } + return rs2Item.getActionFromList(Arrays.asList( + "invoke", "empty", "consume", "reminisce", "signal", "squash")); } - // Constants for widget IDs - private static final int SLOT_ONE = 26083331; - private static final int SLOT_TWO = 26083332; - private static final int SLOT_THREE = 26083333; - private static final int SLOT_ONE_CW_ROTATION = 26083347; - private static final int SLOT_ONE_ACW_ROTATION = 26083348; - private static final int SLOT_TWO_CW_ROTATION = 26083349; - private static final int SLOT_TWO_ACW_ROTATION = 26083350; - private static final int SLOT_THREE_CW_ROTATION = 26083351; - private static final int SLOT_THREE_ACW_ROTATION = 26083352; - private static int fairyRingGraphicId = 569; - private static boolean handleFairyRing(Transport transport) { - Rs2ItemModel startingWeapon = null; - TileObject fairyRingObject = PohTeleports.isInHouse() ? PohTeleports.getFairyRings() : Rs2GameObject.getAll(o -> Objects.equals(o.getWorldLocation(), transport.getOrigin())).stream().findFirst().orElse(null); - if (fairyRingObject == null) return false; - if (!PohTeleports.isInHouse() && !Rs2GameObject.canWalkTo(fairyRingObject, 25)) return false; - boolean hasLumbridgeElite = Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; - if (!hasLumbridgeElite) { - if (Rs2Equipment.isWearing(EquipmentInventorySlot.WEAPON)) { - startingWeapon = Rs2Equipment.get(EquipmentInventorySlot.WEAPON); - } - if (!Rs2Equipment.isWearing("Dramen staff") && !Rs2Equipment.isWearing("Lunar staff")) { - if (Rs2Inventory.contains("Dramen staff")) { - Rs2Inventory.equip("Dramen staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Dramen staff")); - } else if (Rs2Inventory.contains("Lunar staff")) { - Rs2Inventory.equip("Lunar staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Lunar staff")); - } else { - return false; - } - } - } - String lastDestinationAction = "last-destination (" + transport.getDisplayInfo() + ")"; - String treeLastDestinationAction = "Ring-last-destination (" + transport.getDisplayInfo() + ")"; - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(fairyRingObject); - log.info("Interacting with Fairy Ring @ {}", fairyRingObject.getWorldLocation()); - // we can use the last-destination to handle fairy rings - if (Rs2GameObject.hasAction(composition, lastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, lastDestinationAction); - } else if (Rs2GameObject.hasAction(composition, treeLastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, treeLastDestinationAction); - } else { - // We have to configure fairy rings through the interface - if (Rs2GameObject.hasAction(composition, "Configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Configure"); - } else if (Rs2GameObject.hasAction(composition, "Ring-configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Ring-configure"); - } - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON), 10000); - if (Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON)) { - log.warn("Fairy ring interface did not open (interrupted by combat?). Retrying next iteration."); - return false; - } - Widget slotOne = Rs2Widget.getWidget(SLOT_ONE); - Widget slotTwo = Rs2Widget.getWidget(SLOT_TWO); - Widget slotThree = Rs2Widget.getWidget(SLOT_THREE); - if (slotOne == null || slotTwo == null || slotThree == null) { - log.warn("Fairy ring slot widget(s) are null; interface may have closed unexpectedly."); - return false; - } - rotateSlotToDesiredRotation(SLOT_ONE, slotOne.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(0)), SLOT_ONE_ACW_ROTATION, SLOT_ONE_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_TWO, slotTwo.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(1)), SLOT_TWO_ACW_ROTATION, SLOT_TWO_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_THREE, slotThree.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(2)), SLOT_THREE_ACW_ROTATION, SLOT_THREE_CW_ROTATION); - Rs2Widget.clickWidget(ComponentID.FAIRY_RING_TELEPORT_BUTTON); - } - sleepUntil(() -> Rs2Player.getGraphicId() == fairyRingGraphicId, 5000); - sleepUntil(() -> Objects.equals(Rs2Player.getWorldLocation(), transport.getDestination()) && Rs2Player.getGraphicId() != fairyRingGraphicId, 10000); - if (startingWeapon != null) { - Rs2ItemModel finalStartingWeapon = startingWeapon; - Rs2Inventory.equip(finalStartingWeapon.getId()); - sleepUntil(() -> Rs2Equipment.isWearing(finalStartingWeapon.getId())); - } - return true; - } - /** - * Rotates a fairy ring slot to the desired rotation value. - * Calculates the most efficient rotation direction (clockwise or anticlockwise) - * and performs the necessary number of rotations to reach the target. - * - * @param slotId The widget ID of the slot to rotate - * @param currentRotation The current rotation value of the slot - * @param desiredRotation The target rotation value to achieve - * @param slotAcwRotationId The widget ID for anticlockwise rotation button - * @param slotCwRotationId The widget ID for clockwise rotation button - */ - private static void rotateSlotToDesiredRotation(int slotId, int currentRotation, int desiredRotation, int slotAcwRotationId, int slotCwRotationId) { - int anticlockwiseTurns = (desiredRotation - currentRotation + 2048) % 2048; - int clockwiseTurns = (currentRotation - desiredRotation + 2048) % 2048; - - int turns = Math.min(clockwiseTurns, anticlockwiseTurns) / 512; - boolean rotateCW = clockwiseTurns <= anticlockwiseTurns; - int rotationWidget = rotateCW ? slotCwRotationId : slotAcwRotationId; - - for (int i = 0; i < turns; i++) { - final int previousRotation = currentRotation; - Rs2Widget.clickWidget(rotationWidget); - - sleepUntil(() -> { - Widget slotWidget = Rs2Widget.getWidget(slotId); - return slotWidget != null && slotWidget.getRotationY() != previousRotation; - }, 2000); - - Widget slotWidget = Rs2Widget.getWidget(slotId); - if (slotWidget != null) { - currentRotation = slotWidget.getRotationY(); - } else { - break; - } - } - sleepUntil(() -> { - Widget slotWidget = Rs2Widget.getWidget(slotId); - return slotWidget != null && slotWidget.getRotationY() == desiredRotation; - }, 3000); - } + /** - * Maps fairy ring letters to their corresponding rotation values. - * Each letter corresponds to a specific rotation degree needed for fairy ring teleportation. + * interact with interfaces like spirit tree etc... * - * @param letter The fairy ring letter (A-Z) to get rotation for - * @return The rotation value (0, 512, 1024, or 1536) for the letter, or -1 if invalid + * @param transport */ - private static int getDesiredRotation(char letter) { - switch (letter) { - case 'A': - case 'I': - case 'P': - return 0; - case 'B': - case 'J': - case 'Q': - return 512; - case 'C': - case 'K': - case 'R': - return 1024; - case 'D': - case 'L': - case 'S': - return 1536; - default: - return -1; - } - } + /** The Lovakengj minecart destination list: TEXT entries under 947:9, one per station. */ + static final int MINECART_MENU_GROUP = 947; + static final int MINECART_MENU_LIST_CHILD = 9; + + + + + + // Constants for widget IDs + static final int SLOT_ONE = 26083331; + static final int SLOT_TWO = 26083332; + static final int SLOT_THREE = 26083333; + + static final int SLOT_ONE_CW_ROTATION = 26083347; + static final int SLOT_ONE_ACW_ROTATION = 26083348; + static final int SLOT_TWO_CW_ROTATION = 26083349; + static final int SLOT_TWO_ACW_ROTATION = 26083350; + static final int SLOT_THREE_CW_ROTATION = 26083351; + static final int SLOT_THREE_ACW_ROTATION = 26083352; + static int fairyRingGraphicId = 569; + + + /** * Checks if the specified item ID corresponds to a teleportation item. @@ -11924,24 +7238,10 @@ private static int getDesiredRotation(char letter) { * @return true if the item is a teleportation item, false otherwise */ public static boolean isTeleportItem(int itemId) { - if (Rs2PathApi.getPathfinderConfig().getAllTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - - Set teleportItemIds = Rs2PathApi.getPathfinderConfig().getAllTransports().values() - .stream() - .flatMap(Set::stream) - .filter(t -> TransportType.isTeleport(t.getType(), t.getOrigin())) - .map(Transport::getItemIdRequirements) - .flatMap(Set::stream) - .flatMap(Set::stream) - .collect(Collectors.toSet()); - - // Items that are not included in transports - teleportItemIds.add(ItemID.DRAMEN_STAFF); - teleportItemIds.add(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF); - - return teleportItemIds.contains(itemId); + return Rs2PathApi.isTeleportItem( + itemId, + ItemID.DRAMEN_STAFF, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF); } @@ -11968,47 +7268,28 @@ public static int findNearestAccessibleTarget(WorldPoint startPoint, List targetSet = new HashSet<>(targets); - - // Store original configuration to restore later - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankItems); - // Configure pathfinder - Rs2PathApi.getPathfinderConfig().refresh(); - // Run pathfinder - Pathfinder pf = new Pathfinder(Rs2PathApi.getPathfinderConfig(), startPoint, targetSet); - pf.run(); - - List path = pf.getPath(); - if (path.isEmpty()) { - log.debug("Unable to find path to any target from starting point: " + startPoint); - return -1; - } - - // Find which target corresponds to the end of the path - WorldPoint nearestTile = path.get(path.size() - 1); - WorldArea nearestTileArea = new WorldArea(nearestTile, tolerance, tolerance); - - // Find the target that matches the final path destination - for (int i = 0; i < targets.size(); i++) { - WorldPoint target = targets.get(i); - WorldArea targetArea = new WorldArea(target, tolerance, tolerance); - if (targetArea.intersectsWith2D(nearestTileArea)) { - log.debug("Found nearest accessible target at index " + i + ": " + target + " (path ended at: " + nearestTile + ")"); - return i; - } - } - - log.debug("Path found but no target matched the destination: " + nearestTile); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.toAny(startPoint, targetSet).withBankItems(useBankItems)); + WorldPoint nearestTile = route.getEndpoint().orElse(null); + if (nearestTile == null) { + log.debug("Unable to find path to any target from starting point: " + startPoint); return -1; + } - } finally { - // Always restore original configuration - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + WorldArea nearestTileArea = new WorldArea(nearestTile, tolerance, tolerance); + for (int i = 0; i < targets.size(); i++) { + WorldPoint target = targets.get(i); + WorldArea targetArea = new WorldArea(target, tolerance, tolerance); + if (targetArea.intersectsWith2D(nearestTileArea)) { + log.debug("Found nearest accessible target at index " + i + ": " + target + " (path ended at: " + nearestTile + ")"); + return i; + } } + + log.debug("Path found but no target matched the destination: " + nearestTile); + return -1; } /** @@ -12058,6 +7339,16 @@ public static List getTransportsForDestination(WorldPoint destination return getTransportsForDestination(destination, useBankItems, TransportType.TELEPORTATION_ITEM); } + /** + * Planner-independent counterpart to {@link #getTransportsForDestination(WorldPoint, boolean)}. + * New banking and execution code must use this exact selected-edge view. + */ + public static List getTransportEdgesForDestination( + WorldPoint destination, boolean useBankItems) + { + return Rs2WalkerBankingPlanner.getTransportEdgesForDestination(destination, useBankItems); + } + /** * Prepares and analyzes required transport items for reaching a destination. * Similar but improved to Rs2Slayer.prepareItemTransports() @@ -12104,6 +7395,12 @@ public static List getMissingTransports(List transports) { return Rs2WalkerBankingPlanner.getMissingTransports(transports); } + public static List getMissingTransportEdges( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdges(transports); + } + /** * Extracts item IDs and their required quantities for the given transports that are missing and available in bank. * Enhanced version that uses Rs2Magic and Rs2Spells systems for actual rune quantities on teleportation spells. @@ -12115,6 +7412,18 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(transports); } + public static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities(transports); + } + + public static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout(transports); + } + /** * Extracts item IDs that are missing for the given transports and available in bank. * Legacy method maintained for backward compatibility. @@ -12219,7 +7528,9 @@ public static WalkerState walkWithBankedTransportsAndState(WorldPoint target, in } } try { - return walkWithBankedTransportsAndStateLocked(target, distance, forceBanking); + return withShadowExecutionEvidence( + () -> walkWithBankedTransportsAndStateLocked( + target, distance, forceBanking)); } finally { walkerLock.unlock(); } @@ -12240,8 +7551,8 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar if (Rs2Tile.getReachableTilesFromTile(pl, distance).containsKey(target) || nearUnwalkableGoal) { return WalkerState.ARRIVED; } - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null && !pathfinder.isDone()) + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (routeStatus.isCalculating()) return WalkerState.MOVING; boolean bankTripWhenCacheUnavailable = config == null || config.bankTripWhenCacheUnavailable(); @@ -12280,15 +7591,22 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar TransportRouteAnalysis comparison = compareRoutes(target); WebWalkLog.tmark("compare_done", System.currentTimeMillis() - compareStartedAt, target, pl, "direct=" + comparison.getDirectDistance() + " bank=" + comparison.getBankingRouteDistance()); - List missingTransports = getMissingTransports(getTransportsForDestination(target, true, TransportType.TELEPORTATION_SPELL)); + List missingTransports = getMissingTransportEdges( + Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(comparison)); - Map missingItemsWithQuantities = getMissingTransportItemIdsWithQuantities(missingTransports); + Rs2TransportLoadout transportLoadout = getMissingTransportEdgeLoadout(missingTransports); + Map missingItemsWithQuantities = transportLoadout.getWithdrawals(); if (!missingTransports.isEmpty()) { - WebWalkLog.bankWalkDebug("missing_items nTrans={} to={} missingKinds={}", - missingTransports.size(), target, missingItemsWithQuantities.size()); + WebWalkLog.bankWalkDebug("missing_items nTrans={} to={} missingKinds={} equipKinds={} satisfiable={}", + missingTransports.size(), target, missingItemsWithQuantities.size(), + transportLoadout.getEquipmentItemIds().size(), transportLoadout.isSatisfiable()); + } + if (!transportLoadout.isSatisfiable()) { + WebWalkLog.spWarn("bank_walk | selected bank route has no executable loadout goal={}", target); + return forceBanking ? WalkerState.EXIT : walkWithStateInternal(target, distance); } // If no missing transport items, go directly - if (missingItemsWithQuantities.isEmpty() && !forceBanking) { + if (transportLoadout.isEmpty() && !forceBanking) { WebWalkLog.spInfo("bank_walk | direct_no_missing_items goal={}", target); WalkerState state = walkWithStateInternal(target, distance); if (state == WalkerState.ARRIVED) { @@ -12315,7 +7633,8 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar log.info("\n\tUsing banking route: \n\t\tStart: {} -> Bank: {} -> Target: {}", Rs2Player.getWorldLocation(), comparison.getBankLocation(), target); // Handle the complete banking workflow using legacy walkTo approach - return walkWithBankingState(comparison.getBankLocation(), missingItemsWithQuantities, target, distance); + return walkWithBankingState( + comparison.getBankLocation(), transportLoadout, target, distance); } else { log.warn("\n\tBanking route requested but no accessible bank found, trying direct route"); return walkWithStateInternal(target, distance); @@ -12329,16 +7648,6 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar } - /** - * Ceiling for how long the inventory-only path may be before a "close" target (≤100 - * chebyshev) loses its right to skip the bank compare. 3x straight-line absorbs honest - * wall-hugging and indoor zigzags; the 60-tile floor keeps tiny distances from tripping - * on ordinary detours around buildings. Anything above this is a real detour — a gate the - * player lacks the item/fare for — and the banked flow must get its chance to fetch it. - */ - static int shortWalkDirectPathCeiling(int chebyshevDistance) { - return Math.max(60, chebyshevDistance * 3); - } /** * When the last bootstrap attempt found no bank it is pointless — and expensive, it runs a @@ -12396,31 +7705,20 @@ private static WalkerState bootstrapBankMirrorForBankedPathing(int distance) { /** - * Handles the complete banking workflow using legacy walkTo: walk to bank, open, withdraw items, close, continue to target. - * Enhanced version that accepts a map of item IDs with their required quantities and returns boolean. - * - * @param bankLocation The bank location to visit - * @param missingItemsWithQuantities Map of item IDs and their required quantities - * @param finalTarget The final destination after banking - * @return true if the banking workflow was successful, false otherwise - */ - private static boolean walkWithBanking(WorldPoint bankLocation, Map missingItemsWithQuantities, WorldPoint finalTarget) { - return walkWithBankingState(bankLocation, missingItemsWithQuantities, finalTarget, 10)== WalkerState.ARRIVED; - } - - /** - * Handles the complete banking workflow using walkWithState: walk to bank, open, withdraw items, close, continue to target. - * Enhanced version that accepts a map of item IDs with their required quantities and returns WalkerState. + * Handles the complete banking workflow using the immutable preparation selected for the exact + * bank-to-target route: walk to bank, withdraw, equip, close, refresh inventory-only policy and + * continue to the target. * - * @param missingItemsWithQuantities Map of item IDs and their required quantities + * @param transportLoadout Withdrawals and equipment changes required by the selected route * @param finalTarget The final destination after banking * @return WalkerState indicating the result of the banking workflow */ private static WalkerState walkWithBankingState(WorldPoint bankLocation, - Map missingItemsWithQuantities, + Rs2TransportLoadout transportLoadout, WorldPoint finalTarget,int distance) { try { - if (bankLocation == null || finalTarget == null) { + if (bankLocation == null || finalTarget == null || transportLoadout == null + || !transportLoadout.isSatisfiable()) { log.warn("Cannot perform banking workflow with null locations"); return WalkerState.EXIT; } @@ -12443,6 +7741,7 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, } // Step 3: Withdraw missing transport items + Map missingItemsWithQuantities = transportLoadout.getWithdrawals(); if (!missingItemsWithQuantities.isEmpty()) { log.debug("Withdrawing transport items with quantities: " + missingItemsWithQuantities); @@ -12456,11 +7755,17 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, if (amountToWithdraw > 0) { if (Rs2Bank.hasBankItem(itemId, amountToWithdraw)) { log.debug("Withdrawing {} x {} (item ID: {})", amountToWithdraw, itemId, itemId); - Rs2Bank.withdrawX(itemId, amountToWithdraw); - sleepUntil(() -> Rs2Inventory.count(itemId) >= currentCount + amountToWithdraw, 3000); + if (!Rs2Bank.withdrawX(itemId, amountToWithdraw) + || !sleepUntil(() -> Rs2Inventory.count(itemId) + >= currentCount + amountToWithdraw, 3000)) { + log.warn("Failed to withdraw required transport item {} x{}", + itemId, amountToWithdraw); + return WalkerState.EXIT; + } } else { log.warn("Required transport item {} not found in bank (need {} but bank has less)", itemId, amountToWithdraw); + return WalkerState.EXIT; } } else { log.debug("Already have enough of item {}: {} (need {})", itemId, currentCount, amountNeeded); @@ -12471,6 +7776,18 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, sleepTickJitter(1); } + for (Integer equipmentItemId : transportLoadout.getEquipmentItemIds()) { + if (Rs2Equipment.isWearing(equipmentItemId)) { + continue; + } + if (!Rs2Inventory.hasItem(equipmentItemId) + || !Rs2Bank.wearItem(equipmentItemId) + || !sleepUntil(() -> Rs2Equipment.isWearing(equipmentItemId), 3000)) { + log.warn("Failed to equip required transport provider {}", equipmentItemId); + return WalkerState.EXIT; + } + } + // Step 4: Close bank Rs2Bank.closeBank(); sleepUntil(() -> !Rs2Bank.isOpen(), 3000); @@ -12478,8 +7795,10 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, log.warn("Failed to close bank after withdrawals"); return WalkerState.EXIT; } - Rs2PathApi.getPathfinderConfig().setUseBankItems(false); - Rs2PathApi.getPathfinderConfig().refresh(finalTarget); + if (!Rs2PathApi.prepareInventoryOnlyRoute(finalTarget)) { + log.warn("Shortest-path configuration unavailable after bank withdrawals"); + return WalkerState.EXIT; + } // Step 5: Continue to final target log.debug("Banking complete, continuing to final target: " + finalTarget); return walkWithStateInternal(finalTarget, distance); @@ -12509,28 +7828,98 @@ public static boolean closeWorldMap() { return sleepUntil(() -> !Rs2Widget.isWidgetVisible(InterfaceID.Worldmap.CLOSE), 3000); } - private static boolean handleBarrowsDigTransport(Transport transport) { - WorldPoint playerAtMound = Rs2Player.getWorldLocation(); - if (playerAtMound == null || !playerAtMound.equals(transport.getOrigin())) { - // Digging is tile-sensitive; let the ordinary route approach finish first. + + + /** + * Pure settle decision after a handled transport. Settling ends as soon as the player is confirmed + * ARRIVED — standing at/next to the transport's planned destination, neither moving nor animating — + * after a one-tick floor for post-action state flux; {@link #TRANSPORT_POST_INTERACT_SETTLE_MS} is + * only the ceiling for when arrival never confirms (unknown destination, drawn-out travel). The old + * check compared against where the player stood when the transport was MARKED handled, which after + * landing is always true while standing still — so the settle could only ever end by timeout, a fixed + * ~900ms freeze after every single transport. + */ + static boolean transportSettlePending(long ageMs, WorldPoint now, WorldPoint plannedDestination, + boolean moving, boolean animating) { + if (ageMs < 0L || ageMs > TRANSPORT_POST_INTERACT_SETTLE_MS) { return false; } - if (!attemptObserved(transport, () -> Rs2Inventory.interact(ItemID.SPADE, "Dig"))) { - return false; + if (ageMs < POST_INTERACT_SETTLE_MIN_MS) { + return true; + } + if (now == null || plannedDestination == null) { + return ageMs <= TRANSPORT_POST_INTERACT_SETTLE_MS / 2; + } + boolean arrivedIdle = now.getPlane() == plannedDestination.getPlane() + && now.distanceTo2D(plannedDestination) <= 1 + && !moving && !animating; + return !arrivedIdle; + } + + static boolean isClientThreadReadTimeout(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof TimeoutException) { + return true; + } + current = current.getCause(); } - boolean enteredCrypt = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf( - transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (enteredCrypt) { - return finishHandledTransport(transport); - } - WebWalkLog.spWarn( - "Barrows dig post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); return false; } + + + + static String normalizeCharterWidgetText(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + return Rs2UiHelper.stripTagsToSpace(text) + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", " "); + } + + /** + * Whether {@code a -> b} is inside the traversal envelope of the door this walker owns within + * the claim window. The live-collision route validator uses this as "the executor owns that + * step, leave it alone": a shut door on the route honestly reads blocked, and recalculating + * the route out from under an in-progress door interaction was observed on a quest door + * (fightarena_door1, 2585,3141) that is in no transport catalog — the catalog check alone cannot + * cover doors the walker handles purely as scene objects. + */ + public static boolean isActiveDoorEdge(WorldPoint a, WorldPoint b) { + long now = System.currentTimeMillis(); + DoorAttemptLedger.Attempt claim = + doorAttemptLedger.latestAttempt(ACTIVE_DOOR_EDGE_CLAIM_MS, now); + if (claim != null && WalledDoorClaimPolicy.ownsTraversalEdge(claim.from, claim.to, a, b)) { + return true; + } + return WalledDoorClaimPolicy.isFresh(routeState.walledDoorEdgeFrom, routeState.walledDoorEdgeTo, + routeState.walledDoorEdgeAtMs, now) + && WalledDoorClaimPolicy.ownsTraversalEdge( + routeState.walledDoorEdgeFrom, routeState.walledDoorEdgeTo, a, b); + } + + static Map captureRawScanDoorLocationsOnClientThread() { + Map locations = new IdentityHashMap<>(); + if (rawScanWallSnapshot != null) { + for (WallObject wall : rawScanWallSnapshot) { + if (wall != null) { + locations.put(wall, ((TileObject) wall).getWorldLocation()); + } + } + } + if (rawScanGameObjectSnapshot != null) { + for (GameObject object : rawScanGameObjectSnapshot) { + if (object != null) { + locations.put(object, ((TileObject) object).getWorldLocation()); + } + } + } + return locations; + } + + static boolean isMiniMapRecoveryClickable(WorldPoint worldPoint) { + return isMiniMapClickable(worldPoint); + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerDoors.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerDoors.java new file mode 100644 index 00000000000..fba32d85578 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerDoors.java @@ -0,0 +1,2831 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.*; +import net.runelite.api.Point; +import net.runelite.api.annotations.Component; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.*; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.devtools.MovementFlag; +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.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; +import net.runelite.client.plugins.microbot.util.coords.Rs2LocalPoint; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldArea; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; +import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.Runes; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; +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 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; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +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; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry; +import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; +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; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits; +import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; +import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; +import javax.inject.Named; +import java.awt.*; +import java.util.*; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static net.runelite.client.plugins.microbot.util.Global.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2Walker.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports.*; + +/** + * The door component extracted from {@code Rs2Walker} (Phase E2, 2026-08-13): the door cascade's + * entry points (segment handler, segment probe, raw-scan focus, pending/unresolved scans), the + * interaction pipeline (throttle, exception, await, verify, nudge), the crossed-face guards and the + * settle/defer predicates — the door-named methods and their exclusive helpers, moved verbatim. + * Extraction, not unification: the cascade's branching is untouched. Members are package-private so + * the walker and the transport component consume them via static import. + */ +@lombok.extern.slf4j.Slf4j +final class Rs2WalkerDoors { + + private Rs2WalkerDoors() { + } + + /** Same package (e.g. unit tests) only — not part of the script API. */ + static DoorAttemptLedger doorAttemptLedgerForTesting() { + return doorAttemptLedger; + } + + + /** + * An object standing ON the walk target is the destination, not an obstacle en route. The + * Stronghold's Gift of Peace chest sits on the corridor walk's goal tile: the plan honestly ends + * on the chest's tile, the tile reads sealed, and the blocker scan "opened" the goal itself — + * ~9s of failed traversal per corridor run before arrived-within-distance conceded (observed on + * three consecutive runs, 2026-08-13). Wall doors are exempt: a door on the goal tile's EDGE may + * genuinely need opening to step onto the goal. The skip only applies when the walk is allowed + * to finish from the near side without crossing, so a distance-0 walk onto an openable tile + * still attempts the open honestly. + */ + static boolean goalTileObjectIsNotAnObstacle(boolean wallDoor, WorldPoint target, int configuredDistance, + WorldPoint probe, WorldPoint fromWp, WorldPoint toWp) { + if (wallDoor || target == null || fromWp == null || fromWp.getPlane() != target.getPlane()) { + return false; + } + if (!target.equals(probe) && !target.equals(toWp)) { + return false; + } + int finishThreshold = tightFinishThreshold(target, target, configuredDistance); + return fromWp.distanceTo2D(target) <= finishThreshold; + } + + static boolean isGoalTileObjectNotObstacle(TileObject object, WorldPoint probe, + WorldPoint fromWp, WorldPoint toWp) { + return goalTileObjectIsNotAnObstacle(object instanceof WallObject, currentTarget, currentWalkDistance, + probe, fromWp, toWp); + } + + /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */ + /** + * 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. + */ + /** @return true only when a canvas click was actually issued, so the caller can size its minimap hold-off. */ + static boolean maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List path) { + if (goal == null || path == null || path.isEmpty()) { + return false; + } + WorldPoint p = Rs2Player.getWorldLocation(); + if (p == null || goal.getPlane() != p.getPlane()) { + return false; + } + if (isWalkCancelled(goal)) { + return false; + } + WorldPoint pathLast = path.get(path.size() - 1); + int finishTh = tightFinishThreshold(goal, pathLast, configuredDistance); + int dGoal = p.distanceTo2D(goal); + if (dGoal <= finishTh) { + return false; + } + // Only nudge with fast-canvas when we are effectively on the final approach. + // This avoids immediate scene-click jumps after ordinary mid-route door opens. + if (dGoal > finishTh + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) { + return false; + } + if (dGoal > DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST) { + return false; + } + LocalPoint goalLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), goal); + if (goalLocal == null || !Rs2Camera.isTileOnScreen(goalLocal)) { + return false; + } + Map around = Rs2Tile.getReachableTilesFromTile(goal, DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS); + if (around == null || around.isEmpty()) { + return false; + } + List candidates = new ArrayList<>(); + for (WorldPoint t : around.keySet()) { + if (t == null || !Rs2Tile.isTileReachable(t)) { + continue; + } + if (p.distanceTo2D(t) > DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER) { + continue; + } + candidates.add(t); + } + if (candidates.isEmpty()) { + return false; + } + // candidates non-empty: index range [0, size-1] is valid for betweenInclusive. + WorldPoint pick = candidates.get(Rs2Random.betweenInclusive(0, candidates.size() - 1)); + if (walkFastCanvas(pick)) { + log.debug("[Walker] door nudge: canvas -> {} (goal={} dGoal={})", pick, goal, dGoal); + waitUntilIdleAfterSceneWalk(goal, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, goal, finishTh); + routeState.lastMovedTimeMs = System.currentTimeMillis(); + routeState.stuckCount = 0; + return true; + } + return false; + } + + + /** + * Identity-only "is this walk still the active one": target unchanged, thread not interrupted. + *

+ * Deliberately does NOT evaluate the caller's completion condition, and that is the whole point. + * {@link #isWalkCancelled} runs a user-supplied {@code walkUntil} callback, which for the quester + * means a radius-40 BFS inside {@code runOnClientThreadOptional}. Calling it from a 100ms wait + * loop starved the client thread until every other thread's client-thread hop timed out — seen + * live as TimeoutExceptions in an unrelated BlockingEvent and in the wait's own isMoving() read. + * Completion is still evaluated by the walker's outer loop at its own checkpoints, so a + * bounded wait costs at most its own budget before the caller's condition is honoured. + */ + static boolean isWalkSuperseded(WorldPoint target) { + WorldPoint activeTarget = currentTarget; + return target == null || activeTarget == null || !target.equals(activeTarget) + || Thread.currentThread().isInterrupted(); + } + + /** + * Whether an ACTIONED scene door sits within one tile of either endpoint of the edge — the + * double-gate wing case above. One scene scan (this path is rare and about to replan anyway), + * geometric filter via {@link #doorTileAdjacentToEdgeEndpoints}. + */ + static boolean sceneDoorAdjacentToEdge(WorldPoint a, WorldPoint b) { + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); + return !Rs2GameObject.getAll(o -> { + WorldPoint loc = o.getWorldLocation(); + if (!doorTileAdjacentToEdgeEndpoints(loc, a, b)) { + return false; + } + if (!Rs2DoorDetection.isDoorLikeSceneObject(o)) { + return false; + } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(o); + return Rs2DoorClassifier.getDoorAction(comp, doorActions) != null; + }, a, 3).isEmpty(); + } + + /** Pure geometry: same plane, and the door tile within one tile (Chebyshev) of either endpoint. */ + static boolean doorTileAdjacentToEdgeEndpoints(WorldPoint doorTile, WorldPoint a, WorldPoint b) { + if (doorTile == null || a == null || b == null || doorTile.getPlane() != a.getPlane()) { + return false; + } + return doorTile.distanceTo2D(a) <= 1 || doorTile.distanceTo2D(b) <= 1; + } + + static boolean hasPendingDoorLikeSceneObjectBeforeDirectClick(List rawPath, + List path, + WorldPoint playerLoc, + int directClickMaxDistance) { + List route = rawPath != null && rawPath.size() >= 2 ? rawPath : path; + if (route == null || route.size() < 2 || playerLoc == null) { + return false; + } + + int closest = getClosestTileIndex(route, playerLoc); + if (closest < 0 || closest >= route.size()) { + return false; + } + + int maxEdges = 12; + int radius = Math.max(3, directClickMaxDistance + 2); + int start = Math.max(0, closest - 2); + int endExclusive = Math.min(route.size() - 1, start + maxEdges); + for (int i = start; i < endExclusive; i++) { + WorldPoint from = route.get(i); + WorldPoint to = route.get(i + 1); + if (from == null || to == null) { + continue; + } + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { + break; + } + if (from.distanceTo2D(playerLoc) > radius && to.distanceTo2D(playerLoc) > radius) { + break; + } + if (shouldDeferDoorHandlingToTransport(route, i)) { + continue; + } + if (hasDoorLikeSceneObjectOnSegment(from, to, playerLoc, radius)) { + return true; + } + } + return false; + } + + static boolean handlePendingDoorDuringInterim(List rawPath, + long timeoutMs, + WorldPoint playerLoc) { + // Timed even though the guards "do nothing": this runs once per tile per pass while an + // interim is in flight, and the guard chain itself pays client-thread hops (isMoving). + long passT0 = System.currentTimeMillis(); + try { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null + || isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown() + || isRecoveryMovementInFlight() || Rs2Player.isMoving()) { + return false; + } + + return handlePendingDoorNearRawPath(rawPath, timeoutMs, playerLoc, 2, 14); + } finally { + WalkPassStats.doorProbeMs.addAndGet(System.currentTimeMillis() - passT0); + } + } + + static boolean handlePendingDoorNearRawPath(List rawPath, + long timeoutMs, + WorldPoint playerLoc, + int backtrackEdges, + int lookaheadEdges) { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null) { + return false; + } + if (Rs2Player.isMoving()) { + return false; + } + + int rawStart = getClosestTileIndex(rawPath, playerLoc); + if (rawStart < 0) { + return false; + } + + int start = Math.max(0, rawStart - Math.max(0, backtrackEdges)); + int endExclusive = Math.min(rawPath.size() - 1, rawStart + Math.max(1, lookaheadEdges)); + for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { + WorldPoint a = rawPath.get(ri); + WorldPoint b = rawPath.get(ri + 1); + if (a == null || b == null) { + continue; + } + if (a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { + break; + } + if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { + continue; + } + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { + continue; + } + if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { + continue; + } + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { + return true; + } + } + return false; + } + + /** Wraps the current scan-scoped probe caches for the extracted door-probe logic. */ + static DoorProbeContext doorProbeContext() { + return new DoorProbeContext(rawScanWallSnapshot, rawScanGameObjectSnapshot, + rawScanDoorLocationSnapshot, rawScanDoorCompositionCache, rawScanDoorSegmentCache, + rawScanDoorEligibilityCache); + } + + /** + * The door menu click, timed. "doorOther" is the residual left after the probe and both waits, and + * at ~790ms of a 3181ms scan it is the only part of door handling that is neither the player + * walking nor a scan — so it needs its own number before anyone optimises against it. + */ + static boolean interactDoorTimed(TileObject object, String action) { + long startedAt = System.currentTimeMillis(); + try { + return Rs2GameObject.interact(object, action); + } finally { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorInteractMs += tookMs; + } + doorLegInteractMs += tookMs; + } + } + + static void resetDoorLegStages() { + doorLegFindMs = 0L; + doorLegInteractMs = 0L; + doorLegAwaitMs = 0L; + doorLegVerifyMs = 0L; + doorLegNudgeMs = 0L; + doorLegExceptionMs = 0L; + } + + static String doorLegStageDetail(long totalMs) { + long accounted = doorLegFindMs + doorLegInteractMs + doorLegAwaitMs + doorLegVerifyMs + + doorLegNudgeMs + doorLegExceptionMs; + return " find=" + doorLegFindMs + " interact=" + doorLegInteractMs + " await=" + doorLegAwaitMs + + " verify=" + doorLegVerifyMs + " nudge=" + doorLegNudgeMs + " exception=" + doorLegExceptionMs + + " other=" + Math.max(0L, totalMs - accounted); + } + + /** + * "Is THIS door still shut?" — a radius-{@link #HANDLER_RANGE} rescan that resolves a composition per + * candidate OUTSIDE the scan-scoped memo, so nothing is cached. Only runs when traversal failed, but + * that is exactly the slow path a stuck door repeats, so it is timed separately. + *

+ * STRICT on the probe tile, for the same reason {@code doorObservedOpen} is: this answer decides + * whether the door we just interacted with opened, and the loose two-tile radius let a NEIGHBOURING + * shut door answer for it. Measured live as {@code saw=strict=false loose=true} — this door open, + * a neighbour shut — reading as "did not traverse", which suppressed markStationaryDoorOpened and + * the post-door route click entirely; the walker stood still ~2s until the generic click machinery + * caught up. In a door-heavy area (the exact place chaining matters) that was every door. + */ + static boolean doorStillHasActionTimed(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + long startedAt = System.currentTimeMillis(); + try { + return doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); + } finally { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorVerifyMs += tookMs; + } + doorLegVerifyMs += tookMs; + } + } + + /** + * The door segment probe, timed. "doorProbe" in the slow-scan line is a RESIDUAL — the whole + * handleDoors call minus the interaction wait — so it silently absorbed the edge-resolution wait, + * the menu interaction and the post-interaction verification too. Attributing the probe itself is + * the only way to tell an expensive scan from an expensive wait, and they want opposite fixes. + */ + static TileObject findDoorNearSegmentTimed(WorldPoint fromWp, WorldPoint toWp, List doorActions) { + long startedAt = System.currentTimeMillis(); + try { + return Rs2DoorProbe.findDoorNearSegment(doorProbeContext(), doorAttemptLedger, + STATIONARY_DOOR_SUPPRESS_MS, fromWp, toWp, doorActions); + } finally { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorFindMs += tookMs; + } + doorLegFindMs += tookMs; + } + } + + + /** + * Exact-tile match first, then a one-tile adjacency fallback — the same preference order the + * previous pair of bounded queries produced. + */ + static WallObject resolveProbeWallObject(WorldPoint probe) { + List snapshot = rawScanWallSnapshot; + if (snapshot != null) { + WallObject adjacent = null; + for (WallObject candidate : snapshot) { + if (candidate == null) { + continue; + } + WorldPoint loc = candidate.getWorldLocation(); + if (loc == null) { + continue; + } + if (loc.equals(probe)) { + return candidate; + } + if (adjacent == null && loc.distanceTo2D(probe) <= 1) { + adjacent = candidate; + } + } + return adjacent; + } + WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); + if (wall == null) { + wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); + } + return wall; + } + + /** @see #resolveProbeWallObject(WorldPoint) */ + static TileObject resolveProbeGameObject(WorldPoint probe) { + List snapshot = rawScanGameObjectSnapshot; + if (snapshot != null) { + GameObject adjacent = null; + for (GameObject candidate : snapshot) { + if (candidate == null) { + continue; + } + WorldPoint loc = candidate.getWorldLocation(); + if (loc == null) { + continue; + } + if (loc.equals(probe)) { + return candidate; + } + if (adjacent == null && loc.distanceTo2D(probe) <= 1) { + adjacent = candidate; + } + } + return adjacent; + } + TileObject object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); + if (object == null) { + object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); + } + return object; + } + + static boolean hasDoorCandidateOnRawSegment(List rawPath, int index) { + if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { + return false; + } + if (shouldDeferDoorHandlingToTransport(rawPath, index)) { + return false; + } + boolean isInstance = Microbot.getClient() + .getTopLevelWorldView() + .getScene() + .isInstance(); + WorldPoint rawFrom = rawPath.get(index); + WorldPoint rawTo = rawPath.get(index + 1); + WorldPoint fromWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawFrom) : rawFrom; + WorldPoint toWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawTo) : rawTo; + if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { + return false; + } + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); + return findDoorNearSegmentTimed(fromWp, toWp, doorActions) != null; + } + + static void setRawScanDoorFocus(int index) { + doorAttemptLedger.setRawScanFocus(index, System.currentTimeMillis()); + } + + static boolean shouldUseFocusedRawDoorIndex(List rawPath, int rawStartIdx) { + Integer idx = doorAttemptLedger.rawScanFocusDoorIdx(); + if (idx == null) { + return false; + } + if (routeState.interimTargetWp != null) { + return false; + } + if (System.currentTimeMillis() - doorAttemptLedger.rawScanFocusSetAtMs() > RAW_SCAN_DOOR_FOCUS_MAX_MS) { + return false; + } + if (doorAttemptLedger.rawScanFocusAttempts() >= RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS) { + return false; + } + if (idx < 0 || idx >= rawPath.size() - 1) { + return false; + } + if (rawStartIdx > idx + 1) { + return false; + } + return Math.abs(rawStartIdx - idx) <= 2; + } + + static void clearRawScanDoorFocus(String reason) { + if (doorAttemptLedger.rawScanFocusDoorIdx() != null && debug) { + walkerDiag("clear raw door focus: %s", reason); + } + doorAttemptLedger.clearRawScanFocus(); + } + + static boolean hasQuestLockKeywords(String text) { + if (text == null || text.isEmpty()) return false; + String lc = text.toLowerCase(); + // Phrases that consistently appear on quest/stat-gated doors and gates. + return lc.contains("quest") || lc.contains("you need to") || lc.contains("you must") + || lc.contains("you have not") || lc.contains("cannot enter") + || lc.contains("can't enter") || lc.contains("requires you"); + } + + static boolean isQuestLockedDoorDialogue() { + if (!Rs2Dialogue.isInDialogue()) return false; + return hasQuestLockKeywords(Rs2Dialogue.getDialogueText()); + } + + static boolean handleDoors(List path, int index) { + return handleDoors(path, index, false); + } + + static boolean handleDoors(List path, int index, boolean allowSegmentProbe) { + if (!Rs2PathApi.getActiveRouteStatus().isPresent() || index >= path.size() - 1) return false; + + // Skip any door whose tile was blacklisted after a prior quest-lock detection — + // avoid re-triggering the same failed interact loop this session. + WorldPoint skipFrom = path.get(index); + WorldPoint skipTo = index + 1 < path.size() ? path.get(index + 1) : null; + if (doorAttemptLedger.isDoorBlacklisted(skipFrom) + || (skipTo != null && doorAttemptLedger.isDoorBlacklisted(skipTo))) { + return false; + } + + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); + boolean isInstance = Microbot.getClient() + .getTopLevelWorldView() + .getScene() + .isInstance(); + + WorldPoint rawFrom = path.get(index); + WorldPoint rawTo = path.get(index + 1); + WorldPoint fromWp = isInstance + ? Rs2WorldPoint.convertInstancedWorldPoint(rawFrom) + : rawFrom; + WorldPoint toWp = isInstance + ? Rs2WorldPoint.convertInstancedWorldPoint(rawTo) + : rawTo; + + if (isInstance && (toWp == null || fromWp == null)) { + // Expected inside the PoH when the next tile is a teleport destination + // (convertInstancedWorldPoint -> fromWorldInstance returns null for tiles + // that aren't in the current instance chunk). Log path context so + // unexpected occurrences outside that case can be diagnosed. + log.debug("[Walker] handleDoors: POH/instance conversion returned null (rawFrom={} fromWp={} rawTo={} toWp={}) idx={}/{} — skipping door check", + rawFrom, fromWp, rawTo, toWp, index, path.size()); + return false; + } + + // Cross-plane path steps are always transports (stairs, ladders, trapdoors) — + // door probes on mismatched planes would emit wrong-plane corner coordinates + // and the plane-guard below would reject them anyway. Let handleTransports + // take it. + if (fromWp.getPlane() != toWp.getPlane()) { + return false; + } + + // A door edge the player has already CROSSED (in route direction) is resolved for this walk, + // whatever the door reads now. The Fight Arena quest doors shut themselves the moment you are + // through, so "shut door on my route" stayed true after crossing and the machinery kept + // re-engaging a door behind the player — watched live as the character stepping BACK through + // the door it had just passed, then oscillating. The axis reading is directional, so a walk + // genuinely routed back the other way derives the reversed edge from its own route tiles and + // is unaffected. + WorldPoint playerForCrossing = Rs2Player.getWorldLocation(); + if (playerForCrossing != null + && Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, playerForCrossing)) { + return false; + } + + if (shouldDeferDoorHandlingToTransport(path, index)) { + return false; + } + + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } + + // A broad raw scan already owns immutable wall/game-object snapshots. Resolve the + // segment directly from them instead of running the probe loop, which repeatedly + // requested the same object definitions on the client thread for adjacent raw edges. + if (allowSegmentProbe + && (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null)) { + TileObject snapshotDoor = findDoorNearSegmentTimed(fromWp, toWp, doorActions); + if (snapshotDoor == null) { + return false; + } + if (snapshotDoor instanceof WallObject) { + return tryHandleDoorObject(snapshotDoor, snapshotDoor.getWorldLocation(), + fromWp, toWp, doorActions, true, path); + } + } + + for (int offset = 0; offset <= 1; offset++) { + int doorIdx = index + offset; + if (doorIdx >= path.size()) continue; + + WorldPoint rawDoorWp = path.get(doorIdx); + WorldPoint doorWp = isInstance + ? Rs2WorldPoint.convertInstancedWorldPoint(rawDoorWp) + : rawDoorWp; + + List probes = Rs2DoorAheadResolver.buildSegmentProbes(fromWp, toWp, doorWp); + + for (WorldPoint probe : probes) { + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } + boolean adjacentToPath = probe.distanceTo(fromWp) <= 1 || probe.distanceTo(toWp) <= 1; + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (!adjacentToPath || playerLoc == null || !Objects.equals(probe.getPlane(), playerLoc.getPlane())) continue; + + // WallObjects can report their world location as an adjacent tile depending on + // orientation / scene representation. Use exact match first, then allow a small + // adjacency fallback so door handling triggers reliably. + WallObject wall = resolveProbeWallObject(probe); + + TileObject object = (wall != null) ? wall : resolveProbeGameObject(probe); + if (object == null) continue; + if (!Rs2DoorGeometry.isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { + Telemetry.recordDoorReject("door-out-of-range"); + continue; + } + if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { + Telemetry.recordDoorReject("catalog-transport-object"); + continue; + } + + ObjectComposition baseComp = Rs2GameObject.convertToObjectComposition(object); + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null) { + Telemetry.recordDoorReject("composition-null"); + continue; + } + if (baseComp != null && baseComp.getImpostorIds() != null + && !Rs2DoorClassifier.isNullOrPlaceholderObjectName(baseComp.getName()) + && Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) { + Telemetry.recordDoorReject("impostor-rejected"); + continue; + } + if (Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) { + Telemetry.recordDoorReject("name-not-door"); + continue; + } + + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + Telemetry.recordDoorReject("skip-close-only-open"); + continue; + } + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + if (action == null) { + Telemetry.recordDoorReject("no-walk-action"); + continue; + } + if (Rs2DoorClassifier.doorActionPriorityIndex(action) == Integer.MAX_VALUE) { + Telemetry.recordDoorReject("non-standard-door-action"); + continue; + } + + boolean found = false; + + final String name = comp.getName(); + + if (object instanceof WallObject) { + // Validate the door's ACTUAL blocked edge against the segment, not the probe + // tile. The probe can sit a tile off the wall (adjacency fallback above), and the + // old probe-orientation check plus the pathTouchesBothEnds shortcut opened doors + // merely beside the path. isDoorOnSegment walks the segment against the wall's + // real edge, matching the GameObject branch and findDoorNearSegment. + if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + if (isPlayerBeyondDoorFace((WallObject) object, fromWp)) { + WebWalkLog.spInfo("door_skip_crossed | mode=segment-door probe={} from={} — already past the face; clicking would carry us back", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } + log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } else { + Telemetry.recordDoorReject("orient-mismatch"); + } + } else { + if (!Rs2DoorClassifier.isRouteDoorObject(false, name, action)) { + Telemetry.recordDoorReject("gameobject-not-a-door"); + continue; + } + if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_skip_goal_object | mode=segment-door probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } + if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } else { + Telemetry.recordDoorReject("gameobject-segment-mismatch"); + } + } + + if (found) { + if (!handleDoorException(object, action)) { + if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + if (shouldThrottleGlobalDoorInteraction(fromWp, toWp)) { + WebWalkLog.spInfo("door_global_await | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + if (doorInteractionDeferredForMovement(probe)) { + 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(); + boolean interacted; + try { + interacted = interactDoorTimed(object, action); + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=segment-door probe={} from={} to={} ex={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + markDoorInteractionSettling(toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); + if (!traversed && isQuestLockedDoorDialogue()) { + String dialogue = Rs2Dialogue.getDialogueText(); + log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", + probe, name, action, dialogue); + doorAttemptLedger.blacklistDoor(probe); + Rs2Dialogue.clickContinue(); + Rs2PathApi.refreshPlanningConfiguration(); + recalculatePath(); + // Resolved by rerouting; return before the wrong-traversal branch so a + // quest/skill-locked door is never learned as a blocked edge (it unlocks when the + // requirement is met). Matches the tryHandleDoorObject quest-locked path. + return true; + } + if (!traversed) { + if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { + doorAttemptLedger.blacklistDoor(probe); + log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + probe, fromWp, toWp, posBefore, posAfter); + // Wrong-traversal is a stable map property (one-way / mis-encoded door geometry), + // so persist it as a learned block that survives restarts and reroutes future paths. + // (Quest/skill-locked doors take the isQuestLockedDoorDialogue() branch above and are + // deliberately NOT learned — they unlock when the requirement is met.) + // + // Unless the CATALOG declares this edge. There we asserted the crossing ourselves, + // so a disagreement is a door-handling failure or a bad row — either way something + // to fix at the source, not to record silently. Wydin's back-room door (transport + // 2069, added to make Pirate's Treasure work at all) was learned blocked here and + // would have quietly undone that fix with nothing in the log to connect the two. + // Warn instead, so a genuinely wrong catalog row is visible rather than absorbed. + if (Rs2PathApi.hasCatalogTransportEdge(fromWp, toWp)) { + log.warn("[Walker] Wrong traversal across CATALOG transport edge {} -> {} (door={}); " + + "not learning it blocked — fix the transport row if this recurs", + fromWp, toWp, probe); + } else { + Rs2PathApi.learnBlockedEdge(fromWp, toWp, + "wrong-traversal door @ " + compactWorldPoint(probe)); + } + } + if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { + log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", + probe, fromWp, toWp); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); + } else { + markStationaryDoorOpened(probe); + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, path)) { + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + return true; + } + } + return false; + } + clearDoorCrossFailures(fromWp, toWp); + markStationaryDoorOpened(probe); + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + } + return true; + } + } + } + + TileObject nearbyDoor = allowSegmentProbe ? findDoorNearSegmentTimed(fromWp, toWp, doorActions) : null; + if (nearbyDoor != null && tryHandleDoorObject(nearbyDoor, nearbyDoor.getWorldLocation(), fromWp, toWp, doorActions, true, path)) { + return true; + } + + return false; + } + + static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, boolean allowSegmentProbe, + List routePath) { + if (object == null || probe == null) return false; + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (!Rs2DoorGeometry.isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { + return false; + } + if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { + return false; + } + + ObjectComposition comp = Rs2DoorProbe.resolveDoorComposition(doorProbeContext(), object); + if (!Rs2DoorClassifier.isDoorComposition(comp, doorActions)) return false; + + String action = Rs2DoorClassifier.getDoorAction(comp, doorActions); + if (action == null) return false; + + boolean found = false; + final String name = comp.getName(); + + if (object instanceof WallObject) { + int orientation = ((WallObject) object).getOrientationA(); + + if (searchNeighborPoint(orientation, probe, fromWp) + || searchNeighborPoint(orientation, probe, toWp) + || (allowSegmentProbe && Rs2DoorGeometry.wallDoorTouchesSegment((WallObject) object, fromWp, toWp))) { + if (isPlayerBeyondDoorFace((WallObject) object, fromWp)) { + WebWalkLog.spInfo("door_skip_crossed | mode=segment-probe probe={} from={} — already past the face; clicking would carry us back", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } + log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } + } else if (Rs2DoorClassifier.isRouteDoorObject(false, name, action)) { + if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_skip_goal_object | mode=segment-probe probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } + if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } + } + + if (!found) return false; + + if (handleDoorException(object, action)) { + return true; + } + + if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + if (shouldThrottleGlobalDoorInteraction(fromWp, toWp)) { + WebWalkLog.spInfo("door_global_await | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + if (doorInteractionDeferredForMovement(probe)) { + 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(); + boolean interacted; + try { + interacted = interactDoorTimed(object, action); + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=segment-probe probe={} from={} to={} ex={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + markDoorInteractionSettling(toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); + if (traversed) { + clearDoorCrossFailures(fromWp, toWp); + markStationaryDoorOpened(probe); + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + return true; + } + if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { + doorAttemptLedger.blacklistDoor(probe); + log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + probe, fromWp, toWp, posBefore, posAfter); + } + if (isQuestLockedDoorDialogue()) { + String dialogue = Rs2Dialogue.getDialogueText(); + log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", + probe, name, action, dialogue); + doorAttemptLedger.blacklistDoor(probe); + Rs2Dialogue.clickContinue(); + Rs2PathApi.refreshPlanningConfiguration(); + recalculatePath(); + return true; + } + + if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { + log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", + probe, fromWp, toWp); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); + } else { + markStationaryDoorOpened(probe); + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, routePath)) { + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + return true; + } + } + return false; + } + + /** + * THE door we clicked is open — not "some door near here is open". + *

+ * The first version of this delegated to {@link #doorStillHasAction}, whose predicate accepts any + * door-like object within TWO tiles of the probe. That is right for its own job (verify, then + * retry) but wrong as a release condition, and it is why the first live run produced no + * {@code releasedBy=door-opened} at all: in a door-heavy area a neighbouring shut door keeps the + * answer "still closed" forever, so the wait ran on to its positional conditions exactly as before. + * Matching on the probe tile itself, or on the geometry of the edge we are crossing, asks about the + * one door the click was aimed at. + *

+ * The other half of the old reading was an ambiguity: "no object matched" was indistinguishable + * from "the action is gone", so anything that put the door out of scan range reported a shut door + * as open. Here the two are separated — an opened door must actually be SEEN without its opening + * action. Seeing nothing is unknown, and unknown is not open, so the wait falls through to the + * positional conditions rather than releasing on an absence. + *

+ * TRANSPORT DOORS (the moves-you class) stay correct through this. They keep their action after + * relocating us, so this stays false and the positional conditions release the wait instead — and + * those fire at once, because being moved is precisely what they detect. + */ + static boolean doorObservedOpen(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || probe == null || action == null + || player.getPlane() != probe.getPlane() + || player.distanceTo2D(probe) > HANDLER_RANGE) { + return false; + } + return !doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); + } + + /** + * What the door observation actually sees, for the {@code door_await} log. + *

+ * Two live runs have now ended without a single {@code releasedBy=door-opened}, and neither could + * say why: the poll count proves the check ran, but not what it read. This names every object the + * strict match considers and the action currently on it, which separates the remaining candidates + * — nothing matched the tile at all, versus something matched and still offers the opening action. + */ + static String describeDoorObservation(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || probe == null) { + return "no-player"; + } + if (player.getPlane() != probe.getPlane()) { + return "plane-mismatch"; + } + int distance = player.distanceTo2D(probe); + if (distance > HANDLER_RANGE) { + return "out-of-range dist=" + distance; + } + try { + // Only the two existing readings, so this adds no new off-client-thread call site of its + // own. They separate the remaining candidates on their own: + // strict=false -> the check said OPEN, so a release that is not door-opened is plumbing + // strict=true -> the door on this very tile still offers the opening action + // strict!=loose -> the tighten worked and a neighbour was answering before + boolean strict = doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); + boolean loose = doorStillHasAction(probe, fromWp, toWp, doorActions, action, false); + return "strict=" + strict + " loose=" + loose + " dist=" + distance + " want=" + action; + } catch (RuntimeException ex) { + return "scan-error:" + ex.getClass().getSimpleName(); + } + } + + /** + * @param strictTile match only the door ON the probe tile or ON the {@code fromWp -> toWp} edge, + * instead of anything within two tiles. Required when the answer decides whether + * THIS door opened; the loose radius lets a neighbouring shut door answer for it. + * Every decision-making caller is strict now — loose remains only for the + * {@code saw=} diagnostic, which reports both readings side by side. + */ + static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action, boolean strictTile) { + if (probe == null || action == null) { + return false; + } + + WorldPoint anchor = Rs2Player.getWorldLocation(); + if (anchor == null || anchor.getPlane() != probe.getPlane()) { + anchor = probe; + } + + TileObject object = Rs2GameObject.getAll( + o -> doorObjectStillHasAction(o, probe, fromWp, toWp, doorActions, action, strictTile), + anchor, Math.max(3, HANDLER_RANGE)) + .stream() + .findFirst() + .orElse(null); + return object != null; + } + + static boolean doorObjectStillHasAction(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action, boolean strictTile) { + if (object == null || object.getWorldLocation() == null || action == null) { + return false; + } + if (!(object instanceof WallObject) && !(object instanceof GameObject)) { + return false; + } + WorldPoint loc = object.getWorldLocation(); + if (probe != null && loc.getPlane() != probe.getPlane()) { + return false; + } + if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { + return false; + } + // The two-tile radius is right for "is anything here still shut" (verify, then retry) but wrong + // for "did THIS door open" — a neighbouring shut door answers for it and the answer never changes. + boolean nearProbe = probe != null + && (strictTile ? loc.equals(probe) : loc.distanceTo2D(probe) <= 2); + boolean onSegment = fromWp != null && toWp != null && Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp); + if (!nearProbe && !onSegment) { + return false; + } + ObjectComposition composition = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + String currentAction = Rs2DoorClassifier.getDoorAction(composition, doorActions); + return currentAction != null && currentAction.equalsIgnoreCase(action); + } + + static void markStationaryDoorOpened(WorldPoint doorTile) { + doorAttemptLedger.markStationaryDoorOpened(doorTile, System.currentTimeMillis()); + } + + /** + * Whether the player already stands on the far side of this wall door's face relative to the + * segment's approach tile — in which case the crossing has happened and clicking the door again + * can only undo it (a moves-you gate carries the player straight back). Shell wrapper over + * {@link Rs2DoorGeometry#playerBeyondWallFace}; see there for the Stronghold bounce this exists + * to prevent. + */ + static boolean isPlayerBeyondDoorFace(WallObject wall, WorldPoint fromWp) { + return Rs2DoorGeometry.playerBeyondWallFace(wall.getOrientationA(), wall.getWorldLocation(), + fromWp, Rs2Player.getWorldLocation()); + } + + static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + return Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp); + } + + static boolean shouldThrottleDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + return doorAttemptLedger.shouldThrottleAttempt(doorTile, fromWp, toWp, + DOOR_ATTEMPT_EDGE_COOLDOWN_MS, System.currentTimeMillis()); + } + + static boolean hasRecentDoorAttemptOnEdge(WorldPoint fromWp, WorldPoint toWp) { + return shouldThrottleDoorAttempt(null, fromWp, toWp); + } + + static boolean hasRecentDoorAttemptNearIndex(List path, int edgeIdx) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return false; + } + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + if (hasRecentDoorAttemptOnEdge(from, to)) { + return true; + } + } + return false; + } + + static boolean waitForRecentDoorEdgeResolutionNearIndex(List path, int edgeIdx, int timeoutMs) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return false; + } + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + if (hasRecentDoorAttemptOnEdge(from, to)) { + return waitForDoorEdgeResolution(from, to, timeoutMs); + } + } + return false; + } + + static long recentDoorAttemptAgeNearIndex(List path, int edgeIdx) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return -1L; + } + long now = System.currentTimeMillis(); + long newestAttemptAt = -1L; + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + Long attemptedAt = doorAttemptLedger.attemptAtMs(from, to); + if (attemptedAt != null) { + newestAttemptAt = Math.max(newestAttemptAt, attemptedAt); + } + } + return newestAttemptAt < 0 ? -1L : Math.max(0L, now - newestAttemptAt); + } + + static boolean isLikelyDoorEdgeTransition(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + return false; + } + // Door crossings are local transitions. Ignore long smoothed hops that can + // accidentally reuse old door attempt keys and stall nearby-wait logic. + return from.distanceTo2D(to) >= 1 && from.distanceTo2D(to) <= 2; + } + + static boolean tryPostDoorFastMinimapClick(List path, int edgeIdx, WorldPoint playerLoc, WorldPoint target) { + if (path == null || path.size() < 2 || playerLoc == null) { + return false; + } + int from = Math.max(0, edgeIdx + 1); + int to = Math.min(path.size() - 1, from + 8); + WorldPoint candidate = null; + int bestDistToTarget = Integer.MAX_VALUE; + for (int i = from; i <= to; i++) { + WorldPoint wp = path.get(i); + if (wp == null || wp.getPlane() != playerLoc.getPlane()) { + break; + } + if (euclideanSq(wp, playerLoc) > POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN * POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN) { + break; + } + if (!Rs2Tile.isTileReachable(wp)) { + continue; + } + int d = target == null ? 0 : wp.distanceTo2D(target); + if (candidate == null || d < bestDistToTarget) { + candidate = wp; + bestDistToTarget = d; + } + } + if (candidate == null || candidate.equals(playerLoc)) { + return false; + } + // Do not issue an immediate fast click while the player is still traversing + // (moving/animation in flight) from the just-handled door edge. + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + boolean clicked = walkMiniMap(candidate); + if (!clicked) { + clicked = walkMiniMapToward(candidate, playerLoc, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + } + if (clicked) { + markFirstMovementClick("post_door_fast_click", target, playerLoc, + "to=" + compactWorldPoint(candidate)); + } + return clicked; + } + + static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target) { + return tryDoorEdgeCrossNudge(fromWp, toWp, target, null); + } + + /** + * Route-aware variant: with the route in hand, the follow-through click goes to the furthest + * REACHABLE route point past the door instead of the single far-side tile. Crossing the edge is + * still crossing it if the destination is further along — the server paths us through the open + * door either way — so one click replaces the nudge-then-route-click pair, which is both faster + * and what a player actually does after opening a door. + *

+ * The reachability gate is the whole safety argument. The previous attempt at this (reverted) + * clicked a tile the walled-route net had just REFUSED, because it selected without the gate. + * Here every candidate must be in the player-origin BFS — the same collision evidence the refusal + * uses — and the BFS runs AFTER the door opened, so it sees through the doorway. No candidate, or + * no route: the single-tile nudge behaves exactly as before. The success test is unchanged. + */ + static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target, + List routePath) { + long nudgeStartedAt = System.currentTimeMillis(); + try { + return tryDoorEdgeCrossNudgeInner(fromWp, toWp, target, routePath); + } finally { + doorLegNudgeMs += System.currentTimeMillis() - nudgeStartedAt; + } + } + + static boolean tryDoorEdgeCrossNudgeInner(WorldPoint fromWp, WorldPoint toWp, WorldPoint target, + List routePath) { + if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { + return false; + } + WorldPoint before = Rs2Player.getWorldLocation(); + if (before == null || before.getPlane() != toWp.getPlane()) { + return false; + } + // At or past the far side: the crossing this nudge exists to produce has happened. Without + // this, a player one step BEYOND toWp still passed the distance gate and the fallback click + // aimed at toWp — one tile backward, straight back into a self-closing door. + if (before.equals(toWp) || Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, before)) { + return true; + } + if (before.distanceTo2D(toWp) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { + return false; + } + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + + WorldPoint clickTo = toWp; + if (routePath != null && !routePath.isEmpty()) { + Map reachable = getClosestIndexReachableTiles(before); + WorldPoint routeTarget = selectPostDoorRouteTarget(routePath, fromWp, toWp, before, reachable, + POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN); + if (routeTarget != null) { + clickTo = routeTarget; + } + } + + boolean clicked = walkFastCanvas(clickTo); + if (!clicked) { + clicked = walkMiniMapToward(clickTo, before, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + } + if (!clicked) { + return false; + } + + markFirstMovementClick("first_door_edge_nudge", target, before, + "to=" + compactWorldPoint(clickTo) + + (clickTo.equals(toWp) ? "" : " pastDoorOf=" + compactWorldPoint(toWp))); + sleepUntil(() -> { + if (isWalkCancelled(target)) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + return isDoorEdgeNudgeResolved(before, now, fromWp, toWp); + }, POST_DOOR_EDGE_NUDGE_WAIT_MS); + + WorldPoint after = Rs2Player.getWorldLocation(); + boolean progressed = isDoorEdgeNudgeResolved(before, after, fromWp, toWp); + if (progressed) { + WebWalkLog.tmark("door_edge_nudge", System.currentTimeMillis() - routeState.walkSessionStartedAtMs, + target, before, "from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); + routeState.lastMovedTimeMs = System.currentTimeMillis(); + routeState.stuckCount = 0; + clearDoorCrossFailures(fromWp, toWp); + } else { + WebWalkLog.spInfo("door_edge_nudge_unresolved | from={} to={} before={} after={}", + compactWorldPoint(fromWp), compactWorldPoint(toWp), compactWorldPoint(before), compactWorldPoint(after)); + // A stationary player who clicked past an "open" door and moved nowhere is the seed-gate + // signature: the door reads open (or opens and instantly re-shuts) while the game refuses + // the crossing. A cancelled wait or an in-flight sample proves nothing. + registerDoorCrossFailure(fromWp, toWp, + before.equals(after) && !Rs2Player.isMoving() + && (target == null || !isWalkCancelled(target)), + "cross-nudge"); + } + return progressed; + } + + + static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { + return tryRecentDoorAttemptEdgeNudge(playerLoc, target, null); + } + + static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target, + List routePath) { + DoorAttemptLedger.Attempt claim = + doorAttemptLedger.latestAttempt(POST_DOOR_NUDGE_RECENT_ATTEMPT_MS, System.currentTimeMillis()); + if (playerLoc == null || claim == null) { + return false; + } + WorldPoint from = claim.from; + WorldPoint to = claim.to; + // A crossing that has ALREADY happened satisfies nothing: at the Stronghold's chained gates + // (2026-08-12) the player stood two tiles past gate 1 while gate 2 blocked the route ahead, + // and this branch kept ending the pass "resolved" over the conquered door — starving the + // miss branch that would have probed gate 2. Same principle as the crossed-face guard: + // done means fall through, and the spent attempt is cleared so it cannot fire again. + if (Rs2DoorGeometry.crossedDoorAxis(from, to, playerLoc)) { + doorAttemptLedger.clearLatestAttempt(); + 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, routePath); + if (nudged) { + WebWalkLog.tmark("recent_door_edge_nudge", System.currentTimeMillis() - routeState.walkSessionStartedAtMs, + target, playerLoc, "from=" + compactWorldPoint(from) + " to=" + compactWorldPoint(to)); + } + return nudged; + } + + /** + * The furthest route point past the just-opened door that the player can PROVABLY walk to. + *

+ * Pure selection over the supplied reachability map — one BFS in the caller, map lookups here — + * rather than a reachability probe per candidate, which is the client-thread cost that froze + * MLM's loop. The edge must be located ON the route (a fold that merely passes nearby proves + * nothing about what lies beyond the door), candidates keep to the player's plane and the + * Euclidean cap, and each must be in the map: a tile the BFS cannot reach is on the far side of + * some OTHER wall, and clicking it is the exact regression the walled-route net exists to refuse. + * Null when nothing qualifies — the caller then keeps the single-tile nudge. + */ + static WorldPoint selectPostDoorRouteTarget(List routePath, WorldPoint fromWp, WorldPoint toWp, + WorldPoint player, Map reachable, + int maxEuclidean) { + if (routePath == null || routePath.size() < 2 || fromWp == null || toWp == null + || player == null || reachable == null || reachable.isEmpty()) { + return null; + } + int edgeIdx = -1; + for (int i = 0; i + 1 < routePath.size(); i++) { + if (fromWp.equals(routePath.get(i)) && toWp.equals(routePath.get(i + 1))) { + edgeIdx = i; + break; + } + } + if (edgeIdx < 0) { + return null; + } + WorldPoint best = null; + for (int i = edgeIdx + 2; i < routePath.size(); i++) { + WorldPoint wp = routePath.get(i); + if (wp == null || wp.getPlane() != player.getPlane()) { + break; + } + if (player.distanceTo2D(wp) > maxEuclidean) { + break; + } + if (wp.equals(player)) { + continue; + } + if (reachable.containsKey(wp)) { + best = wp; + } + } + return best; + } + + static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, WorldPoint fromWp, WorldPoint toWp) { + if (before == null || after == null || fromWp == null || toWp == null) { + return false; + } + if (before.equals(after)) { + return false; + } + if (before.getPlane() != after.getPlane() + || after.getPlane() != fromWp.getPlane() + || after.getPlane() != toWp.getPlane()) { + return false; + } + int beforeTo = before.distanceTo2D(toWp); + int afterTo = after.distanceTo2D(toWp); + if (after.equals(toWp) || afterTo == 0) { + return true; + } + if (afterTo <= 1 && afterTo < beforeTo) { + return true; + } + // The near-toWp rule alone cannot see a crossing that keeps going, and with the nudge now + // clicking a route point PAST the door, keeping going is the intended outcome. It also has a + // blind spot the live log caught even for short hops: a nudge starts on fromWp (beforeTo=1), + // so afterTo < beforeTo only fires on exactly toWp — and a RUNNING player covers two tiles a + // tick and skips that tile entirely (observed 3369 -> 3367 -> 3365, reported unresolved). + // Crossing the door's axis is the fact being tested, so test it directly. + return hasCrossedDoorAxis(fromWp, toWp, after); + } + + /** + * Whether {@code after} lies at or beyond the far side of the {@code fromWp -> toWp} door edge. + * Shared with the ranged door await, which uses the same reading as its "passed the door" release. + */ + static boolean hasCrossedDoorAxis(WorldPoint fromWp, WorldPoint toWp, WorldPoint after) { + return Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, after); + } + + /** + * Edge-aware variant: the full window only binds a re-click of the SAME edge; a different door + * right after a successful open is chaining, not hammering, and owes one tick. The dialogue + * defer is unconditional either way — an open quest dialogue blocks every door equally. + */ + static boolean shouldThrottleGlobalDoorInteraction(WorldPoint fromWp, WorldPoint toWp) { + DoorAttemptLedger.Attempt lastClaim = doorAttemptLedger.latestAttempt(); + boolean sameEdge = fromWp != null && toWp != null && lastClaim != null + && lastClaim.isSameDirectedEdge(fromWp, toWp); + return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(System.currentTimeMillis(), + doorAttemptLedger.globalCooldownUntilMs(), sameEdge, + DOOR_INTERACTION_GLOBAL_COOLDOWN_MS, DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS) + || shouldDeferDoorInteractionForDialogue(); + } + + /** + * A guarded door answers with a conversation instead of opening ("you can't go in there"). The + * walker reads the lack of movement as "no progress, retry" and clicks again — and that click + * CANCELS the menu the previous click just opened, destroying the only thing that can get us + * through. Whatever answers dialogue (the questing layer) then never sees a menu that survives + * long enough to act on, so the walk livelocks at the door. + * + *

Deferring is BOUNDED: if nothing answers within {@link #DOOR_DIALOGUE_DEFER_MAX_MS} the + * walker resumes clicking, so a stray conversation with no handler cannot stall a plain walk + * that has no dialogue logic behind it. + */ + static boolean shouldDeferDoorInteractionForDialogue() { + if (!Rs2Dialogue.hasSelectAnOption()) { + routeState.doorDialogueDeferSinceMs = 0L; + return false; + } + long now = System.currentTimeMillis(); + if (routeState.doorDialogueDeferSinceMs == 0L) { + routeState.doorDialogueDeferSinceMs = now; + WebWalkLog.spInfo("door_dialogue_defer | an option menu is open — not re-clicking the door"); + } + return doorDialogueDeferActive(routeState.doorDialogueDeferSinceMs, now, DOOR_DIALOGUE_DEFER_MAX_MS); + } + + /** + * Pure half of the dialogue hold-off: defer only while the menu has been up for less than + * {@code maxDeferMs}. Split out because an unbounded version of this gate would trade a livelock + * at a guarded door for a permanent stall at any unanswered conversation. + */ + static boolean doorDialogueDeferActive(long deferSinceMs, long nowMs, long maxDeferMs) { + return deferSinceMs > 0L && nowMs - deferSinceMs < maxDeferMs; + } + + static boolean isDoorInteractionSettling() { + long now = System.currentTimeMillis(); + if (now >= doorAttemptLedger.settleUntilMs()) { + return false; + } + // Early exit: the interaction's purpose was opening the door — once its far side is reachable, + // the edge is open and there is nothing left to settle (previously this was a flat 900ms freeze + // after every door). One-tick floor for object-state flux; the window is cleared on success so + // repeated checks this tick don't re-run the reachability probe. + WorldPoint farSide = doorAttemptLedger.settleFarSide(); + if (farSide != null + && now - doorAttemptLedger.settleStartedAtMs() >= POST_INTERACT_SETTLE_MIN_MS + && Rs2Tile.isTileReachable(farSide)) { + doorAttemptLedger.endSettleEarly(); + return false; + } + return true; + } + + static boolean isDoorEdgePassSkipCoolingDown() { + return System.currentTimeMillis() - routeState.lastDoorEdgePassSkipAtMs < DOOR_EDGE_SKIP_COOLDOWN_MS; + } + + static void markDoorInteractionSettling(WorldPoint farSideWp) { + doorAttemptLedger.markSettling(farSideWp, System.currentTimeMillis(), DOOR_POST_INTERACT_SETTLE_MS); + } + + static void markGlobalDoorInteractionCooldown() { + doorAttemptLedger.markGlobalCooldownUntil( + Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS)); + } + + static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + doorAttemptLedger.markAttempt(doorTile, fromWp, toWp, System.currentTimeMillis()); + } + + /** + * Registers a door attempt that concluded without crossing its edge; on the third such failure + * the edge is blocked in the planner and the route recalculated, so the walk routes around or + * ends honestly instead of ping-ponging. The block is scoped to the CURRENT walk, not the + * session: a door that refuses for game-state reasons (Tithe Farm's seed gate) opens the moment + * the condition is met, and a session block would stop the Tithe plugin's own seeded walk-in + * from ever routing through it — the museum lesson, where one layer's block silently broke the + * other layer's fix. {@link #withdrawWalkScopedDoorBlocks} returns the edges at the next walk + * session start. Not a door-tile blacklist either: the planner, not the door handler, owes the + * reroute. + */ + static void registerDoorCrossFailure(WorldPoint fromWp, WorldPoint toWp, + boolean conclusiveSample, String mode) { + if (fromWp == null || toWp == null) { + return; + } + if ("refused-open".equals(mode)) { + doorAttemptLedger.markRefusedOpen(fromWp, toWp, System.currentTimeMillis()); + } + DoorAttemptLedger.Strike strike = doorAttemptLedger.registerCrossFailure( + fromWp, toWp, + conclusiveSample, + System.currentTimeMillis(), + DOOR_CROSS_FAILURE_DECAY_MS, + DOOR_CROSS_FAILURE_STRIKE_LIMIT); + if (strike != DoorAttemptLedger.Strike.STRIKE_OUT) { + return; + } + String reason = "door-strike-out (" + mode + ")"; + if (Rs2PathApi.learnBlockedEdge(fromWp, toWp, reason)) { + doorAttemptLedger.recordWalkScopedBlock(fromWp, toWp); + } + if (Rs2PathApi.learnBlockedEdge(toWp, fromWp, reason)) { + doorAttemptLedger.recordWalkScopedBlock(toWp, fromWp); + } + WebWalkLog.spInfo("door_strike_out | from={} to={} mode={} — {} concluded attempts never crossed; " + + "blocking edge for this walk and replanning", + compactWorldPoint(fromWp), compactWorldPoint(toWp), mode, DOOR_CROSS_FAILURE_STRIKE_LIMIT); + recalculatePath(); + } + + /** + * Withdraws every strike-out block the previous walk earned. Called at walk session start: the + * new walk may run under changed conditions (seeds acquired, key obtained), so each refused door + * gets a fresh chance — and a walk retried without the condition just re-earns the strike-out in + * a few attempts, loudly, instead of inheriting a stale block silently. + */ + static void withdrawWalkScopedDoorBlocks() { + for (WorldPoint[] edge : doorAttemptLedger.drainWalkScopedBlocks()) { + Rs2PathApi.unlearnBlockedEdge(edge[0], edge[1], "walk-scoped door strike-out expired"); + } + } + + static void clearDoorCrossFailures(WorldPoint fromWp, WorldPoint toWp) { + doorAttemptLedger.clearCrossFailures(fromWp, toWp); + } + + /** + * A refused-open only counts when the attempt genuinely concluded AT the door: player stationary + * on (or beside) the near-side tile. A ranged click whose wait expired mid-approach samples a + * player still tiles away and proves nothing about the door. + */ + static boolean isConclusiveRefusedOpenSample(WorldPoint posAfter, WorldPoint fromWp) { + return posAfter != null && fromWp != null + && !Rs2Player.isMoving() + && posAfter.getPlane() == fromWp.getPlane() + && posAfter.distanceTo2D(fromWp) <= 1; + } + + static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + return doorAttemptLedger.recentlyOpenedDoorOnSegment( + fromWp, toWp, STATIONARY_DOOR_SUPPRESS_MS, System.currentTimeMillis()); + } + + static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { + return doorAttemptLedger.wasStationaryDoorOpenedWithin( + doorTile, STATIONARY_DOOR_SUPPRESS_MS, System.currentTimeMillis()); + } + + static boolean isDoorLikeCatalogTransportSegment(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return false; + } + return isDoorLikeCatalogTransportSegment(path.get(index), path.get(index + 1)); + } + + static boolean isDoorLikeCatalogTransportSegment(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return false; + } + return hasDoorLikeDirectedCatalogTransport(from, to) + || hasDoorLikeDirectedCatalogTransport(to, from) + || hasDoorLikeAdjacentOriginShortTransportHop(from, to) + || hasDoorLikeAdjacentOriginShortTransportHop(to, from); + } + + /** + * Catalog transports normally bypass generic door probing so the exact selected edge keeps + * execution ownership. Door-like rows are the compatibility exception because many ordinary + * Open/Pass rows still rely on the door cascade. The Al Kharid toll gate has an explicit + * executor with dialogue and exact-landing semantics, so allowing the generic scanner to take + * it first creates two conflicting completion contracts. + */ + static boolean shouldDeferDoorHandlingToTransport(List path, int index) { + if (!isCatalogBackedTransportSegment(path, index)) { + return false; + } + return !isDoorLikeCatalogTransportSegment(path, index) + || isAlKharidTollGateSegment(path.get(index), path.get(index + 1)); + } + + static boolean isAlKharidTollGateSegment(WorldPoint from, WorldPoint to) { + return from != null + && to != null + && AL_KHARID_TOLL_GATE_POINTS.contains(from) + && AL_KHARID_TOLL_GATE_POINTS.contains(to) + && from.getPlane() == to.getPlane() + && Math.abs(from.getX() - to.getX()) == 1 + && from.getY() == to.getY(); + } + + static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, WorldPoint dest) { + return Rs2PathApi.hasCatalogTransportEdge(origin, dest); + } + + static boolean hasDoorLikeDirectedCatalogTransport(WorldPoint origin, WorldPoint dest) { + if (origin == null || dest == null) { + return false; + } + return Rs2PathApi.getCatalogTransportEdges(origin).stream() + .anyMatch(t -> Objects.equals(t.getDestination(), dest) && Rs2DoorProbe.isDoorLikeCatalogTransport(t)); + } + + /** + * True when some catalog origin one step from {@code from} has a same-plane adjacent transport to {@code to}. + * Restricted to {@link #isAdjacentSamePlaneTransport} rows so long-distance transports do not suppress doors. + */ + static boolean matchesAdjacentOriginShortTransportHop(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + return false; + } + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) { + continue; + } + WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { + if (Objects.equals(t.getDestination(), to) && isAdjacentSamePlaneTransport(t)) { + return true; + } + } + } + } + return false; + } + + static boolean hasDoorLikeAdjacentOriginShortTransportHop(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + return false; + } + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) { + continue; + } + WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { + if (Objects.equals(t.getDestination(), to) + && isAdjacentSamePlaneTransport(t) + && Rs2DoorProbe.isDoorLikeCatalogTransport(t)) { + return true; + } + } + } + } + return false; + } + + static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { + waitForDoorInteractionProgress(fromWp, toWp, null, null, null, null); + } + + /** + * Door-identified variant: lets the await release the moment the door is OPEN rather than when we + * have finished walking through it. An unlocked door opens within a game tick, so the traversal + * that used to be waited out is time the server is already spending walking us — time in which the + * next door on the route could be clicked. Falls back to the positional conditions when the door + * cannot be identified or the config switch is off. + */ + static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp, + WorldPoint probe, List doorActions, + String action) { + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, null); + } + + static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp, + WorldPoint probe, List doorActions, + String action, TileObject object) { + long startedAt = System.currentTimeMillis(); + AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); + java.util.function.BooleanSupplier doorOpened = + (probe == null || action == null || !doorInteractionWhileApproachingEnabled()) + ? null + : () -> doorObservedOpen(probe, fromWp, toWp, doorActions, action); + java.util.function.Supplier observation = + (probe == null || action == null) ? null + : () -> describeDoorObservation(probe, fromWp, toWp, doorActions, action); + // Ranged budgets can hold for seconds, so a hold must release when the plan it belongs to + // stops existing — the walk cancelled or re-targeted, OR the route replanned under the same + // target. The second case is what live collision does when it sees the awaited edge blocked: + // it recalculates and routes around, and holding the old plan's door after that is pure + // waste (measured: the replan fired a second before a 6.9s ranged timeout expired). + // A new Pathfinder instance IS the replan signal; the reference is captured at click time. + WorldPoint walkTarget = currentTarget; + Object plannerAtClick = Rs2PathApi.getPathfinder(); + java.util.function.BooleanSupplier cancelled = () -> { + // isWalkSuperseded(null) answers true, and a door can legitimately be handled outside a + // walk session (recovery paths); no target means there is nothing to be cancelled. + // Identity-only by necessity — see isWalkSuperseded: the completion-evaluating variant + // runs a caller callback that can be a client-thread BFS, and this is a 100ms loop. + if (walkTarget != null && isWalkSuperseded(walkTarget)) { + return true; + } + Object plannerNow = Rs2PathApi.getPathfinder(); + return plannerAtClick != null && plannerNow != null && plannerNow != plannerAtClick; + }; + // The wall-face reading that stays true when a moves-you gate deposits the player a tile + // off the planned route -- the case every positional release condition goes blind on. + // Orientation and tile are captured ONCE: both are immutable for the object's lifetime, and + // reading a TileObject inside a poll loop risks a stale scene reference mid-await. + java.util.function.BooleanSupplier doorCrossed = null; + if (object instanceof WallObject) { + final int wallOrientation = ((WallObject) object).getOrientationA(); + final WorldPoint wallTile = object.getWorldLocation(); + doorCrossed = () -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && Rs2DoorGeometry.playerBeyondWallFace(wallOrientation, wallTile, fromWp, now); + }; + } + try { + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation, cancelled, doorCrossed); + } finally { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorInteractionWaitMs += tookMs; + } + doorLegAwaitMs += tookMs; + } + } + + /** A shut door that just REFUSED an Open will not resolve by being stared at; poll briefly, then move on. */ + private static final int REFUSED_OPEN_EDGE_WAIT_MS = 300; + private static final long REFUSED_OPEN_FRESH_MS = 15_000L; + + static boolean waitForDoorEdgeResolution(WorldPoint fromWp, WorldPoint toWp, int timeoutMs) { + long startedAt = System.currentTimeMillis(); + boolean refusedCap = doorAttemptLedger.hasFreshRefusedOpen(fromWp, toWp, startedAt, REFUSED_OPEN_FRESH_MS); + if (refusedCap) { + timeoutMs = Math.min(timeoutMs, REFUSED_OPEN_EDGE_WAIT_MS); + } + DoorResolution resolution = Rs2WalkerAwaits.awaitDoorEdgeResolution(fromWp, toWp, timeoutMs); + long elapsed = System.currentTimeMillis() - startedAt; + // Counted separately or it lands in the scan's doorProbe residual and reads as probe cost — + // this wait alone has been measured at 1897ms (FAILED_TIMEOUT). + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorEdgeWaitMs += elapsed; + } + WebWalkLog.tmark("door_edge_wait_done", elapsed, currentTarget, + Rs2Player.getWorldLocation(), + "result=" + resolution + " from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp) + + (refusedCap ? " refusedCap=true" : "")); + return resolution == DoorResolution.RESOLVED; + } + + static boolean isDoorEdgeResolved(WorldPoint fromWp, WorldPoint toWp) { + return Rs2WalkerAwaits.isDoorEdgeResolved(fromWp, toWp); + } + + static boolean didTraverseInteractedDoor(WorldPoint start, WorldPoint end, WorldPoint objectLoc, + WorldPoint fromWp, WorldPoint toWp) { + if (start == null || end == null || objectLoc == null || toWp == null) { + return false; + } + if (start.getPlane() != end.getPlane() || end.getPlane() != objectLoc.getPlane() || end.getPlane() != toWp.getPlane()) { + return false; + } + if (start.equals(end)) { + return false; + } + if (!movedAcrossInteractedObject(start, end, objectLoc)) { + return false; + } + int beforeTo = start.distanceTo2D(toWp); + int afterTo = end.distanceTo2D(toWp); + if (afterTo >= beforeTo) { + return false; + } + // Keep the traversal check anchored to the active segment. + return fromWp == null || fromWp.getPlane() == end.getPlane(); + } + + static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoint end, WorldPoint fromWp, WorldPoint toWp) { + return shouldBlacklistDoorAfterWrongTraversal(start, end, fromWp, toWp, false); + } + + /** + * As {@link #shouldBlacklistDoorAfterWrongTraversal(WorldPoint, WorldPoint, WorldPoint, WorldPoint)} + * but aware of whether the {@code end} position was sampled while the player was STILL WALKING. The + * interact walks the player to the door first and the progress wait can time out en route, so a + * moving sample is just a point along the path — not a traversal verdict. Deciding from one poisoned + * Wydin's shop door: before=3008,3207 (en route), after=3012,3211 (seven tiles from the edge, mid + * walk) was blacklisted AND learn-persisted as a blocked edge. A same-plane moving sample must never + * blacklist; a plane change is still trusted (the door acted — walking cannot change plane). + */ + static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoint end, WorldPoint fromWp, + WorldPoint toWp, boolean sampledWhileMoving) { + if (start == null || end == null || toWp == null) { + return false; + } + if (start.equals(end)) { + return false; + } + if (start.getPlane() != end.getPlane()) { + return true; + } + if (sampledWhileMoving) { + return false; + } + if (!startedNearDoorEdge(start, fromWp, toWp)) { + return false; + } + int moved = start.distanceTo2D(end); + if (moved < 3) { + return false; + } + int startTo = start.distanceTo2D(toWp); + int endTo = end.distanceTo2D(toWp); + if (endTo <= startTo + 1) { + return false; + } + if (fromWp == null || fromWp.getPlane() != end.getPlane()) { + return true; + } + int startFrom = start.distanceTo2D(fromWp); + int endFrom = end.distanceTo2D(fromWp); + return endFrom >= startFrom + 2; + } + + 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; + } + + 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()); + int startRelY = Integer.compare(start.getY(), objectLoc.getY()); + int endRelY = Integer.compare(end.getY(), objectLoc.getY()); + return startRelX != endRelX || startRelY != endRelY; + } + + static boolean hasDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { + return false; + } + if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { + return false; + } + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } + + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (isPendingRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (isPendingRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + static boolean hasUnresolvedDoorLikeObjectNearRawPath(List rawPath, + int rawEdgeStart, + WorldPoint playerLoc, + int backtrackEdges, + int lookaheadEdges, + int radiusTiles) { + if (rawPath == null || rawPath.size() < 2 || playerLoc == null || rawEdgeStart < 0) { + return false; + } + + int start = Math.max(0, rawEdgeStart - Math.max(0, backtrackEdges)); + int endExclusive = Math.min(rawPath.size() - 1, rawEdgeStart + Math.max(1, lookaheadEdges)); + for (int ri = start; ri < endExclusive && ri < rawPath.size() - 1; ri++) { + WorldPoint from = rawPath.get(ri); + WorldPoint to = rawPath.get(ri + 1); + if (from == null || to == null) { + continue; + } + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) { + break; + } + if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { + continue; + } + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { + continue; + } + if (hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + static boolean hasUnresolvedDoorLikeSceneObjectOnSegment(WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (fromWp == null || toWp == null || playerLoc == null || radiusTiles <= 0) { + return false; + } + if (fromWp.getPlane() != toWp.getPlane() || fromWp.getPlane() != playerLoc.getPlane()) { + return false; + } + + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (isUnresolvedRouteDoorObject(wall, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + for (GameObject object : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (isUnresolvedRouteDoorObject(object, fromWp, toWp, playerLoc, radiusTiles)) { + return true; + } + } + return false; + } + + static boolean isUnresolvedRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + WorldPoint location = object.getWorldLocation(); + if (location.getPlane() != playerLoc.getPlane() + || location.distanceTo2D(playerLoc) > radiusTiles + || (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) + || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + // A wall door whose face the player is already beyond is resolved, not unresolved: conquered + // moves-you gates keep their Open action forever, and counting one as an obstacle vetoed the + // continuation click that ends the fold stall. Same truth as door_skip_crossed. + if (object instanceof WallObject && Rs2DoorGeometry.playerBeyondWallFace( + ((WallObject) object).getOrientationA(), location, fromWp, playerLoc)) { + return false; + } + + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null + || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName()) + || Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + return false; + } + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); + } + + static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, + WorldPoint playerLoc, int radiusTiles) { + if (object == null || object.getWorldLocation() == null) { + return false; + } + WorldPoint location = object.getWorldLocation(); + if (location.getPlane() != playerLoc.getPlane() + || location.distanceTo2D(playerLoc) > radiusTiles + || doorAttemptLedger.isDoorBlacklisted(location) + || (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) + || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + // Same crossed-face resolution as isUnresolvedRouteDoorObject: a conquered gate behind the + // player must not defer short walks as a "pending" route door. + if (object instanceof WallObject && Rs2DoorGeometry.playerBeyondWallFace( + ((WallObject) object).getOrientationA(), location, fromWp, playerLoc)) { + return false; + } + + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null + || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName()) + || Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + return false; + } + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); + } + + /** + * Door handling can include dialogue and waits; bound it so the walker cannot hang + * indefinitely on a bad interact. If the timeout elapses, return false so the main + * loop can continue (stall detection / replans). + */ + static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs) { + return handleDoorsWithTimeout(path, index, timeoutMs, false, false); + } + + static boolean handleDoorsWithTimeoutBudgeted(List path, int index, long timeoutMs, + boolean allowSegmentProbe) { + return handleDoorsWithTimeout(path, index, timeoutMs, true, allowSegmentProbe); + } + + static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, + boolean passBudgeted, boolean allowSegmentProbe) { + long start = System.currentTimeMillis(); + WorldPoint[] segment = resolveDoorSegment(path, index); + boolean claimableSegment = segment != null && segment.length >= 2 + && segment[0] != null && segment[1] != null; + WorldPoint playerBeforeAttempt = Rs2Player.getWorldLocation(); + resetDoorLegStages(); + if (passBudgeted && claimableSegment + && !doorAttemptLedger.tryClaimEdgeThisPass(segment[0], segment[1], playerBeforeAttempt)) { + routeState.lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); + WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); + return false; + } + boolean handled = handleDoors(path, index, allowSegmentProbe); + if (!handled) { + // Do not consume one-shot budget when no interaction happened; allow + // a later resolver in the same pass to attempt this edge. + if (passBudgeted && claimableSegment) { + doorAttemptLedger.releaseEdgeThisPass(segment[0], segment[1]); + } + return false; + } + WebWalkLog.tmark("door_interaction_done", System.currentTimeMillis() - start, currentTarget, playerBeforeAttempt, + "idx=" + index + doorLegStageDetail(System.currentTimeMillis() - start)); + long remaining = timeoutMs - (System.currentTimeMillis() - start); + if (remaining <= 0) { + return true; + } + WorldPoint before = Rs2Player.getWorldLocation(); + int remainingInt = (int) Math.min(Integer.MAX_VALUE, remaining); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + if (before != null && now != null && !before.equals(now)) return true; + return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); + }, remainingInt); + + if (segment != null && !isDoorEdgeResolved(segment[0], segment[1])) { + WebWalkLog.spInfo("door_edge_post_unresolved | idx={} from={} to={}", + index, compactWorldPoint(segment[0]), compactWorldPoint(segment[1])); + } else if (segment != null) { + WebWalkLog.tmark("door_edge_resolved", System.currentTimeMillis() - start, currentTarget, + Rs2Player.getWorldLocation(), + "from=" + compactWorldPoint(segment[0]) + " to=" + compactWorldPoint(segment[1])); + } + return true; + } + + static WorldPoint[] resolveDoorSegment(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return null; + } + WorldPoint fromWp = path.get(index); + WorldPoint toWp = path.get(index + 1); + if (fromWp == null || toWp == null) { + return null; + } + boolean isInstance = Microbot.getClient() + .getTopLevelWorldView() + .getScene() + .isInstance(); + if (!isInstance) { + return new WorldPoint[] {fromWp, toWp}; + } + WorldPoint convertedFrom = Rs2WorldPoint.convertInstancedWorldPoint(fromWp); + WorldPoint convertedTo = Rs2WorldPoint.convertInstancedWorldPoint(toWp); + if (convertedFrom == null || convertedTo == null) { + return null; + } + return new WorldPoint[] {convertedFrom, convertedTo}; + } + + /** + * Last-resort door resolver for "tile unreachable near player" stalls. + * Scans a very small radius around the player for door-like wall/game objects + * and interacts with the best candidate action. + */ + static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int radiusTiles) { + if (playerLoc == null || radiusTiles <= 0) return false; + + TileObject best = null; + String bestAction = null; + int bestActionPri = Integer.MAX_VALUE; + int bestDist = Integer.MAX_VALUE; + int scannedWalls = 0; + int scannedGames = 0; + int candidates = 0; + + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + scannedWalls++; + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; + candidates++; + + // Allow empty-action doors: use default interact. + String actionFinal = action == null ? "" : action; + int dist = w.getWorldLocation() == null ? Integer.MAX_VALUE : w.getWorldLocation().distanceTo2D(playerLoc); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); + if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { + best = w; + bestAction = actionFinal; + bestActionPri = pri; + bestDist = dist; + } + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + scannedGames++; + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; + candidates++; + + String actionFinal = action == null ? "" : action; + int dist = g.getWorldLocation() == null ? Integer.MAX_VALUE : g.getWorldLocation().distanceTo2D(playerLoc); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : Rs2DoorClassifier.doorActionPriorityIndex(actionFinal); + if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { + best = g; + bestAction = actionFinal; + bestActionPri = pri; + bestDist = dist; + } + } + + if (best == null || bestAction == null) { + log.info("[Walker] fallback door-scan: no candidates (radius={} player={} scannedWalls={} scannedGames={} candidates={})", + radiusTiles, playerLoc, scannedWalls, scannedGames, candidates); + return false; + } + + WorldPoint before = Rs2Player.getWorldLocation(); + log.info("[Walker] fallback door-scan: action={} at {}", bestAction.isEmpty() ? "" : bestAction, best.getWorldLocation()); + if (bestAction.isEmpty()) { + Rs2GameObject.interact(best); + } else { + Rs2GameObject.interact(best, bestAction); + } + Rs2Player.waitForWalking(); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + if (before != null && now != null && !before.equals(now)) return true; + return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); + }, 1500); + return true; + } + + /** + * LOS-based door resolution: when a path says "go through that door" but local reachability + * says "unreachable", we may be a few tiles away from the actual door object. Scan door-like + * objects in a wider radius and require line-of-sight from the player, then interact with the + * best candidate (closest to the upcoming path tiles). + */ + static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, List path, int startIdx, int radiusTiles) { + if (playerLoc == null || path == null || path.size() < 2) return false; + if (startIdx < 0 || startIdx >= path.size()) return false; + + TileObject best = null; + String bestAction = null; + int bestScore = Integer.MAX_VALUE; + + // Look a little ahead along the path to bias toward the intended door edge. + int endIdx = Math.min(path.size() - 1, startIdx + 10); + + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; + + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(w); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; + + String actionFinal = action == null ? "" : action; + + // Score by proximity to upcoming path tiles (lower is better). + int score = Integer.MAX_VALUE; + WorldPoint objWp = w.getWorldLocation(); + if (objWp != null) { + for (int j = startIdx; j <= endIdx; j++) { + WorldPoint pj = path.get(j); + if (pj == null) continue; + score = Math.min(score, objWp.distanceTo2D(pj)); + } + // Tie-break toward closer objects. + score = score * 10 + objWp.distanceTo2D(playerLoc); + } + + if (best == null || score < bestScore) { + best = w; + bestAction = actionFinal; + bestScore = score; + } + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; + + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(g); + if (comp == null || Rs2DoorClassifier.isNullOrPlaceholderObjectName(comp.getName())) continue; + if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = Rs2DoorClassifier.pickWalkDoorAction(comp); + + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); + if (!doorLike) continue; + if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; + + String actionFinal = action == null ? "" : action; + + int score = Integer.MAX_VALUE; + WorldPoint objWp = g.getWorldLocation(); + if (objWp != null) { + for (int j = startIdx; j <= endIdx; j++) { + WorldPoint pj = path.get(j); + if (pj == null) continue; + score = Math.min(score, objWp.distanceTo2D(pj)); + } + score = score * 10 + objWp.distanceTo2D(playerLoc); + } + + if (best == null || score < bestScore) { + best = g; + bestAction = actionFinal; + bestScore = score; + } + } + + if (best == null) { + log.info("[Walker] LOS door-scan: no candidates (radius={} player={} idx={}/{})", radiusTiles, playerLoc, startIdx, path.size()); + return false; + } + + log.info("[Walker] LOS door-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, best.getWorldLocation()); + if (bestAction == null || bestAction.isEmpty()) { + Rs2GameObject.interact(best); + } else { + Rs2GameObject.interact(best, bestAction); + } + Rs2Player.waitForWalking(); + return true; + } + + static String normalizePathAdjFamilyKey(TileObject object, String action) { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + String name = comp != null && comp.getName() != null ? comp.getName().toLowerCase(Locale.ROOT).trim() : "unknown"; + String act = action == null ? "" : action.toLowerCase(Locale.ROOT).trim(); + WorldPoint loc = object != null ? object.getWorldLocation() : null; + int plane = loc != null ? loc.getPlane() : -1; + int objectId = object != null ? object.getId() : -1; + int idRangeLow = objectId >= 0 ? objectId - 1 : -1; + int idRangeHigh = objectId >= 0 ? objectId + 1 : -1; + return name + "|" + act + "|p" + plane + "|id=" + idRangeLow + "-" + idRangeHigh; + } + + static boolean arePathAdjFamiliesCompatible(String a, String b) { + if (Objects.equals(a, b)) { + return true; + } + if (a == null || b == null) { + return false; + } + int aIdTag = a.indexOf("|id="); + int bIdTag = b.indexOf("|id="); + if (aIdTag <= 0 || bIdTag <= 0) { + return false; + } + String aBase = a.substring(0, aIdTag); + String bBase = b.substring(0, bIdTag); + if (!Objects.equals(aBase, bBase)) { + return false; + } + int[] aRange = parsePathAdjIdRange(a.substring(aIdTag + 4)); + int[] bRange = parsePathAdjIdRange(b.substring(bIdTag + 4)); + if (aRange == null || bRange == null) { + return false; + } + return Math.max(aRange[0], bRange[0]) <= Math.min(aRange[1], bRange[1]); + } + + static int[] parsePathAdjIdRange(String range) { + if (range == null || range.isEmpty()) { + return null; + } + int sep = range.indexOf('-'); + if (sep <= 0 || sep >= range.length() - 1) { + return null; + } + try { + int low = Integer.parseInt(range.substring(0, sep)); + int high = Integer.parseInt(range.substring(sep + 1)); + if (high < low) { + return null; + } + return new int[] {low, high}; + } catch (NumberFormatException ignored) { + return null; + } + } + + static void markNearbyDoorFamilyOpened(TileObject originObject, WorldPoint originLocation, String action, int radiusTiles) { + if (originObject == null || originLocation == null || radiusTiles <= 0) { + return; + } + String familyKey = normalizePathAdjFamilyKey(originObject, action); + if (familyKey == null || familyKey.isEmpty()) { + markStationaryDoorOpened(originLocation); + return; + } + markStationaryDoorOpened(originLocation); + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, originLocation, radiusTiles)) { + if (wall == null || wall.getWorldLocation() == null) { + continue; + } + if (wall.getWorldLocation().getPlane() != originLocation.getPlane()) { + continue; + } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(wall); + String neighborFamily = normalizePathAdjFamilyKey(wall, comp == null ? null : Rs2DoorClassifier.pickWalkDoorAction(comp)); + if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { + markStationaryDoorOpened(wall.getWorldLocation()); + } + } + for (GameObject game : Rs2GameObject.getGameObjects(o -> true, originLocation, radiusTiles)) { + if (game == null || game.getWorldLocation() == null) { + continue; + } + if (game.getWorldLocation().getPlane() != originLocation.getPlane()) { + continue; + } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(game); + String neighborFamily = normalizePathAdjFamilyKey(game, comp == null ? null : Rs2DoorClassifier.pickWalkDoorAction(comp)); + if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { + markStationaryDoorOpened(game.getWorldLocation()); + } + } + } + + static List buildPathAdjDoorComponents( + Collection candidates, + int startIdx, + WorldPoint playerLoc) { + if (candidates == null || candidates.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(candidates); + boolean[] visited = new boolean[list.size()]; + List components = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + if (visited[i]) { + continue; + } + PathAdjDoorCandidate seed = list.get(i); + visited[i] = true; + java.util.Deque queue = new ArrayDeque<>(); + queue.add(i); + List members = new ArrayList<>(); + members.add(seed); + while (!queue.isEmpty()) { + int idx = queue.removeFirst(); + PathAdjDoorCandidate a = list.get(idx); + for (int j = 0; j < list.size(); j++) { + if (visited[j]) { + continue; + } + PathAdjDoorCandidate b = list.get(j); + if (!arePathAdjFamiliesCompatible(a.familyKey, b.familyKey)) { + continue; + } + if (a.location == null || b.location == null) { + continue; + } + int tileGap = a.location.distanceTo2D(b.location); + int edgeGap = Math.abs(a.edgeIdx - b.edgeIdx); + if (tileGap > PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP + && edgeGap > PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP) { + continue; + } + visited[j] = true; + queue.addLast(j); + members.add(b); + } + } + PathAdjDoorCandidate best = null; + int earliestEdge = Integer.MAX_VALUE; + int bestLocalScore = Integer.MAX_VALUE; + Set locs = new LinkedHashSet<>(); + for (PathAdjDoorCandidate c : members) { + locs.add(c.location); + earliestEdge = Math.min(earliestEdge, c.edgeIdx); + int pri = c.actionPriority == Integer.MAX_VALUE ? 100 : c.actionPriority; + int localScore = c.edgeDist * 100 + pri * 10 + + (playerLoc != null && c.location != null ? c.location.distanceTo2D(playerLoc) : 0); + if (best == null || localScore < bestLocalScore) { + best = c; + bestLocalScore = localScore; + } + } + int edgeOffset = Math.max(0, earliestEdge - startIdx); + int componentScore = edgeOffset * 1000 + bestLocalScore; + components.add(new PathAdjDoorComponent(best, componentScore, locs)); + } + return components; + } + + + + /** + * Scan a few path indices near the player (<= radius tiles) and attempt to resolve + * any door/gate blocks before issuing further minimap clicks. + */ + static boolean tryHandleNearbyDoorsWithTimeout(List path, int startIdx, int radiusTiles, long timeoutMs) { + if (path == null || path.isEmpty() || startIdx < 0) return false; + final WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) return false; + + int start = Math.min(startIdx, path.size() - 2); + for (int j = start; j < path.size() - 1; j++) { + WorldPoint wp = path.get(j); + if (wp == null) continue; + if (wp.getPlane() != playerLoc.getPlane()) break; + if (wp.distanceTo2D(playerLoc) > radiusTiles) { + // Path is ordered; once we're beyond radius, later indices will likely be further. + break; + } + if (handleDoorsWithTimeout(path, j, timeoutMs)) { + return true; + } + } + return false; + } + + static boolean handleDoorException(TileObject object, String action) { + long startedAt = System.currentTimeMillis(); + try { + if (isInStrongholdOfSecurity()) { + return handleStrongholdOfSecurityAnswer(object, action); + } + return false; + } finally { + doorLegExceptionMs += System.currentTimeMillis() - startedAt; + } + } + + static boolean isInStrongholdOfSecurity() { + List mapRegionIds = List.of(7505, 7504, 7760, 7503, 7759, 7758, 7757, 8013, 7756, 8012, 8017, 8530, 9297); + return mapRegionIds.contains(Rs2Player.getWorldLocation().getRegionID()); + } + + static boolean handleStrongholdOfSecurityAnswer(TileObject object, String action) { + // Captured before the click: crossing is judged against where the approach started, and the + // wall's orientation/tile are immutable for the object's lifetime. + final WorldPoint before = Rs2Player.getWorldLocation(); + final int wallOrientation = object instanceof WallObject ? ((WallObject) object).getOrientationA() : -1; + final WorldPoint wallTile = object.getWorldLocation(); + Rs2GameObject.interact(object, action); + // The gates only ask their question until it has been answered; every later crossing just + // carries the player through. The old sleepUntilInDialogue here waited its FULL flat timeout + // on every questionless gate — the leg breakdown traced the corridor's constant ~5.4s per + // gate (find=0 interact=0 await=0 verify=0 nudge=0, all of it "other") to this one line, + // ~60 seconds of sleeps across eleven gates for dialogues that never came. Wait for + // whichever actually happens: the dialogue, or the crossing itself. + // Distance-scaled, like the door await's traversal budget: a ranged click spends its first + // seconds being server-walked to the gate, and the flat 5s expired MID-APPROACH — measured + // as every far-clicked gate paying the full budget and then a duplicate re-attempt from up + // close (5399ms + 576ms for one gate), while near clicks released in ~0.3-2.7s. + final int clickDistance = before != null && wallTile != null && before.getPlane() == wallTile.getPlane() + ? before.distanceTo2D(wallTile) : 0; + final int strongholdWaitMs = 5000 + Math.min(6000, clickDistance * 600); + sleepUntil(() -> { + if (Rs2Dialogue.isInDialogue()) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + return wallOrientation > 0 && now != null + && Rs2DoorGeometry.playerBeyondWallFace(wallOrientation, wallTile, before, now); + }, strongholdWaitMs); + + // Not all the doors ask questions, so only if dialogue is shown we will attempt to get the answer + if (!Rs2Dialogue.isInDialogue()) return true; + + // Skip over first door dialogue & don't forget to set up two-factor warning + if (Rs2Dialogue.getDialogueText().toLowerCase().contains("two-factor authentication options") || Rs2Dialogue.getDialogueText().toLowerCase().contains("hopefully you will learn
much from us.")) { + Rs2Dialogue.sleepUntilHasContinue(); + sleepUntil(() -> !Rs2Dialogue.hasContinue() || Rs2Dialogue.getDialogueText().toLowerCase().contains("to pass you must answer me"), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + if (!Rs2Dialogue.isInDialogue()) return true; + } + + String dialogueAnswer = null; + int attempts = 0; + final int maxAttempts = 5; + + // We attempt to find the answer multiple times in-case there is dialogue that appears before the question + while (dialogueAnswer == null && attempts < maxAttempts) { + if (currentTarget == null) break; + dialogueAnswer = StrongholdAnswer.findAnswer(Rs2Dialogue.getDialogueText()); + if (dialogueAnswer == null) { + Rs2Dialogue.clickContinue(); + Rs2Random.waitEx(800, 100); + } + attempts++; + } + + if (dialogueAnswer != null) { + Rs2Dialogue.clickContinue(); + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption(dialogueAnswer); + Rs2Dialogue.sleepUntilHasContinue(); + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Player.waitForAnimation(1200); + return true; + } + + return false; + } + + /** + * Determines whether a given neighbor tile lies immediately adjacent to + * a reference tile, in the direction specified by a wall orientation code. + * + * @param orientation the wall orientation code: + *

    + *
  • 1 = west
  • + *
  • 2 = north
  • + *
  • 4 = east
  • + *
  • 8 = south
  • + *
  • 16 = northwest
  • + *
  • 32 = northeast
  • + *
  • 64 = southeast
  • + *
  • 128 = southwest
  • + *
+ * @param point the reference {@link WorldPoint} representing the tile at the wall’s base + * @param neighbor the {@link WorldPoint} to test for adjacency + * @return {@code true} if {@code neighbor} is exactly one tile away from {@code point} + * in the direction indicated by {@code orientation}, {@code false} otherwise + */ + static boolean searchNeighborPoint(int orientation, WorldPoint point, WorldPoint neighbor) { + int dx = neighbor.getX() - point.getX(); + int dy = neighbor.getY() - point.getY(); + + switch (orientation) { + case 1: // west + return dx == -1 && dy == 0; + case 2: // north + return dx == 0 && dy == 1; + case 4: // east + return dx == 1 && dy == 0; + case 8: // south + return dx == 0 && dy == -1; + case 16: // northwest + return dx == -1 && dy == 1; + case 32: // northeast + return dx == 1 && dy == 1; + case 64: // southeast + return dx == 1 && dy == -1; + case 128: // southwest + return dx == -1 && dy == -1; + default: + return false; + } + } + + static boolean handleDoorsInRawSegment(List rawPath, int rawFrom, int rawTo, + long timeoutMs, + Map reachableCache) { + long passT0 = System.currentTimeMillis(); + try { + return handleDoorsInRawSegmentInner(rawPath, rawFrom, rawTo, timeoutMs, reachableCache); + } finally { + WalkPassStats.segDoorMs.addAndGet(System.currentTimeMillis() - passT0); + } + } + + private static boolean handleDoorsInRawSegmentInner(List rawPath, int rawFrom, int rawTo, + long timeoutMs, + Map reachableCache) { + WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; + long startedAt = System.currentTimeMillis(); + for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { + long elapsed = System.currentTimeMillis() - startedAt; + if (elapsed >= timeoutMs) { + return false; + } + if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) + && reachableCache.containsKey(rawPath.get(ri + 1)) + && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), + playerLoc, HANDLER_RANGE)) { + continue; + } + long remainingTimeoutMs = Math.max(1L, timeoutMs - elapsed); + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, remainingTimeoutMs, false)) { + return true; + } + if (isDoorInteractionSettling()) { + return false; + } + } + return false; + } + + /** Same switch, for opening the nearest route door without waiting out the approach walk. */ + static boolean doorInteractionWhileApproachingEnabled() { + return rangedTransportDispatchEnabled(); + } + + /** + * Whether a door interaction must wait because the player is moving. + *

+ * Relaxing only the caller-side gate was not enough: the interaction sites carry their own + * {@code isMoving()} checks, so the handler ran during the approach and then declined anyway. + *

+ * Scoping the permission to the segment loop was ALSO not enough — door handling is reached from + * the recovery path and the raw scene scan as well, and a Falador castle run on the fixed build + * still logged {@code door_interact_deferred | reason=moving mode=segment-door} from the + * reachability-miss recovery. Those entry points each act on the door blocking the route RIGHT + * NOW, so there is no ordering left to protect at this level: the only question here is whether + * the walker is allowed to interrupt its own walk, which is exactly what the feature is for. + * Route ordering is enforced where it belongs — the segment loop, which iterates many segments + * and still only lets the nearest one act while moving. + */ + static boolean doorInteractionDeferredForMovement(WorldPoint doorTile) { + if (!Rs2Player.isMoving()) { + return false; + } + if (!doorInteractionWhileApproachingEnabled()) { + return true; + } + // While MOVING, only act on a door we are practically standing at. The probe searches ten + // tiles, which was harmless while interaction required standing still — arriving implied + // proximity. Acting mid-walk removed that implication, and the walker opened the door at + // (2985,3341) from nine tiles out while (2981,3340) was still shut in front of it: the + // interaction timed out against the closed near door, the player drifted backwards, and the + // walk lost ~15s to recovery clicks before the real blocker was handled. + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return playerLoc == null + || doorTile == null + || doorTile.getPlane() != playerLoc.getPlane() + || doorTile.distanceTo2D(playerLoc) > DOOR_APPROACH_INTERACT_MAX_TILES; + } + + static void logRouteClear(String reason) { + routeState.lastRouteClearReason = reason == null ? "" : reason; + routeState.lastRouteClearAtMs = System.currentTimeMillis(); + if (reason == null || reason.isBlank()) { + WebWalkLog.routeClearMissingReason(Thread.currentThread().getName()); + } else { + WebWalkLog.routeClear(reason); + } + } + + static boolean walkReachableMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { + int currentDistance = euclideanSq(playerLoc, target); + return Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet().stream() + .filter(tile -> tile != null + && tile.getPlane() == playerLoc.getPlane() + && !tile.equals(playerLoc) + && euclideanSq(playerLoc, tile) <= maxEuclidean * maxEuclidean + && euclideanSq(tile, target) < currentDistance) + .sorted(Comparator + .comparingInt((WorldPoint tile) -> euclideanSq(tile, target)) + .thenComparing(Comparator.comparingInt((WorldPoint tile) -> euclideanSq(playerLoc, tile)).reversed())) + .filter(Rs2Walker::walkMiniMap) + .findFirst() + .map(tile -> { + log.info("[Walker] Minimap click target {} was outside clip; used reachable fallback {}", target, tile); + return true; + }) + .orElse(false); + } + + static HashMap nearbyTilesIgnoringCollision( + WorldPoint origin, int radius) { + HashMap result = new HashMap<>(); + if (origin == null || radius < 0) { + return result; + } + int boundedRadius = Math.min(radius, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + for (int dx = -boundedRadius; dx <= boundedRadius; dx++) { + for (int dy = -boundedRadius; dy <= boundedRadius; dy++) { + int distance = Math.max(Math.abs(dx), Math.abs(dy)); + if (distance <= boundedRadius) { + result.put(new WorldPoint( + origin.getX() + dx, + origin.getY() + dy, + origin.getPlane()), distance); + } + } + } + return result; + } + + /** + * Updates world-map marker and restarts pathfinding for {@code target}. Does not assign + * {@link #currentTarget}; callers set it when appropriate. + */ + static void applyWalkerDestination(WorldPoint target) { + Rs2WalkerLifecycleRuntime.applyWalkerDestination(target); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerMovement.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerMovement.java new file mode 100644 index 00000000000..4b6ed419cb8 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerMovement.java @@ -0,0 +1,1243 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.*; +import net.runelite.api.Point; +import net.runelite.api.annotations.Component; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.*; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.devtools.MovementFlag; +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.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; +import net.runelite.client.plugins.microbot.util.coords.Rs2LocalPoint; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldArea; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; +import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.Runes; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; +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 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; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +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; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry; +import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; +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; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits; +import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; +import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; +import javax.inject.Named; +import java.awt.*; +import java.util.*; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +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.Global.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2Walker.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports.*; + +/** + * The movement/click component extracted from {@code Rs2Walker} (Phase E3, 2026-08-14): minimap, + * canvas and scene click issuance, route click-target selection, interim-target lifecycle, short + * walks, idle nudges and stamina — the movement-family methods and their exclusive helpers, moved + * verbatim. The route loop keeps deciding WHEN to move; this class owns HOW a movement is issued. + * Members are package-private; the four walker classes consume each other via static imports. + */ +@lombok.extern.slf4j.Slf4j +final class Rs2WalkerMovement { + + private Rs2WalkerMovement() { + } + + /** + * How far a minimap stride may reach at {@code minimapZoom}, in tiles — for EVERY zoom level, in + * both directions. The minimap shows {@code 20 * 4 / zoom} tiles of radius (the scale + * Perspective.localToMinimap uses), so reach follows what the user's zoom makes visible: zoomed + * out, big strides (capped at the BFS horizon); zoomed in, short ones (a click must land inside + * the visible circle, two tiles off the rim). An unreadable zoom falls back to the flat reach + * the walker always had. + */ + static int zoomAwareMinimapReach(double minimapZoom, int minTiles, int capTiles, int fallbackTiles) { + if (minimapZoom <= 0) { + return fallbackTiles; + } + int visibleRadius = (int) Math.floor(20.0 * 4.0 / minimapZoom) - 2; + return Math.max(minTiles, Math.min(visibleRadius, capTiles)); + } + + /** Shell wrapper: the live zoom read, clamped to [functional floor, BFS horizon]. */ + static int normalMinimapReach() { + try { + return zoomAwareMinimapReach(Microbot.getClient().getMinimapZoom(), + MIN_MINIMAP_REACH_EUCLIDEAN, ZOOMED_OUT_MINIMAP_REACH_CAP, + NORMAL_MINIMAP_REACH_EUCLIDEAN); + } catch (Exception e) { + return NORMAL_MINIMAP_REACH_EUCLIDEAN; + } + } + + static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { + if (routeState.firstMovementClickMarked) { + return; + } + long startedAt = routeState.walkSessionStartedAtMs; + if (startedAt <= 0) { + return; + } + routeState.firstMovementClickMarked = true; + WebWalkLog.tmark(phase, System.currentTimeMillis() - startedAt, target, at, detail); + } + + static boolean shouldRunActiveRouteIdleNudge(boolean idleNudgeDue, + boolean immediateRouteTransportPending) { + return idleNudgeDue && !immediateRouteTransportPending; + } + + static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs) { + waitUntilIdleAfterSceneWalk(cancelGoal, timeoutMs, null, 0); + } + + /** + * Waits until idle, walk cancel, or player within {@code arrivalMaxChebyshev} Chebyshev steps of + * {@code arrivalGoal} (same plane; see {@link WorldPoint#distanceTo2D(WorldPoint)}) — avoids burning full + * timeout when {@code Rs2Player#isMoving()} lies during animations. Arrival uses an inclusive bound: + * {@code distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev} (unlike {@link #OFFSET}-style guards that use + * {@code distanceTo2D < OFFSET}). If arrival distance triggers while still + * moving, runs a short second phase idle-only wait. Phase 2 does not run when phase 1 ends only due to the + * outer timeout while still far from {@code arrivalGoal} (by design). + */ + static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs, + WorldPoint arrivalGoal, int arrivalMaxChebyshev) { + assert cancelGoal != null; + assert timeoutMs > 0; + sleepUntil(() -> { + if (isWalkCancelled(cancelGoal)) { + return true; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + if (arrivalGoal != null && arrivalMaxChebyshev >= 0 && pl != null + && arrivalGoal.getPlane() == pl.getPlane() + && pl.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev) { + return true; + } + return !Rs2Player.isMoving(); + }, timeoutMs); + // Sample player once after phase 1 — rare tick skew vs isMoving(); phase 2 only refines idle after arrival exit. + WorldPoint plAfter = Rs2Player.getWorldLocation(); + boolean withinArrival = arrivalGoal != null && arrivalMaxChebyshev >= 0 && plAfter != null + && arrivalGoal.getPlane() == plAfter.getPlane() + && plAfter.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev; + if (withinArrival && Rs2Player.isMoving()) { + sleepUntil(() -> isWalkCancelled(cancelGoal) || !Rs2Player.isMoving(), + POST_SCENE_WALK_IDLE_SECOND_PHASE_MS_MAX); + } + } + + static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) { + int data = flagMap[point.getSceneX()][point.getSceneY()]; + Set movementFlags = MovementFlag.getSetFlags(data); + + if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_EAST) + && Rs2Tile.isWalkable(point.dx(1))) + return true; + + if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_WEST) + && Rs2Tile.isWalkable(point.dx(-1))) + return true; + + if (movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_NORTH) + && Rs2Tile.isWalkable(point.dy(1))) + return true; + + return movementFlags.contains(MovementFlag.BLOCK_MOVEMENT_SOUTH) + && Rs2Tile.isWalkable(point.dy(-1)); + } + + static int computeStaminaThreshold(String playerName, long installSeed) { + if (playerName == null || playerName.isEmpty()) { + return STAMINA_THRESHOLD_FALLBACK; + } + long nameHash = mix64(playerName.toLowerCase()); + long seed = nameHash ^ installSeed; + java.util.Random rng = new java.util.Random(seed); + if (rng.nextDouble() < STAMINA_HARDCORE_PROBABILITY) { + int span = STAMINA_HARDCORE_MAX - STAMINA_HARDCORE_MIN + 1; + return STAMINA_HARDCORE_MIN + rng.nextInt(span); + } + int span = STAMINA_CASUAL_MAX - STAMINA_CASUAL_MIN + 1; + return STAMINA_CASUAL_MIN + rng.nextInt(span); + } + + static long mix64(String s) { + long h = 0xcbf29ce484222325L; + for (int i = 0; i < s.length(); i++) { + h ^= s.charAt(i); + h *= 0x100000001b3L; + } + return h; + } + + static int staminaThreshold() { + String name = null; + try { + var player = Microbot.getClient().getLocalPlayer(); + if (player != null) name = player.getName(); + } catch (Exception ignored) { + } + if (name == null || name.isEmpty()) { + return staminaThresholdCached; + } + if (!name.equals(staminaSeedName)) { + staminaSeedName = name; + staminaThresholdCached = computeStaminaThreshold(name, Microbot.getInstallSeed()); + } + return staminaThresholdCached; + } + + /** Side-effect free: a "could I click this?" probe must never move the user's zoom. */ + static boolean isMiniMapClickable(WorldPoint worldPoint) { + if (worldPoint == null) { + return false; + } + Point point = Rs2MiniMap.worldToMinimap(worldPoint); + return point != null && (disableWalkerUpdate || Rs2MiniMap.isPointInsideMinimap(point)); + } + + static boolean walkRawPathMiniMapToward(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean) { + return walkRawPathMiniMapTargetToward(rawPath, target, playerLoc, maxEuclidean, -1) != null; + } + + static WorldPoint clickMiniMapOrFallback(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean, + boolean allowDirectionalFallback) { + return clickMiniMapOrFallback(rawPath, target, playerLoc, maxEuclidean, allowDirectionalFallback, -1); + } + + static WorldPoint clickMiniMapOrFallback(List rawPath, + WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean, + boolean allowDirectionalFallback, + int rawAnchorIndex) { + long passT0 = System.currentTimeMillis(); + try { + return clickMiniMapOrFallbackInner(rawPath, target, playerLoc, maxEuclidean, + allowDirectionalFallback, rawAnchorIndex); + } finally { + WalkPassStats.clickIssueMs.addAndGet(System.currentTimeMillis() - passT0); + } + } + + private static WorldPoint clickMiniMapOrFallbackInner(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; + } + + 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 null; + } + if (walkMiniMap(fallback)) { + log.info("[Walker] Minimap click target {} was outside clip; used route fallback {}", target, fallback); + return fallback; + } + return null; + } + + static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { + if (target == null || playerLoc == null || target.getPlane() != playerLoc.getPlane()) { + return false; + } + + int dx = target.getX() - playerLoc.getX(); + int dy = target.getY() - playerLoc.getY(); + double distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= 1) { + return false; + } + + if (walkReachableMiniMapToward(target, playerLoc, maxEuclidean)) { + return true; + } + + int cappedRadius = Math.max(2, maxEuclidean); + // The scaled-radius points below are geometric guesses toward an off-clip target. Right + // after a teleport (or when the target sits behind a wall) that guess can be an unreachable + // tile far off the route, producing the "random click far from the path" behaviour. Only + // click a guess that is actually reachable from the player. + Set reachable = Rs2Tile + .getReachableTilesFromTile(playerLoc, Math.max(2, cappedRadius)).keySet(); + int[] radii = new int[] {cappedRadius, 10, 8, 6, 4}; + for (int radius : radii) { + if (radius >= distance) { + continue; + } + + double scale = radius / distance; + WorldPoint fallback = new WorldPoint( + playerLoc.getX() + (int) Math.round(dx * scale), + playerLoc.getY() + (int) Math.round(dy * scale), + playerLoc.getPlane()); + if (fallback.equals(playerLoc)) { + continue; + } + if (!reachable.contains(fallback)) { + continue; + } + if (Rs2Walker.walkMiniMap(fallback)) { + log.info("[Walker] Minimap click target {} was outside clip; used fallback {}", target, fallback); + return true; + } + } + + return false; + } + + // findFurthestRawPathPointMatching (pure) moved to geometry/WalkerPathGeometry (P1); this game-coupled + // wrapper supplies the constant forward-search window and the lazy reachable-closest fallback. UNGATED — + // it is the pure-selection unit the tests exercise; live click paths use the gated variant below. + static WorldPoint findFurthestRawPathPointMatching(List rawPath, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex, + Predicate isCandidate) { + return WalkerPathGeometry.findFurthestRawPathPointMatching(rawPath, playerLoc, maxEuclidean, + rawAnchorIndex, isCandidate, ROUTE_PROGRESS_FORWARD_SEARCH_TILES, + () -> getClosestTileIndex(rawPath, playerLoc)); + } + + /** + * The route crosses from reachable to unreachable at some edge; that edge is impassable in reality, + * whatever the shipped map says. Learn it so the pathfinder routes around it instead of replanning + * the same way forever. + *

+ * Refusing the click was always correct, but on its own it is not a recovery: the planner keeps + * producing the same route, the net keeps refusing it, and the walker oscillates. Seen at Sinclair + * Mansion, where the shipped map has no walls at all for the building — probed as n/s/e/w all open + * on every tile the walker kept trying — while the live scene reported 330 blocked edges the static + * map calls open. Four refusals, no progress, no escape. + *

+ * The first strike blocks the edge for THIS session (see + * {@code PathfinderConfig#learnBlockedEdge}), so the replan below routes around it immediately; + * persistence across sessions still needs an independent second strike, which is what stops a + * transient refusal poisoning the store. learnBlockedEdge returns false for an edge already known, + * so the replan fires once per edge rather than on every refusal. + */ + static void learnWalledRouteEdge(List rawPath, WorldPoint playerLoc, + Map reachable) { + WorldPoint[] edge = firstWalledRawEdge(rawPath, playerLoc, reachable, + CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + if (edge == null) { + return; + } + // A shut door is not a wall. The catalog already says this edge is crossable BY ACTION, so a + // refused click across it means the door is closed, not that the way is blocked — and learning + // it poisons the exact edge the route depends on. Dwarf Cannon showed this: Captain Lawgof's + // outpost gates ship as transports 15604 and 15605 in both directions, and both were learned as + // walled at strike 1 of 2 while the quester tried to reach him through the fence. A second + // independent strike would have persisted them and routed around that outpost permanently. + // + // The sibling fix for this ("a shut transport door is not a blocked route step") taught the + // route-step VALIDATOR the same thing; the learning path was never covered. + if (Rs2PathApi.hasCatalogTransportEdge(edge[0], edge[1])) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — catalog transport, a shut door is not a wall", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + return; + } + // The same rule for ORDINARY scene doors, which have no catalog row to hit the guard above. + // A refused click across a shut door means the door is closed, not that the way is walled — + // the door pipeline (and its strike-out) owns that edge. Without this, the Tithe Farm run + // (2026-08-12) learned the lobby door edge as walled for the WHOLE SESSION one second after + // the strike-out had deliberately scoped its own block to the walk — so the plugin's later + // seeded walk-in would have found the door unroutable until a client restart. + if (findDoorNearSegmentTimed(edge[0], edge[1], + List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass")) != null) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — scene door on the edge, the door pipeline owns it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + rememberWalledDoorEdge(edge); + return; + } + // ADJACENCY, not just the exact edge. Double gates (Stronghold "Gate of War") are two wall + // objects: only the primary wing carries the Open action; the slave wing is actionless. A raw + // route step through the slave wing's line finds no door ON its own segment — the check above + // passes — and the edge gets learned as walled while the door pipeline is opening the primary + // wing one tile away. Measured 2026-08-13 14:00: edges (1875,5240)->(1876,5240) (parallel + // beside the gate) and (1903,5242)->(1904,5243) (diagonal sharing the gate's corner) both + // learned mid-corridor, each costing a replan. Not learning is always recoverable — the + // refused click just falls back as before; learning wrongly poisons routing for the session. + if (sceneDoorAdjacentToEdge(edge[0], edge[1])) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — scene door adjacent to the edge (double-gate wing), the door pipeline owns it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + rememberWalledDoorEdge(edge); + return; + } + // Via the Rs2PathApi wrapper rather than the config directly: it takes the pathfinder mutex, + // which matters because the replan below runs straight after. Same return contract — true only + // when the edge was newly blocked for this session. + if (Rs2PathApi.learnBlockedEdge(edge[0], edge[1], "route-click-walled")) { + WebWalkLog.spInfo("walled_edge_learned | {} -> {} — replanning around it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + recalculatePath(); + } + } + + /** Hands the door-bearing walled edge to recovery so it approaches the door instead of replanning. */ + private static void rememberWalledDoorEdge(WorldPoint[] edge) { + routeState.walledDoorEdgeFrom = edge[0]; + routeState.walledDoorEdgeTo = edge[1]; + routeState.walledDoorEdgeAtMs = System.currentTimeMillis(); + } + + /** + * First raw-path step that leaves the player-origin BFS: {@code a} reachable, {@code b} not. + *

+ * Both endpoints must sit inside the BFS budget, or "not reachable" means merely far away and the + * edge is innocent — the same guard the refusal itself uses. + *

+ * That proximity guard is Chebyshev, and the BFS budget counts STEPS, so on its own it does not + * mean what it looks like: a tile thirteen tiles away as the crow flies can be thirty steps away + * around a building, and it is then absent from the BFS for want of budget rather than because + * anything blocks it. Refusing a click on that evidence is merely conservative; LEARNING a blocked + * edge from it corrupts routing for the rest of the session. + *

+ * Measured at the Port Sarim / Land's End docks: a click to (2760,3238) was refused as walled and + * the edge (2759,3230)->(2759,3231) was learned — and nine seconds later the walker was standing on + * (2760,3238), having simply walked there. So {@code a} must also be strictly INSIDE the frontier: + * the BFS expands every tile below its budget, so an interior {@code a} whose neighbour {@code b} is + * still missing proves {@code b} unreachable, whereas an {@code a} sitting AT the budget never had + * its neighbours enumerated at all and proves nothing. + */ + static WorldPoint[] firstWalledRawEdge(List rawPath, WorldPoint playerLoc, + Map reachable, int stepBudget) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null + || reachable == null || reachable.isEmpty()) { + return null; + } + // Deliberately no getClosestTileIndex here: that reads the scene on the client thread, and this + // must stay pure so the decision table can cover it. The reachable set already confines the + // answer to the player's immediate surroundings, so a full scan is both cheap and sufficient. + final int maxDistance = stepBudget - 2; + for (int i = 0; i + 1 < rawPath.size(); i++) { + WorldPoint a = rawPath.get(i); + WorldPoint b = rawPath.get(i + 1); + if (a == null || b == null + || a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { + continue; + } + // Both ends inside the BFS budget, or "unreachable" only means "far" and the edge is + // innocent. Skip rather than stop: a route may leave and re-enter the budget. + if (playerLoc.distanceTo2D(a) > maxDistance || playerLoc.distanceTo2D(b) > maxDistance) { + continue; + } + Integer stepsToA = reachable.get(a); + // At the budget, a's neighbours were never enumerated, so b's absence is ignorance, not a + // wall. Only an interior a can convict the edge. + if (stepsToA == null || stepsToA >= stepBudget) { + continue; + } + if (!reachable.containsKey(b)) { + return new WorldPoint[]{a, b}; + } + } + return null; + } + + /** + * Selects the next minimap click target from the raw route, gated on collision reachability. + *

+ * Preference order: + *

    + *
  1. Furthest-forward raw point that is collision-reachable from the player. A point on the + * far side of a wall is Euclidean-close but not reachable within the sampled area, so it is + * excluded — this is what stops the walker clicking through castle walls / into buildings.
  2. + *
  3. Furthest-forward raw point that is off the loaded scene. Collision cannot be verified for + * unloaded tiles, but a minimap click toward a distant route point is still correct, so long + * outdoor routes keep flowing.
  4. + *
+ * Returns {@code null} when neither exists; the caller then falls back to wall-distance nudging + * plus {@link #findReachableRejoinRawPathPoint} rejoin handling. + */ + static WorldPoint selectRouteClickTarget(List rawPath, WorldPoint playerLoc, + int maxEuclidean, int rawAnchorIndex) { + long passT0 = System.currentTimeMillis(); + try { + return selectRouteClickTargetInner(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + } finally { + WalkPassStats.clickSelectMs.addAndGet(System.currentTimeMillis() - passT0); + } + } + + private static WorldPoint selectRouteClickTargetInner(List rawPath, WorldPoint playerLoc, + int maxEuclidean, int rawAnchorIndex) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { + routeState.lastRouteClickTier = "norawpath"; + return null; + } + // Anti-ban: vary HOW FAR ALONG the route we click. Selection otherwise always returns the + // furthest candidate inside a fixed radius, so every click covers the same tile span — a + // deterministic signature. Varying the reach is the safe axis: it only changes how far + // forward we pick, never sideways, so the target stays on the planned route (#20). Lateral + // tile offsets are the wrong axis and were removed for exactly that reason (#15); lateral + // randomness belongs inside the tile (click-point jitter), not in tile selection. + int jitteredReach = routeClickReach(maxEuclidean); + WorldPoint selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, rawAnchorIndex); + if (selected == null && jitteredReach < maxEuclidean) { + // A shortened reach must never be the reason selection fails — that would drop the click + // onto the caller's off-route wall-nudge clamp. Retry at full reach before giving up. + selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + } + if (selected == null && rawAnchorIndex >= 0) { + // The smoothed->raw anchor can point past the player's vicinity (stale mapping, sparse + // smoothing, or a replanned route). The anchored forward scan then breaks immediately on + // the Euclidean bound and yields nothing for EVERY predicate — which is exactly the + // sel=none case that dropped route clicks onto the off-route wall-nudge clamp. Retry + // anchored at the player's own closest raw tile before giving up. + // Keep the jitter on this path too. The player-anchored retry fires on most first clicks + // of a route, so using full reach here bypassed the reach variation exactly where it is + // most visible — measured click distances clustered at 9.0-10.0 instead of spreading. + selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, -1); + if (selected == null && jitteredReach < maxEuclidean) { + selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, -1); + } + if (selected != null) { + routeState.lastRouteClickTier = routeState.lastRouteClickTier + "@player"; + } + } + return selected; + } + + /** + * Per-click route reach, jittered below {@code maxEuclidean} so consecutive clicks do not all + * cover the same tile span. + *

+ * 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); + } + + static WorldPoint selectRouteClickTargetAnchored(List rawPath, WorldPoint playerLoc, + int maxEuclidean, int rawAnchorIndex) { + // Click the furthest forward point ON THE RAW ROUTE that is within minimap reach. + // + // A minimap click is resolved by the GAME's own pathing, so line of sight is irrelevant to + // walking: a player clicks past a corner, through a doorway, or around a building and the + // server routes them there. Requiring straight LOS made the walker advance corner-to-corner, + // stopping at each one to re-aim — a visible tell, and it bought no correctness. The + // invariant that actually matters is that the target sits ON the planned route, so wherever + // the server routes us we still arrive on that route. + // + // The off-route click (3176,3428) that started this came from the caller's + // smoothed-waypoint Euclidean clamp after selection returned null on a stale anchor — not + // from a lack of line of sight. Pending doors/gates are handled by + // handlePendingDoorBeforeRouteClick, not by shortening the click. + WorldPoint forward = findFurthestRawPathPointMatchingGated(rawPath, playerLoc, maxEuclidean, + rawAnchorIndex, Rs2Walker::isKnownWalkableOrUnloaded); + if (forward != null && !forward.equals(playerLoc)) { + routeState.lastRouteClickTier = "route"; + return forward; + } + routeState.lastRouteClickTier = "none"; + return null; + } + + 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; + } + + return findFurthestRawPathPointMatchingGated(rawPath, playerLoc, maxEuclidean, rawAnchorIndex, + candidate -> !candidate.equals(playerLoc) + && isKnownWalkableOrUnloaded(candidate) + && isMiniMapClickable(candidate)); + } + + static boolean shouldIssueActiveRouteIdleNudge() { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + long now = System.currentTimeMillis(); + if (playerLoc == null || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Player.isInteracting() + || Rs2LeaguesTransport.isTeleportInProgress() + || Rs2LeaguesTransport.isLeaguesAreaTeleportPending(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { + routeState.idleNudgeLastObservedLocation = playerLoc; + routeState.idleNudgeStationarySinceMs = now; + return false; + } + // While door recovery is actively suppressed (unresolved door on the blocked edge, handlers cooling + // down), the nudge MUST NOT fire: its forward click is not door-aware and can select a tile on the + // far side of the closed door, which routes the player around the building and off the route. The + // suppress branch itself walks the player to the door's near side; standing there waiting for the + // cooldown is the correct behavior, not idleness to nudge out of. + if (now - routeState.doorRecoverySuppressedAtMs < DOOR_SUPPRESS_NUDGE_HOLDOFF_MS) { + routeState.idleNudgeLastObservedLocation = playerLoc; + routeState.idleNudgeStationarySinceMs = now; + return false; + } + if (!playerLoc.equals(routeState.idleNudgeLastObservedLocation)) { + routeState.idleNudgeLastObservedLocation = playerLoc; + routeState.idleNudgeStationarySinceMs = now; + return false; + } + if (routeState.idleNudgeStationarySinceMs <= 0L) { + routeState.idleNudgeStationarySinceMs = now; + return false; + } + return now - routeState.idleNudgeStationarySinceMs >= ACTIVE_ROUTE_IDLE_NUDGE_MS + && now - routeState.lastActiveRouteIdleNudgeAtMs >= ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS; + } + + static boolean tryIssueRouteRecoveryClick(List rawPath, + List path, + WorldPoint target, + int configuredDistance, + String logLabel) { + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, logLabel, + STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN, true); + } + + static boolean tryIssueRouteContinuationClick(List rawPath, + List path, + WorldPoint target, + int configuredDistance) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || path == null || path.isEmpty()) { + return false; + } + if (rawPath != null && !rawPath.isEmpty()) { + int rawIdx = getClosestTileIndex(rawPath, playerLoc); + if (rawIdx >= 0 && hasUnresolvedDoorLikeObjectNearRawPath(rawPath, + rawIdx, + playerLoc, + UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, + UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, + HANDLER_RANGE)) { + return false; + } + } + int pathIdx = Math.max(0, getClosestTileIndex(path, playerLoc)); + if (hasUpcomingNearbyTransportStep(path, pathIdx, playerLoc, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { + return false; + } + if (target != null && TailDecision.suppressTailReclick(Rs2Player.isMoving(), + playerLoc.distanceTo2D(target), INTERIM_CLOSE_TILES)) { + return false; + } + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", + normalMinimapReach(), false); + } + + 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"; + } + + 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; + } + + 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; + } + + static WalkerState tryDirectShortWalk(WorldPoint target, + int distance, + List rawPath, + List path, + boolean inInstance) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (target == null || playerLoc == null || path == null || path.isEmpty()) { + return WalkerState.MOVING; + } + + WorldPoint end = path.get(path.size() - 1); + int finishTh = tightFinishThreshold(target, end, distance); + + int initialDist = playerLoc.distanceTo(target); + if (initialDist <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:already-within-distance"); + return WalkerState.ARRIVED; + } + + final int directClickMaxDistance = 13; + if (playerLoc.getPlane() != target.getPlane() || initialDist > directClickMaxDistance) { + return WalkerState.MOVING; + } + + if (end == null || end.getPlane() != target.getPlane() || end.distanceTo(target) > distance) { + return WalkerState.MOVING; + } + + if (hasPendingExplicitTransportStepBeforeArrival(rawPath, target, distance) + || hasPendingExplicitTransportStepBeforeArrival(path, target, distance)) { + return WalkerState.MOVING; + } + if (!inInstance && hasPendingDoorLikeSceneObjectBeforeDirectClick(rawPath, path, playerLoc, + directClickMaxDistance)) { + log.debug("[Walker] defer tryDirectShortWalk minimap: route has pending door/gate scene object"); + return WalkerState.MOVING; + } + + if (!inInstance && !Rs2Tile.isWalkable(end)) { + return WalkerState.MOVING; + } + if (!inInstance && !Rs2Tile.isTileReachable(end)) { + return WalkerState.MOVING; + } + if (!inInstance && localRouteDetoursFromComputedRoute(rawPath, end, directClickMaxDistance)) { + return WalkerState.MOVING; + } + long suppressUntil = routeState.suppressTryDirectShortWalkUntilMs; + if (suppressUntil != 0L && System.currentTimeMillis() < suppressUntil) { + log.debug("[Walker] defer tryDirectShortWalk minimap (post door canvas nudge, {}ms window)", + POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS); + return WalkerState.MOVING; + } + + 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; + } + + final WorldPoint before = playerLoc; + boolean moved = sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); + }, 800); + + if (!moved) { + 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; + } + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); + }, 800); + } + + WorldPoint afterClick = Rs2Player.getWorldLocation(); + if (afterClick != null && afterClick.distanceTo(target) <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-click"); + return WalkerState.ARRIVED; + } + + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= finishTh || !Rs2Player.isMoving()); + }, 4000); + + WorldPoint afterWalk = Rs2Player.getWorldLocation(); + if (afterWalk != null && afterWalk.distanceTo(target) <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-walk"); + return WalkerState.ARRIVED; + } + + return WalkerState.MOVING; + } + + static boolean clickRouteBackedShortWalk(List rawPath, + WorldPoint end, + WorldPoint playerLoc, + int maxEuclidean, + int rawAnchorIndex) { + boolean directTargetInRange = shouldAttemptDirectMinimapTarget(end, playerLoc, maxEuclidean); + if (directTargetInRange && walkMiniMap(end)) { + return true; + } + + // distanceTo() is Chebyshev distance, while the minimap clip is effectively circular. + // A diagonal endpoint can therefore pass the short-walk gate while being well outside the + // clip. In that case select a normal forward raw-route point immediately instead of first + // issuing a predictably rejected endpoint click and reporting the continuation as a fallback. + WorldPoint routeTarget = findFurthestVisibleKnownRawPathPoint( + rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + if (routeTarget != null + && !routeTarget.equals(playerLoc) + && !routeTarget.equals(end) + && walkMiniMap(routeTarget)) { + if (directTargetInRange) { + log.debug("[Walker] Direct short-walk target {} was outside the minimap clip; continuing via route {}", + end, routeTarget); + } + return true; + } + return walkFastCanvasOnScreenOnly(end, true); + } + + static boolean shouldAttemptDirectMinimapTarget(WorldPoint target, + WorldPoint playerLoc, + int maxEuclidean) { + if (target == null || playerLoc == null || maxEuclidean < 0 + || target.getPlane() != playerLoc.getPlane()) { + return false; + } + long dx = (long) target.getX() - playerLoc.getX(); + long dy = (long) target.getY() - playerLoc.getY(); + long radius = maxEuclidean; + return dx * dx + dy * dy <= radius * radius; + } + + static boolean hasPendingExplicitTransportStepBeforeArrival(List path, + WorldPoint target, + int distance) { + return hasPendingRouteStepBeforeArrival(path, target, distance, i -> isCatalogBackedTransportSegment(path, i)); + } + + static boolean hasPendingRouteStepBeforeArrival(List path, + WorldPoint target, + int distance, + java.util.function.IntPredicate routeStepAtIndex) { + if (path == null || path.size() < 2 || routeStepAtIndex == null) { + return false; + } + + for (int i = 0; i < path.size() - 1; i++) { + WorldPoint point = path.get(i); + if (target != null && point != null && point.distanceTo(target) <= distance) { + return false; + } + if (routeStepAtIndex.test(i)) { + return true; + } + } + return false; + } + + static boolean localRouteDetoursFromComputedRoute(List rawPath, + WorldPoint end, + int directClickMaxDistance) { + if (rawPath == null || rawPath.size() < 2 || end == null) { + return false; + } + + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || playerLoc.getPlane() != end.getPlane()) { + return false; + } + + int rawStart = getClosestTileIndex(rawPath, playerLoc); + if (rawStart < 0 || rawStart >= rawPath.size() - 1) { + return false; + } + + int rawEnd = -1; + for (int i = rawStart; i < rawPath.size(); i++) { + WorldPoint point = rawPath.get(i); + if (point == null || point.getPlane() != end.getPlane()) { + break; + } + if (point.equals(end)) { + rawEnd = i; + break; + } + } + if (rawEnd < 0) { + return false; + } + + int computedSteps = rawEnd - rawStart; + if (computedSteps <= 0) { + return false; + } + + final int detourSlackTiles = 4; + int searchDistance = Math.max(directClickMaxDistance * 3, computedSteps + detourSlackTiles + 1); + Integer localSteps = Rs2Tile.getReachableTilesFromTile(playerLoc, searchDistance).get(end); + return localSteps == null || localSteps > computedSteps + detourSlackTiles; + } + + + static int interimPreclickTiles() { + try { + return interimPreclickTiles(Rs2Player.isRunEnabled()); + } catch (Exception e) { + return INTERIM_PRECLICK_TILES; + } + } + + static int interimPreclickTiles(boolean runEnabled) { + return runEnabled ? INTERIM_RUN_PRECLICK_TILES : INTERIM_PRECLICK_TILES; + } + + static boolean shouldClearInterimTarget(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs) { + return shouldClearInterimTarget(interim, playerLoc, setAtMs, lastProgressAtMs, nowMs, Integer.MAX_VALUE); + } + + /** + * @param bestDistanceSeen closest the player has been to {@code interim} while holding it, or + * {@link Integer#MAX_VALUE} when unknown (then the abandon check is inert). + */ + static boolean shouldClearInterimTarget(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs, + int bestDistanceSeen) { + if (interim == null) { + return false; + } + if (playerLoc == null || playerLoc.getPlane() != interim.getPlane()) { + return true; + } + if (playerLoc.distanceTo2D(interim) <= INTERIM_CLOSE_TILES) { + return true; + } + // An interim the player is walking AWAY from is dead, and nothing else here notices. + // interimLastProgressAtMs is renewed whenever the ROUTE INDEX advances, so a player making + // honest progress along the route — in the opposite direction to a checkpoint the route has + // since moved past — renews the interim every pass and the stale-progress escape can never + // fire. Measured: interim held at (2973,3350) while the player walked 2961,3349 -> 2960,3343, + // moving=true throughout, renewed until interimAgeMs=9999 and only then "expired" — with a + // transport dispatch waiting behind it the whole time. + if (bestDistanceSeen != Integer.MAX_VALUE + && playerLoc.distanceTo2D(interim) > bestDistanceSeen + INTERIM_ABANDON_MARGIN_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); + } + + static void recordInterimDistanceProgress(WorldPoint interim, WorldPoint playerLoc, long nowMs) { + int distance = distanceToInterimOrMax(interim, playerLoc); + if (distance < routeState.interimLastDistanceToTarget) { + routeState.interimLastDistanceToTarget = distance; + routeState.interimLastProgressAtMs = nowMs; + } + } + + static boolean clearInterimTargetIfReachedOrExpired(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = routeState.interimTargetWp; + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (interim != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + if (bestIdxNow > routeState.interimLastBestPathIdx) { + routeState.interimLastBestPathIdx = bestIdxNow; + routeState.interimLastProgressAtMs = nowMs; + } + } + if (!shouldClearInterimTarget(interim, playerLoc, routeState.interimSetAtMs, + routeState.interimLastProgressAtMs, nowMs, routeState.interimLastDistanceToTarget)) { + 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 (routeState.interimLastDistanceToTarget != Integer.MAX_VALUE + && playerLoc.distanceTo2D(interim) + > routeState.interimLastDistanceToTarget + INTERIM_ABANDON_MARGIN_TILES) { + reason = "moving-away"; + } else if (routeState.interimLastProgressAtMs > 0L && nowMs - routeState.interimLastProgressAtMs > INTERIM_PROGRESS_TIMEOUT_MS) { + reason = "stale-progress"; + } else { + reason = "expired"; + } + clearInterimTarget(reason); + return true; + } + + static boolean shouldYieldForActiveRecoveryInterim(WorldPoint interim, + WorldPoint playerLoc, + long setAtMs, + long lastProgressAtMs, + long nowMs, + int bestDistanceSeen, + long lastMovedAtMs, + long lastRecoveryClickAtMs, + boolean playerMoving) { + if (interim == null) { + return false; + } + if (shouldClearInterimTarget( + interim, playerLoc, setAtMs, lastProgressAtMs, nowMs, bestDistanceSeen)) { + return false; + } + if (shouldDeferRouteWorkForActiveInterim(interim, + playerLoc, + setAtMs, + lastProgressAtMs, + nowMs, + bestDistanceSeen, + lastMovedAtMs, + playerMoving, + INTERIM_CLOSE_TILES)) { + return true; + } + return isRecentEvent(nowMs, lastRecoveryClickAtMs, RECOVERY_MOVEMENT_IN_FLIGHT_MS); + } + + static boolean shouldYieldForActiveRecoveryInterim(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = routeState.interimTargetWp; + if (interim == null) { + return false; + } + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (playerLoc != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + if (bestIdxNow > routeState.interimLastBestPathIdx) { + routeState.interimLastBestPathIdx = bestIdxNow; + routeState.interimLastProgressAtMs = nowMs; + } + } + return shouldYieldForActiveRecoveryInterim(interim, + playerLoc, + routeState.interimSetAtMs, + routeState.interimLastProgressAtMs, + nowMs, + routeState.interimLastDistanceToTarget, + routeState.lastMovedTimeMs, + routeState.lastUnreachableRecoveryClickAtMs, + Rs2Player.isMoving()); + } + + static boolean shouldYieldForActiveRouteInterim(WorldPoint playerLoc, + List path, + long nowMs) { + WorldPoint interim = routeState.interimTargetWp; + if (interim == null) { + return false; + } + recordInterimDistanceProgress(interim, playerLoc, nowMs); + if (playerLoc != null && path != null && !path.isEmpty()) { + int bestIdxNow = getClosestTileIndex(path, playerLoc); + if (bestIdxNow > routeState.interimLastBestPathIdx) { + routeState.interimLastBestPathIdx = bestIdxNow; + routeState.interimLastProgressAtMs = nowMs; + } + } + return shouldDeferRouteWorkForActiveInterim(interim, + playerLoc, + routeState.interimSetAtMs, + routeState.interimLastProgressAtMs, + nowMs, + routeState.interimLastDistanceToTarget, + routeState.lastMovedTimeMs, + Rs2Player.isMoving(), + INTERIM_CLOSE_TILES); + } + + static void clearInterimTarget(String reason) { + WorldPoint old = routeState.interimTargetWp; + if (old != null) { + if ("close".equals(reason)) { + WebWalkLog.spDebug("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + } else { + WebWalkLog.spInfo("interim_clear | reason={} interim={}", reason, compactWorldPoint(old)); + } + } + routeState.interimTargetWp = null; + routeState.interimTargetIdx = -1; + routeState.interimSetAtMs = 0L; + routeState.interimLastProgressAtMs = 0L; + routeState.interimLastBestPathIdx = -1; + routeState.interimLastDistanceToTarget = Integer.MAX_VALUE; + routeState.interimLastRetargetAtMs = 0L; + } + + /** + * Ceiling for how long the inventory-only path may be before a "close" target (≤100 + * chebyshev) loses its right to skip the bank compare. 3x straight-line absorbs honest + * wall-hugging and indoor zigzags; the 60-tile floor keeps tiny distances from tripping + * on ordinary detours around buildings. Anything above this is a real detour — a gate the + * player lacks the item/fare for — and the banked flow must get its chance to fetch it. + */ + static int shortWalkDirectPathCeiling(int chebyshevDistance) { + return Math.max(60, chebyshevDistance * 3); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java new file mode 100644 index 00000000000..b880a0bc751 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Coordinate-free terminal outcomes for blocking walks observed while shadow mode was enabled. */ +public final class Rs2WalkerShadowExecutionStats +{ + private final long arrived; + private final long unreachable; + private final long exited; + private final long recoveryArrived; + private final long recoveryUnreachable; + private final long recoveryExited; + + Rs2WalkerShadowExecutionStats( + long arrived, + long unreachable, + long exited, + long recoveryArrived, + long recoveryUnreachable, + long recoveryExited) + { + this.arrived = requireNonNegative(arrived, "arrived"); + this.unreachable = requireNonNegative(unreachable, "unreachable"); + this.exited = requireNonNegative(exited, "exited"); + this.recoveryArrived = requireNonNegative(recoveryArrived, "recoveryArrived"); + this.recoveryUnreachable = requireNonNegative( + recoveryUnreachable, "recoveryUnreachable"); + this.recoveryExited = requireNonNegative(recoveryExited, "recoveryExited"); + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getArrived() { return arrived; } + public long getUnreachable() { return unreachable; } + public long getExited() { return exited; } + public long getRecoveryArrived() { return recoveryArrived; } + public long getRecoveryUnreachable() { return recoveryUnreachable; } + public long getRecoveryExited() { return recoveryExited; } + public long getTerminal() { return arrived + unreachable + exited; } + public long getRecoveryTerminal() + { + return recoveryArrived + recoveryUnreachable + recoveryExited; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java new file mode 100644 index 00000000000..911362768f6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java @@ -0,0 +1,3151 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.*; +import net.runelite.api.Point; +import net.runelite.api.annotations.Component; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.*; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.devtools.MovementFlag; +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.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; +import net.runelite.client.plugins.microbot.util.coords.Rs2LocalPoint; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldArea; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; +import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.Runes; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; +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 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; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +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; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry; +import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; +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; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits; +import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; +import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; +import javax.inject.Named; +import java.awt.*; +import java.util.*; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static net.runelite.client.plugins.microbot.util.Global.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2Walker.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors.*; + +/** + * The transport-execution component extracted from {@code Rs2Walker} (Phase E1, 2026-08-13): the + * per-type transport handlers, the terminal-travel machinery and their private helpers — the + * dispatcher {@code handleSelectedTransport} and its exclusive call-graph closure, moved verbatim. + * Shared walker state and helpers remain in {@code Rs2Walker} (same package) and are consumed via + * static imports; the walker calls back in through the package-private dispatcher. + */ +@lombok.extern.slf4j.Slf4j +final class Rs2WalkerTransports { + + private Rs2WalkerTransports() { + } + + /** + * 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 + * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. + */ + private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) < maxChebyshevExclusive; + } + + /** + * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). + */ + private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; + } + + + + + + + /** + * Executes the exact transport retained by the active route through its registered Microbot executor. + * Candidate discovery must happen through immutable route steps, never by rescanning the mutable + * transport catalog. The local transport payload is isolated here because POH execution still carries + * subtype behavior that is not part of the planner-independent edge value. + */ + static boolean handleSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection) { + if (selection == null || !selection.isExecutable()) { + if (selection != null) { + WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", + selection.getEdge().getType(), + compactWorldPoint(selection.getEdge().getOrigin()), + compactWorldPoint(selection.getEdge().getDestination())); + } + return false; + } + Transport selectedTransport = selection.getLocalExecutionTransport(); + Rs2TerminalTravelMode terminalTravelMode = selection.getEdge().getTerminalTravelMode(); + if (path == null || selectedTransport == null + || indexOfStartPoint < 0 || indexOfStartPoint >= path.size()) { + return false; + } + if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 + && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + return false; + } + if (log.isDebugEnabled()) { + log.debug("[Walker] handleTransports at {}: exact planned candidate — {} executor={}", + path.get(indexOfStartPoint), selectedTransport.getDisplayInfo(), selection.getExecutor()); + } + // When the player is inside a POH instance, the player's raw world-location plane is + // the instance-template plane and has no relationship to the POH-transport origin plane. + // Skip the plane guard in that case so POH transports can actually be considered. + boolean inPohInstance = Microbot.getClient().getTopLevelWorldView().getScene().isInstance() + && net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile() != null; + + // Pre-compute path point index map for O(1) lookups instead of repeated O(n) scans + Map pathFirstIndex = new HashMap<>(path.size()); + for (int idx = 0; idx < path.size(); idx++) { + pathFirstIndex.putIfAbsent(path.get(idx), idx); + } + + for (Transport transport : Collections.singletonList(selectedTransport)) { + Collection worldPointCollections; + //in some cases the getOrigin is null, for teleports that start the player location + if (transport.getOrigin() == null) { + worldPointCollections = Collections.singleton(null); + } else if (inPohInstance && transport.getType() == TransportType.POH) { + // POH fix: when the player is inside a POH instance, the transport's exit-portal + // origin is an overworld tile that doesn't map into the player's instance chunks, + // so toLocalInstance() returns an empty collection and the inner loop never runs. + // Pass the origin through directly so the per-i dispatch below can execute. + worldPointCollections = Collections.singleton(transport.getOrigin()); + } else { + worldPointCollections = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), transport.getOrigin()); + } + log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", + transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); + originLoop: + for (WorldPoint origin : worldPointCollections) { + WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); + if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null + && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { + continue; + } + + // Hoist path-constant checks out of the inner loop: destination must exist in path + if (!pathFirstIndex.containsKey(transport.getDestination())) { + log.debug("[Walker] skip {}: destination {} not in path", transport.getDisplayInfo(), transport.getDestination()); + continue; + } + // QUETZAL is not {@link TransportType#isTeleport} — without this, stall/off-path recalc can re-open the map and + // click the same landing repeatedly while already there (no movement → infinite stall loop). + if (transport.getType() == TransportType.QUETZAL) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { + log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", + transport.getDisplayInfo(), OFFSET, transport.getDestination()); + continue; + } + } + if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { + log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); + continue; + } + } + // A crossed edge must never be crossed BACK. After landing on the destination the + // origin can still sit inside the raw dispatch radius, and the origin-distance + // check knows nothing about sides: the Kebos shortcut (1389->1391, 2026-08-14 + // 17:00) landed the player on 1391 and the next two passes matched the origin from + // the far side and re-crossed — in front of other players. Being on the + // destination SIDE (strictly closer to the destination than the origin, same + // plane) is proof the edge is behind us — exact tile equality was not enough: + // 17:31 the player stood ONE TILE off the destination (1391,3310) and the recovery + // path re-crossed anyway. Strictness keeps staircase chains alive (origin and + // destination share x,y, both distances 0). A genuine reverse crossing is a + // DIFFERENT planned edge (origin and destination swapped), which this never skips. + if (plOriginLoop != null && transport.getOrigin() != null + && plOriginLoop.getPlane() == transport.getDestination().getPlane() + && plOriginLoop.distanceTo2D(transport.getDestination()) + < plOriginLoop.distanceTo2D(transport.getOrigin())) { + WebWalkLog.spInfo("transport_skip_already_at_destination | name={} origin={} dest={}", + transport.getDisplayInfo(), + compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + continue; + } + + // Pre-compute origin/destination indices once per transport (not per inner iteration) + int precomputedIndexOfOrigin = -1; + int precomputedIndexOfDest = -1; + if (!TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + Integer originIdx = pathFirstIndex.get(transport.getOrigin()); + Integer destIdx = pathFirstIndex.get(transport.getDestination()); + precomputedIndexOfOrigin = originIdx != null ? originIdx : -1; + precomputedIndexOfDest = destIdx != null ? destIdx : -1; + if (log.isDebugEnabled()) { + log.debug("[Walker] filter4 {}: indexOfOrigin={}, indexOfDestination={}, pathSize={}, originInPath={}, destInPath={}", + transport.getDisplayInfo(), precomputedIndexOfOrigin, precomputedIndexOfDest, path.size(), + precomputedIndexOfOrigin != -1, precomputedIndexOfDest != -1); + } + if (precomputedIndexOfDest == -1) continue; + if (precomputedIndexOfOrigin == -1) continue; + if (precomputedIndexOfDest < precomputedIndexOfOrigin) continue; + } + + for (int i = indexOfStartPoint; i < path.size(); i++) { + WorldPoint plPathLoop = Rs2Player.getWorldLocation(); + if (plPathLoop == null) { + // Cannot verify plane / dispatch — do not burn remaining path indices this tick. + break; + } + if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { + log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); + break; // plane won't change across iterations, so break instead of continue + } + + if (i == indexOfStartPoint) { + log.debug("[Walker] reached pre-dispatch for {}: i={}, path[i]={}, origin={}, equalsOrigin={}", + transport.getDisplayInfo(), i, path.get(i), origin, path.get(i).equals(origin)); + } + + if (path.get(i).equals(origin)) { + if (selection.getExecutor() == Rs2TransportExecutor.BARROWS_DIG) { + WorldPoint digOrigin = transport.getOrigin(); + WorldPoint playerAtMound = Rs2Player.getWorldLocation(); + if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { + // Digging is tile-sensitive. Let the ordinary path click finish the + // approach instead of firing the spade from an adjacent mound tile. + return false; + } + boolean dug = attemptObserved(transport, + () -> Rs2Inventory.interact(ItemID.SPADE, "Dig")); + if (!dug) { + return false; + } + boolean enteredCrypt = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf( + transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (enteredCrypt) { + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "Barrows dig post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + return false; + } + + if (isTerminalTravelTransport(transport.getType())) { + if (terminalTravelMode == Rs2TerminalTravelMode.UNSUPPORTED) { + WebWalkLog.spWarn( + "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", + transport.getType(), compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + break originLoop; + } + + Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); + if (npc != null && Rs2Npc.canWalkTo(npc, 20)) { + String npcAction = resolveTerminalNpcInteractionAction( + npc, transport); + if (npcAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal NPC has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; + } + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; + } + if (!npcAction.equalsIgnoreCase(transport.getAction())) { + WebWalkLog.spInfo( + "terminal NPC action fallback name={} configured={} selected={} dest={}", + transport.getName(), transport.getAction(), npcAction, + transport.getDisplayInfo()); + } + + // Wrap with observation so Leagues blocked-region chat can attribute this attempt. + if (attemptObserved(transport, () -> Rs2Npc.interact(npc, npcAction))) { + Rs2Player.waitForWalking(); + sleepUntil(Rs2Dialogue::isInDialogue, 600 * 2); + + if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption("Can you take me somewhere?"); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")) { + sleepTickJitter(2); + Rs2Dialogue.clickContinue(); + } + // Right-clicking the destination is always preferred and needs no + // dialogue — that is what DIRECT means. But the mode is decided + // statically from a name whitelist, so an NPC whose row names a + // destination it no longer offers (Veos: the row says + // "Port Piscarilius", the game now asks in conversation) resolved + // to DIRECT, skipped destination selection entirely, and left the + // walker staring at the destination menu. + // + // resolveTerminalNpcInteractionAction already told us which action + // the NPC actually offered. If it had to fall back to a generic one + // then the destination was NOT chosen by the click and has to be + // chosen in the dialogue, whatever the static mode says. + Rs2TerminalTravelMode effectiveTravelMode = terminalTravelMode; + if (!npcAction.equalsIgnoreCase(transport.getAction()) + && transport.getDisplayInfo() != null + && !transport.getDisplayInfo().isBlank()) { + effectiveTravelMode = Rs2TerminalTravelMode.DIALOGUE_DESTINATION; + } + if (!selectTerminalTravelDialogueDestination( + transport, effectiveTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } + } + } else { + TileObject terminalObject = findTerminalTravelObject(transport); + if (terminalObject != null) { + String objectAction = resolveTransportObjectAction( + terminalObject, + Collections.singletonList(transport.getAction())) + .orElse(""); + if (objectAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal object has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; + } + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; + } + prepareTransportObjectForInteraction(terminalObject); + final TileObject selectedTerminalObject = terminalObject; + if (attemptObserved(transport, () -> Rs2GameObject.interact( + selectedTerminalObject, objectAction))) { + if (!selectTerminalTravelDialogueDestination( + transport, terminalTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } + } + } else { + WorldPoint originTile = path.get(i); + boolean clicked = Rs2Walker.walkFastCanvas(originTile); + if (!clicked) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc != null) { + clicked = walkMiniMapToward(originTile, playerLoc, 13); + } + } + if (!clicked) { + clicked = Rs2Walker.walkMiniMap(originTile); + } + if (!clicked) { + log.debug("[Walker] terminal travel fallback click failed for {}", originTile); + } + sleep(1200, 1600); + } + } + + // Terminal travel is terminal for this transport scan. The exact edge can be + // clicked at most once in one top-level walk invocation; callers can start + // a fresh walk after a surfaced failure, but this invocation never spams the + // target for later path indices or another local-instance copy of the origin. + break originLoop; + } + + if (transport.getType() == TransportType.CHARTER_SHIP) { + if (attemptObserved(transport, () -> handleCharterShip(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + boolean charterLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!charterLanded) { + WebWalkLog.spWarn( + "charter ship post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + sleepTickJitter(4); // wait 4 extra ticks before walking + return finishHandledTransport(transport); + } + } + } + + log.debug("[Walker] Handling {} transport: {} (i={}, path[i]={}, origin={})", + transport.getType(), transport.getDisplayInfo(), i, path.get(i), origin); + if (transport.getType() == TransportType.POH) { + boolean pohResult = attemptObserved(transport, () -> handlePohTransport(transport)); + log.debug("[Walker] handlePohTransport({}) returned {}", transport.getDisplayInfo(), pohResult); + if (pohResult) { + // Shares ship/NPC/boat 10s landing budget — intentional single timeout constant. + boolean pohNearDest = sleepUntil( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!pohNearDest) { + WebWalkLog.spWarn( + "POH post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (pohNearDest) { + return finishHandledTransport(transport); + } + } + } + + if (transport.getType() == TransportType.CANOE) { + if (attemptObserved(transport, () -> handleCanoe(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.HOT_AIR_BALLOON) { + if (attemptObserved(transport, () -> Rs2HotAirBalloon.handle(selection.getEdge()))) { + boolean balloonLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (balloonLanded) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "hot-air balloon post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + // This is a specialized map interaction. Do not fall through to the generic + // object handler and click the same basket again during this walker tick. + return false; + } + + if (transport.getType() == TransportType.SPIRIT_TREE) { + if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { + log.debug("[Walker] skip spirit tree transport — setting is off"); + continue; + } + if (attemptObserved(transport, () -> handleSpiritTree(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + boolean spiritLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!spiritLanded) { + WebWalkLog.spWarn( + "spirit tree post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (spiritLanded) { + return finishHandledTransport(transport); + } + } + } + + if (transport.getType() == TransportType.QUETZAL) { + if (attemptObserved(transport, () -> handleQuetzal(transport))) { + boolean landedNearDest = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!landedNearDest) { + WebWalkLog.spWarn( + "quetzal post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.MAGIC_CARPET) { + if (attemptObserved(transport, () -> handleMagicCarpet(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.WILDERNESS_OBELISK) { + if (attemptObserved(transport, () -> handleWildernessObelisk(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.GNOME_GLIDER) { + if (attemptObserved(transport, () -> handleGlider(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), + TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + sleepTickJitter(3); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.FAIRY_RING) { + WorldPoint plFairy = Rs2Player.getWorldLocation(); + WorldPoint tdFairy = transport.getDestination(); + boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); + if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_MINIGAME) { + if (attemptObserved(transport, () -> handleMinigameTeleport(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_ITEM) { + if (attemptObserved(transport, () -> handleTeleportItem(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_SPELL) { + if (attemptObserved(transport, () -> handleTeleportSpell(transport))) { + if (isLumbridgeHomeTeleport(transport)) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 600, 35000); + } else { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + } + Rs2Tab.switchTo(InterfaceTab.INVENTORY); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.SEASONAL_TRANSPORT) { + if (attemptObservedWithoutAttemptRecord(transport, () -> handleSeasonalTransport(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getObjectId() <= 0) break; + + final int transportObjectId = transport.getObjectId(); + final String transportAction = transport.getAction(); + final List transportActions = getTransportActionOptions(transportAction); + // Climb-down transports have a closed-variant (trapdoor/manhole/grate/hatch) + // that shares the same tile but a different object ID. Infer the closed + // variant from ObjectComposition (any nearby object with an "Open" action + // and a matching name) rather than a hardcoded ID pair, so new variants + // work without a code change. + final boolean allowClosedVariant = "Climb-down".equalsIgnoreCase(transportAction) + || "Climb down".equalsIgnoreCase(transportAction); + + final boolean allowAlKharidTollGateVariant = isAlKharidTollGateObjectId(transportObjectId); + // The FIRST transport of a walk costs ~12.7s in the segment handler while the same + // transport mid-route costs ~1.8s, and the plane-change waits account for only + // ~1.5s of it (measured over three Falador castle runs). This scan runs once per + // CANDIDATE transport at the tile, and a staircase tile carries several rows, so + // the suspicion is N scans rather than one. Time it and say how many candidates + // were queued, so the next run distinguishes "one slow scan" from "many scans". + long objectScanStartedAt = System.currentTimeMillis(); + final Integer legacyClosedId = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); + // Most catalog transports can use their stable object id. The Al Kharid gate cannot: + // its historical catalog ids collide with unrelated live objects in newer injected-client + // revisions. Select that edge by its transformed live composition and route geometry instead. + // This deliberately has no id fallback: clicking an unrelated object is worse than failing + // closed and replanning. + List matched; + if (allowAlKharidTollGateVariant) { + matched = Rs2GameObject.getAll( + o -> isAlKharidTollGateSceneCandidate(transport, o), + transport.getOrigin(), 3); + } else { + // Id-only first: these are plain field reads, no composition resolution. + matched = Rs2GameObject.getAll(o -> { + int id = o.getId(); + if (id == transportObjectId) return true; + return legacyClosedId != null && id == legacyClosedId; + }, transport.getOrigin(), 10); + } + if (matched.isEmpty() && allowClosedVariant) { + // Only now pay for compositions, and only on the transport's own tile: a closed + // variant (trapdoor/manhole/grate/hatch) sits where the transport is, never ten + // tiles away. Previously this ran for EVERY object within 10 tiles whenever the + // action was Climb-down, one client-thread hop each — measured at 5.5-10.9 + // SECONDS for a single scan inside Falador castle, and the reason descending + // stairs was slow while ascending was not. + matched = Rs2GameObject.getAll(o -> { + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); + if (comp == null || comp.getActions() == null) return false; + String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); + boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") + || nm.contains("grate") || nm.contains("hatch"); + if (!nameMatches) return false; + return Arrays.stream(comp.getActions()).filter(Objects::nonNull) + .anyMatch(a -> a.equalsIgnoreCase("Open")); + }, transport.getOrigin(), 2); + } + List objects = matched.stream() + .sorted(Comparator + .comparingInt((TileObject o) -> resolveTransportObjectAction(o, transportActions).isPresent() ? 0 : 1) + .thenComparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) + .collect(Collectors.toList()); + + long objectScanMs = System.currentTimeMillis() - objectScanStartedAt; + if (objectScanMs >= TRANSPORT_OBJECT_SCAN_SLOW_MS) { + WebWalkLog.spInfo("transport_object_scan | slow scanMs={} objectId={} candidatesAtTile={} matches={} origin={}", + objectScanMs, transportObjectId, 1, objects.size(), + compactWorldPoint(transport.getOrigin())); + } + TileObject object = objects.stream().findFirst().orElse(null); + if (object instanceof GroundObject) { + object = objects.stream() + .filter(o -> !Objects.equals(o.getWorldLocation(), Rs2Player.getWorldLocation())) + .min(Comparator.comparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getOrigin())) + .thenComparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getDestination()))).orElse(null); + } + + if (object != null) { + // Skip reachability check for GroundObjects and Magic Mushtrees + if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { + if (!Rs2Tile.isTileReachable(transport.getOrigin())) { + break; + } + } + + // Closed variant detection: if the found object doesn't advertise the + // transport action but does advertise "Open", open it first and re-find + // the now-open object before invoking handleObject. + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + if (comp != null && comp.getActions() != null) { + String[] actions = comp.getActions(); + boolean hasTransportAction = resolveTransportObjectAction(actions, transportActions).isPresent(); + boolean hasOpen = Arrays.stream(actions).filter(Objects::nonNull) + .anyMatch(a -> a.equalsIgnoreCase("Open")); + if (!hasTransportAction && hasOpen) { + log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", + transport.getOrigin(), object.getId(), comp.getName(), transportAction); + final int closedId = object.getId(); + Rs2GameObject.interact(object, "Open"); + Rs2Player.waitForAnimation(2000); + TileObject reopened = Rs2GameObject.getAll(o -> { + if (o.getId() == closedId) return false; + ObjectComposition c = Rs2GameObject.convertToObjectComposition(o); + if (c == null || c.getActions() == null) return false; + return resolveTransportObjectAction(c.getActions(), transportActions).isPresent(); + }, transport.getOrigin(), 3).stream() + .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) + .orElse(null); + if (reopened != null) object = reopened; + } + } + + String interactionAction = resolveTransportObjectAction(object, transportActions) + .orElse(transportAction); + if (!Objects.equals(interactionAction, transportAction)) { + log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", + interactionAction, transportAction, object.getWorldLocation(), object.getId()); + } + prepareTransportObjectForInteraction(object); + if (!handleObject(transport, object, interactionAction)) { + return false; + } + sleepUntil(() -> !Rs2Player.isAnimating()); + WorldPoint destWait = transport.getDestination(); + int maxInclusive = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; + if (destWait == null) { + return false; + } + boolean landedAfterObject = waitForPostHandleObjectLanding(transport, destWait, maxInclusive); + if (!landedAfterObject) { + WorldPoint afterInteraction = Rs2Player.getWorldLocation(); + // Adjacent same-plane transports demand landing on the EXACT destination + // tile (maxInclusive == 0), and agility shortcuts routinely deposit the + // player a tile off it — so a crossing can physically succeed while this + // check still fails. Suppression previously ran only on the success path, + // which left the inverse transport immediately eligible: the walker + // crossed, took the same shortcut straight back, and stranded itself. If + // we are no longer on the origin we did cross, so suppress both tiles + // regardless of the landing verdict. The landing result itself is + // unchanged — this still returns false and replans. + if (isAdjacentSamePlaneTransport(transport) + && afterInteraction != null + && !afterInteraction.equals(transport.getOrigin())) { + markAdjacentSamePlaneTransportHandled(transport, object); + } + WebWalkLog.spWarn( + "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", + POST_HANDLE_OBJECT_LANDING_WAIT_MS, + compactWorldPoint(destWait), + compactWorldPoint(afterInteraction)); + } + if (landedAfterObject) { + markAdjacentSamePlaneTransportHandled(transport, object); + return finishHandledTransport(transport); + } + return false; + } + } + } + } + return false; + } + + private static boolean waitForPostHandleObjectLanding(Transport transport, + WorldPoint destWait, + 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; + } + if (!isAdjacentSamePlaneTransport(transport) + || System.currentTimeMillis() - waitStartedAt < POST_HANDLE_OBJECT_FAILED_SETTLE_MS) { + return false; + } + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || destWait == null || playerLoc.getPlane() != destWait.getPlane() + || Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + 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) + && settledAwayFromOrigin) { + settledAwayFromAdjacentDestination.set(true); + return true; + } + 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())); + return false; + } + 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); + if (destinationDistance <= Math.max(1, maxInclusive) + && playerLoc.distanceTo2D(origin) > 0) { + return true; + } + if (transport.getType() != TransportType.AGILITY_SHORTCUT) { + return false; + } + + // Some adjacent shortcut catalogues describe a multi-object animation as one-tile + // hops. The Falador stepping stones, for example, can carry 3154 -> 3149 while the + // selected edge says 3154 -> 3153. Accept only a tightly bounded forward, collinear + // overshoot; sideways movement, reverse movement, and arbitrary teleports still fail. + int edgeX = destWait.getX() - origin.getX(); + int edgeY = destWait.getY() - origin.getY(); + int movedX = playerLoc.getX() - origin.getX(); + int movedY = playerLoc.getY() - origin.getY(); + int forwardProgress = movedX * edgeX + movedY * edgeY; + int lateralOffset = Math.abs(movedX * edgeY - movedY * edgeX); + return forwardProgress > 0 + && forwardProgress <= 6 + && lateralOffset <= 1; + } + + /** + * Handles the transportation process specifically for instances of PohTransport. + * Any Transport param that reaches this is assumed to be a PohTransport. + * + * @param transport the transport object to be checked and processed + * @return true if the transport is an instance of PohTransport and its transport method executes successfully, false otherwise + */ + private static boolean handlePohTransport(Transport transport) { + if(!(transport instanceof PohTransport)) { + throw new IllegalStateException("handlePohTransport should not be called for non-PohTransports"); + } + return ((PohTransport)transport).execute(); + } + + private static List getTransportActionOptions(String action) { + if (action == null || action.isBlank()) { + return Collections.emptyList(); + } + + List actions = new ArrayList<>(); + actions.add(action); + if ("Bottom-floor".equalsIgnoreCase(action)) { + actions.add("Climb-down"); + actions.add("Climb down"); + } else if ("Top-floor".equalsIgnoreCase(action)) { + actions.add("Climb-up"); + actions.add("Climb up"); + } + return actions; + } + + private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null || comp.getActions() == null) { + return Optional.empty(); + } + return resolveTransportObjectAction(comp.getActions(), actionOptions); + }).orElse(Optional.empty()); + } + + private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { + if (objectActions == null || actionOptions == null || actionOptions.isEmpty()) { + return Optional.empty(); + } + + for (String desired : actionOptions) { + for (String actual : objectActions) { + if (actual != null && desired.equalsIgnoreCase(Rs2UiHelper.stripColTags(actual))) { + return Optional.of(actual); + } + } + } + return Optional.empty(); + } + + private static void prepareTransportObjectForInteraction(TileObject tileObject) { + if (tileObject == null || tileObject.getLocalLocation() == null) { + return; + } + if (!Rs2Camera.isTileOnScreen(tileObject)) { + Rs2Camera.turnTo(tileObject); + sleepUntil(() -> Rs2Camera.isTileOnScreen(tileObject), 1200); + } + } + + private static boolean handleObject(Transport transport, TileObject tileObject) { + return handleObject(transport, tileObject, transport.getAction()); + } + + /** + * A transport may be gated on an item that its own vendor sells on the spot (the Shantay pass + * pattern: the gate wants a ticket, Shantay sells tickets two tiles away). The catalog rows in + * {@code purchasable_items.tsv} say which item, which vendor, and how close the vendor must be + * to the transport origin; the transports.tsv duplicate-row OR (item row + currency-twin row) + * already made the planner route through such transports for players holding only the coins. + * This pre-step completes the currency variant: buy the item before interacting. Free rows + * (e.g. a gate's exit direction) carry neither item nor currency requirements and never match. + * + *

Vendor interaction is by NPC id — a name lookup once partial-matched the nearer + * "Shantay Guard" (Actions=[Talk-to, null, Pass]) and the buy silently failed. + */ + private static void ensureRequiredItemBeforeTransport(Transport transport) { + PurchasableItemCatalog.PurchasableItem purchasable = PurchasableItemCatalog.forTransport(transport); + if (purchasable == null || Rs2Inventory.hasItem(purchasable.itemId)) { + return; + } + WebWalkLog.spInfo("purchasable_buy | item={} vendor={} action={} at={}", + purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction, + compactWorldPoint(Rs2Player.getWorldLocation())); + if (Rs2Npc.interact(purchasable.vendorNpcId, purchasable.vendorAction)) { + sleepUntil(() -> Rs2Inventory.hasItem(purchasable.itemId), 4000); + } + if (!Rs2Inventory.hasItem(purchasable.itemId)) { + WebWalkLog.spWarn("purchasable_buy failed | item={} vendor={} action={} — no item acquired", + purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction); + } + } + + private static boolean handleObject(Transport transport, TileObject tileObject, String action) { + ensureRequiredItemBeforeTransport(transport); + WorldPoint before = Rs2Player.getWorldLocation(); + Rs2GameObject.interact(tileObject, action); + // Unlike the other exception handlers, a toll-gate interaction is not complete merely + // because the menu action was issued: it may first server-walk from several tiles away and + // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so + // it cannot emit a transport handoff for a player who is still west/east of the gate. + if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { + return handleAlKharidTollGate(transport); + } + if (handleObjectExceptions(transport, tileObject)) return true; + WorldPoint tdObj = transport.getDestination(); + WorldPoint plObj = Rs2Player.getWorldLocation(); + if (tdObj == null || plObj == null) { + return false; + } + if (tdObj.getPlane() == plObj.getPlane()) { + if (transport.getType() == TransportType.AGILITY_SHORTCUT) { + Rs2Player.waitForAnimation(); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return isPlayerWithinChebyshevInclusive(tdObj, 2) + || isSettledNearAdjacentSamePlaneLanding(transport, now, tdObj, 0); + }, 10000); + } else if (transport.getType() == TransportType.MINECART) { + if (interactWithAdventureLog(transport)) { + sleepTickJitter(2); // wait extra 2 game ticks before moving + } else { + sleepUntil(() -> Rs2Player.getPoseAnimation() == 2148, 5000); + sleepUntil(() -> Rs2Player.getPoseAnimation() != 2148, 10000); + } + } else if (transport.getType() == TransportType.TELEPORTATION_PORTAL) { + sleepTickJitter(2); // wait extra 2 game ticks before moving + } else { + Rs2Player.waitForWalking(); + Rs2Dialogue.clickOption("Yes please"); //shillo village cart + if (isAdjacentSamePlaneTransport(transport)) { + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.equals(transport.getDestination()) + || !now.equals(before) + || !Rs2Player.isMoving()); + }, 2000); + WorldPoint afterOpen = Rs2Player.getWorldLocation(); + if (afterOpen != null && !afterOpen.equals(transport.getDestination())) { + boolean clicked = walkMiniMap(transport.getDestination()); + if (!clicked) { + clicked = walkFastCanvas(transport.getDestination()); + } + if (clicked) { + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return now != null && td != null && now.equals(td); + }, 3000); + } + } + } + } + return true; + } else { + WorldPoint plZ = Rs2Player.getWorldLocation(); + if (plZ == null) { + return false; + } + int z = plZ.getPlane(); + // Instrumentation: the FIRST plane-change transport of a walk consistently costs ~9.5s + // while the same kind mid-route costs ~2.2s (measured across two Falador castle runs). + // The waits below bound at 1800 + 5000 + jitter, and a failed start returns false and is + // retried, so two attempts would explain it — but that is inference. These timings say + // which of start-detection, plane-detection or retry actually burns the seconds. + long planeChangeStartedAt = System.currentTimeMillis(); + boolean started = sleepUntil(() -> { + WorldPoint p = Rs2Player.getWorldLocation(); + return p != null && (p.getPlane() != z || Rs2Player.isMoving() || Rs2Player.isAnimating()); + }, 1800); + long startWaitMs = System.currentTimeMillis() - planeChangeStartedAt; + if (!started) { + WebWalkLog.spInfo("transport_plane_change | no_start startWaitMs={} obj={} action={} — returning for retry", + startWaitMs, tileObject.getId(), transport.getAction()); + return false; + } + WorldPoint plAfterStart = Rs2Player.getWorldLocation(); + boolean planeChanged = plAfterStart != null && plAfterStart.getPlane() != z + || sleepUntil(() -> { + WorldPoint p = Rs2Player.getWorldLocation(); + return p != null && p.getPlane() != z; + }, 5000); + long planeWaitMs = System.currentTimeMillis() - planeChangeStartedAt - startWaitMs; + if (planeChanged) { + // gaussRand is an unbounded Box-Muller draw, so mean 300 / dev 120 goes negative past + // ~2.5 sigma (about one call in 160) and Thread.sleep throws IllegalArgumentException, + // killing the whole walk. Seen live: "timeout value is negative" here aborted a + // Falador castle run into ShortestPathScript auto-retry 1/3. Clamping only removes the + // impossible tail — the jitter this sleep exists to provide is untouched. + sleep(Math.max(MIN_PLANE_CHANGE_SETTLE_MS, (int) Rs2Random.gaussRand(300.0, 120.0))); + } + WebWalkLog.spInfo("transport_plane_change | changed={} startWaitMs={} planeWaitMs={} totalMs={} obj={}", + planeChanged, startWaitMs, planeWaitMs, + System.currentTimeMillis() - planeChangeStartedAt, tileObject.getId()); + return planeChanged; + } + } + + private static boolean finishHandledTransport(Transport transport) { + long handoffStartedAt = System.currentTimeMillis(); + routeState.lastTransportHandledAtMs = handoffStartedAt; + routeState.lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; + routeState.lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; + WorldPoint goal = currentTarget; + WorldPoint transportDest = transport != null ? transport.getDestination() : null; + boolean expectedTransport = consumeExpectedTransportDestination(transportDest); + boolean hasPrecomputedContinuation = hasPrecomputedContinuationFromTransport(transport); + if (goal != null) { + WebWalkLog.tmark("transport_handoff_enter", + 0L, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest) + + " expected=" + expectedTransport + + " precomputed=" + hasPrecomputedContinuation + + " type=" + (transport != null ? transport.getType() : "null")); + } + if ((expectedTransport || hasPrecomputedContinuation) && goal != null) { + WebWalkLog.tmark(expectedTransport ? "transport_handoff_expected_hit" : "transport_handoff_precomputed_hit", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); + return true; + } + if (goal != null && transportDest != null) { + // Destination-aware handoff: prepare next path from known landing tile. + boolean queued = restartPathfinding(transportDest, goal); + WebWalkLog.tmark("transport_handoff_restart", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "queued=" + queued + " dest=" + compactWorldPoint(transportDest)); + if (!queued && shouldRecalculatePathAfterTransport(transport)) { + recalculatePath(); + WebWalkLog.tmark("transport_handoff_recalc_fallback", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); + } + } else if (goal != null && shouldRecalculatePathAfterTransport(transport)) { + recalculatePath(); + WebWalkLog.tmark("transport_handoff_recalc_goal_only", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); + } + return true; + } + + private static boolean consumeExpectedTransportDestination(WorldPoint destination) { + if (destination == null) { + return false; + } + synchronized (expectedTransportDestinations) { + while (!expectedTransportDestinations.isEmpty()) { + WorldPoint expected = expectedTransportDestinations.peekFirst(); + if (expected == null) { + expectedTransportDestinations.pollFirst(); + continue; + } + if (sameOrNearTransportDestination(expected, destination)) { + expectedTransportDestinations.pollFirst(); + return true; + } + break; + } + return false; + } + } + + private static boolean sameOrNearTransportDestination(WorldPoint a, WorldPoint b) { + return a != null + && b != null + && a.getPlane() == b.getPlane() + && a.distanceTo2D(b) <= TRANSPORT_DEST_MATCH_CHEBYSHEV; + } + + private static boolean hasPrecomputedContinuationFromTransport(Transport transport) { + if (transport == null || transport.getDestination() == null) { + return false; + } + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isReady()) { + return false; + } + List walkPath = routeStatus.getWalkablePath(); + if (walkPath == null || walkPath.size() < 2) { + return false; + } + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + int closest = getClosestTileIndex(walkPath, playerLoc); + if (closest < 0) { + return false; + } + WorldPoint destination = transport.getDestination(); + for (int i = Math.max(0, closest - 2); i < walkPath.size(); i++) { + WorldPoint point = walkPath.get(i); + if (sameOrNearTransportDestination(point, destination)) { + return i < walkPath.size() - 1; + } + } + return false; + } + + static boolean shouldRecalculatePathAfterTransport(Transport transport) { + if (transport == null || transport.getDestination() == null) { + return false; + } + if (TransportType.isTeleport(transport.getType())) { + return true; + } + if (transport.getOrigin() == null) { + return false; + } + return transport.getOrigin().getPlane() != transport.getDestination().getPlane() + || transport.getOrigin().distanceTo2D(transport.getDestination()) > OFFSET; + } + + private static void markAdjacentSamePlaneTransportHandled(Transport transport, TileObject tileObject) { + for (WorldPoint point : adjacentSamePlaneTransportSuppressionPoints(transport, tileObject)) { + markStationaryDoorOpened(point); + } + } + + static Set adjacentSamePlaneTransportSuppressionPoints(Transport transport, TileObject tileObject) { + if (!isAdjacentSamePlaneTransport(transport)) { + return Collections.emptySet(); + } + + Set points = new LinkedHashSet<>(); + points.add(transport.getOrigin()); + points.add(transport.getDestination()); + if (tileObject != null && tileObject.getWorldLocation() != null) { + points.add(tileObject.getWorldLocation()); + } + return points; + } + + static boolean isTerminalTravelTransport(TransportType transportType) { + return transportType == TransportType.SHIP + || transportType == TransportType.NPC + || transportType == TransportType.BOAT; + } + + private static boolean selectTerminalTravelDialogueDestination( + Transport transport, Rs2TerminalTravelMode mode) { + if (mode == Rs2TerminalTravelMode.DIRECT) { + return true; + } + if (mode != Rs2TerminalTravelMode.DIALOGUE_DESTINATION + || transport == null + || transport.getDisplayInfo() == null + || transport.getDisplayInfo().isBlank()) { + return false; + } + // Several single-destination NPCs (Mountain Guide) travel the player DIRECTLY off the + // initial click — no destination dialogue ever appears. This wait then burned its whole + // budget standing at the destination (Auburn Valley 2026-08-14: 7.7s, resolved=false, + // rescued only because the next pass found the player already across). Release on the + // evidence that settles the question either way: the dialogue, or the landing itself. + // The budget must OUTLAST the longest direct flight: the quetzal legs run 7-8s from click + // to landing, so a 5s wait always lost the race and warned right at touchdown (18:00 and + // 18:28 runs). Both releases fire early, so a long budget costs nothing when things work — + // the timeout is reached only when neither dialogue nor landing ever happened. + final WorldPoint dialogueWaitStart = Rs2Player.getWorldLocation(); + final WorldPoint terminalDest = transport.getDestination(); + if (!sleepUntil(() -> Rs2Dialogue.hasSelectAnOption() + || hasLandedAtTerminalDestination(Rs2Player.getWorldLocation(), dialogueWaitStart, terminalDest), + 12_000)) { + WebWalkLog.spWarn( + "terminal travel destination dialogue did not appear name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return false; + } + if (!Rs2Dialogue.hasSelectAnOption() + && hasLandedAtTerminalDestination(Rs2Player.getWorldLocation(), dialogueWaitStart, terminalDest)) { + WebWalkLog.spInfo("terminal travel landed without destination dialogue name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return true; + } + if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { + return true; + } + // The destination is not in THIS menu. Several ferrymen answer a "can you take me somewhere" + // option with the destination list, so open it and look again rather than giving up — the + // walker previously stopped here with the destination menu on screen and walked away. + for (String opener : TERMINAL_TRAVEL_MENU_OPENERS) { + if (!Rs2Dialogue.hasSelectAnOption() || !Rs2Dialogue.clickOption(opener)) { + continue; + } + WebWalkLog.spInfo("terminal travel menu opened via '{}' name={} dest={}", + opener, transport.getName(), transport.getDisplayInfo()); + sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000); + if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { + return true; + } + } + WebWalkLog.spWarn( + "terminal travel destination option missing name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return false; + } + + /** + * Whether the player is standing at a terminal-travel destination they were not standing at + * when the dialogue wait began. The moved-CLOSER requirement is what keeps a short crossing + * honest: standing beside the ferryman at an origin that happens to sit near the destination + * proves nothing — only having closed distance on the destination does. Plane must match; the + * 5-tile allowance covers the landing AREA around the configured tile (the Mountain Guide + * dropped the player at 1365,3309 for a 1361,3309 destination, 2026-08-14 17:30, and the old + * 2-tile radius burned the whole dialogue wait standing there). + */ + static boolean hasLandedAtTerminalDestination(WorldPoint player, WorldPoint waitStart, + WorldPoint dest) { + return player != null && dest != null + && player.getPlane() == dest.getPlane() + && player.distanceTo2D(dest) <= 5 + && !player.equals(waitStart) + && (waitStart == null + || player.distanceTo2D(dest) < waitStart.distanceTo2D(dest)); + } + + private static TileObject findTerminalTravelObject(Transport transport) { + if (transport == null || transport.getOrigin() == null) { + return null; + } + TileObject object = Rs2GameObject.getAll( + candidate -> isTerminalTravelObjectSceneCandidate(transport, candidate), + transport.getOrigin(), 3).stream().findFirst().orElse(null); + if (object != null) { + WebWalkLog.spInfo( + "terminal travel object selected type={} name={} action={} origin={} dest={}", + transport.getType(), transport.getName(), transport.getAction(), + compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + } + return object; + } + + private static boolean isTerminalTravelObjectSceneCandidate(Transport transport, + TileObject object) { + if (object == null) { + return false; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition composition = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + return composition != null + && isTerminalTravelObjectCompositionCandidate( + transport, + object.getWorldLocation(), + composition.getName(), + composition.getActions()); + }).orElse(false); + } + + static boolean isTerminalTravelObjectCompositionCandidate(Transport transport, + WorldPoint objectLocation, + String objectName, + String[] objectActions) { + if (transport == null + || !isTerminalTravelTransport(transport.getType()) + || transport.getOrigin() == null + || objectLocation == null + || objectName == null + || transport.getName() == null + || transport.getAction() == null + || objectLocation.getPlane() != transport.getOrigin().getPlane() + || objectLocation.distanceTo2D(transport.getOrigin()) > 3 + || !Rs2UiHelper.stripColTags(objectName).trim().equalsIgnoreCase( + Rs2UiHelper.stripColTags(transport.getName()).trim())) { + return false; + } + return resolveTransportObjectAction( + objectActions, + Collections.singletonList(transport.getAction())).isPresent(); + } + + private static boolean awaitTerminalTravelLanding(Transport transport, + List path, + int destinationIndex) { + boolean landed = sleepUntil( + () -> hasReachedTerminalTravelLanding( + transport, path, destinationIndex, Rs2Player.getWorldLocation()), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!landed) { + WebWalkLog.spWarn( + "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + return landed; + } + + /** + * Returns interaction actions in executor preference order. Some legacy ship rows encode their + * destination label as the direct NPC menu action. The current Port Sarim NPCs instead expose + * {@code Travel}; keep the configured label first for compatible clients, then use that observed + * live fallback. Explicit dialogue and quick-travel actions must never be replaced implicitly. + */ + static List terminalNpcInteractionCandidates(TransportType transportType, + String configuredAction) { + LinkedHashSet candidates = new LinkedHashSet<>(); + if (configuredAction != null && !configuredAction.isBlank()) { + candidates.add(configuredAction); + } + if (transportType == TransportType.SHIP + && !isExplicitShipMenuAction(configuredAction)) { + candidates.add("Travel"); + } + return List.copyOf(candidates); + } + + private static boolean isExplicitShipMenuAction(String action) { + return action != null + && (action.equalsIgnoreCase("Travel") + || action.equalsIgnoreCase("Talk-to") + || action.equalsIgnoreCase("Quick-Travel") + || action.equalsIgnoreCase("Take-boat")); + } + + private static String resolveTerminalNpcInteractionAction(Rs2NpcModel npc, Transport transport) { + if (npc == null || transport == null) { + return ""; + } + for (String candidate : terminalNpcInteractionCandidates( + transport.getType(), transport.getAction())) { + // Query one candidate at a time: Rs2Npc#getAvailableAction otherwise returns NPC-menu + // order, which commonly places Talk-to before the exact configured action. + String available = Rs2Npc.getAvailableAction(npc, Collections.singletonList(candidate)); + if (!available.isEmpty()) { + return available; + } + } + return ""; + } + + static boolean markTerminalTravelAttempt(Transport transport) { + if (transport == null || transport.getOrigin() == null || transport.getDestination() == null) { + return false; + } + String key = transport.getType() + + "|" + rangedTransportEdgeKey(transport.getOrigin(), transport.getDestination()) + + "|" + transport.getObjectId() + + "|" + Objects.toString(transport.getName(), "") + + "|" + Objects.toString(transport.getAction(), ""); + return TERMINAL_TRAVEL_ATTEMPTED_EDGES.add(key); + } + + /** + * Accepts the exact catalogued landing or the immediately following path point. The latter covers + * modern ship travel that skips an obsolete deck tile and completes the next gangplank step in one + * server action. It deliberately does not scan arbitrary later route points, which could report a + * false landing when a route loops near its origin. + */ + static boolean hasReachedTerminalTravelLanding(Transport transport, + List path, + int destinationIndex, + WorldPoint playerLocation) { + if (transport == null || playerLocation == null || transport.getDestination() == null) { + return false; + } + WorldPoint origin = transport.getOrigin(); + if (origin != null + && origin.getPlane() == playerLocation.getPlane() + && origin.distanceTo2D(playerLocation) <= 1) { + return false; + } + if (isNearSamePlane(playerLocation, transport.getDestination(), + TRANSPORT_NEAR_LANDING_CHEBYSHEV)) { + return true; + } + if (path == null || destinationIndex < 0 || destinationIndex + 1 >= path.size()) { + return false; + } + WorldPoint immediateContinuation = path.get(destinationIndex + 1); + return immediateContinuation != null + && !immediateContinuation.equals(transport.getDestination()) + && isNearSamePlane(playerLocation, immediateContinuation, + TRANSPORT_NEAR_LANDING_CHEBYSHEV); + } + + 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 isAlKharidTollGateSceneCandidate(Transport transport, TileObject object) { + if (!(object instanceof WallObject) && !(object instanceof GameObject)) { + return false; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + return comp != null + && isAlKharidTollGateCompositionCandidate( + transport, object.getWorldLocation(), comp.getName(), comp.getActions()) + && Rs2DoorGeometry.isDoorOnSegment( + object, transport.getOrigin(), transport.getDestination()); + }).orElse(false); + } + + static boolean isAlKharidTollGateCompositionCandidate(Transport transport, + WorldPoint objectLocation, + String objectName, + String[] objectActions) { + if (!isAlKharidTollGateTransport(transport) + || objectLocation == null + || !AL_KHARID_TOLL_GATE_POINTS.contains(objectLocation) + || objectName == null + || !objectName.toLowerCase(Locale.ROOT).contains("gate")) { + return false; + } + return resolveTransportObjectAction( + objectActions, getTransportActionOptions(transport.getAction())).isPresent(); + } + + static boolean hasReachedAlKharidTollDestination(Transport transport, WorldPoint playerLocation) { + return isAlKharidTollGateTransport(transport) + && playerLocation != null + && playerLocation.equals(transport.getDestination()); + } + + private static boolean handleAlKharidTollGate(Transport transport) { + // Object interaction can begin out of range. Wait for server-walking, the confirmation + // dialogue, or the crossing itself instead of sampling isMoving() immediately after click. + sleepUntil(() -> Rs2Player.isMoving() + || Rs2Dialogue.hasSelectAnOption() + || hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()), + AL_KHARID_TOLL_INTERACTION_START_WAIT_MS); + + if (Rs2Player.isMoving() + && !hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation())) { + Rs2Player.waitForWalking(); + } + + boolean confirmed = false; + if (!hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()) + && (Rs2Dialogue.hasSelectAnOption() + || sleepUntil(Rs2Dialogue::hasSelectAnOption, + AL_KHARID_TOLL_INTERACTION_START_WAIT_MS))) { + confirmed = Rs2Dialogue.clickOption("Yes, okay", "Yes"); + } + + boolean reachedDestination = hasReachedAlKharidTollDestination( + transport, Rs2Player.getWorldLocation()) + || sleepUntil(() -> hasReachedAlKharidTollDestination( + transport, Rs2Player.getWorldLocation()), + POST_HANDLE_OBJECT_LANDING_WAIT_MS); + if (!reachedDestination) { + WebWalkLog.spWarn( + "Al Kharid toll gate crossing unresolved confirmed={} dest={} at={}", + confirmed, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + return reachedDestination; + } + + private static boolean handleObjectExceptions(Transport transport, TileObject tileObject) { + for (Map.Entry entry : OPEN_TO_CLOSED_MAPPINGS.entrySet()) { + final int closedTrapdoorId = entry.getKey(); + final int openTrapdoorId = entry.getValue(); + + if (transport.getObjectId() == openTrapdoorId) { + if (tileObject.getId() == closedTrapdoorId) { + Rs2GameObject.interact(tileObject, "Open"); + sleepUntil(() -> Rs2GameObject.exists(openTrapdoorId)); + TileObject openTrapdoor = Rs2GameObject.getAll(o -> o.getId() == openTrapdoorId, tileObject.getWorldLocation(), 10).stream().findFirst().orElse(null); + if (openTrapdoor != null) { + Rs2GameObject.interact(openTrapdoor, transport.getAction()); + } + } else if (tileObject.getId() == openTrapdoorId) { + Rs2GameObject.interact(tileObject, transport.getAction()); + } + sleepUntil(() -> !Rs2Player.isAnimating()); + boolean trapdoorLanded = sleepUntilTrue( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!trapdoorLanded) { + WebWalkLog.spWarn( + "trapdoor post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + return true; + } + } + + //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(); + Rs2Player.waitForAnimation(); + return true; + } + // Handle Leaves Traps in Isafdar Forest + if (tileObject.getId() == ObjectID.REGICIDE_PITFALL_SIDE) { + Rs2Player.waitForAnimation(1200); + if (Rs2Player.getWorldLocation().getY() > 6400) { + Rs2GameObject.interact(ObjectID.REGICIDE_TRAP_HAND_HOLDS); + sleepUntil(() -> Rs2Player.getWorldLocation().getY() < 6400); + } else { + sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating()); + } + return true; + } + // Handle Ferox Encalve Barrier + if (tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER || tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER_M) { + if (Rs2Dialogue.isInDialogue()) { + if (Rs2Dialogue.getDialogueText().toLowerCase().contains("when returning to the enclave")) { + Rs2Dialogue.clickContinue(); + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.keyPressForDialogueOption("Yes, and don't ask again."); + Rs2Dialogue.sleepUntilNotInDialogue(); + return true; + } + } + } + // Handle Cobwebs blocking path + if (tileObject.getId() == ObjectID.BIGWEB_SLASHABLE && !Rs2Equipment.isWearing(ItemID.ARANEA_BOOTS)) { + sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating(1200)); + final WorldPoint webLocation = tileObject.getWorldLocation(); + final WorldPoint currentPlayerPoint = Rs2Player.getWorldLocation(); + boolean doesWebStillExist = Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isPresent(); + if (doesWebStillExist) { + sleepUntil(() -> Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isEmpty(), + () -> { + Rs2GameObject.interact(tileObject, "slash"); + Rs2Player.waitForAnimation(); + }, 8000, 1200); + } + Rs2Walker.walkFastCanvas(transport.getDestination()); + return sleepUntil(() -> !Objects.equals(currentPlayerPoint, Rs2Player.getWorldLocation())); + } + + // Handle Brimhaven Dungeon Entrance + if (tileObject.getId() == 20877) { + if (Rs2Player.isMoving()) { + Rs2Player.waitForWalking(); + } + Rs2Dialogue.sleepUntilHasQuestion("Pay 875 coins to enter?"); + Rs2Dialogue.clickOption("Yes"); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return now != null && td != null && now.equals(td); + }); + return true; + } + // Handle Brimhaven Dungeon Stepping Stones + if (tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE1 || tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE2) { + Rs2Player.waitForAnimation(600 * 7); + return true; + } + + // Handle Morte Myre Cave Agility Shortcut + if (tileObject.getId() == ObjectID.FAIRY2_ROUTE_CAVEWALLTUNNEL) { + Rs2Player.waitForAnimation((600 * 4 ) + 300); + return true; + } + + // Handle Crash Site Cavern Gate + if (tileObject.getId() == 28807 && transport.getOrigin().equals(new WorldPoint(2435,3519, 0))) { + if (Rs2Player.isMoving()) { + Rs2Player.waitForWalking(); + } + Rs2Dialogue.sleepUntilInDialogue(); + Rs2Dialogue.clickOption("yes"); + return true; + } + + // Handle Cave Entrance inside of Asgarnia Ice Caves + if (tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_EAST || tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_WEST) { + Rs2Player.waitForAnimation(); + } + + // Handle Rev Cave Dialogue + if (tileObject.getId() == ObjectID.WILD_CAVE_ENTRANCE_LOW) { + if (Rs2Player.isMoving()) { + Rs2Player.waitForWalking(); + } + Widget dialogueSprite = Rs2Dialogue.getDialogueSprite(); + if (dialogueSprite != null && dialogueSprite.getItemId() == 1004) { + Rs2Dialogue.clickContinue(); + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption("Yes, don't ask again"); + Rs2Dialogue.sleepUntilNotInDialogue(); + } + return true; + } + + if (tileObject.getId() == ObjectID.HEROROCKSLIDE) { + Rs2Player.waitForAnimation(600 * 4); + return true; + } + + if (Rs2GameObject.getObjectIdsByName("Fossil_Rowboat").contains(tileObject.getId())) { + if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; + + char option = transport.getDisplayInfo().charAt(0); + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Keyboard.keyPress(option); + sleepUntil(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 10000); + return true; + } + + // Handle door/gate near wilderness agility course + if (tileObject.getId() == ObjectID.BALANCEGATE52A || tileObject.getId() == ObjectID.BALANCEGATE52B_RIGHT || tileObject.getId() == ObjectID.BALANCEGATE52B_LEFT) { + Rs2Player.waitForAnimation(600 * 4); + return true; + } + + if (tileObject.getId() == ObjectID.AERIAL_FISHING_BOAT) { + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption(transport.getDisplayInfo(), true); + sleepUntil(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 10000); + return true; + } + + // Handle Magic Mushtree (Fossil Island Mycelium Transportation System) + if (MagicMushtree.isMagicMushtree(tileObject)) { + return MagicMushtree.handleTransport(transport); + } + return false; + } + + private static boolean handleWildernessObelisk(Transport transport) { + GameObject obelisk = Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()); + + if (obelisk != null) { + Rs2GameObject.interact(obelisk, transport.getAction()); + sleepUntil(() -> Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()) != null); + walkFastCanvas(transport.getOrigin()); + return sleepUntilTrue(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 100, 10000); + } + return false; + } + + private static boolean handleTeleportSpell(Transport transport) { + if (Rs2Pvp.isInWilderness() && !isTeleportAllowedAtWildernessLevel( + Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()), transport.getMaxWildernessLevel())) return false; + if (!prepareTeleportSpellProviders(transport)) return false; + boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); + + String spellName = hasMultipleDestination + ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() + : transport.getDisplayInfo().toLowerCase(); + + String option = hasMultipleDestination + ? transport.getDisplayInfo().split(":")[1].trim().toLowerCase() + : "cast"; + + int identifier = hasMultipleDestination + ? 2 + : 1; + + Optional homeTeleport = + TransportExecutionRegistry.homeTeleportFor(transport.getDisplayInfo()); + if (homeTeleport.isPresent()) { + return Rs2Magic.quickCast(homeTeleport.get().getDisplayName()); + } + + MagicAction magicSpell = Arrays.stream(MagicAction.values()).filter(x -> x.getName().toLowerCase().contains(spellName)).findFirst().orElse(null); + if (magicSpell != null) { + return Rs2Magic.cast(magicSpell, option, identifier); + } + return false; + } + + /** + * Equip any inventory staff/tome selected by a source-aware upstream spell requirement before + * casting. An item merely present in the inventory never acts as an infinite rune provider. + */ + private static boolean prepareTeleportSpellProviders(Transport transport) { + List requirements = transport.getItemRequirements(); + if (requirements == null || requirements.isEmpty()) { + return true; + } + + Map runeQuantities = new HashMap<>(); + Rs2Magic.getRunes().forEach((rune, quantity) -> + runeQuantities.put(rune.getItemId(), quantity)); + java.util.function.IntUnaryOperator currentQuantity = itemId -> { + Runes rune = Runes.byItemId(itemId); + if (rune != null) { + return runeQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); + }; + + TransportItemRequirement.ProviderSelection providers = + TransportItemRequirement.selectProviders( + requirements, + currentQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .orElse(null); + if (providers == null) { + return false; + } + if (!equipTransportProvider(providers.getStaffItemId()) + || !equipTransportProvider(providers.getOffhandItemId())) { + return false; + } + + Map verifiedRuneQuantities = new HashMap<>(); + Rs2Magic.getRunes().forEach((rune, quantity) -> + verifiedRuneQuantities.put(rune.getItemId(), quantity)); + return TransportItemRequirement.selectProviders( + requirements, + itemId -> { + Runes rune = Runes.byItemId(itemId); + if (rune != null) { + return verifiedRuneQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); + }, + Rs2Equipment::isWearing, + Rs2Equipment::isWearing).isPresent(); + } + + private static boolean equipTransportProvider(int itemId) { + if (itemId <= 0 || Rs2Equipment.isWearing(itemId)) { + return true; + } + return Rs2Inventory.hasItem(itemId) + && Rs2Inventory.wield(itemId) + && sleepUntil(() -> Rs2Equipment.isWearing(itemId), 3000); + } + + private static boolean isLumbridgeHomeTeleport(Transport transport) { + return transport.getDisplayInfo() != null + && transport.getDisplayInfo().toLowerCase().startsWith("lumbridge home teleport"); + } + + private static boolean handleTeleportItem(Transport transport) { + WorldPoint plWild = Rs2Player.getWorldLocation(); + if (Rs2Pvp.isInWilderness() && plWild != null + && !isTeleportAllowedAtWildernessLevel( + Rs2Pvp.getWildernessLevelFrom(plWild), transport.getMaxWildernessLevel())) { + return false; + } + boolean succesfullAction = false; + for (Set itemIds : transport.getItemIdRequirements()) { + if (succesfullAction) + break; + 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 = reachedDistanceOrDefault(); + if (reachRd > 0 && isPlayerWithinChebyshevOf(transport.getDestination(), reachRd)) { + break; + } + if (succesfullAction) break; + + //If an action is succesfully we break out of the loop + succesfullAction = handleWearableTeleports(transport, itemId) || handleInventoryTeleports(transport, itemId); + } + } + return succesfullAction; + } + + private static boolean handleInventoryTeleports(Transport transport, int itemId) { + Rs2ItemModel rs2Item = Rs2Inventory.get(itemId); + if (rs2Item == null) return false; + + // A list of generic teleports that can be used if no parsable destination action is found + List genericKeyWords = Arrays.asList( + "invoke", "empty", "consume", "open", "teleport", "rub", "break", "reminisce", "signal", "play", "commune", "squash", "blow" + ); + + // Return true when the item does not use a generic keyword to teleport to its destination + boolean hasParsableDestination = transport.getDisplayInfo().contains(":"); + String destination = teleportItemLeafAction(transport.getDisplayInfo()); + + boolean wildernessTransport = Rs2PathApi.isInWilderness(transport.getDestination()); + + log.debug("Trying to find action for destination={}", destination); + // Check if item has destination as direct action + String itemAction = rs2Item.getAction(destination); + + // Check if item has destination as sub-menu action + Map.Entry sub = rs2Item.getIndexOfSubAction(destination); + if (itemAction == null && sub != null && sub.getKey() != null) { + itemAction = destination; + } + + // If there's only one destination with the item possible, a generic action will also work + if (itemAction == null && !hasParsableDestination) { + itemAction = rs2Item.getActionFromList(genericKeyWords); + } + + if (itemAction != null) { + boolean interaction = Rs2Inventory.interact(rs2Item, itemAction); + if (!interaction) { + return false; + } else if (wildernessTransport) { + Rs2Dialogue.sleepUntilInDialogue(); + return Rs2Dialogue.clickOption("Yes", "Okay"); + } else if (isQuetzalWhistleItemId(itemId)) { + return finishQuetzalWhistleTransport(transport); + } + return true; + } + + // If no location-based action found, try generic actions + itemAction = rs2Item.getActionFromList(genericKeyWords); + + if (itemAction == null) { + log.debug("No generic keyword found for={}, genericKeywords={}", itemAction, String.join(",", genericKeyWords)); + return false; + } + + if (Rs2Inventory.interact(itemId, itemAction)) { + log.debug("Traveling with genericAction={}, to {} - ({})", itemAction, transport.getDisplayInfo(), transport.getDestination()); + + if (itemAction.equalsIgnoreCase("open") && itemId == ItemID.BOOKOFSCROLLS_CHARGED) { + return handleMasterScrollBook(destination); + } else if (isQuetzalWhistleItemId(itemId)) { + return finishQuetzalWhistleTransport(transport); + } else if (isDialogueBasedTeleportItem(transport.getDisplayInfo())) { + // Multi-destination teleport items: wait for destination selection dialogue + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption(destination); + log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); + return true; + } else if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { + // Burning amulet in inventory: confirm wilderness teleport + Rs2Dialogue.sleepUntilInDialogue(); + Rs2Dialogue.clickOption("Okay, teleport to level"); + log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); + return true; + } else if (wildernessTransport) { + Rs2Dialogue.sleepUntilInDialogue(); + return Rs2Dialogue.clickOption("Yes", "Okay"); + } else { + Rs2Player.waitForAnimation(); + log.info("Unsure how to handle this itemTransport={} action={}", transport, itemAction); + } + } + return false; + } + + private static boolean handleWearableTeleports(Transport transport, int itemId) { + Rs2ItemModel rs2Item = Rs2Equipment.get(itemId); + if (rs2Item == null) return false; + if (transport.getDisplayInfo().contains(":")) { + String destination = teleportItemLeafAction(transport.getDisplayInfo()); + + if (transport.getDisplayInfo().toLowerCase().contains("slayer ring")) { + Rs2Equipment.invokeMenu(rs2Item, "teleport"); + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption(destination); + } else { + Rs2Equipment.invokeMenu(rs2Item, destination); + if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { + Rs2Dialogue.sleepUntilInDialogue(); + Rs2Dialogue.clickOption("Okay, teleport to level"); + } + } + log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); + return true; + } + return false; + } + + /** + * Returns the executable leaf from a display hierarchy. Upstream labels may describe nested + * categories (for example {@code Max cape: POH Portals: Rimmington}); RuneLite item sub-ops are + * looked up by their leaf action, not by the intermediate display category. + */ + static String teleportItemLeafAction(String displayInfo) { + if (displayInfo == null) { + return ""; + } + String[] segments = displayInfo.split(":"); + return segments[segments.length - 1].trim().toLowerCase(Locale.ROOT); + } + + static boolean isTeleportAllowedAtWildernessLevel(int currentLevel, int maximumLevel) { + return currentLevel <= maximumLevel; + } + + /** + * Checks if the teleport item requires dialogue-based destination selection. + * These are items that, when rubbed/activated, show a dialogue menu to choose destination. + * + * @param displayInfo the displayInfo from the transport + * @return true if the item requires dialogue handling + */ + private static boolean isDialogueBasedTeleportItem(String displayInfo) { + if (displayInfo == null) return false; + String lowerDisplayInfo = displayInfo.toLowerCase(); + return lowerDisplayInfo.contains("slayer ring") + || lowerDisplayInfo.contains("games necklace") + || lowerDisplayInfo.contains("skills necklace") + || lowerDisplayInfo.contains("ring of dueling") + || lowerDisplayInfo.contains("ring of wealth") + || lowerDisplayInfo.contains("amulet of glory") + || lowerDisplayInfo.contains("combat bracelet") + || lowerDisplayInfo.contains("digsite pendant") + || lowerDisplayInfo.contains("necklace of passage") + || lowerDisplayInfo.contains("giantsoul amulet"); + } + + /** + * Forwards to {@link Rs2LeaguesTransport#recordTransportAttempt} for Leagues locked-region chat correlation. + * Delegate records only teleport-like transports while Leagues is active (seasonal + spells/items, e.g. ectophial). + */ + public static void recordTransportAttempt(Transport transport) + { + Rs2LeaguesTransport.recordTransportAttempt(transport); + } + + /** + * Writes {@code phase="result"} for {@link Rs2LeaguesTransport#appendTransportObservation} (seasonal rows only). + */ + private static void recordTransportResult(Transport transport, boolean success) + { + if (transport == null || transport.getType() != TransportType.SEASONAL_TRANSPORT) + { + return; + } + if (!Rs2LeaguesTransport.isLeaguesActive()) + { + return; + } + Rs2LeaguesTransport.appendTransportObservation("result", transport, success, success ? "ok" : "fail"); + } + + /** Wraps an action with {@link #recordTransportAttempt} + {@link #recordTransportResult} (seasonal JSONL, Leagues snapshot for teleports). + * @see net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport + */ + private static boolean attemptObserved(Transport transport, BooleanSupplier action) + { + if (transport == null || action == null) + { + return false; + } + boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); + // Snapshot attempt for Leagues locked-region chat correlation (avoid churn outside leagues). + if (leaguesActive) + { + recordTransportAttempt(transport); + } + boolean ok = action.getAsBoolean(); + if (leaguesActive) + { + recordTransportResult(transport, ok); + } + return ok; + } + + /** + * Like {@link #attemptObserved} but does not call {@link #recordTransportAttempt} before the action. + * Seasonal handlers record attempts at their click sites so {@link Rs2LeaguesTransport#getLastTransportAttemptSnapshot} + * matches the handler that actually ran (Leagues Area vs MoA). + */ + private static boolean attemptObservedWithoutAttemptRecord(Transport transport, BooleanSupplier action) + { + if (transport == null || action == null) + { + return false; + } + boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); + boolean ok = action.getAsBoolean(); + if (leaguesActive) + { + recordTransportResult(transport, ok); + } + return ok; + } + + /** + * Tries configured seasonal transport handlers for the same {@link Transport} row. + * Attempt recording is done inside each handler (for built-ins, {@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}) + * — use {@link #attemptObservedWithoutAttemptRecord} at the call site. + */ + private static boolean handleSeasonalTransport(Transport transport) { + if (transport == null) { + return false; + } + String displayInfo = transport.getDisplayInfo(); + if (displayInfo == null) return false; + + List handlers = seasonalTransportHandlers; + for (SeasonalTransportHandler h : handlers) + { + if (h == null) + { + continue; + } + if (!h.matches(transport)) + { + continue; + } + if (h.tryUse(transport)) + { + return true; + } + } + Telemetry.incrementSeasonalHandlerMiss(); + if (log.isDebugEnabled() && SEASONAL_HANDLER_MISS_LOGGED_COUNT.get() < SEASONAL_HANDLER_MISS_LOG_CAP) + { + WorldPoint destWp = transport.getDestination(); + String hash = Integer.toHexString(displayInfo.hashCode()); + String tail = displayInfo.length() > 160 + ? displayInfo.substring(0, 160) + "|h" + hash + : displayInfo + "|h" + hash; + final String missKey; + Integer packedTileOrNull = null; + if (destWp != null) + { + packedTileOrNull = WorldPointUtil.packWorldPoint(destWp); + missKey = Integer.toHexString(packedTileOrNull) + "|" + tail; + } + else + { + missKey = "nodest|" + tail; + } + if (SEASONAL_HANDLER_MISS_LOGGED.add(missKey)) + { + // Best-effort cap: only increment while below cap; duplicates and races are fine for debug-only logs. + for (;;) + { + int prev = SEASONAL_HANDLER_MISS_LOGGED_COUNT.get(); + if (prev >= SEASONAL_HANDLER_MISS_LOG_CAP) + { + break; + } + if (SEASONAL_HANDLER_MISS_LOGGED_COUNT.compareAndSet(prev, prev + 1)) + { + break; + } + } + String sample = displayInfo.length() > 160 ? displayInfo.substring(0, 160) + "…" : displayInfo; + if (packedTileOrNull != null) + { + sample = sample + " destPacked=" + Integer.toHexString(packedTileOrNull); + } + log.debug("[Walker] seasonal transport unmatched by configured handlers (expect pathfinder-only matching rows); key={} sample={}", + missKey, sample); + } + } + return false; + } + + private static boolean handleSpiritTree(Transport transport) { + // Get Transport Information + String displayInfo = transport.getDisplayInfo(); + int objectId = transport.getObjectId(); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: displayInfo={}, objectId={}", displayInfo, objectId); + } + if (displayInfo == null || displayInfo.isEmpty()) { + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: displayInfo empty, returning false"); + } + return false; + } + + if (!Rs2Widget.isWidgetVisible(ComponentID.ADVENTURE_LOG_CONTAINER)) { + TileObject spiritTree = Rs2GameObject.findObjectById(objectId); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: findObjectById({}) returned {}", + objectId, spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + } + if (spiritTree == null) { + // POH fix: handleSpiritTree's findObjectById uses the transport's objectId + // which is keyed from the TSV. Inside a POH the spirit tree is a different + // object id than the overworld TSV expects. Fall back to the PohTeleports + // helper which knows the full set of POH spirit-tree ids. + spiritTree = PohTeleports.getSpiritTree(); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: POH fallback getSpiritTree() returned {}", + spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + } + } + boolean interactResult = Rs2GameObject.interact(spiritTree, "Travel"); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: interact(spiritTree, Travel) returned {}", interactResult); + } + if (!interactResult) { + return false; + } + } + + boolean result = interactWithAdventureLog(transport); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: interactWithAdventureLog returned {}", result); + } + return result; + } + + private static boolean handleMinigameTeleport(Transport transport) { + final Object[] selectedOpListener = new Object[]{489, 0, 0}; + final List teleportGraphics = List.of(800, 802, 803, 804); + + @Component final int GROUPING_BUTTON_COMPONENT_ID = 46333957; // 707.5 + + @Component final int DROPDOWN_BUTTON_COMPONENT_ID = 4980760; // 76.24 + final int DROPDOWN_SELECTED_SPRITE_ID = 773; + + @Component final int MINIGAME_LIST = 4980758; // 76.22 + @Component final int SELECTED_MINIGAME = 4980747; // 76.11 + @Component final int TELEPORT_BUTTON = 4980768; // 76.32 + + // Minigame teleports cant be used if a dialogue is open. + if (Rs2Dialogue.isInDialogue()) { + var playerLocation = Rs2Player.getLocalLocation(); + walkFastLocal(playerLocation); + } + + if (Rs2Tab.getCurrentTab() != InterfaceTab.CHAT) { + Rs2Tab.switchTo(InterfaceTab.CHAT); + sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.CHAT); + } + + Widget groupingBtn = Rs2Widget.getWidget(GROUPING_BUTTON_COMPONENT_ID); + if (groupingBtn == null) return false; + + if (!Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)) { + Rs2Widget.clickWidget(groupingBtn); + sleepUntil(() -> Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)); + } + + boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); + String destination = hasMultipleDestination + ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() + : transport.getDisplayInfo().trim().toLowerCase(); + + Widget selectedWidget = Rs2Widget.getWidget(SELECTED_MINIGAME); + if (selectedWidget == null) return false; + if (!selectedWidget.getText().equalsIgnoreCase(destination)) { + Widget dropdownBtn = Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID); + if (dropdownBtn == null) return false; + + if (dropdownBtn.getSpriteId() != DROPDOWN_SELECTED_SPRITE_ID) { + Rs2Widget.clickWidget(dropdownBtn); + sleepUntil(() -> Rs2Widget.findWidget(DROPDOWN_SELECTED_SPRITE_ID, List.of(Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID))) != null); + } + + Widget minigameWidgetParent = Rs2Widget.getWidget(MINIGAME_LIST); + if (minigameWidgetParent == null) return false; + List minigameWidgetList = Arrays.stream(minigameWidgetParent.getDynamicChildren()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Widget destinationWidget = Rs2Widget.findWidget(destination, minigameWidgetList); + if (destinationWidget == null) return false; + + NewMenuEntry destinationMenuEntry = new NewMenuEntry() + .option("Select") + .target("") + .identifier(1) + .type(MenuAction.CC_OP) + .param0(destinationWidget.getIndex()) + .param1(minigameWidgetParent.getId()) + .forceLeftClick(false); + + Microbot.doInvoke(destinationMenuEntry, new Rectangle(1, 1)); + sleepUntil(() -> Rs2Widget.getWidget(SELECTED_MINIGAME).getText().equalsIgnoreCase(destination)); + } + + Widget teleportBtn = Rs2Widget.getWidget(TELEPORT_BUTTON); + if (teleportBtn == null) return false; + Rs2Widget.clickWidget(teleportBtn); + + if (transport.getDisplayInfo().toLowerCase().contains("rat pits")) { + Rs2Dialogue.sleepUntilSelectAnOption(); + Rs2Dialogue.clickOption(transport.getDisplayInfo().split(":")[1].trim().toLowerCase()); + } + + sleepUntil(Rs2Player::isAnimating); + return sleepUntilTrue(() -> !Rs2Player.isAnimating() && teleportGraphics.stream().noneMatch(Rs2Player::hasSpotAnimation), 100, 20000); + } + + static int canoeMapMainComponentId(int stationObjectId) { + if (stationObjectId >= 60845 && stationObjectId <= 60849) { + return InterfaceID.CanoeMapDougne.MAIN_MAP; + } + if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { + return InterfaceID.CanoeMapLum.MAIN_MAP; + } + return -1; + } + + static int canoeMapDestinationsComponentId(int stationObjectId) { + if (stationObjectId >= 60845 && stationObjectId <= 60849) { + return InterfaceID.CanoeMapDougne.DESTINATIONS; + } + if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { + return InterfaceID.CanoeMapLum.DESTINATIONS; + } + return -1; + } + + private static boolean handleCanoe(Transport transport) { + String displayInfo = transport.getDisplayInfo(); + if (displayInfo == null || displayInfo.isEmpty()) return false; + + List validActions = List.of("chop-down", "shape-canoe", "float canoe", "paddle canoe"); + ObjectComposition CANOE_COMPOSITION = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); + if (CANOE_COMPOSITION == null) return false; + + String currentAction = Arrays.stream(CANOE_COMPOSITION.getActions()) + .filter(Objects::nonNull) + .filter(act -> validActions.contains(act.toLowerCase())).findFirst().orElse(null); + if (currentAction == null || currentAction.isEmpty()) { + log.error("Unable to find canoe action"); + return false; + } + + switch (currentAction) { + case "Chop-down": + Rs2GameObject.interact(transport.getObjectId(), "Chop-down"); + sleepUntil(() -> Rs2Player.isAnimating(1200)); + return sleepUntilTrue(() -> { + ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); + + if (composition == null) return false; + return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); + }, 300, 10000); + case "Shape-Canoe": + @Component final int CANOE_SELECTION_PARENT = 27262976; // 416.3 + @Component final int CANOE_SHAPING_TEXT = 27262986; // 416.10 + + Rs2GameObject.interact(transport.getObjectId(), "Shape-Canoe"); + boolean isCanoeShapeTextVisible = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(CANOE_SHAPING_TEXT), 100, 10000); + if (!isCanoeShapeTextVisible) { + log.error("Canoe shape text is not visible within timeout period"); + return false; + } + + final int woodcuttingLevel = Rs2Player.getRealSkillLevel(Skill.WOODCUTTING); + String canoeOption; + if (woodcuttingLevel >= 57) { + canoeOption = "Waka canoe"; + } else if (woodcuttingLevel >= 42) { + canoeOption = "Stable dugout canoe"; + } else if (woodcuttingLevel >= 27) { + canoeOption = "Dugout canoe"; + } else if (woodcuttingLevel >= 12) { + canoeOption = "Log canoe"; + } else { + // Not high enough level to make any canoe + return false; + } + + Widget canoeSelectionParentWidget = Rs2Widget.getWidget(CANOE_SELECTION_PARENT); + if (canoeSelectionParentWidget == null) return false; + Widget canoeSelectionWidget = Rs2Widget.findWidget("Make " + canoeOption, List.of(canoeSelectionParentWidget)); + Rs2Widget.clickWidget(canoeSelectionWidget); + sleepUntil(() -> Rs2Player.isAnimating(1200)); + return sleepUntilTrue(() -> { + ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); + + if (composition == null) return false; + return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); + }, 300, 10000); + case "Float Canoe": + Rs2GameObject.interact(transport.getObjectId(), "Float Canoe"); + sleepUntil(() -> Rs2Player.isAnimating(1200)); + return sleepUntilTrue(() -> { + ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); + + if (composition == null) return false; + return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); + }, 300, 10000); + case "Paddle Canoe": + int canoeMapMain = canoeMapMainComponentId(transport.getObjectId()); + int canoeMapDestinations = canoeMapDestinationsComponentId(transport.getObjectId()); + if (canoeMapMain < 0 || canoeMapDestinations < 0) { + log.error("Unsupported canoe station object id: {}", transport.getObjectId()); + return false; + } + if (!Rs2GameObject.interact(transport.getObjectId(), "Paddle Canoe")) { + log.error("Failed to interact with canoe station"); + return false; + } + + // Wait for the player to actually walk to the canoe station and stop moving + // before checking for the destination map widget. The interact call only + // queues the click; the player still has to walk there. + sleepUntil(Rs2Player::isMoving, 2000); + sleepUntilTrue(() -> !Rs2Player.isMoving(), 100, 30000); + + // OSRS uses separate interfaces for the River Lum and River Dougne chains. + boolean isDestinationMapVisible = sleepUntilTrue( + () -> Rs2Widget.isWidgetVisible(canoeMapMain), + 100, 10000); + if (!isDestinationMapVisible) { + log.error("Canoe destination map not visible within timeout period for station {}", + transport.getObjectId()); + return false; + } + + Widget destinationListWidget = Rs2Widget.getWidget(canoeMapDestinations); + if (destinationListWidget == null) return false; + Widget destination = Rs2Widget.findWidget("Travel to " + displayInfo, List.of(destinationListWidget), false); + if (destination == null) { + log.error("Could not find canoe destination widget for: {}", displayInfo); + return false; + } + Rs2Widget.clickWidget(destination); + + Rs2Dialogue.waitForCutScene(100, 15000); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), 100, 5000); + } + return false; + } + + private static boolean isQuetzalWhistleItemId(int itemId) { + return itemId == ItemID.HG_QUETZALWHISTLE_BASIC + || itemId == ItemID.HG_QUETZALWHISTLE_ENHANCED + || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED + || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED_INFINITE; + } + + /** + * Labels match {@code quetzals.tsv} destination rows (map icon text). + */ + static String quetzalMapLabelForDestination(WorldPoint dest) { + assert dest != null; + final int[][] coords = { + {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3222, 0}, {1548, 2995, 0}, + {1437, 3171, 0}, {1779, 3111, 0}, {1700, 3037, 0}, {1670, 2933, 0}, {1446, 3108, 0}, + {1613, 3300, 0}, {1226, 3091, 0}, {1344, 3022, 0}, {1411, 3361, 0}, + }; + final String[] labels = { + "Aldarin", "Civitas illa Fortis", "Hunter Guild", "Quetzacalli Gorge", "Sunset Coast", + "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum", + "Salvager Overlook", "Tal Teklan", "Kastori", "Auburnvale", + }; + assert coords.length == labels.length; + // Bank / script targets often sit several tiles off quetzals.tsv landing coords. + final int matchTiles = 15; + for (int i = 0; i < coords.length; i++) { + WorldPoint p = new WorldPoint(coords[i][0], coords[i][1], coords[i][2]); + if (dest.distanceTo2D(p) <= matchTiles && dest.getPlane() == p.getPlane()) { + return labels[i]; + } + } + return null; + } + + /** + * Option text on the Quetzal map — Renu uses {@link InterfaceID.QuetzalMenu}, whistle uses {@link InterfaceID.QuetzalwhistleMenu} + * (same icon labels). Prefers resolving from {@link Transport#getDestination()} so bank/custom tiles match. + */ + private static String resolveQuetzalMapOptionLabel(Transport transport) { + assert transport != null; + WorldPoint dest = transport.getDestination(); + if (dest != null) { + String byCoords = quetzalMapLabelForDestination(dest); + if (byCoords != null && !byCoords.isEmpty()) { + return byCoords; + } + } + String di = transport.getDisplayInfo(); + if (di != null && di.contains(":")) { + String[] parts = di.split(":", 2); + if (parts.length >= 2) { + String loc = parts[1].trim(); + if (!loc.isEmpty()) { + return loc; + } + } + } + return dest != null ? quetzalMapLabelForDestination(dest) : null; + } + + /** True when any Quetzal or whistle-map layer is visible (CONTENTS alone can stay hidden while MAP/ICONS show). */ + private static boolean isQuetzalMapInterfaceVisible() { + return Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.UNIVERSE) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.MAP) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.ICONS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.CONTENTS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.UNIVERSE) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.MAP) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.ICONS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.CONTENTS); + } + + private static boolean finishQuetzalWhistleTransport(Transport transport) { + assert transport != null; + WorldPoint dest = transport.getDestination(); + assert dest != null; + WorldPoint pl = Rs2Player.getWorldLocation(); + if (pl != null && pl.getPlane() == dest.getPlane() && pl.distanceTo2D(dest) < OFFSET) { + log.debug("Quetzal whistle: already within {} tiles of {}, skipping map", OFFSET, dest); + return true; + } + String mapLabel = resolveQuetzalMapOptionLabel(transport); + if (mapLabel == null || mapLabel.isEmpty()) { + log.warn("Quetzal whistle: could not resolve map label (displayInfo={}, destination={})", + transport.getDisplayInfo(), dest); + return false; + } + Rs2Player.waitForAnimation(1800); + sleepUntil(() -> isQuetzalMapInterfaceVisible() || !Rs2Player.isAnimating(), 1400); + sleep(Rs2Random.between(120, 260)); + return clickQuetzalMapDestination(mapLabel, dest); + } + + /** + * Finds destination row/icon; map can open before icon layer is built — search full subtree from several roots, + * not only {@link Widget#getDynamicChildren()} of {@link InterfaceID.QuetzalMenu#ICONS}. + */ + private static Widget findQuetzalMapDestinationWidget(String mapOptionLabel) { + assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); + int[] roots = { + InterfaceID.QuetzalMenu.ICONS, + InterfaceID.QuetzalMenu.MAP, + InterfaceID.QuetzalMenu.SCROLL, + InterfaceID.QuetzalMenu.CONTENTS, + InterfaceID.QuetzalMenu.UNIVERSE, + InterfaceID.QuetzalwhistleMenu.ICONS, + InterfaceID.QuetzalwhistleMenu.MAP, + InterfaceID.QuetzalwhistleMenu.SCROLL, + InterfaceID.QuetzalwhistleMenu.CONTENTS, + InterfaceID.QuetzalwhistleMenu.UNIVERSE, + }; + for (int rootId : roots) { + // Widget#getDynamicChildren / isHidden must not run off the client thread — use marshalled helpers. + if (Rs2Widget.isHidden(rootId)) { + continue; + } + Widget root = Rs2Widget.getWidget(rootId); + if (root == null) { + continue; + } + Widget hit = Rs2Widget.findWidget(mapOptionLabel, List.of(root), false); + if (hit != null) { + return hit; + } + } + return null; + } + + /** + * Opens no NPC — caller must already have opened the Quetzal map (whistle or Renu). + */ + private static boolean clickQuetzalMapDestination(String mapOptionLabel, WorldPoint expectedDestination) { + assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); + assert expectedDestination != null; + long quetzalStartAt = System.currentTimeMillis(); + + WorldPoint here = Rs2Player.getWorldLocation(); + if (here != null && here.getPlane() == expectedDestination.getPlane() + && here.distanceTo2D(expectedDestination) < OFFSET) { + log.debug("Quetzal map: already within {} tiles of {}, skipping map click", OFFSET, expectedDestination); + return true; + } + + boolean mapVisible = sleepUntilTrue(() -> isQuetzalMapInterfaceVisible(), 100, QUETZAL_MAP_VISIBLE_WAIT_MS); + if (!mapVisible) { + log.error("Quetzal map UI not visible within timeout (label={}, checked UNIVERSE/MAP/ICONS/CONTENTS)", + mapOptionLabel); + return false; + } + WebWalkLog.tmark("quetzal_ui_opened", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + + // ICONS subtree can attach shortly after the shell — brief pause before walking widget tree from walker thread. + sleep(Rs2Random.between(80, 160)); + + AtomicReference destRef = new AtomicReference<>(); + boolean iconReady = sleepUntilTrue(() -> { + Widget w = findQuetzalMapDestinationWidget(mapOptionLabel); + destRef.set(w); + return w != null; + }, 120, QUETZAL_ICON_READY_WAIT_MS); + Widget actionWidget = destRef.get(); + if (!iconReady || actionWidget == null) { + log.error("Could not find Quetzal map icon for: {} (waited for widget tree after map visible)", mapOptionLabel); + return false; + } + WebWalkLog.tmark("quetzal_option_found", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + + Rs2Widget.clickWidget(actionWidget); + log.info("Quetzal map: traveling to {} -> {}", mapOptionLabel, expectedDestination); + WebWalkLog.tmark("quetzal_click_sent", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(expectedDestination, OFFSET), 100, 8000); + } + + private static boolean handleQuetzal(Transport transport) { + String displayInfo = transport.getDisplayInfo(); + if (displayInfo == null || displayInfo.isEmpty()) return false; + + WorldPoint destCheck = transport.getDestination(); + WorldPoint plCheck = Rs2Player.getWorldLocation(); + if (destCheck != null && plCheck != null && plCheck.getPlane() == destCheck.getPlane() + && plCheck.distanceTo2D(destCheck) < OFFSET) { + log.debug("Quetzal Renu: already within {} tiles of {}, skip travel UI", OFFSET, destCheck); + return true; + } + + Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); + + if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { + Rs2Player.waitForWalking(); + WorldPoint dest = transport.getDestination(); + String mapLabel = resolveQuetzalMapOptionLabel(transport); + if (mapLabel == null || mapLabel.isEmpty() || dest == null) { + return false; + } + return clickQuetzalMapDestination(mapLabel, dest); + } + return false; + } + + private static boolean handleMasterScrollBook(String destination) { + boolean isMasterScrollBookOpen = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(InterfaceID.Bookofscrolls.CONTENTS), 100, 10000); + if (!isMasterScrollBookOpen) { + log.error("Master Scroll Book did not open within timeout period"); + return false; + } + + Widget bookOfScrollsWidget = Rs2Widget.getWidget(InterfaceID.Bookofscrolls.CONTENTS); + List bookOfScrollsChildren = Arrays.stream(bookOfScrollsWidget.getStaticChildren()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + Widget destinationWidget = Rs2Widget.findWidget(destination, bookOfScrollsChildren, false); + if (destinationWidget == null) return false; + boolean interaction = Rs2Widget.clickWidget(destinationWidget); + if (interaction && destination.equalsIgnoreCase("Revenant cave")) { + Rs2Dialogue.sleepUntilInDialogue(); + return Rs2Dialogue.clickOption("Yes, teleport me now"); + } + return interaction; + } + + private static boolean handleMagicCarpet(Transport transport) { + final int flyingPoseAnimation = 6936; + var rugMerchant = Rs2Npc.getNpc(transport.getObjectId()); + if (rugMerchant == null) return false; + + Rs2Npc.interact(rugMerchant, transport.getAction()); + Rs2Dialogue.sleepUntilInDialogue(); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> Rs2Player.getPoseAnimation() == flyingPoseAnimation, 10000); + return sleepUntilTrue(() -> Rs2Player.getPoseAnimation() != flyingPoseAnimation, 600,60000); + } + + private static boolean handleCharterShip(Transport transport) { + String npcName = transport.getName(); + + Rs2NpcModel npc = Rs2Npc.getNpc(npcName); + log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); + if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { + Rs2Player.waitForWalking(); + 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; + } + + Widget textMatch = findCharterDestinationTextWidget(root, destinationText); + if (textMatch == null) { + return null; + } + + Widget clickable = findClickableCharterWidget(textMatch, root); + return clickable != null ? clickable : textMatch; + }).orElse(null); + } + + private static Widget findCharterDestinationTextWidget(Widget widget, String destinationText) { + if (widget == null || widget.isHidden()) { + return null; + } + if (charterWidgetMatchesDestination(widget, destinationText)) { + return widget; + } + + Widget[] staticChildren = widget.getStaticChildren(); + Widget found = findCharterDestinationTextWidget(staticChildren, destinationText); + if (found != null) { + return found; + } + + Widget[] dynamicChildren = widget.getDynamicChildren(); + found = findCharterDestinationTextWidget(dynamicChildren, destinationText); + if (found != null) { + return found; + } + + 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; + } + 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 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); + } + } + + private static boolean isMinecartMenuVisible() { + return !Rs2Widget.isHidden(MINECART_MENU_GROUP, MINECART_MENU_LIST_CHILD); + } + + private static boolean interactWithAdventureLog(Transport transport) { + if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; + + // Two menus arrive here, and they are different interfaces: spirit trees and their kin open + // the adventure log (187), but the Lovakengj minecart opens its own list (947, "Minecart + // rides: 20 coins"). Waiting on 187 alone made every minecart trip time out for 10s and + // return false without ever seeing its menu — the user-visible "it never selects the + // destination". Verified live at Hosidius South: 947:9 holds "1: Arceuus".."C: Shayzien + // West" as plain TEXT entries, and clicking the row by its verbatim displayInfo rides. + boolean menuVisible = sleepUntilTrue( + () -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER) || isMinecartMenuVisible(), + Rs2Player::isMoving, 100, 10000); + + if (!menuVisible) { + log.warn("[Walker] destination menu (187/947) did not open for {}", transport.getDisplayInfo()); + return false; + } + if (isMinecartMenuVisible()) { + return selectMinecartDestination(transport); + } + + String displayInfo = transport.getDisplayInfo(); + // The menu prefixes every option with its shortcut key — digits for the first nine entries + // and LETTERS after that (the Lovakengj minecart runs 1-9 then A: Port Piscarilius through + // C: Shayzien West, read off the live interface). The old strip handled only digit prefixes, + // so letter-keyed destinations searched for "A: Port Piscarilius" verbatim and could never + // match a widget that stores the name apart from its key. + String destinationString = displayInfo.replaceAll("^[0-9A-Za-z]:\\s*", ""); + + // Null-safe on purpose: the old List.of(getWidget(187, 3)) THREW on a null child rather than + // returning false, and the null branch below used to return with no log at all — this class + // of failure reached the user as "it just doesn't select". + Widget optionsRoot = Rs2Widget.getWidget(187, 3); + Widget destinationWidget = optionsRoot == null ? null + : Rs2Widget.findWidget(destinationString, List.of(optionsRoot)); + if (destinationWidget != null) { + Rs2Widget.clickWidget(destinationWidget); + log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); + } + + // Text lookup failed. This menu is BUILT for keyboard selection — child 187:1 is literally + // named "keylisteners" in the cache, and every option's shortcut key is the displayInfo + // prefix we just stripped. Pressing it is also what a human at this menu actually does. + char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); + boolean hasShortcut = displayInfo.length() > 1 && displayInfo.charAt(1) == ':' + && Character.isLetterOrDigit(shortcutKey); + if (hasShortcut) { + log.warn("[Walker] destination '{}' not found by text in menu 187:3 (rootNull={}); pressing shortcut '{}'", + destinationString, optionsRoot == null, shortcutKey); + Rs2Keyboard.keyPress(shortcutKey); + log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); + } + + log.warn("[Walker] destination '{}' not found in menu 187:3 and displayInfo '{}' carries no shortcut key", + destinationString, displayInfo); + return false; + } + + /** + * Selects a station in the minecart list (947:9). The tsv displayInfo is the row's verbatim text + * ("7: Lovakengj"), so a text click is the primary path — verified live to ride. The rows are + * also keyboard-built (the prefix is the shortcut), so a failed click falls back to the key. + */ + private static boolean selectMinecartDestination(Transport transport) { + String displayInfo = transport.getDisplayInfo(); + boolean selected = Rs2Widget.clickWidget(displayInfo, + Optional.of(MINECART_MENU_GROUP), MINECART_MENU_LIST_CHILD, true); + if (!selected && displayInfo.length() > 1 && displayInfo.charAt(1) == ':' + && Character.isLetterOrDigit(displayInfo.charAt(0))) { + char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); + log.warn("[Walker] minecart row '{}' not clickable; pressing shortcut '{}'", displayInfo, shortcutKey); + Rs2Keyboard.keyPress(shortcutKey); + selected = true; + } + if (!selected) { + log.warn("[Walker] minecart destination '{}' not found in menu 947:9", displayInfo); + return false; + } + log.info("Traveling to {} - ({}) via minecart menu", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 10000); + } + + private static boolean handleGlider(Transport transport) { + int TA_QUIR_PRIW = 9043972; + int SINDARPOS = 9043975; + int LEMANTO_ANDRA = 9043978; + int KAR_HEWO = 9043981; + int GANDIUS = 9043984; + int OOKOOKOLLY_UNDRI = 9043993; + int LEMANTOLLY_UNDRI = 9043989; + + // Get Transport Information + String displayInfo = transport.getDisplayInfo(); + String npcName = transport.getName(); + String action = transport.getAction(); + + final int GLIDER_PARENT_WIDGET = 138; + final int GLIDER_CHILD_WIDGET = 0; + + // Check if the widget is already visible + boolean isGliderMenuVisible = Rs2Widget.getWidget(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET) != null; + if (!isGliderMenuVisible) { + // Find the glider NPC + var gnome = Rs2Npc.getNpc(npcName); // Use the NPC name to find the NPC + if (gnome == null) { + return false; + } + + // Interact with the gnome glider NPC + if (Rs2Npc.interact(gnome, action)) { + sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); + } + } + + + // Wait for the widget to become visible + boolean widgetVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET), Rs2Player::isMoving, 100, 10000); + + if (!widgetVisible) { + log.error("Widget did not become visible within the timeout."); + return false; + } + + if (displayInfo.isEmpty()) return false; + + switch (displayInfo) { + case "Kar-Hewo": + return Rs2Widget.clickWidget(KAR_HEWO); + case "Ta Quir Priw": + return Rs2Widget.clickWidget(TA_QUIR_PRIW); + case "Sindarpos": + return Rs2Widget.clickWidget(SINDARPOS); + case "Lemanto Andra": + return Rs2Widget.clickWidget(LEMANTO_ANDRA); + case "Gandius": + return Rs2Widget.clickWidget(GANDIUS); + case "Ookookolly Undri": + return Rs2Widget.clickWidget(OOKOOKOLLY_UNDRI); + case "Lemantolly Undri": + return Rs2Widget.clickWidget(LEMANTOLLY_UNDRI); + default: + log.error("{} not found on the interface.", displayInfo); + return false; + } + } + + private static boolean handleFairyRing(Transport transport) { + + Rs2ItemModel startingWeapon = null; + + TileObject fairyRingObject = PohTeleports.isInHouse() ? PohTeleports.getFairyRings() : Rs2GameObject.getAll(o -> Objects.equals(o.getWorldLocation(), transport.getOrigin())).stream().findFirst().orElse(null); + if (fairyRingObject == null) return false; + + if (!PohTeleports.isInHouse() && !Rs2GameObject.canWalkTo(fairyRingObject, 25)) return false; + + boolean hasLumbridgeElite = Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; + + if (!hasLumbridgeElite) { + if (Rs2Equipment.isWearing(EquipmentInventorySlot.WEAPON)) { + startingWeapon = Rs2Equipment.get(EquipmentInventorySlot.WEAPON); + } + + if (!Rs2Equipment.isWearing("Dramen staff") && !Rs2Equipment.isWearing("Lunar staff")) { + if (Rs2Inventory.contains("Dramen staff")) { + Rs2Inventory.equip("Dramen staff"); + sleepUntil(() -> Rs2Equipment.isWearing("Dramen staff")); + } else if (Rs2Inventory.contains("Lunar staff")) { + Rs2Inventory.equip("Lunar staff"); + sleepUntil(() -> Rs2Equipment.isWearing("Lunar staff")); + } else { + return false; + } + } + } + + String lastDestinationAction = "last-destination (" + transport.getDisplayInfo() + ")"; + String treeLastDestinationAction = "Ring-last-destination (" + transport.getDisplayInfo() + ")"; + ObjectComposition composition = Rs2GameObject.convertToObjectComposition(fairyRingObject); + log.info("Interacting with Fairy Ring @ {}", fairyRingObject.getWorldLocation()); + + // we can use the last-destination to handle fairy rings + if (Rs2GameObject.hasAction(composition, lastDestinationAction, true)) { + Rs2GameObject.interact(fairyRingObject, lastDestinationAction); + } else if (Rs2GameObject.hasAction(composition, treeLastDestinationAction, true)) { + Rs2GameObject.interact(fairyRingObject, treeLastDestinationAction); + } else { + // We have to configure fairy rings through the interface + if (Rs2GameObject.hasAction(composition, "Configure", true)) { + Rs2GameObject.interact(fairyRingObject, "Configure"); + } else if (Rs2GameObject.hasAction(composition, "Ring-configure", true)) { + Rs2GameObject.interact(fairyRingObject, "Ring-configure"); + } + sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON), 10000); + + if (Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON)) { + log.warn("Fairy ring interface did not open (interrupted by combat?). Retrying next iteration."); + return false; + } + + Widget slotOne = Rs2Widget.getWidget(SLOT_ONE); + Widget slotTwo = Rs2Widget.getWidget(SLOT_TWO); + Widget slotThree = Rs2Widget.getWidget(SLOT_THREE); + if (slotOne == null || slotTwo == null || slotThree == null) { + log.warn("Fairy ring slot widget(s) are null; interface may have closed unexpectedly."); + return false; + } + + rotateSlotToDesiredRotation(SLOT_ONE, slotOne.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(0)), SLOT_ONE_ACW_ROTATION, SLOT_ONE_CW_ROTATION); + rotateSlotToDesiredRotation(SLOT_TWO, slotTwo.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(1)), SLOT_TWO_ACW_ROTATION, SLOT_TWO_CW_ROTATION); + rotateSlotToDesiredRotation(SLOT_THREE, slotThree.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(2)), SLOT_THREE_ACW_ROTATION, SLOT_THREE_CW_ROTATION); + Rs2Widget.clickWidget(ComponentID.FAIRY_RING_TELEPORT_BUTTON); + } + + sleepUntil(() -> Rs2Player.getGraphicId() == fairyRingGraphicId, 5000); + sleepUntil(() -> Objects.equals(Rs2Player.getWorldLocation(), transport.getDestination()) && Rs2Player.getGraphicId() != fairyRingGraphicId, 10000); + + if (startingWeapon != null) { + Rs2ItemModel finalStartingWeapon = startingWeapon; + Rs2Inventory.equip(finalStartingWeapon.getId()); + sleepUntil(() -> Rs2Equipment.isWearing(finalStartingWeapon.getId())); + } + return true; + } + + /** + * Rotates a fairy ring slot to the desired rotation value. + * Calculates the most efficient rotation direction (clockwise or anticlockwise) + * and performs the necessary number of rotations to reach the target. + * + * @param slotId The widget ID of the slot to rotate + * @param currentRotation The current rotation value of the slot + * @param desiredRotation The target rotation value to achieve + * @param slotAcwRotationId The widget ID for anticlockwise rotation button + * @param slotCwRotationId The widget ID for clockwise rotation button + */ + private static void rotateSlotToDesiredRotation(int slotId, int currentRotation, int desiredRotation, int slotAcwRotationId, int slotCwRotationId) { + int anticlockwiseTurns = (desiredRotation - currentRotation + 2048) % 2048; + int clockwiseTurns = (currentRotation - desiredRotation + 2048) % 2048; + + int turns = Math.min(clockwiseTurns, anticlockwiseTurns) / 512; + boolean rotateCW = clockwiseTurns <= anticlockwiseTurns; + int rotationWidget = rotateCW ? slotCwRotationId : slotAcwRotationId; + + for (int i = 0; i < turns; i++) { + final int previousRotation = currentRotation; + Rs2Widget.clickWidget(rotationWidget); + + sleepUntil(() -> { + Widget slotWidget = Rs2Widget.getWidget(slotId); + return slotWidget != null && slotWidget.getRotationY() != previousRotation; + }, 2000); + + Widget slotWidget = Rs2Widget.getWidget(slotId); + if (slotWidget != null) { + currentRotation = slotWidget.getRotationY(); + } else { + break; + } + } + + sleepUntil(() -> { + Widget slotWidget = Rs2Widget.getWidget(slotId); + return slotWidget != null && slotWidget.getRotationY() == desiredRotation; + }, 3000); + } + + /** + * Maps fairy ring letters to their corresponding rotation values. + * Each letter corresponds to a specific rotation degree needed for fairy ring teleportation. + * + * @param letter The fairy ring letter (A-Z) to get rotation for + * @return The rotation value (0, 512, 1024, or 1536) for the letter, or -1 if invalid + */ + private static int getDesiredRotation(char letter) { + switch (letter) { + case 'A': + case 'I': + case 'P': + return 0; + case 'B': + case 'J': + case 'Q': + return 512; + case 'C': + case 'K': + case 'R': + return 1024; + case 'D': + case 'L': + case 'S': + return 1536; + default: + return -1; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java index e7d53f334df..1db22e71bb2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java @@ -20,6 +20,12 @@ public class TransportRouteAnalysis { /** Complete path of WorldPoints representing the direct route to destination */ private final List directPath; + + /** Exact immutable edge sequence selected for the direct route, when captured by the planner */ + private final List directRouteSteps; + + /** Whether {@link #directRouteSteps} is an exact planner result rather than a legacy omission */ + private final boolean directRouteStepsExact; /** Reference to the nearest accessible BankLocation object, null if no bank is accessible */ private final BankLocation nearestBank; @@ -29,10 +35,22 @@ public class TransportRouteAnalysis { /** Path of WorldPoints from starting point to the nearest bank */ private final List pathToBank; + + /** Exact immutable edge sequence selected from the start to the bank */ + private final List routeToBankSteps; + + /** Whether {@link #routeToBankSteps} is an exact planner result */ + private final boolean routeToBankStepsExact; /** Path of WorldPoints from bank to destination, accounting for items available in bank */ private final List pathFromBank; + /** Exact immutable edge sequence selected from the bank to the destination */ + private final List routeFromBankSteps; + + /** Whether {@link #routeFromBankSteps} is an exact planner result */ + private final boolean routeFromBankStepsExact; + /** Explicit direct distance captured at analysis time (tiles), or -1 if unavailable */ private final int directDistance; @@ -57,21 +75,103 @@ public TransportRouteAnalysis(List directPath, List pathFromBank,String analysis) { this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, deriveRouteDistance(directPath), - deriveBankingRouteDistance(pathToBank, pathFromBank)); + deriveBankingRouteDistance(pathToBank, pathFromBank), + null, null, null); } public TransportRouteAnalysis(List directPath, BankLocation nearestBank, WorldPoint bankLocation, List pathToBank, List pathFromBank, String analysis, int directDistance, int bankingRouteDistance) { - this.directPath = directPath; + this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, + directDistance, bankingRouteDistance, null, null, null); + } + + /** + * Constructs an analysis carrying the exact immutable route steps selected by each search. + * + *

The appended step parameters preserve the two historical constructor descriptors for Hub + * compatibility. New Microbot code must use this form so banking and execution never infer a + * transport later by rescanning mutable catalog endpoints.

+ */ + public TransportRouteAnalysis(List directPath, + BankLocation nearestBank, WorldPoint bankLocation, List pathToBank, + List pathFromBank, String analysis, + int directDistance, int bankingRouteDistance, + List directRouteSteps, + List routeToBankSteps, + List routeFromBankSteps) { + this.directPath = immutablePath(directPath); this.nearestBank = nearestBank; this.bankLocation = bankLocation; - this.pathToBank = pathToBank; - this.pathFromBank = pathFromBank; + this.pathToBank = immutablePath(pathToBank); + this.pathFromBank = immutablePath(pathFromBank); this.analysis = analysis; this.directDistance = directDistance; this.bankingRouteDistance = bankingRouteDistance; + this.directRouteStepsExact = directRouteSteps != null; + this.routeToBankStepsExact = routeToBankSteps != null; + this.routeFromBankStepsExact = routeFromBankSteps != null; + this.directRouteSteps = immutableSteps("direct", this.directPath, directRouteSteps); + this.routeToBankSteps = immutableSteps("to-bank", this.pathToBank, routeToBankSteps); + this.routeFromBankSteps = immutableSteps("from-bank", this.pathFromBank, routeFromBankSteps); + } + + private static List immutablePath(List path) { + return path == null ? List.of() : List.copyOf(path); + } + + private static List immutableSteps( + String label, List path, List steps) { + if (steps == null) { + return List.of(); + } + List immutable = List.copyOf(steps); + int expected = Math.max(0, path.size() - 1); + if (immutable.size() != expected) { + throw new IllegalArgumentException(label + " steps must describe every path edge: expected " + + expected + ", got " + immutable.size()); + } + for (int index = 0; index < immutable.size(); index++) { + Rs2RouteStep step = immutable.get(index); + if (!path.get(index).equals(step.getFrom()) || !path.get(index + 1).equals(step.getTo())) { + throw new IllegalArgumentException(label + " step " + index + " is not contiguous with path"); + } + } + return immutable; + } + + /** Exact selected transport edges for the direct route, in route order. */ + public List getDirectTransportEdges() { + return transportEdges(directRouteSteps); + } + + /** Exact selected transport edges for the start-to-bank leg, in route order. */ + public List getTransportEdgesToBank() { + return transportEdges(routeToBankSteps); + } + + /** Exact selected transport edges for the bank-to-target leg, in route order. */ + public List getTransportEdgesFromBank() { + return transportEdges(routeFromBankSteps); + } + + /** Exact selected transport edges for both banking legs, in route order. */ + public List getBankingTransportEdges() { + List combined = new ArrayList<>(); + combined.addAll(getTransportEdgesToBank()); + combined.addAll(getTransportEdgesFromBank()); + return List.copyOf(combined); + } + + private static List transportEdges(List steps) { + List transports = new ArrayList<>(); + for (Rs2RouteStep step : steps) { + if (step.isTransport()) { + transports.add(step.getTransport().orElseThrow(IllegalStateException::new)); + } + } + return List.copyOf(transports); } /** @@ -161,6 +261,7 @@ public String toString() { * Gets all required transports for the direct path with default parameters. * @return List of required transports for direct path */ + @Deprecated public List getTransportsForDirectPath(){ return getTransportsForDirectPath(0, TransportType.TELEPORTATION_ITEM, true); } @@ -172,6 +273,7 @@ public List getTransportsForDirectPath(){ * @param applyFiltering Whether to apply filtering * @return List of required transports for direct path */ + @Deprecated public List getTransportsForDirectPath(int startIndex, TransportType prefTransportType, boolean applyFiltering){ List transports = Rs2Walker.getTransportsForPath(directPath, startIndex, prefTransportType, applyFiltering); return transports; @@ -181,6 +283,7 @@ public List getTransportsForDirectPath(int startIndex, TransportType * Gets all required transports for the banking route with default parameters. * @return List of required transports for banking route (to and from bank) */ + @Deprecated public List getTransportsForBankingPath(){ return getTransportsForBankingPath(0, TransportType.TELEPORTATION_ITEM, true); } @@ -192,6 +295,7 @@ public List getTransportsForBankingPath(){ * @param applyFiltering Whether to apply filtering * @return List of required transports for banking route (to and from bank) */ + @Deprecated public List getTransportsForBankingPath(int startIndex, TransportType prefTransportType, boolean applyFiltering){ List transportsToTargetToBank = Rs2Walker.getTransportsForPath(pathToBank, startIndex, prefTransportType, applyFiltering); List transportsToTargetFromBank = Rs2Walker.getTransportsForPath(pathFromBank, startIndex, prefTransportType, applyFiltering); @@ -205,6 +309,7 @@ public List getTransportsForBankingPath(int startIndex, TransportType * Gets missing transports for the direct path. * @return List of missing transports for direct path */ + @Deprecated public List getMissingTransportsForDirectPath(){ List missingTransports = Rs2Walker.getMissingTransports(getTransportsForDirectPath()); return missingTransports; @@ -214,6 +319,7 @@ public List getMissingTransportsForDirectPath(){ * Gets missing transport items with their quantities for the direct path. * @return Map of item IDs to their required quantities */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForDirectPath(){ List missingTransports = getMissingTransportsForDirectPath(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); @@ -224,6 +330,7 @@ public Map getMissingTransportsItemsWithQuantitiesForDirectPat * Gets missing transports for the banking route (to and from bank). * @return List of missing transports for the banking route */ + @Deprecated public List getMissingTransportsForBankingRoute(){ List missingTransports = Rs2Walker.getMissingTransports(getTransportsForBankingPath(0, TransportType.TELEPORTATION_ITEM, true)); return missingTransports; @@ -233,6 +340,7 @@ public List getMissingTransportsForBankingRoute(){ * Gets missing transport items with their quantities for the banking route. * @return Map of item IDs to their required quantities */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForBankingRoute(){ List missingTransports = getMissingTransportsForBankingRoute(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); @@ -243,6 +351,7 @@ public Map getMissingTransportsItemsWithQuantitiesForBankingRo * Gets all required transports for the path to bank with default parameters. * @return List of required transports for path to bank */ + @Deprecated public List getTransportsForPathToBank() { return getTransportsForPathToBank(0, TransportType.TELEPORTATION_ITEM, true); } @@ -254,6 +363,7 @@ public List getTransportsForPathToBank() { * @param applyFiltering Whether to apply filtering * @return List of required transports for path to bank */ + @Deprecated public List getTransportsForPathToBank(int startIndex, TransportType prefTransportType, boolean applyFiltering) { return Rs2Walker.getTransportsForPath(pathToBank, startIndex, prefTransportType, applyFiltering); } @@ -262,6 +372,7 @@ public List getTransportsForPathToBank(int startIndex, TransportType * Gets all required transports for the path from bank with default parameters. * @return List of required transports for path from bank */ + @Deprecated public List getTransportsForPathFromBank() { return getTransportsForPathFromBank(0, TransportType.TELEPORTATION_ITEM, true); } @@ -273,6 +384,7 @@ public List getTransportsForPathFromBank() { * @param applyFiltering Whether to apply filtering * @return List of required transports for path from bank */ + @Deprecated public List getTransportsForPathFromBank(int startIndex, TransportType prefTransportType, boolean applyFiltering) { return Rs2Walker.getTransportsForPath(pathFromBank, startIndex, prefTransportType, applyFiltering); } @@ -281,6 +393,7 @@ public List getTransportsForPathFromBank(int startIndex, TransportTyp * Gets missing transports specifically for the path from bank to destination. * @return List of missing transports for path from bank */ + @Deprecated public List getMissingTransportsForPathFromBank() { List missingTransports = Rs2Walker.getMissingTransports(getTransportsForPathFromBank()); return missingTransports; @@ -290,10 +403,10 @@ public List getMissingTransportsForPathFromBank() { * Gets missing transport items with their quantities specifically for the path from bank to destination. * @return Map of item IDs to their required quantities for path from bank */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForPathFromBank() { List missingTransports = getMissingTransportsForPathFromBank(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); return missingItemsWithQuantities; } } - diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java new file mode 100644 index 00000000000..f9bdb0d07c6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java @@ -0,0 +1,372 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import shortestpath.DestinationRequirements; +import shortestpath.ShortestPathConfig; +import shortestpath.WorldPointUtil; +import shortestpath.pathfinder.CollisionMap; +import shortestpath.pathfinder.PathStep; +import shortestpath.pathfinder.Pathfinder; +import shortestpath.pathfinder.PathfinderConfig; +import shortestpath.pathfinder.PathfinderResult; +import shortestpath.pathfinder.SplitFlagMap; +import shortestpath.pathfinder.TransportAvailability; +import shortestpath.pathfinder.WildernessChecker; +import shortestpath.transport.Transport; +import shortestpath.transport.TransportType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Production-packaged adapter for the pinned reviewed upstream planner core. */ +final class UpstreamRoutePlanner implements Rs2RoutePlanner +{ + static final String REVISION = "ff8e961b32120175709df9630ece9468cc11347f"; + private static final ShortestPathConfig EMPTY_CONFIG = new ShortestPathConfig() + { + @Override + public void setBuiltTeleportationBoxes(String content) + { + } + + @Override + public void setBuiltTeleportationPortalsPoh(String content) + { + } + }; + + private static final class StaticMapHolder + { + private static final SplitFlagMap INSTANCE = SplitFlagMap.fromResources(); + } + + @Override + public String getEngineId() + { + return "shortest-path-upstream@" + REVISION; + } + + @Override + public Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(snapshot, "snapshot"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("upstream planner requires a resolved policy")); + if (snapshot.getPolicy() != policy) + { + throw new IllegalArgumentException("route request and planning snapshot policy differ"); + } + + IdentityHashMap exactEdges = new IdentityHashMap<>(); + TransportAvailability.Builder catalog = new TransportAvailability.Builder( + Math.max(1, snapshot.getAdmittedTransports().size())); + for (Rs2TransportEdge edge : snapshot.getAdmittedTransports()) + { + if ((policy.isIgnoreTeleportAndItems() || policy.isTeleportsDisabled()) + && edge.getOrigin() == null && edge.isTeleport()) + { + continue; + } + Transport converted = convert(edge); + catalog.add(converted); + exactEdges.put(converted, edge); + } + + Set packedTargets = new LinkedHashSet<>(); + for (WorldPoint target : request.getTargets()) + { + packedTargets.add(WorldPointUtil.packWorldPoint(target)); + } + UpstreamConfig config = new UpstreamConfig( + request, snapshot, catalog.build()); + Pathfinder pathfinder = new Pathfinder( + config, WorldPointUtil.packWorldPoint(request.getStart()), packedTargets); + pathfinder.run(); + PathfinderResult result = pathfinder.getResult(); + if (result == null) + { + throw new IllegalStateException("pinned upstream planner did not publish a result"); + } + return toResult(request, snapshot, result, exactEdges); + } + + private static Transport convert(Rs2TransportEdge edge) + { + int origin = edge.getOrigin() == null + ? WorldPointUtil.UNDEFINED : WorldPointUtil.packWorldPoint(edge.getOrigin()); + return new Transport.TransportBuilder() + .origin(origin) + .destination(WorldPointUtil.packWorldPoint(edge.getDestination())) + .type(mapType(edge)) + .duration(edge.getDuration()) + .displayInfo(edge.getDisplayInfo()) + .isConsumable(edge.isConsumable()) + .maxWildernessLevel(edge.getMaxWildernessLevel()) + .build(); + } + + /** + * Project the planner-independent type into the pinned upstream schema. + * + *

Keep this switch exhaustive. A newly introduced Microbot transport category must be reviewed + * before it can enter the upstream catalog; silently flattening it to {@code TRANSPORT} can alter + * teleport admission, wilderness limits, delayed visits, or transport cost.

+ */ + static TransportType mapType(Rs2TransportEdge edge) + { + if (edge.getOrigin() == null) + { + switch (edge.getType()) + { + case QUETZAL_WHISTLE: + return TransportType.QUETZAL_WHISTLE; + case SEASONAL_TRANSPORT: + // Upstream's seasonal category is anchored; originless seasonal rows are + // Microbot teleports and therefore use upstream teleport admission/costing. + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_ITEM: + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_MINIGAME: + return TransportType.TELEPORTATION_MINIGAME; + case TELEPORTATION_SPELL: + return TransportType.TELEPORTATION_SPELL; + case TELEPORTATION_SPELL_HOME: + return TransportType.TELEPORTATION_SPELL_HOME; + default: + throw unsupportedType(edge, "originless transport category is not an upstream teleport"); + } + } + + switch (edge.getType()) + { + case TRANSPORT: + return TransportType.TRANSPORT; + case AGILITY_SHORTCUT: + return TransportType.AGILITY_SHORTCUT; + case GRAPPLE_SHORTCUT: + return TransportType.GRAPPLE_SHORTCUT; + case BOAT: + return TransportType.BOAT; + case CANOE: + return TransportType.CANOE; + case CHARTER_SHIP: + return TransportType.CHARTER_SHIP; + case SHIP: + return TransportType.SHIP; + case FAIRY_RING: + return TransportType.FAIRY_RING; + case QUETZAL: + return TransportType.QUETZAL; + case QUETZAL_WHISTLE: + return TransportType.QUETZAL_WHISTLE; + case GNOME_GLIDER: + return TransportType.GNOME_GLIDER; + case MINECART: + return TransportType.MINECART; + case POH: + case NPC: + // These Microbot-owned execution families have no upstream type. They remain + // distinct on the exact Rs2TransportEdge retained by the adapter. + return TransportType.TRANSPORT; + case SPIRIT_TREE: + return TransportType.SPIRIT_TREE; + case TELEPORTATION_BOX: + return TransportType.TELEPORTATION_BOX; + case TELEPORTATION_LEVER: + return TransportType.TELEPORTATION_LEVER; + case TELEPORTATION_PORTAL: + return TransportType.TELEPORTATION_PORTAL; + case TELEPORTATION_PORTAL_POH: + return TransportType.TELEPORTATION_PORTAL_POH; + case TELEPORTATION_MINIGAME: + return TransportType.TELEPORTATION_MINIGAME; + case TELEPORTATION_ITEM: + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_SPELL: + return TransportType.TELEPORTATION_SPELL; + case TELEPORTATION_SPELL_HOME: + return TransportType.TELEPORTATION_SPELL_HOME; + case WILDERNESS_OBELISK: + return TransportType.WILDERNESS_OBELISK; + case MAGIC_CARPET: + return TransportType.MAGIC_CARPET; + case HOT_AIR_BALLOON: + return TransportType.HOT_AIR_BALLOON; + case MAGIC_MUSHTREE: + return TransportType.MAGIC_MUSHTREE; + case SEASONAL_TRANSPORT: + return TransportType.SEASONAL_TRANSPORTS; + case UNKNOWN: + default: + throw unsupportedType(edge, "transport category has no reviewed upstream projection"); + } + } + + private static IllegalArgumentException unsupportedType(Rs2TransportEdge edge, String reason) + { + return new IllegalArgumentException(reason + ": " + edge.getType()); + } + + private static Rs2RouteResult toResult( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + PathfinderResult result, + IdentityHashMap exactEdges) + { + List sourcePath = result.getPathSteps() == null + ? Collections.emptyList() : result.getPathSteps(); + List path = new ArrayList<>(sourcePath.size()); + for (PathStep step : sourcePath) + { + path.add(WorldPointUtil.unpackWorldPoint(step.getPackedPosition())); + } + List steps = new ArrayList<>(Math.max(0, path.size() - 1)); + long cost = 0L; + for (int i = 1; i < sourcePath.size(); i++) + { + WorldPoint from = path.get(i - 1); + WorldPoint to = path.get(i); + Transport selected = sourcePath.get(i).getTransport(); + if (selected == null) + { + steps.add(Rs2RouteStep.walk(from, to)); + cost += WorldPointUtil.distanceBetween( + sourcePath.get(i - 1).getPackedPosition(), sourcePath.get(i).getPackedPosition()); + cost += snapshot.getAdditionalWalkingCost( + sourcePath.get(i).getPackedPosition(), request.getTargets()); + } + else + { + Rs2TransportEdge exact = exactEdges.get(selected); + if (exact == null) + { + throw new IllegalStateException( + "upstream selected a transport outside the projected catalog"); + } + steps.add(Rs2RouteStep.transport(from, to, exact)); + cost += selected.getDuration(); + if (selected.getOrigin() == WorldPointUtil.UNDEFINED) + { + cost += snapshot.getPolicy().getDistanceBeforeUsingTeleport(); + } + } + } + return new Rs2RouteResult( + request.getStart(), + request.getTargets(), + path, + steps, + Rs2RouteTermination.valueOf(result.getTerminationReason().name()), + new Rs2RouteMetrics( + result.getElapsedNanos(), + cost, + result.getNodesChecked(), + result.getTransportsChecked())); + } + + private static final class UpstreamConfig extends PathfinderConfig + { + private final Rs2RouteRequest request; + private final Rs2PlanningSnapshot snapshot; + private final TransportAvailability availability; + private final CollisionMap collisionMap; + private final Set restricted; + + private UpstreamConfig( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + TransportAvailability availability) + { + super(null, EMPTY_CONFIG, StaticMapHolder.INSTANCE, + Collections.emptyMap(), + Collections.>emptyMap(), + Collections.>emptyMap(), + Collections.emptyMap()); + this.request = request; + this.snapshot = snapshot; + this.availability = availability; + this.collisionMap = new CollisionMap( + StaticMapHolder.INSTANCE, snapshot::collisionOverride); + Set packedRestricted = new LinkedHashSet<>(); + for (WorldPoint point : snapshot.getPolicy().getRestrictedPoints()) + { + packedRestricted.add(WorldPointUtil.packWorldPoint(point)); + } + this.restricted = Collections.unmodifiableSet(packedRestricted); + } + + @Override + public CollisionMap getMap() + { + return collisionMap; + } + + @Override + public TransportAvailability getTransportAvailability(boolean bankVisited) + { + return availability; + } + + @Override + public boolean isBankPathEnabled() + { + return false; + } + + @Override + public boolean bankAccessible(int packedPosition) + { + return false; + } + + @Override + public long getCalculationCutoffMillis() + { + return snapshot.getPolicy().getCalculationCutoffMillis(); + } + + @Override + public boolean avoidWilderness( + int packedPosition, int packedNeighborPosition, boolean targetInWilderness) + { + return snapshot.getPolicy().isAvoidWilderness() + && !targetInWilderness + && !WildernessChecker.isInWilderness(packedPosition) + && WildernessChecker.isInWilderness(packedNeighborPosition); + } + + @Override + public boolean avoidBlockedRegion( + int packedPosition, int packedNeighborPosition, boolean targetInBlockedRegion) + { + return restricted.contains(packedNeighborPosition) + || snapshot.isWalkingEdgeBlocked(packedPosition, packedNeighborPosition); + } + + @Override + public int getAdditionalWalkingCost(int packedDestination) + { + return snapshot.getAdditionalWalkingCost( + packedDestination, request.getTargets()); + } + + @Override + public int getAdditionalTransportCost(Transport transport) + { + return transport.getOrigin() == WorldPointUtil.UNDEFINED + ? snapshot.getPolicy().getDistanceBeforeUsingTeleport() : 0; + } + + @Override + public int getDifferentialCost(Transport transport) + { + return 0; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalkPassStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalkPassStats.java new file mode 100644 index 00000000000..91e22d419b2 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalkPassStats.java @@ -0,0 +1,65 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Per-pass stage stopwatch for the walk loop (task #25 slice 2). + * + *

The 16:35 Varlamore walk showed 5-12s passes with every single wait bounded at ≤1.2s — the + * time is the SUM of per-tile handler work (door probes during interim, segment-handler + * eligibility, click selection/issuance), invisible once the post-transport tmark window expires. + * The handlers accumulate their elapsed time here from their own bodies — deliberately NOT from + * call sites in {@code processWalk}, which sits at its architecture-guard line cap — and + * {@code walkerHeartbeat} emits one {@code pass_slow} line at the start of the next pass whenever + * the previous pass ran long. The residual (total minus stages) is itself a finding: it names how + * much of a slow pass the instrumented stages do NOT explain. + * + *

Statics are safe here for the same reason they are everywhere else in the walker: one walk + * loop runs at a time. Atomics are cheap insurance against a stray helper thread, not a + * concurrency design. + */ +final class WalkPassStats { + + static final AtomicLong doorProbeMs = new AtomicLong(); + static final AtomicLong segDoorMs = new AtomicLong(); + static final AtomicLong segTransportMs = new AtomicLong(); + static final AtomicLong rawSceneScanMs = new AtomicLong(); + static final AtomicLong currentTileMs = new AtomicLong(); + static final AtomicLong clickSelectMs = new AtomicLong(); + static final AtomicLong clickIssueMs = new AtomicLong(); + + private WalkPassStats() { + } + + /** Formats the collected stages for the {@code pass_slow} line. Does not reset. */ + static String snapshot(long totalMs) { + long doorProbe = doorProbeMs.get(); + long segDoor = segDoorMs.get(); + long segTransport = segTransportMs.get(); + long rawSceneScan = rawSceneScanMs.get(); + long currentTile = currentTileMs.get(); + long clickSelect = clickSelectMs.get(); + long clickIssue = clickIssueMs.get(); + long residual = totalMs - doorProbe - segDoor - segTransport - rawSceneScan + - currentTile - clickSelect - clickIssue; + return "doorProbe=" + doorProbe + + "ms segDoor=" + segDoor + + "ms segTransport=" + segTransport + + "ms rawSceneScan=" + rawSceneScan + + "ms currentTile=" + currentTile + + "ms clickSelect=" + clickSelect + + "ms clickIssue=" + clickIssue + + "ms residual=" + residual + "ms"; + } + + /** Clears all stages; called at every pass start so each pass owns its numbers. */ + static void reset() { + doorProbeMs.set(0); + segDoorMs.set(0); + segTransportMs.set(0); + rawSceneScanMs.set(0); + currentTileMs.set(0); + clickSelectMs.set(0); + clickIssueMs.set(0); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicy.java new file mode 100644 index 00000000000..436b393716a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicy.java @@ -0,0 +1,66 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry; + +/** Pure state and traversal-envelope policy for a remembered scene-door route edge. */ +final class WalledDoorClaimPolicy { + static final long FRESH_MS = 6_000L; + + enum Decision { + NONE, + EXPIRED, + CROSSED, + ACTION_IN_FLIGHT, + HANDLE_AT_EDGE, + APPROACH, + INVALID + } + + private WalledDoorClaimPolicy() { + } + + static boolean isFresh(WorldPoint from, WorldPoint to, long claimedAtMs, long nowMs) { + return from != null && to != null && claimedAtMs > 0L && nowMs - claimedAtMs <= FRESH_MS; + } + + static Decision decide(WorldPoint from, WorldPoint to, long claimedAtMs, long nowMs, + WorldPoint player, boolean moving, boolean nearSideReachable) { + if (from == null || to == null || player == null || claimedAtMs <= 0L) { + return Decision.NONE; + } + if (!isFresh(from, to, claimedAtMs, nowMs)) { + return Decision.EXPIRED; + } + if (from.getPlane() != to.getPlane() || player.getPlane() != from.getPlane() + || from.distanceTo2D(to) != 1) { + return Decision.INVALID; + } + if (player.distanceTo2D(to) <= 1 && Rs2DoorGeometry.crossedDoorAxis(from, to, player)) { + return Decision.CROSSED; + } + if (moving) { + return Decision.ACTION_IN_FLIGHT; + } + if (player.distanceTo2D(from) <= 1) { + return Decision.HANDLE_AT_EDGE; + } + return nearSideReachable ? Decision.APPROACH : Decision.INVALID; + } + + static boolean ownsTraversalEdge(WorldPoint doorFrom, WorldPoint doorTo, + WorldPoint routeFrom, WorldPoint routeTo) { + if (doorFrom == null || doorTo == null || routeFrom == null || routeTo == null + || doorFrom.getPlane() != doorTo.getPlane() + || routeFrom.getPlane() != routeTo.getPlane() + || doorFrom.getPlane() != routeFrom.getPlane()) { + return false; + } + if ((doorFrom.equals(routeFrom) && doorTo.equals(routeTo)) + || (doorFrom.equals(routeTo) && doorTo.equals(routeFrom))) { + return true; + } + return (doorTo.equals(routeFrom) && doorTo.distanceTo2D(routeTo) == 1) + || (doorTo.equals(routeTo) && doorTo.distanceTo2D(routeFrom) == 1); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java index 635253f34b4..0094d6bd0e7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java @@ -1,6 +1,5 @@ package net.runelite.client.plugins.microbot.util.walker.awaits; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.function.BooleanSupplier; @@ -11,13 +10,6 @@ public final class Rs2WalkerRuntimeAwaits { private Rs2WalkerRuntimeAwaits() { } - public static boolean awaitPathfinderDone(Pathfinder pathfinder, int timeoutMs) { - if (pathfinder == null) { - return false; - } - return sleepUntilTrue(pathfinder::isDone, 100, timeoutMs); - } - public static boolean awaitCondition(BooleanSupplier condition, int pollMs, int timeoutMs) { if (condition == null) { return false; 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 d392df64339..6e57c4207c3 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 @@ -5,17 +5,27 @@ import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.RuneFilter; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteResult; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteTermination; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteStep; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportItemRequirement; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportLoadout; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; import net.runelite.client.plugins.microbot.shortestpath.PurchasableItemCatalog; import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; 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.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; import net.runelite.client.plugins.microbot.util.magic.Runes; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.TransportRouteAnalysis; @@ -23,11 +33,15 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; @Slf4j @@ -36,31 +50,94 @@ public final class Rs2WalkerBankingPlanner { private Rs2WalkerBankingPlanner() { } + /** + * Plan the destination and retain only the immutable transport edges selected by that search. + * + *

This is the planner-independent banking contract. In particular, it does not rescan the + * mutable transport catalog by origin/destination after pathfinding, so two transports sharing an + * edge cannot be confused.

+ */ + public static List getTransportEdgesForDestination( + WorldPoint destination, boolean useBankItems) + { + if (destination == null) + { + return List.of(); + } + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null) + { + log.debug("Unable to plan transport edges without a player location"); + return List.of(); + } + + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, destination) + .withBankItems(useBankItems)); + if (route.getPath().isEmpty()) + { + log.debug("Unable to find path to destination: {}", destination); + return List.of(); + } + + List selected = route.getTransportSteps().stream() + .map(Rs2RouteStep::getTransport) + .map(transport -> transport.orElseThrow( + () -> new IllegalStateException("typed route step has no transport metadata"))) + .collect(Collectors.toList()); + List transports = applyTransportEdgeFiltering(selected); + transports.forEach(transport -> log.debug("Transport edge found: {} -> {} ({})", + transport.getOrigin(), transport.getDestination(), transport.getType())); + return transports; + } + + /** + * Return the bank-to-target transport requirements from the exact route already compared. + * + *

This must not perform another search from the player's current pre-bank location. Doing so + * can select a different transport network from the route whose distance caused the banking + * decision, and then withdraw items for a route that will never be executed.

+ */ + public static List getRequiredTransportEdgesFromBank( + TransportRouteAnalysis analysis) + { + if (analysis == null || !analysis.isRouteFromBankStepsExact()) + { + return List.of(); + } + return applyTransportEdgeFiltering(analysis.getTransportEdgesFromBank()); + } + + /** + * Compatibility API for Hub plugins compiled against concrete shortest-path transports. + * New code must use {@link #getTransportEdgesForDestination(WorldPoint, boolean)}. + */ + @Deprecated public static List getTransportsForDestination(WorldPoint destination, boolean useBankItems, TransportType prefTransportType) { if (destination == null) { return new ArrayList<>(); } + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null) { + return new ArrayList<>(); + } - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); - Pathfinder pf = new Pathfinder(Rs2PathApi.getPathfinderConfig(), Rs2Player.getWorldLocation(), destination); - pf.run(); - - List path = pf.getPath(); - if (path.isEmpty()) { - log.debug("Unable to find path to destination: " + destination); - return new ArrayList<>(); - } - - List transports = Rs2Walker.getTransportsForPath(path, 0, prefTransportType, true); - transports.forEach(t -> log.debug("Transport found: " + t)); - return transports; - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, destination) + .withBankItems(useBankItems)); + List path = route.getPath(); + if (path.isEmpty()) { + log.debug("Unable to find path to destination: " + destination); + return new ArrayList<>(); } + + // This deprecated return type cannot carry the planner-owned immutable edge, so retain the + // historical catalog view for binary compatibility. The active banking path above uses exact + // Rs2TransportEdge instances and never enters this endpoint-based adapter. + List transports = Rs2Walker.getTransportsForPath( + path, 0, prefTransportType, true); + transports.forEach(t -> log.debug("Transport found: " + t)); + return transports; } /** @@ -86,6 +163,82 @@ static boolean planningCoversPlainTransport(Transport transport) { || (transport.getItemIdRequirements() != null && !transport.getItemIdRequirements().isEmpty()); } + /** Legacy concrete-transport filter while banking consumers migrate to immutable edge views. */ + public static List applyTransportFiltering(List transports) { + return transports.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM + || t.getType() == TransportType.FAIRY_RING + || t.getType() == TransportType.TELEPORTATION_SPELL + || t.getType() == TransportType.CANOE + || 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 + || planningCoversPlainTransport(t) + || t.getType() == TransportType.SEASONAL_TRANSPORT + && Rs2LeaguesTransport.isLeaguesActive() + && t.getDisplayInfo() != null + && t.getDisplayInfo().toLowerCase().startsWith("leagues area:")) + .peek(t -> { + if (t.getType() == TransportType.FAIRY_RING + && (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) + && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) { + t.setItemIdRequirements(Set.of(Set.of( + ItemID.DRAMEN_STAFF, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF))); + } + if (isCurrencyBasedTransport(t.getType()) + && (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) + && t.getCurrencyName() != null && !t.getCurrencyName().isEmpty() + && t.getCurrencyAmount() > 0) { + int currencyItemId = getCurrencyItemId(t.getCurrencyName()); + if (currencyItemId != -1) { + t.setItemIdRequirements(Set.of(Set.of(currencyItemId))); + log.debug("Set currency requirement for {}: {} x{} (ID: {})", + t.getType(), t.getCurrencyName(), t.getCurrencyAmount(), currencyItemId); + } + } + }) + .collect(Collectors.toList()); + } + + static boolean planningCoversPlainTransportEdge(Rs2TransportEdge transport) + { + return transport != null + && transport.getType() == Rs2TransportType.TRANSPORT + && (transport.getCurrencyAmount() > 0 || !transport.getItemRequirements().isEmpty()); + } + + /** Filter selected immutable route edges down to transports relevant to bank preparation. */ + public static List applyTransportEdgeFiltering( + List transports) + { + if (transports == null) + { + return List.of(); + } + return transports.stream() + .filter(transport -> transport.getType() == Rs2TransportType.TELEPORTATION_ITEM + || transport.getType() == Rs2TransportType.FAIRY_RING + || transport.getType() == Rs2TransportType.TELEPORTATION_SPELL + || transport.getType() == Rs2TransportType.CANOE + || transport.getType() == Rs2TransportType.BOAT + || transport.getType() == Rs2TransportType.CHARTER_SHIP + || transport.getType() == Rs2TransportType.SHIP + || transport.getType() == Rs2TransportType.MINECART + || transport.getType() == Rs2TransportType.MAGIC_CARPET + || transport.getType() == Rs2TransportType.SPIRIT_TREE + || planningCoversPlainTransportEdge(transport) + || transport.getType() == Rs2TransportType.SEASONAL_TRANSPORT + && Rs2LeaguesTransport.isLeaguesActive() + && transport.getDisplayInfo() != null + && transport.getDisplayInfo().toLowerCase(Locale.ROOT) + .startsWith("leagues area:")) + .collect(Collectors.toUnmodifiableList()); + } + public static boolean hasRequiredTransportItems(Transport transport) { if (transport == null) { return false; @@ -107,6 +260,14 @@ public static boolean hasRequiredTransportItems(Transport transport) { || transport.getType() == TransportType.MAGIC_CARPET || planningCoversPlainTransport(transport)) { if (transport.getType() == TransportType.TELEPORTATION_SPELL && transport.getDisplayInfo() != null) { + if (!transport.getItemRequirements().isEmpty()) { + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + Rs2WalkerBankingPlanner::carriedRequirementItemQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .isPresent(); + } String spellName = transport.getDisplayInfo().contains(":") ? transport.getDisplayInfo().split(":")[0].trim() : transport.getDisplayInfo().trim(); @@ -124,21 +285,97 @@ public static boolean hasRequiredTransportItems(Transport transport) { && !transport.getCurrencyName().isEmpty() && transport.getCurrencyAmount() > 0) { int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); - return Rs2Inventory.count(currencyItemId) >= transport.getCurrencyAmount(); + return Rs2Inventory.itemQuantity(currencyItemId) >= transport.getCurrencyAmount(); } if (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) { return true; } - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)); + return transport.getItemRequirements().stream() + .allMatch(requirement -> requirement.isSatisfiedBy( + Rs2WalkerBankingPlanner::carriedItemQuantity)); } return true; } + public static boolean hasRequiredTransportEdgeItems(Rs2TransportEdge transport) + { + if (transport == null) + { + return false; + } + if (transport.getType() == Rs2TransportType.FAIRY_RING) + { + return hasFairyRingAccess(); + } + if (!isBankPlanningTransport(transport)) + { + return true; + } + if (isSpellTransport(transport) && transport.getDisplayInfo() != null) + { + if (!transport.getItemRequirements().isEmpty()) + { + return Rs2TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + Rs2WalkerBankingPlanner::carriedRequirementItemQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .isPresent(); + } + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(spellLookupName(transport.getDisplayInfo())); + return rs2Spell != null && Rs2Magic.hasRequiredRunes(rs2Spell); + } + if (isCurrencyBasedTransport(transport.getType()) + && transport.getItemRequirements().isEmpty() + && !transport.getCurrencyName().isEmpty() + && transport.getCurrencyAmount() > 0) + { + int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); + return currencyItemId > 0 + && Rs2Inventory.itemQuantity(currencyItemId) >= transport.getCurrencyAmount(); + } + return transport.getItemRequirements().stream() + .allMatch(requirement -> requirement.isSatisfiedBy( + Rs2WalkerBankingPlanner::carriedItemQuantity)); + } + + private static boolean hasFairyRingAccess() + { + return Rs2Inventory.hasItem(ItemID.DRAMEN_STAFF) + || Rs2Equipment.isWearing(ItemID.DRAMEN_STAFF) + || Rs2Inventory.hasItem(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Rs2Equipment.isWearing(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; + } + + private static boolean isBankPlanningTransport(Rs2TransportEdge transport) + { + Rs2TransportType type = transport.getType(); + return type == Rs2TransportType.TELEPORTATION_ITEM + || isSpellTransport(transport) + || type == Rs2TransportType.CANOE + || type == Rs2TransportType.BOAT + || type == Rs2TransportType.CHARTER_SHIP + || type == Rs2TransportType.SHIP + || type == Rs2TransportType.MINECART + || type == Rs2TransportType.MAGIC_CARPET + || planningCoversPlainTransportEdge(transport); + } + + private static boolean isSpellTransport(Rs2TransportEdge transport) + { + return transport.getType() == Rs2TransportType.TELEPORTATION_SPELL; + } + + private static String spellLookupName(String displayInfo) + { + return displayInfo.contains(":") + ? displayInfo.split(":", 2)[0].trim().toLowerCase(Locale.ROOT) + : displayInfo.trim().toLowerCase(Locale.ROOT); + } + public static List getMissingTransports(List transports) { if (transports == null) { return new ArrayList<>(); @@ -149,7 +386,32 @@ public static List getMissingTransports(List transports) { .collect(Collectors.toList()); } + public static List getMissingTransportEdges( + List transports) + { + if (transports == null) + { + return List.of(); + } + return transports.stream() + .filter(transport -> !hasRequiredTransportEdgeItems(transport)) + .collect(Collectors.toUnmodifiableList()); + } + public static Map getMissingTransportItemIdsWithQuantities(List transports) { + return getMissingTransportItemIdsWithQuantities(transports, Rs2Bank::count); + } + + /** + * Pure selection seam for tests and callers that already hold a bank snapshot. + * + *

The public entry point supplies {@link Rs2Bank#count(int)}. Keeping the provider outside the + * selection rules prevents headless tests from waiting on the client thread and lets the AND/OR + * choice policy be verified independently of the bank widget. + */ + static Map getMissingTransportItemIdsWithQuantities( + List transports, + IntUnaryOperator bankQuantityProvider) { if (transports == null) { return new HashMap<>(); } @@ -162,7 +424,7 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis if (!spellRuneRequirements.isEmpty()) { spellRuneRequirements.forEach((runeItemId, requiredQuantity) -> { try { - int bankQuantity = Rs2Bank.count(runeItemId); + int bankQuantity = bankQuantityProvider.applyAsInt(runeItemId); int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {} short={})", @@ -194,22 +456,35 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return; } - if (transport.getItemIdRequirements() != null) { - for (Set alternativeItems : transport.getItemIdRequirements()) { - int requiredQuantity = (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) - ? transport.getCurrencyAmount() - : 1; + if (transport.getItemRequirements() != null) { + for (TransportItemRequirement requirement : transport.getItemRequirements()) { + if (requirement.isSatisfiedBy(Rs2WalkerBankingPlanner::carriedItemQuantity)) { + continue; + } + Set alternativeItems = requirement.getItemIds(); Integer preferredItemId = null; int preferredBankQuantity = 0; for (Integer itemId : alternativeItems) { + int requiredQuantity = requirement.getRequiredQuantity(itemId); + if (requiredQuantity == 0) { + continue; + } int bankQuantity = 0; try { - bankQuantity = Rs2Bank.count(itemId); + bankQuantity = bankQuantityProvider.applyAsInt(itemId); } catch (Exception e) { log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); } - if (preferredItemId == null || bankQuantity > preferredBankQuantity) { + int preferredRequired = preferredItemId == null + ? Integer.MAX_VALUE + : requirement.getRequiredQuantity(preferredItemId); + boolean satisfies = bankQuantity >= requiredQuantity; + boolean preferredSatisfies = preferredItemId != null + && preferredBankQuantity >= preferredRequired; + if (preferredItemId == null + || (satisfies && !preferredSatisfies) + || (satisfies == preferredSatisfies && bankQuantity > preferredBankQuantity)) { preferredItemId = itemId; preferredBankQuantity = bankQuantity; } @@ -226,23 +501,22 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis .orElse(null); int currencyItemId = purchasable == null ? -1 : getCurrencyItemId(purchasable.costCurrencyName); if (currencyItemId > 0) { - // One fare per required ITEM. requiredQuantity above is a currency - // amount for currency-based rows, so it must not be used as a count. + int requiredQuantity = requirement.getRequiredQuantity(purchasable.itemId); int itemsNeeded = isCurrencyBasedTransport(transport.getType()) ? 1 : requiredQuantity; int fare = purchasable.costAmount * itemsNeeded; itemQuantityMap.merge(currencyItemId, fare, Integer::sum); log.debug("Transport item {} not banked but purchasable — withdrawing fare {} x{} instead", purchasable.itemId, purchasable.costCurrencyName, fare); - break; + continue; } } if (preferredItemId != null) { + int requiredQuantity = requirement.getRequiredQuantity(preferredItemId); int currentQuantity = itemQuantityMap.getOrDefault(preferredItemId, 0); itemQuantityMap.put(preferredItemId, currentQuantity + requiredQuantity); log.debug("Added transport item requirement: itemId={} x{} (bank has: {} short={})", preferredItemId, requiredQuantity, preferredBankQuantity, preferredBankQuantity < requiredQuantity); } - break; } } }); @@ -250,6 +524,300 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return itemQuantityMap; } + public static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports) + { + return getMissingTransportEdgeLoadout(transports).getWithdrawals(); + } + + /** + * Build one atomic preparation contract for the exact selected edges. + * + *

Rune quantities include the inventory, rune pouch, equipped providers and combination runes. + * Bank rune quantities are kept separate from raw bank stacks because a semantic contribution + * still has to resolve to a concrete item that can actually be withdrawn.

+ */ + public static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports) + { + Map carriedRunes = runeQuantities( + RuneFilter.builder().includeBank(false).build()); + Map bankRunes = runeQuantities(RuneFilter.builder() + .includeInventory(false) + .includeEquipment(false) + .includeRunePouch(false) + .includeBank(true) + .build()); + return getMissingTransportEdgeLoadout( + transports, + Rs2Bank::count, + Rs2WalkerBankingPlanner::carriedItemQuantity, + itemId -> carriedRunes.getOrDefault(itemId, carriedItemQuantity(itemId)), + itemId -> bankRunes.getOrDefault(itemId, safeQuantity(Rs2Bank::count, itemId)), + Rs2Equipment::isWearing); + } + + /** Compatibility view for callers that only consume withdrawals. */ + static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider) + { + return getMissingTransportEdgeLoadout( + transports, + bankQuantityProvider, + carriedQuantityProvider, + carriedQuantityProvider, + bankQuantityProvider, + ignored -> false).getWithdrawals(); + } + + /** Pure source-aware selection seam used by banking regressions. */ + static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider, + IntUnaryOperator carriedRequirementQuantityProvider, + IntUnaryOperator bankRequirementQuantityProvider, + IntPredicate equippedItemProvider) + { + if (transports == null) + { + return Rs2TransportLoadout.empty(); + } + Map withdrawals = new LinkedHashMap<>(); + LinkedHashSet equipmentItemIds = new LinkedHashSet<>(); + for (Rs2TransportEdge transport : transports) + { + if (isSpellTransport(transport) && transport.getItemRequirements().isEmpty()) + { + for (Map.Entry rune : getSpellRuneRequirements(transport).entrySet()) + { + int bankQuantity = safeQuantity(bankQuantityProvider, rune.getKey()); + if (bankQuantity < rune.getValue()) + { + return Rs2TransportLoadout.unavailable(); + } + withdrawals.merge(rune.getKey(), rune.getValue(), Integer::sum); + } + continue; + } + + if (isCurrencyBasedTransport(transport.getType()) + && transport.getCurrencyAmount() > 0 + && transport.getItemRequirements().isEmpty()) + { + int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); + if (currencyItemId > 0) + { + withdrawals.merge( + currencyItemId, transport.getCurrencyAmount(), Integer::sum); + } + continue; + } + + List requirements = transport.getItemRequirements(); + if (transport.getType() == Rs2TransportType.FAIRY_RING + && requirements.isEmpty() + && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) + { + requirements = List.of(new Rs2TransportItemRequirement(Map.of( + ItemID.DRAMEN_STAFF, 1, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF, 1))); + } + + Rs2TransportItemRequirement.ProviderSelection providers = + Rs2TransportItemRequirement.selectEquipmentProviders( + requirements, + itemId -> safeSum( + safeQuantity(carriedRequirementQuantityProvider, itemId), + safeQuantity(bankRequirementQuantityProvider, itemId)), + itemId -> safeSum( + safeQuantity(carriedQuantityProvider, itemId), + safeQuantity(bankQuantityProvider, itemId)) > 0, + itemId -> safeSum( + safeQuantity(carriedQuantityProvider, itemId), + safeQuantity(bankQuantityProvider, itemId)) > 0) + .orElse(null); + if (providers == null) + { + return Rs2TransportLoadout.unavailable(); + } + if (!addProviderPreparation( + providers.getStaffItemId(), withdrawals, equipmentItemIds, + bankQuantityProvider, carriedQuantityProvider, equippedItemProvider) + || !addProviderPreparation( + providers.getOffhandItemId(), withdrawals, equipmentItemIds, + bankQuantityProvider, carriedQuantityProvider, equippedItemProvider)) + { + return Rs2TransportLoadout.unavailable(); + } + + for (Rs2TransportItemRequirement requirement : requirements) + { + if (requirement.isSatisfiedBy(carriedRequirementQuantityProvider) + || requirement.getStaffAlternatives().contains(providers.getStaffItemId()) + || requirement.getOffhandAlternatives().contains(providers.getOffhandItemId())) + { + continue; + } + if (!addPreferredRequirement( + withdrawals, + requirement.getAlternatives(), + transport.getType(), + bankQuantityProvider, + carriedRequirementQuantityProvider)) + { + return Rs2TransportLoadout.unavailable(); + } + } + } + if (withdrawals.isEmpty() && equipmentItemIds.isEmpty()) + { + return Rs2TransportLoadout.empty(); + } + return new Rs2TransportLoadout( + withdrawals, new ArrayList<>(equipmentItemIds), true); + } + + private static boolean addProviderPreparation( + int itemId, + Map withdrawals, + Set equipmentItemIds, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider, + IntPredicate equippedItemProvider) + { + if (itemId <= 0 || equippedItemProvider.test(itemId)) + { + return true; + } + equipmentItemIds.add(itemId); + if (safeQuantity(carriedQuantityProvider, itemId) > 0) + { + return true; + } + if (safeQuantity(bankQuantityProvider, itemId) <= 0) + { + return false; + } + withdrawals.merge(itemId, 1, Math::max); + return true; + } + + private static boolean addPreferredRequirement( + Map requested, + Map alternatives, + Rs2TransportType transportType, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider) + { + Integer preferredItemId = null; + int preferredBankQuantity = 0; + int preferredDeficit = Integer.MAX_VALUE; + for (Map.Entry alternative : alternatives.entrySet()) + { + int itemId = alternative.getKey(); + int requiredQuantity = alternative.getValue(); + if (requiredQuantity == 0) + { + continue; + } + int bankQuantity = safeQuantity(bankQuantityProvider, itemId); + int deficit = Math.max(0, + requiredQuantity - safeQuantity(carriedQuantityProvider, itemId)); + boolean satisfies = bankQuantity >= deficit; + boolean preferredSatisfies = preferredItemId != null + && preferredBankQuantity >= preferredDeficit; + if (preferredItemId == null + || satisfies && !preferredSatisfies + || satisfies == preferredSatisfies && deficit < preferredDeficit + || satisfies == preferredSatisfies && deficit == preferredDeficit + && bankQuantity > preferredBankQuantity) + { + preferredItemId = itemId; + preferredBankQuantity = bankQuantity; + preferredDeficit = deficit; + } + } + + if (preferredItemId == null) + { + return false; + } + if (preferredBankQuantity < preferredDeficit) + { + PurchasableItemCatalog.PurchasableItem purchasable = alternatives.keySet().stream() + .map(PurchasableItemCatalog::byItemId) + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + int currencyItemId = purchasable == null + ? -1 : getCurrencyItemId(purchasable.costCurrencyName); + if (currencyItemId > 0) + { + int requiredQuantity = alternatives.get(purchasable.itemId); + int itemsNeeded = isCurrencyBasedTransport(transportType) + ? 1 : requiredQuantity; + requested.merge( + currencyItemId, purchasable.costAmount * itemsNeeded, Integer::sum); + return true; + } + return false; + } + if (preferredDeficit > 0) + { + requested.merge(preferredItemId, preferredDeficit, Integer::sum); + } + return true; + } + + private static Map runeQuantities(RuneFilter filter) + { + Map quantities = new HashMap<>(); + Rs2Magic.getRunes(filter).forEach((rune, quantity) -> + quantities.put(rune.getItemId(), quantity)); + return quantities; + } + + private static int safeSum(int first, int second) + { + long sum = (long) Math.max(0, first) + Math.max(0, second); + return sum >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) sum; + } + + private static int safeQuantity(IntUnaryOperator provider, int itemId) + { + try + { + return Math.max(0, provider.applyAsInt(itemId)); + } + catch (Exception exception) + { + log.debug("Could not check bank for item {}: {}", itemId, exception.getMessage()); + return 0; + } + } + + private static int carriedItemQuantity(int itemId) { + int quantity = Rs2Inventory.itemQuantity(itemId); + net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel equipped = Rs2Equipment.get(itemId); + if (equipped != null) { + quantity += Math.max(1, equipped.getQuantity()); + } + return quantity; + } + + private static int carriedRequirementItemQuantity(int itemId) + { + Runes rune = Runes.byItemId(itemId); + if (rune == null) + { + return carriedItemQuantity(itemId); + } + return Rs2Magic.getRunes().getOrDefault(rune, 0); + } + public static List getMissingTransportItemIds(List transports) { return new ArrayList<>(getMissingTransportItemIdsWithQuantities(transports).keySet()); } @@ -273,57 +841,69 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP try { performanceLog.append("\tStart Point: ").append(startPoint).append(", Target: ").append(target).append("\n"); long directPathStartTime = System.nanoTime(); - List directPath = Rs2Walker.getWalkPath(startPoint, target); + Rs2RouteResult directRoute = planRoute( + startPoint, target, false, Rs2RouteRequest.Purpose.BANK_ROUTE_DIRECT); + List directPath = directRoute.getPath(); + List directRouteSteps = directRoute.getSteps(); long directPathEndTime = System.nanoTime(); double directPathTimeMs = (directPathEndTime - directPathStartTime) / 1_000_000.0; - int directDistance = Rs2Walker.getTotalTilesFromPath(directPath, target); + int directDistance = comparableRouteDistance(directRoute, target); performanceLog.append("\t-Direct path calculation: ").append(String.format("%.2f ms", directPathTimeMs)) .append(" (").append(directPath.size()).append(" waypoints, ").append(directDistance).append(" tiles)\n"); BankLocation nearestBank = null; List pathToBank = new ArrayList<>(); + List routeToBankSteps = List.of(); List pathFromBankToTarget = new ArrayList<>(); + List routeFromBankSteps = List.of(); int bankingRouteDistance = -1; try { - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(true); - Rs2PathApi.getPathfinderConfig().refresh(target); + performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); - performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); + long bankSearchStartTime = System.nanoTime(); + nearestBank = Rs2Bank.getNearestBank(startPoint); + long bankSearchEndTime = System.nanoTime(); + double bankSearchTimeMs = (bankSearchEndTime - bankSearchStartTime) / 1_000_000.0; - long bankSearchStartTime = System.nanoTime(); - nearestBank = Rs2Bank.getNearestBank(startPoint); - long bankSearchEndTime = System.nanoTime(); - double bankSearchTimeMs = (bankSearchEndTime - bankSearchStartTime) / 1_000_000.0; - - if (nearestBank != null) { + if (nearestBank != null) { WorldPoint bankLocation = nearestBank.getWorldPoint(); performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)); performanceLog.append("\t -> Found: ").append(nearestBank).append(" at ").append(bankLocation).append("\n"); long pathToBankStartTime = System.nanoTime(); - pathToBank = Rs2Walker.getWalkPath(startPoint, bankLocation); + Rs2RouteResult bankRoute = planRoute( + startPoint, bankLocation, false, + Rs2RouteRequest.Purpose.BANK_ROUTE_TO_BANK); + pathToBank = bankRoute.getPath(); + routeToBankSteps = bankRoute.getSteps(); long pathToBankEndTime = System.nanoTime(); double pathToBankTimeMs = (pathToBankEndTime - pathToBankStartTime) / 1_000_000.0; - int distanceToBank = Rs2Walker.getTotalTilesFromPath(pathToBank, bankLocation); + int distanceToBank = comparableRouteDistance(bankRoute, bankLocation); long pathFromBankStartTime = System.nanoTime(); - pathFromBankToTarget = Rs2Walker.getWalkPath(bankLocation, target); + Rs2RouteResult bankTargetRoute = planRoute( + bankLocation, target, true, + Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK); + pathFromBankToTarget = bankTargetRoute.getPath(); + routeFromBankSteps = bankTargetRoute.getSteps(); long pathFromBankEndTime = System.nanoTime(); double pathFromBankTimeMs = (pathFromBankEndTime - pathFromBankStartTime) / 1_000_000.0; - List bankLegTransports = Rs2Walker.getTransportsForPath( - pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); + List bankLegTransports = bankTargetRoute.getTransportSteps().stream() + .map(Rs2RouteStep::getTransport) + .map(transport -> transport.orElseThrow( + () -> new IllegalStateException("transport step has no edge"))) + .collect(Collectors.toList()); long spellCount = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_SPELL) .count(); long itemCount = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM) + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_ITEM) .count(); - int distanceFromBankRaw = Rs2Walker.getTotalTilesFromPath(pathFromBankToTarget, target); - int distanceFromBank = effectiveDistanceFromBank(pathFromBankToTarget, distanceFromBankRaw); + int distanceFromBankRaw = comparableRouteDistance(bankTargetRoute, target); + int distanceFromBank = effectiveDistanceFromBank( + pathFromBankToTarget, bankTargetRoute.getSteps(), distanceFromBankRaw); performanceLog.append("\t-Path to bank calculation: ").append(String.format("%.2f ms", pathToBankTimeMs)) .append(" (").append(pathToBank.size()).append(" waypoints, ").append(distanceToBank).append(" tiles)\n"); @@ -333,8 +913,8 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP .append(" spells=").append(spellCount) .append(" items=").append(itemCount) .append("\n"); - Transport firstSpellTransport = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + Rs2TransportEdge firstSpellTransport = bankLegTransports.stream() + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_SPELL) .findFirst() .orElse(null); if (firstSpellTransport != null) { @@ -366,13 +946,9 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP bankingRouteDistance = distanceToBank + distanceFromBank; } performanceLog.append("\t-Total banking route distance: ").append(bankingRouteDistance).append(" tiles\n"); - } else { - performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)) - .append("\t -> No accessible bank found\n"); - } - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + } else { + performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)) + .append("\t -> No accessible bank found\n"); } } catch (Exception e) { performanceLog.append("Banking route calculation failed: ").append(e.getMessage()).append("\n"); @@ -388,11 +964,12 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP WebWalkLog.compareDetail(performanceLog.toString()); WebWalkLog.compareSummary(totalTimeMs, directDistance, -1, "direct_only_bank_unavailable"); return new TransportRouteAnalysis(directPath, null, null, new ArrayList<>(), new ArrayList<>(), - "Direct route only (banking route unavailable)"); + "Direct route only (banking route unavailable)", directDistance, -1, + directRouteSteps, List.of(), List.of()); } - final boolean tie = directDistance == bankingRouteDistance; - final boolean directStrictlyFaster = directDistance < bankingRouteDistance; + final boolean tie = directDistance >= 0 && directDistance == bankingRouteDistance; + final boolean directStrictlyFaster = directDistance >= 0 && directDistance < bankingRouteDistance; final boolean preferTransportToTarget = Rs2PathApi.override("preferTransportToTarget", false); final String recommendation; final String verdictOneLine; @@ -418,7 +995,8 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP return new TransportRouteAnalysis(directPath, nearestBank, nearestBank != null ? nearestBank.getWorldPoint() : null, pathToBank, pathFromBankToTarget, recommendation, - directDistance, bankingRouteDistance); + directDistance, bankingRouteDistance, + directRouteSteps, routeToBankSteps, routeFromBankSteps); } catch (Exception e) { long totalEndTime = System.nanoTime(); double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; @@ -429,6 +1007,37 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP } } + private static Rs2RouteResult planRoute( + WorldPoint start, + WorldPoint target, + boolean useBankItems, + Rs2RouteRequest.Purpose purpose) + { + return Rs2PathApi.plan( + Rs2RouteRequest.to(start, target) + .withRefreshTarget(target) + .withBankItems(useBankItems) + .withPurpose(purpose)); + } + + /** Only a typed, completed route may participate in bank-vs-direct distance comparison. */ + static int comparableRouteDistance(Rs2RouteResult route, WorldPoint target) { + if (route == null) { + return -1; + } + return comparableRouteDistance(route.getPath(), target, route.getTerminationReason(), + route.isTargetReached(0)); + } + + static int comparableRouteDistance(List path, WorldPoint target, + Rs2RouteTermination termination, boolean targetReached) { + if (target == null || termination != Rs2RouteTermination.TARGET_REACHED || !targetReached) { + return -1; + } + int distance = Rs2Walker.getTotalTilesFromPath(path, target); + return distance == Integer.MAX_VALUE ? -1 : distance; + } + private static Map getSpellRuneRequirements(Transport transport) { Map runeRequirements = new HashMap<>(); if (transport.getType() != TransportType.TELEPORTATION_SPELL || transport.getDisplayInfo() == null) { @@ -465,6 +1074,31 @@ private static Map getSpellRuneRequirements(Transport transpor return runeRequirements; } + private static Map getSpellRuneRequirements(Rs2TransportEdge transport) + { + Map runeRequirements = new HashMap<>(); + if (!isSpellTransport(transport) || transport.getDisplayInfo() == null) + { + return runeRequirements; + } + try + { + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(spellLookupName(transport.getDisplayInfo())); + if (rs2Spell == null) + { + return runeRequirements; + } + Rs2Magic.getRequiredRunes(rs2Spell, 1, true).forEach((rune, quantity) -> + runeRequirements.put(rune.getItemId(), quantity)); + } + catch (Exception exception) + { + log.warn("Error getting spell rune requirements for transport '{}': {}", + transport.getDisplayInfo(), exception.getMessage()); + } + return runeRequirements; + } + /** Package-private so the planning tests can select the same rows this collector accepts. */ static boolean isCurrencyBasedTransport(TransportType transportType) { return transportType == TransportType.BOAT @@ -475,6 +1109,16 @@ static boolean isCurrencyBasedTransport(TransportType transportType) { || transportType == TransportType.TRANSPORT; } + static boolean isCurrencyBasedTransport(Rs2TransportType transportType) + { + return transportType == Rs2TransportType.BOAT + || transportType == Rs2TransportType.CHARTER_SHIP + || transportType == Rs2TransportType.SHIP + || transportType == Rs2TransportType.MINECART + || transportType == Rs2TransportType.MAGIC_CARPET + || transportType == Rs2TransportType.TRANSPORT; + } + private static int getCurrencyItemId(String currencyName) { if (currencyName == null || currencyName.trim().isEmpty()) { return -1; @@ -497,64 +1141,72 @@ private static int getCurrencyItemId(String currencyName) { * For originless TELEPORTATION_ITEM / TELEPORTATION_SPELL edges, trim pre-teleport walking * from the bank leg metric and keep the post-teleport tail. */ - private static int effectiveDistanceFromBank(List pathFromBankToTarget, int rawDistance) { - if (pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() || rawDistance == Integer.MAX_VALUE) { - return rawDistance; - } + static int effectiveDistanceFromBank( + List pathFromBankToTarget, + List routeSteps, + int rawDistance) { + if (pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() || rawDistance == Integer.MAX_VALUE) { + return rawDistance; + } + if (routeSteps == null || routeSteps.isEmpty()) { + return rawDistance; + } - List transports = Rs2Walker.getTransportsForPath(pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); - if (transports.isEmpty()) { - return rawDistance; - } + int firstTransportStep = -1; + Rs2TransportEdge firstTransport = null; + for (int i = 0; i < routeSteps.size(); i++) { + Rs2RouteStep step = routeSteps.get(i); + if (step != null && step.isTransport()) { + firstTransportStep = i; + firstTransport = step.getTransport().orElse(null); + break; + } + } + if (firstTransport == null) { + return rawDistance; + } - // Use first transport that the bank->target path actually consumes and model: - // walk_to_transport + transport_hop + post_transport_tail. - Transport firstTransport = transports.get(0); - int modeledDistance = transportModeledDistance(pathFromBankToTarget, firstTransport, rawDistance); + // Use the exact first transport selected by this route. Endpoint rematching is ambiguous when + // multiple catalog entries share an origin/destination pair and could score the wrong command. + int modeledDistance = transportModeledDistance( + pathFromBankToTarget, firstTransportStep, firstTransport, rawDistance); if (modeledDistance == Integer.MAX_VALUE) { return rawDistance; } return Math.min(rawDistance, modeledDistance); } - private static boolean isImmediateBankTeleport(Transport transport) { - if (transport == null || transport.getOrigin() != null) { - return false; - } - return transport.getType() == TransportType.TELEPORTATION_ITEM - || transport.getType() == TransportType.TELEPORTATION_SPELL; - } + private static boolean isImmediateBankTeleport(Rs2TransportEdge transport) { + if (transport == null || transport.getOrigin() != null) { + return false; + } + return transport.getType() == Rs2TransportType.TELEPORTATION_ITEM + || transport.getType() == Rs2TransportType.TELEPORTATION_SPELL; + } - private static int transportModeledDistance(List pathFromBankToTarget, Transport transport, int fallbackRawDistance) { - if (transport == null || pathFromBankToTarget == null || pathFromBankToTarget.isEmpty()) { - return fallbackRawDistance; - } - - WorldPoint destination = transport.getDestination(); - if (destination == null) { - return fallbackRawDistance; - } - int destinationIndex = pathFromBankToTarget.indexOf(destination); - if (destinationIndex < 0) { - return fallbackRawDistance; - } + private static int transportModeledDistance( + List pathFromBankToTarget, + int transportStepIndex, + Rs2TransportEdge transport, + int fallbackRawDistance) { + if (transport == null || pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() + || transportStepIndex < 0 || transportStepIndex >= pathFromBankToTarget.size() - 1) { + return fallbackRawDistance; + } - int originIndex; - if (isImmediateBankTeleport(transport)) { - originIndex = 0; - } else { - WorldPoint origin = transport.getOrigin(); - originIndex = origin == null ? 0 : pathFromBankToTarget.indexOf(origin); - if (originIndex < 0) { - originIndex = 0; - } - } - - if (destinationIndex < originIndex) { - return fallbackRawDistance; - } + WorldPoint stepOrigin = pathFromBankToTarget.get(transportStepIndex); + WorldPoint stepDestination = pathFromBankToTarget.get(transportStepIndex + 1); + if (!stepDestination.equals(transport.getDestination())) { + return fallbackRawDistance; + } + if (!isImmediateBankTeleport(transport) + && transport.getOrigin() != null + && !stepOrigin.equals(transport.getOrigin())) { + return fallbackRawDistance; + } - int walkToTransport = Math.max(0, originIndex); + int destinationIndex = transportStepIndex + 1; + int walkToTransport = isImmediateBankTeleport(transport) ? 0 : transportStepIndex; int transportHop = 1; int postTransportTail = Math.max(0, pathFromBankToTarget.size() - destinationIndex); return walkToTransport + transportHop + postTransportTail; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java new file mode 100644 index 00000000000..84dc6aca8a9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java @@ -0,0 +1,391 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The single owner of "which door edges has this walker ATTEMPTED, and when" — D3 slice 1 of the + * door-attempt lifecycle (DETECTED → ATTEMPTED → CROSSED | REFUSED | EXPIRED). + * + *

Before the ledger, this one fact lived in two independent stores with different lifetimes: + * a per-edge timestamp map ({@code recentDoorAttemptByEdge}, session-lived, decayed on read) feeding + * the anti-hammer cooldown, and a most-recent-attempt triple ({@code routeState.lastDoorAttempt*}, + * walk-lived) feeding the post-attempt nudge, the active-edge claim and the same-edge cooldown + * variant. Their disagreement was a live bug: the Stronghold of Security's chained gates (2026-08-12) + * had the triple still pointing at a conquered gate while the map knew about the next one, and the + * nudge victory-lapped the door already crossed. One owner makes that class of disagreement + * unrepresentable. + * + *

The two lifetimes are preserved as two facets of one record set, not two stores: + *

    + *
  • per-edge attempt times survive walk boundaries and decay by cooldown — hammering the + * same door across two walks is still hammering;
  • + *
  • the latest attempt (the walker's current claim on an edge) is dropped at walk start + * and the moment a crossing is observed — a claim on the previous walk's door, or on a door + * already behind us, satisfies nothing.
  • + *
+ * + *

All time is injected ({@code nowMs}) so decision tables can drive the clock. The class is + * instance-based for the same reason; the walker holds one static instance. + */ +public final class DoorAttemptLedger { + + /** One attempted door edge — an immutable snapshot of the walker's claim on it. */ + public static final class Attempt { + public final WorldPoint from; + public final WorldPoint to; + public final long attemptedAtMs; + + Attempt(WorldPoint from, WorldPoint to, long attemptedAtMs) { + this.from = from; + this.to = to; + this.attemptedAtMs = attemptedAtMs; + } + + /** Direction-blind edge identity — the active-edge claim covers both crossing directions. */ + public boolean matchesEdge(WorldPoint a, WorldPoint b) { + if (a == null || b == null) { + return false; + } + return (a.equals(from) && b.equals(to)) || (a.equals(to) && b.equals(from)); + } + + /** Direction-AWARE identity — the same-edge cooldown deliberately binds one direction only. */ + public boolean isSameDirectedEdge(WorldPoint fromWp, WorldPoint toWp) { + return from.equals(fromWp) && to.equals(toWp); + } + } + + /** Outcome of registering one concluded-but-uncrossed attempt against an edge. */ + public enum Strike { + /** The sample cannot prove a refusal (player still moving, or the walk was cancelled mid-wait). */ + NOT_COUNTED, + /** Counted; the edge has strikes left. */ + COUNTED, + /** The edge has struck out: block it for this walk and replan. */ + STRIKE_OUT + } + + private final Map attemptAtByEdgeKey = new ConcurrentHashMap<>(); + private final Map crossFailuresByEdgeKey = new ConcurrentHashMap<>(); + private final Map stationaryDoorOpenedAtByTile = new ConcurrentHashMap<>(); + private final Set blacklistedDoorTiles = ConcurrentHashMap.newKeySet(); + private final java.util.concurrent.ConcurrentLinkedQueue walkScopedBlocks = + new java.util.concurrent.ConcurrentLinkedQueue<>(); + private volatile Attempt latest; + + /** + * Records an attempt. Edge-keyed attempts (both endpoints known) also become the latest claim; + * tile-keyed attempts (probe-only door, no resolved edge) feed the cooldown map alone, exactly + * as the pre-ledger stores behaved. + */ + public void markAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp, long nowMs) { + attemptAtByEdgeKey.put(Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp), nowMs); + if (fromWp != null && toWp != null) { + latest = new Attempt(fromWp, toWp, nowMs); + } + } + + /** + * The anti-hammer gate: true while the edge's last attempt is younger than the cooldown. + * Purges every expired entry as a side effect, as the map-based version always did. + */ + public boolean shouldThrottleAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp, + long cooldownMs, long nowMs) { + attemptAtByEdgeKey.entrySet().removeIf(entry -> nowMs - entry.getValue() > cooldownMs); + Long last = attemptAtByEdgeKey.get(Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp)); + return last != null && nowMs - last < cooldownMs; + } + + /** Raw attempt time for an edge, or null — the age query behind the nearby-wait heuristics. */ + public Long attemptAtMs(WorldPoint fromWp, WorldPoint toWp) { + return attemptAtByEdgeKey.get(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp)); + } + + /** The current claim regardless of age (the same-edge cooldown never age-filtered). */ + public Attempt latestAttempt() { + return latest; + } + + /** The current claim if it is younger than {@code maxAgeMs}; null once it has gone stale. */ + public Attempt latestAttempt(long maxAgeMs, long nowMs) { + Attempt attempt = latest; + if (attempt == null) { + return null; + } + long ageMs = nowMs - attempt.attemptedAtMs; + return (ageMs < 0L || ageMs > maxAgeMs) ? null : attempt; + } + + /** + * Withdraws the latest claim — at walk start (the claim belongs to the previous walk) and when a + * crossing is observed (done means fall through; a spent claim must not nudge again). Per-edge + * attempt times deliberately survive: the cooldown is anti-hammer, not a claim. + */ + public void clearLatestAttempt() { + latest = null; + } + + // ---- the REFUSED facet (D3 slice 2): strike counting and walk-scoped blocks ---- + + /** + * Counts attempts that CONCLUDED at the door without crossing it — a click that opened nothing + * (action still present), or a cross-click past an apparently open door that moved the player + * nowhere. Doors that refuse for game-state reasons (Tithe Farm's seed gate, key doors, favour + * gates) produce exactly this signature and nothing else: no dialogue, no traversal, no collision + * change. Without a strike-out the walker retries the same edge forever — measured at 4+ minutes + * of door/recovery ping-pong on Farm door 27445 before a human cancelled it. + * + *

{@code conclusiveSample} is the caller's evidence gate: the player must be stationary at the + * near side when sampled. A moving sample proves only that the approach was still in flight — + * the same trap that once blacklisted Wydin's door off a mid-walk position. Strikes are keyed by + * the normalized (direction-blind) edge, decay after {@code decayMs}, and a strike-out consumes + * the entry so a re-attempted edge starts fresh. + */ + public Strike registerCrossFailure(WorldPoint fromWp, WorldPoint toWp, boolean conclusiveSample, + long nowMs, long decayMs, int strikeLimit) { + if (!conclusiveSample || fromWp == null || toWp == null) { + return Strike.NOT_COUNTED; + } + String edgeKey = Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp); + crossFailuresByEdgeKey.entrySet().removeIf(entry -> nowMs - entry.getValue()[1] > decayMs); + long[] entry = crossFailuresByEdgeKey.compute(edgeKey, (k, v) -> + v == null ? new long[]{1, nowMs} : new long[]{v[0] + 1, nowMs}); + if (entry[0] >= strikeLimit) { + crossFailuresByEdgeKey.remove(edgeKey); + return Strike.STRIKE_OUT; + } + return Strike.COUNTED; + } + + /** A successful crossing forgives the edge's strikes (transient refusals should not accumulate). */ + public void clearCrossFailures(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + String edgeKey = Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp); + crossFailuresByEdgeKey.remove(edgeKey); + refusedOpenAtMsByEdgeKey.remove(edgeKey); + } + } + + /** + * When the most recent attempt on an edge ended REFUSED — clicked Open, never traversed, action + * still present — waiting for that edge to "resolve" is waiting for a shut door to open by + * itself. Recorded unconditionally (unlike strikes, which demand a stationary conclusive + * sample): this timestamp only shortens a WAIT, so the evidence bar is deliberately lower. + * Cleared by a successful crossing ({@link #clearCrossFailures}). + */ + private final Map refusedOpenAtMsByEdgeKey = new ConcurrentHashMap<>(); + + public void markRefusedOpen(WorldPoint fromWp, WorldPoint toWp, long nowMs) { + if (fromWp != null && toWp != null) { + refusedOpenAtMsByEdgeKey.put(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp), nowMs); + } + } + + public boolean hasFreshRefusedOpen(WorldPoint fromWp, WorldPoint toWp, long nowMs, long freshMs) { + if (fromWp == null || toWp == null) { + return false; + } + Long at = refusedOpenAtMsByEdgeKey.get(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp)); + return at != null && nowMs - at <= freshMs; + } + + /** + * Remembers a planner edge-block earned by a strike-out so the NEXT walk can withdraw it. The + * block is walk-scoped, not session-scoped: a door that refuses for game-state reasons opens the + * moment the condition is met, and a session block would stop the owning plugin's own walk-in + * from ever routing through it — the museum lesson. + */ + public void recordWalkScopedBlock(WorldPoint fromWp, WorldPoint toWp) { + walkScopedBlocks.add(new WorldPoint[]{fromWp, toWp}); + } + + /** Returns and forgets every walk-scoped block — called once at walk session start. */ + public java.util.List drainWalkScopedBlocks() { + java.util.List drained = new java.util.ArrayList<>(); + WorldPoint[] edge; + while ((edge = walkScopedBlocks.poll()) != null) { + drained.add(edge); + } + return drained; + } + + // ---- tile-keyed facets (D3 slice 3): recently-opened suppression and the session blacklist ---- + + /** + * Records that a stationary (non-moves-you) door at this tile was just opened. For the suppress + * window that follows, probes must not re-find it — re-clicking an open door closes it, which + * was the original two-clicks-per-door bug. + */ + public void markStationaryDoorOpened(WorldPoint doorTile, long nowMs) { + if (doorTile != null) { + stationaryDoorOpenedAtByTile.put(doorTile, nowMs); + } + } + + /** + * Whether a recently-opened stationary door sits on (within 2 tiles of either end of) the + * {@code fromWp -> toWp} segment. Purges expired entries as a side effect, as the map-based + * version always did. + */ + public boolean recentlyOpenedDoorOnSegment(WorldPoint fromWp, WorldPoint toWp, long suppressMs, long nowMs) { + if (fromWp == null || toWp == null) { + return false; + } + final int segmentDoorSuppressDist = 2; + stationaryDoorOpenedAtByTile.entrySet().removeIf(entry -> nowMs - entry.getValue() > suppressMs); + return stationaryDoorOpenedAtByTile.keySet().stream() + .anyMatch(door -> door != null + && door.getPlane() == fromWp.getPlane() + && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist + || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); + } + + /** Exact-tile variant; expires the entry on a stale read exactly as the old direct-map read did. */ + public boolean wasStationaryDoorOpenedWithin(WorldPoint doorTile, long suppressMs, long nowMs) { + if (doorTile == null) { + return false; + } + Long openedAt = stationaryDoorOpenedAtByTile.get(doorTile); + if (openedAt == null) { + return false; + } + if (nowMs - openedAt > suppressMs) { + stationaryDoorOpenedAtByTile.remove(doorTile); + return false; + } + return true; + } + + /** + * Session-permanent refusal: a door proven quest/stat-locked by a failed interact (dialogue with + * lock keywords, or a locked message). Unlike the walk-scoped strike-out blocks, these never + * come back within the session — the lock will not open because the walker retried. + */ + public void blacklistDoor(WorldPoint doorTile) { + if (doorTile != null) { + blacklistedDoorTiles.add(doorTile); + } + } + + public boolean isDoorBlacklisted(WorldPoint doorTile) { + return doorTile != null && blacklistedDoorTiles.contains(doorTile); + } + + /** Test hook: the blacklist is session-permanent by design, so only tests may empty it. */ + public void clearBlacklist() { + blacklistedDoorTiles.clear(); + } + + // ---- walk-runtime facets (D3 slice 4): pass claims, settle window, cooldown, raw-scan focus ---- + + private final Map edgeAttemptPosByKeyThisPass = new ConcurrentHashMap<>(); + private volatile long settleStartedAtMs; + private volatile long settleUntilMs; + private volatile WorldPoint settleFarSideWp; + private volatile long globalCooldownUntilMs; + private volatile Integer rawScanFocusDoorIdx; + private volatile long rawScanFocusSetAtMs; + private volatile int rawScanFocusAttempts; + + /** A new tail pass gets a fresh per-edge attempt budget (formerly doorEdgesAttemptedThisTail). */ + public void beginTailPass() { + edgeAttemptPosByKeyThisPass.clear(); + } + + /** + * One-shot budget per edge per pass, re-armed once the player has genuinely MOVED since the + * previous attempt (within one tile of the recorded position = still the same stand, refuse). + * A null recorded position never binds — preserving the old map's null-value semantics. + */ + public boolean tryClaimEdgeThisPass(WorldPoint fromWp, WorldPoint toWp, WorldPoint playerBeforeAttempt) { + if (fromWp == null || toWp == null) { + return true; + } + String edgeKey = Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp); + WorldPoint previous = edgeAttemptPosByKeyThisPass.get(edgeKey); + if (previous != null && playerBeforeAttempt != null + && previous.getPlane() == playerBeforeAttempt.getPlane() + && previous.distanceTo2D(playerBeforeAttempt) <= 1) { + return false; + } + if (playerBeforeAttempt != null) { + edgeAttemptPosByKeyThisPass.put(edgeKey, playerBeforeAttempt); + } else { + edgeAttemptPosByKeyThisPass.remove(edgeKey); + } + return true; + } + + /** Hands the budget back when no interaction happened — a later resolver may try the edge this pass. */ + public void releaseEdgeThisPass(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + edgeAttemptPosByKeyThisPass.remove(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp)); + } + } + + /** Starts the door settle window, remembering the far-side tile so it can end when the edge opens. */ + public void markSettling(WorldPoint farSideWp, long nowMs, long settleMs) { + settleStartedAtMs = nowMs; + settleUntilMs = nowMs + settleMs; + settleFarSideWp = farSideWp; + } + + public long settleStartedAtMs() { + return settleStartedAtMs; + } + + public long settleUntilMs() { + return settleUntilMs; + } + + public WorldPoint settleFarSide() { + return settleFarSideWp; + } + + /** The far side proved reachable: the edge is open, there is nothing left to settle. */ + public void endSettleEarly() { + settleUntilMs = 0L; + settleFarSideWp = null; + } + + public void markGlobalCooldownUntil(long untilMs) { + globalCooldownUntilMs = untilMs; + } + + public long globalCooldownUntilMs() { + return globalCooldownUntilMs; + } + + /** The raw scene scan commits to one door index for a bounded number of attempts. */ + public void setRawScanFocus(int index, long nowMs) { + rawScanFocusDoorIdx = index; + rawScanFocusSetAtMs = nowMs; + rawScanFocusAttempts = 0; + } + + public Integer rawScanFocusDoorIdx() { + return rawScanFocusDoorIdx; + } + + public long rawScanFocusSetAtMs() { + return rawScanFocusSetAtMs; + } + + public int rawScanFocusAttempts() { + return rawScanFocusAttempts; + } + + public void recordRawScanFocusAttempt() { + rawScanFocusAttempts++; + } + + public void clearRawScanFocus() { + rawScanFocusDoorIdx = null; + rawScanFocusSetAtMs = 0L; + rawScanFocusAttempts = 0; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java index 8545312f3b1..4e10b73edb6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java @@ -17,7 +17,7 @@ public final class Rs2DoorClassifier { private static final String[] DOOR_LIKE_NAME_FRAGMENTS = { - "door", "gate", "barrier", "stile", "portcullis", "archway", "cattlegate", "fence" + "door", "gate", "barrier", "stile", "portcullis", "archway", "cattlegate", "fence", "curtain" }; /** {@code fence} must be whole-word — substring matches {@code defence} ("fence" inside) otherwise. */ @@ -29,9 +29,44 @@ public final class Rs2DoorClassifier { "push", "climb-over", "climb-through", "squeeze-through", "cross", "force", "exit" ); + /** + * Actions that carry the player ACROSS the obstacle rather than opening an edge in it. + * + *

The distinction decides who owns the crossing. The door cascade's completion contract is + * "the blocked edge became passable" — it clicks, then waits for the edge to open. A stile never + * opens: you climb over it and end up on the far side, so that wait can only ever time out. + * + *

Measured near Ardougne: a Stile at (2637,3350) with action Climb-over classified as a door + * on its name, was taken by the door cascade, logged {@code door_edge_post_unresolved}, and cost + * twenty seconds of refused clicks, a recovery wander and a replan before the transport handler + * finally crossed it in one action. See {@code walker-transport-doors}: moves-you obstacles are + * their own class and belong to the transport handler. + */ + private static final List MOVES_YOU_ACTIONS = List.of( + "climb-over", "climb-through", "squeeze-through", "cross" + ); + private Rs2DoorClassifier() { } + /** + * Whether {@code action} moves the player across the obstacle instead of opening it. + * + * @see #MOVES_YOU_ACTIONS + */ + public static boolean isMovesYouAction(String action) { + if (action == null) { + return false; + } + String al = action.toLowerCase(Locale.ROOT).trim(); + for (String movesYou : MOVES_YOU_ACTIONS) { + if (al.startsWith(movesYou)) { + return true; + } + } + return false; + } + public static boolean isNullOrPlaceholderObjectName(String name) { if (name == null) { return true; @@ -112,6 +147,48 @@ public static boolean isDoorLikeGameObjectName(String name) { return false; } + /** + * Actions whose VERB alone proves traversal — a chest never says Walk-through. Deliberately + * excludes open/enter/push/force/exit, which scenery shares (Open on a chest, Enter on a cave). + */ + private static final List TRAVERSAL_PROOF_ACTIONS = List.of( + "pay-toll", "pick-lock", "walk-through", "go-through", "pass" + ); + + public static boolean isTraversalProofAction(String action) { + if (action == null) { + return false; + } + String al = action.toLowerCase(Locale.ROOT).trim(); + for (String t : TRAVERSAL_PROOF_ACTIONS) { + if (al.startsWith(t)) { + return true; + } + } + return false; + } + + /** + * Route-door classification — the decide table's first column (D3 requirement #3). + * + *

WALL objects: any walk action proves doorhood — a wall that opens is a door, whatever its + * name (unchanged semantics). + * + *

GAME objects: the NAME must prove doorhood, or the ACTION must be traversal-proof. Bare + * Open/Enter/Push on a non-door name is scenery: the Gift of Peace chest (Stronghold, + * 2026-08-13) was Open-clicked as a route door en route, costing 7-9s of failed traversal per + * encounter — and any Open-actioned coffin, cupboard or sarcophagus on a route segment would do + * the same. Large double gates ARE GameObjects, which is why the rule is name-or-verb rather + * than a flat name filter. + */ + public static boolean isRouteDoorObject(boolean wallObject, String name, String walkAction) { + if (wallObject) { + return isDoorLikeGameObjectName(name) + || (walkAction != null && doorActionPriorityIndex(walkAction) < Integer.MAX_VALUE); + } + return isDoorLikeGameObjectName(name) || isTraversalProofAction(walkAction); + } + /** Whether a (real, non-impostor) composition exposes one of {@code doorActions}. */ public static boolean isDoorComposition(ObjectComposition comp, List doorActions) { if (comp == null || comp.getImpostorIds() != null || isNullOrPlaceholderObjectName(comp.getName()) || comp.getActions() == null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java index 6e2df08e534..d8c9d945998 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java @@ -42,7 +42,7 @@ public static boolean isDoorLikeSceneObject(TileObject object) { return false; } String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof net.runelite.api.WallObject, + comp.getName(), action); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java index 89b727accd7..d78e28c16d6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java @@ -19,6 +19,73 @@ public static boolean isDoorOnSegment(TileObject object, WorldPoint fromWp, Worl return isDoorOnSegment(object, object == null ? null : object.getWorldLocation(), fromWp, toWp); } + /** + * Whether {@code at} lies at or beyond the far side of the cardinal {@code from -> to} door edge, + * measured along the edge's own axis. This is the unambiguous "we are past the door" reading — + * unlike near-side proximity, which fires while still approaching. Door edges are cardinal; + * anything else answers false. + */ + public static boolean crossedDoorAxis(WorldPoint from, WorldPoint to, WorldPoint at) { + if (from == null || to == null || at == null + || from.getPlane() != to.getPlane() || at.getPlane() != to.getPlane()) { + return false; + } + int dx = to.getX() - from.getX(); + int dy = to.getY() - from.getY(); + if (dx != 0 && dy == 0) { + int travelled = at.getX() - from.getX(); + return dx > 0 ? travelled >= 1 : travelled <= -1; + } + if (dy != 0 && dx == 0) { + int travelled = at.getY() - from.getY(); + return dy > 0 ? travelled >= 1 : travelled <= -1; + } + return false; + } + + /** + * Whether {@code at} already stands on the FAR side of this wall door's face relative to + * {@code from} — the crossing the door exists to produce has happened, so clicking it again can + * only carry the player backward. + * + *

Anchored to the wall's own face rather than the route segment, unlike + * {@link #crossedDoorAxis}, which is cardinal-only and reads the segment. Both properties + * mattered at the Stronghold of Security's paired Gates of War (2026-08-12): the route step + * (1886,5244)->(1887,5243) was DIAGONAL, and the moves-you gate deposited the player at + * (1887,5244) — a tile off the planned to-tile — so the segment-based reading answered false + * while the raw scan's backtrack window kept re-finding the gate; each re-click carried the + * player back through it, a two-sided bounce every ~6 seconds. + * + *

Corner walls (orientation 16..128) answer false: their face does not divide the plane + * along a single axis. + * + * @param orientationA the wall's {@code getOrientationA()}: 1=west, 2=north, 4=east, 8=south + */ + public static boolean playerBeyondWallFace(int orientationA, WorldPoint wallTile, + WorldPoint from, WorldPoint at) { + if (wallTile == null || from == null || at == null + || wallTile.getPlane() != from.getPlane() || at.getPlane() != from.getPlane()) { + return false; + } + switch (orientationA) { + case 1: // west face: boundary between x = wallTile.x-1 and x = wallTile.x + return sidesDiffer(from.getX(), at.getX(), wallTile.getX()); + case 4: // east face: boundary between x = wallTile.x and x = wallTile.x+1 + return sidesDiffer(from.getX(), at.getX(), wallTile.getX() + 1); + case 2: // north face: boundary between y = wallTile.y and y = wallTile.y+1 + return sidesDiffer(from.getY(), at.getY(), wallTile.getY() + 1); + case 8: // south face: boundary between y = wallTile.y-1 and y = wallTile.y + return sidesDiffer(from.getY(), at.getY(), wallTile.getY()); + default: + return false; + } + } + + /** Opposite sides of the boundary that lies just before {@code boundary} ({@code >=} vs {@code <}). */ + private static boolean sidesDiffer(int a, int b, int boundary) { + return (a >= boundary) != (b >= boundary); + } + /** As above, with the object's location supplied (see {@link #wallDoorTouchesSegment}). */ public static boolean isDoorOnSegment(TileObject object, WorldPoint objectLocation, WorldPoint fromWp, WorldPoint toWp) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java index 4357448c64d..8b13f757ac0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -16,49 +16,27 @@ public static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, Worl return compactWorldPoint(doorTile) + "|" + compactWorldPoint(fromWp) + "->" + compactWorldPoint(toWp); } - public static boolean shouldThrottleDoorAttempt(Map recentDoorAttemptByEdge, - long cooldownMs, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - String key = doorAttemptKey(doorTile, fromWp, toWp); - long now = System.currentTimeMillis(); - recentDoorAttemptByEdge.entrySet().removeIf(entry -> now - entry.getValue() > cooldownMs); - Long last = recentDoorAttemptByEdge.get(key); - return last != null && now - last < cooldownMs; - } - - public static void markDoorAttempt(Map recentDoorAttemptByEdge, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - recentDoorAttemptByEdge.put(doorAttemptKey(doorTile, fromWp, toWp), System.currentTimeMillis()); - } - - public static void markStationaryDoorOpened(Map recentlyOpenedStationaryDoors, WorldPoint doorTile) { - if (doorTile != null) { - recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); - } + public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { + return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; } - public static boolean recentlyOpenedStationaryDoorOnSegment(Map recentlyOpenedStationaryDoors, - long suppressMs, - WorldPoint fromWp, - WorldPoint toWp) { - if (fromWp == null || toWp == null) { - return false; + /** + * Edge-scoped variant. The full window is anti-hammer for ONE door — re-clicking the same edge + * before the world has caught up. A DIFFERENT door immediately after a successful open is not + * hammering, it is chaining, and holding it for the full window serialised every pair of nearby + * doors. A different edge owes only the cross-edge floor (one game tick): enough that two clicks + * cannot land inside the same tick, no more. + * + * @param fullCooldownMs the window {@code nextAllowedAtMs} was stamped with + * @param crossEdgeCooldownMs the floor a different edge still owes + */ + public static boolean shouldThrottleGlobalDoorInteraction(long nowMs, long nextAllowedAtMs, + boolean sameEdgeAsLastAttempt, + long fullCooldownMs, long crossEdgeCooldownMs) { + if (sameEdgeAsLastAttempt) { + return nowMs < nextAllowedAtMs; } - final int segmentDoorSuppressDist = 2; - long now = System.currentTimeMillis(); - recentlyOpenedStationaryDoors.entrySet().removeIf(entry -> now - entry.getValue() > suppressMs); - return recentlyOpenedStationaryDoors.keySet().stream() - .anyMatch(door -> door != null - && door.getPlane() == fromWp.getPlane() - && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); - } - - public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { - return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; + return nowMs < nextAllowedAtMs - (fullCooldownMs - crossEdgeCooldownMs); } public static long markGlobalDoorInteractionCooldown(long cooldownMs) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index 5191cadb7df..8796b768a6b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java @@ -11,11 +11,11 @@ import net.runelite.api.TileObject; import net.runelite.api.WallObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; /** * Door-probe logic that operates against a {@link DoorProbeContext} (the scan-scoped caches) and @@ -47,7 +47,7 @@ public static ObjectComposition resolveDoorComposition(DoorProbeContext ctx, Til /** * True when this scene object is the interactable listed on a transport catalog row (same - * coordinates and object ids as TSV loaded into {@link Rs2PathApi#getTransports()}), and is not + * coordinates and object ids as TSV loaded into the shortest-path catalog), and is not * itself door-like. Used to avoid treating a catalog transport as a plain door. */ public static boolean isCatalogTransportObject(TileObject object) { @@ -62,18 +62,10 @@ public static boolean isCatalogTransportObject(TileObject object) { if (id <= 0) { return false; } - Map> map = Rs2PathApi.getTransports(); - if (map == null || map.isEmpty()) { - return false; - } - for (int dx = -1; dx <= 1; dx++) { + for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { WorldPoint catalogOrigin = new WorldPoint(loc.getX() + dx, loc.getY() + dy, loc.getPlane()); - Set transports = map.get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { if (t != null && t.getObjectId() == id && !isDoorLikeCatalogTransport(t)) { return true; } @@ -83,11 +75,18 @@ public static boolean isCatalogTransportObject(TileObject object) { return false; } - public static boolean isDoorLikeCatalogTransport(Transport transport) { - if (transport == null || transport.getType() != TransportType.TRANSPORT) { + public static boolean isDoorLikeCatalogTransport(Rs2TransportEdge transport) { + if (transport == null || transport.getType() != Rs2TransportType.TRANSPORT) { return false; } - return Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getName()) + // The ACTION wins over the name. A stile is named door-like and a fence gap is not named at + // all, but both are crossed by moving through them, and the door cascade can only wait for an + // edge to open — a wait a moves-you obstacle can never satisfy. Deciding on the name alone is + // what handed a Climb-over stile to the door handler and cost twenty seconds per crossing. + if (Rs2DoorClassifier.isMovesYouAction(transport.getAction())) { + return false; + } + return Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getTarget()) || Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getDisplayInfo()) || isDoorLikeTransportAction(transport.getAction()); } @@ -100,7 +99,7 @@ private static boolean isDoorLikeTransportAction(String action) { } /** Whether {@code object} (at {@code objectLocation}) is a walk-through door lying on the segment. */ - public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set blacklist, + public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, DoorAttemptLedger ledger, TileObject object, WorldPoint objectLocation, WorldPoint playerLoc, WorldPoint fromWp, WorldPoint toWp, List doorActions, int searchDistance) { @@ -112,7 +111,7 @@ public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set searchDistance - || blacklist.contains(loc) + || ledger.isDoorBlacklisted(loc) || (!(object instanceof WallObject) && !(object instanceof GameObject))) { return false; } @@ -124,7 +123,13 @@ public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set toWp} segment, using scan snapshots when present. */ - public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set blacklist, - Map recentlyOpened, long stationaryDoorSuppressMs, + public static TileObject findDoorNearSegment(DoorProbeContext ctx, DoorAttemptLedger ledger, + long stationaryDoorSuppressMs, WorldPoint fromWp, WorldPoint toWp, List doorActions) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null || fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return null; } - if (Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment(recentlyOpened, stationaryDoorSuppressMs, fromWp, toWp)) { + if (ledger.recentlyOpenedDoorOnSegment(fromWp, toWp, stationaryDoorSuppressMs, System.currentTimeMillis())) { return null; } @@ -181,7 +186,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, locations.get(o), + .filter(o -> isDoorCandidateOnSegment(ctx, ledger, o, locations.get(o), playerLoc, fromWp, toWp, doorActions, searchDistance)) .min(Comparator.comparingInt(o -> locations.get(o).distanceTo2D(playerLoc))) .orElse(null); @@ -190,7 +195,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, o.getWorldLocation(), + return Rs2GameObject.getAll(o -> isDoorCandidateOnSegment(ctx, ledger, o, o.getWorldLocation(), playerLoc, fromWp, toWp, doorActions, searchDistance), playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 17feaa108c8..0eb376b4bb2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -18,6 +18,39 @@ public final class Rs2WalkerAwaits { private static final long DOOR_IDLE_ACCEPT_MIN_MS = 1_200L; /** Above this combined wait, say which condition released the door await. */ private static final long DOOR_AWAIT_SLOW_LOG_MS = 900L; + /** + * An unlocked door opens within one game tick of the click landing, so there is nothing to observe + * before then and polling earlier only spends client-thread time. Checked on an interval rather + * than every poll because the observation is a scene scan (~60ms measured), not a field read. + */ + private static final long DOOR_OPEN_POLL_START_MS = 250L; + private static final long DOOR_OPEN_POLL_INTERVAL_MS = 250L; + /** + * A click issued from further than the legacy dispatch band is a RANGED click: the server has to + * walk us to the door before anything can open, so the wait is an approach, not a traversal. + */ + static final int RANGED_CLICK_MIN_TILES = 3; + /** Walking pace is one tile per 0.6s; running arrives sooner and releases early via the edge read. */ + private static final long APPROACH_MS_PER_TILE = 600L; + /** Hard ceiling on any door await. The stall release keeps long budgets from ever stranding us. */ + private static final long DOOR_TRAVERSAL_MAX_BUDGET_MS = 8_000L; + + /** + * How long the traversal phase may hold, given how far from the door the click was issued. + *

+ * The flat 2200ms cap was sized for adjacent clicks — walk a step, door opens, step through. A + * ranged click spends its first seconds being WALKED to the door by the server, so the flat cap + * expired mid-approach: the wait released by timeout, the recovery machinery got its window (the + * competing-clicks race), and the door was then handled a second time from adjacent. Measured as + * two full interactions per ranged door. + */ + static long traversalBudgetMs(int clickDistanceTiles) { + if (clickDistanceTiles < RANGED_CLICK_MIN_TILES) { + return DOOR_TRAVERSAL_PROGRESS_WAIT_MS; + } + return Math.min(DOOR_TRAVERSAL_PROGRESS_WAIT_MS + (clickDistanceTiles - 2) * APPROACH_MS_PER_TILE, + DOOR_TRAVERSAL_MAX_BUDGET_MS); + } private Rs2WalkerAwaits() { } @@ -38,6 +71,55 @@ private static boolean conversationOpened() { } public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, null); + } + + /** + * @param doorOpened observes the DOOR (its "Open" action is gone), as opposed to every other + * release condition here, which observes the PLAYER. May be {@code null}. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, null); + } + + /** + * @param doorObservation describes what the door observation last SAW, carried onto the slow log. + * Two live runs failed to explain why {@code door-opened} never fires, and + * "the poll ran and said no" is not an explanation without the reading + * behind it. Evaluated once, only when the log is about to print. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, doorObservation, null); + } + + /** + * @param cancelled the walk this door belongs to was cancelled or re-targeted; holding an await + * for a route that no longer exists serves nobody. Matters now that ranged + * budgets can reach seconds where the flat cap bounded the stale hold at 2.2s. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation, + java.util.function.BooleanSupplier cancelled) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, doorObservation, cancelled, null); + } + + /** + * @param doorCrossed observes the WALL FACE: the player already stands on the far side of the + * door's own face relative to the approach tile. The one reading that stays + * true when a moves-you gate deposits the player DIAGONALLY off the planned + * to-tile — where arrived-far-side and crossedDoorAxis both go blind (the + * attempt edge itself can be diagonal, and the deposit tile is not toWp). + * Cheap per poll; may be {@code null} when the door is not a wall object. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation, + java.util.function.BooleanSupplier cancelled, + java.util.function.BooleanSupplier doorCrossed) { if (ticket == null) { return; } @@ -61,45 +143,127 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // "doors feel slow" into a specific target — the same play that took the transport problem // from four rounds of guessing to a one-shot fix. final String[] releasedBy = {"timeout"}; + final long[] lastOpenPollAt = {0L}; + // Carried into the slow log: a release that is NOT door-opened is ambiguous between "the + // observation never ran" and "it ran and the door was shut", and the first live run could not + // tell those apart. The count settles it without another round trip. + final int[] openPolls = {0}; + final String[] lastEdge = {"-"}; + final int[] totalPolls = {0}; + final int[] movingPolls = {0}; + final int[] animatingPolls = {0}; + + // A ranged click is an APPROACH, not a traversal: the server walks us to the door, the door + // opens on arrival (its 0-1 tick is measured from the interaction, not from the click), and + // only then is there anything to traverse. Live edge= data proved every "still shut" reading + // during the walk was CORRECT — the door genuinely is shut until we get there. The positional + // conditions are therefore wrong for this phase: "progress" fired at Chebyshev 2 mid-approach + // and "edge-resolved" fires on reaching the near side, both before the door opened, so every + // ranged door failed verification and was interacted twice. While approaching, a ranged wait + // holds until a DOOR outcome (edge open / opening action gone / conversation) or a stall. + final int clickDistance = ticket.beforePosition() == null || fromWp == null + || ticket.beforePosition().getPlane() != fromWp.getPlane() + ? 0 + : ticket.beforePosition().distanceTo2D(fromWp); + final boolean ranged = clickDistance >= RANGED_CLICK_MIN_TILES; + long traversalPhaseAt = System.currentTimeMillis(); sleepUntil(() -> { if (Thread.currentThread().isInterrupted() || conversationOpened()) { releasedBy[0] = "conversation-or-interrupt"; return true; } + if (cancelled != null && cancelled.getAsBoolean()) { + releasedBy[0] = "cancelled-or-replanned"; + return true; + } WorldPoint now = Rs2Player.getWorldLocation(); if (now == null) { return false; } - boolean edgeResolved = isDoorEdgeResolved(fromWp, toWp); + boolean edgeResolved = !ranged && isDoorEdgeResolved(fromWp, toWp); if (edgeResolved) { releasedBy[0] = "edge-resolved"; return true; } - if (Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { + if (doorCrossed != null && doorCrossed.getAsBoolean()) { + releasedBy[0] = "crossed-face"; + return true; + } + // The one positional reading a ranged hold may trust: we are ON the far side, or past the + // door along its own axis. Near-side proximity stays disabled for ranged clicks — that was + // the premature release — but "past" is unambiguous, and it is how a hold ends when the + // server walks us to the far side through another opening without the door ever needing to + // open. Measured as a 6.9s ranged timeout with the player standing on toWp, door shut. + if (ranged && (now.equals(toWp) || Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, now))) { + releasedBy[0] = "passed-door"; + return true; + } + // The door observations. The collision edge is authoritative — the client's flags are + // server-driven, so an opened door clears its block on that tick, whatever its menu says. + // Throttled because the fallback is a scene scan, not a field read. + long nowMs = System.currentTimeMillis(); + if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { + lastOpenPollAt[0] = nowMs; + openPolls[0]++; + boolean edgeOpen = Rs2Tile.isEdgePassable(fromWp, toWp); + // Captured HERE, not when the log prints: the previous diagnostic read the door after + // the wait had already released and reported the state at the wrong instant. + lastEdge[0] = Rs2Tile.lastEdgeDecision(); + if (edgeOpen) { + releasedBy[0] = "door-edge-open"; + return true; + } + // A "blocked" edge reading is definitive — the door is shut — so the scene-scan + // fallback only runs when the edge could not be decided (instance, off-scene, ...). + if (!"blocked".equals(lastEdge[0]) + && doorOpened != null && doorOpened.getAsBoolean()) { + releasedBy[0] = "door-opened"; + return true; + } + } + if (!ranged && Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { releasedBy[0] = "arrived-far-side"; return true; } - if (hasMeaningfulDoorProgress(ticket.beforePosition(), now, fromWp, toWp)) { + if (!ranged && hasMeaningfulDoorProgress(ticket.beforePosition(), now, fromWp, toWp)) { releasedBy[0] = "progress"; return true; } long elapsedMs = System.currentTimeMillis() - ticket.startedAtMs(); - boolean idleAccepted = shouldAcceptIdleDoorAwait( - Rs2Player.isMoving(), - Rs2Player.isAnimating(), - elapsedMs, - edgeResolved); + // Counted so a timeout can say why the stall release never fired — "idle-accept was + // silent" is ambiguous between the player walking the whole budget (correct silence) + // and the pose-based isMoving trap (a bug). The tally answers it from one log line. + boolean moving = Rs2Player.isMoving(); + boolean animating = Rs2Player.isAnimating(); + totalPolls[0]++; + if (moving) { + movingPolls[0]++; + } + if (animating) { + animatingPolls[0]++; + } + boolean idleAccepted = shouldAcceptIdleDoorAwait(moving, animating, elapsedMs, edgeResolved); if (idleAccepted) { releasedBy[0] = "idle-accepted"; } return idleAccepted; - }, DOOR_TRAVERSAL_PROGRESS_WAIT_MS); + }, (int) traversalBudgetMs(clickDistance)); long traversalWaitMs = System.currentTimeMillis() - traversalPhaseAt; if (startWaitMs + traversalWaitMs >= DOOR_AWAIT_SLOW_LOG_MS) { - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, fromWp, toWp); + String saw = "-"; + if (doorObservation != null && !"door-opened".equals(releasedBy[0])) { + try { + String detail = doorObservation.get(); + saw = detail == null ? "-" : detail; + } catch (RuntimeException ignored) { + saw = "error"; + } + } + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} clickDist={} ranged={} openPolls={} edge={} polls={} movingPolls={} animPolls={} saw={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, clickDistance, ranged, openPolls[0], lastEdge[0], + totalPolls[0], movingPolls[0], animatingPolls[0], saw, fromWp, toWp); } } @@ -119,6 +283,20 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f * {@code edgeResolved} is retained in the signature because callers pass their own observation * and it keeps the decision table explicit about the case that used to be the only one accepted. */ + /** + * Whether to spend a door-open observation on this poll. + *

+ * Two rules, both about cost rather than correctness. An unlocked door opens within one game tick + * of the click landing, so an observation before {@link #DOOR_OPEN_POLL_START_MS} can only ever + * report "still shut" and is pure waste. And the observation is a scene scan (~60ms measured), not + * a field read, so at the poll rate of the surrounding wait it would otherwise run several times a + * second for the whole budget — the cost that made door handling expensive in the first place. + */ + static boolean shouldPollDoorOpen(long sinceTraversalStartMs, long sinceLastPollMs) { + return sinceTraversalStartMs >= DOOR_OPEN_POLL_START_MS + && sinceLastPollMs >= DOOR_OPEN_POLL_INTERVAL_MS; + } + @SuppressWarnings("unused") static boolean shouldAcceptIdleDoorAwait(boolean moving, boolean animating, long elapsedMs, boolean edgeResolved) { if (moving || animating) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java index 4ecbf8588d3..65bedbdd767 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java @@ -1,22 +1,21 @@ package net.runelite.client.plugins.microbot.util.walker.lifecycle; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.Player; import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowContext; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; +import java.util.Objects; @Slf4j public final class Rs2WalkerLifecycleRuntime { @@ -25,6 +24,24 @@ private Rs2WalkerLifecycleRuntime() { } public static void applyWalkerDestination(WorldPoint target) { + applyWalkerDestination(target, false); + } + + /** Apply a destination while retaining whether the request was a recovery/replan. */ + public static void applyWalkerDestination(WorldPoint target, boolean replan) { + applyWalkerDestination(target, replan + ? Rs2PlannerShadowContext.Invocation.ACTIVE_REPLAN + : Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } + + /** Apply a destination with explicit, evidence-only invocation classification. */ + public static void applyWalkerDestination( + WorldPoint target, + Rs2PlannerShadowContext.Invocation invocation) { + Objects.requireNonNull(invocation, "invocation"); + if (invocation == Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY) { + throw new IllegalArgumentException("active destination cannot be a synchronous query"); + } if (target == null) { return; } @@ -74,12 +91,12 @@ public static void applyWalkerDestination(WorldPoint target) { } return Rs2Player.getWorldLocation(); }); - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - final WorldPoint effectiveStart = (Rs2PathApi.isStartPointSet() && pathfinder != null) - ? pathfinder.getStart() + final WorldPoint effectiveStart = Rs2PathApi.isStartPointSet() + ? Rs2PathApi.getActiveRouteStart().orElse(start) : start; Rs2PathApi.setLastLocation(effectiveStart); - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(effectiveStart, target)); + Microbot.getClientThread().runOnSeperateThread( + () -> restartPathfinding(effectiveStart, Set.of(target), invocation)); } public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { @@ -87,56 +104,25 @@ public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { } public static boolean restartPathfinding(WorldPoint start, Set ends) { - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null) { - pathfinder.cancel(); - if (Rs2PathApi.getPathfinderFuture() != null) { - Rs2PathApi.getPathfinderFuture().cancel(true); - } - } + return restartPathfinding( + start, ends, Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } - if (Rs2PathApi.getPathfindingExecutor() == null) { - ThreadFactory shortestPathNaming = new ThreadFactoryBuilder().setNameFormat("shortest-path-%d").build(); - Rs2PathApi.setPathfindingExecutor(Executors.newSingleThreadExecutor(shortestPathNaming)); + private static boolean restartPathfinding( + WorldPoint start, + Set ends, + Rs2PlannerShadowContext.Invocation invocation) { + if (start == null || ends == null || ends.isEmpty()) { + return false; } - WorldPoint refreshTarget = ends != null && !ends.isEmpty() ? ends.iterator().next() : null; - Rs2PathApi.getPathfinderConfig().refresh(refreshTarget); - if (Rs2Player.isInCave()) { - // Cave pathfinding runs synchronously, so no Future represents the pathfinder installed below. - // Clear the cancelled asynchronous handle instead of leaving stale "work in flight" state. - Rs2PathApi.setPathfinderFuture(null); - pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends); - pathfinder.run(); - try { - Rs2PathApi.getPathfinderConfig().setIgnoreTeleportAndItems(true); - Pathfinder pathfinderWithoutTeleports = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends); - pathfinderWithoutTeleports.run(); - - boolean noTeleportPathAvailable = !pathfinderWithoutTeleports.getPath().isEmpty(); - boolean basePathAvailable = pathfinder != null && !pathfinder.getPath().isEmpty(); - if (!noTeleportPathAvailable) { - Rs2PathApi.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); - return true; - } - - WorldPoint lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size() - 1); - int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; - boolean pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= reachedDistance; - if (pathWithoutTeleportsIsReachable - && basePathAvailable - && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { - Rs2PathApi.setPathfinder(pathfinderWithoutTeleports); - } else { - Rs2PathApi.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); - } - } finally { - Rs2PathApi.getPathfinderConfig().setIgnoreTeleportAndItems(false); - } - } else { - Rs2PathApi.setPathfinder(new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends)); - Rs2PathApi.setPathfinderFuture(Rs2PathApi.getPathfindingExecutor().submit(Rs2PathApi.getPathfinder())); - } - return true; + int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; + return Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.toAny(start, ends) + .withRefreshTarget(refreshTarget) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.ALWAYS), + Rs2Player.isInCave(), + reachedDistance, + invocation); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java index 84a2cf4d99e..abf4ce9ebda 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java @@ -2,9 +2,6 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; - -import java.util.Set; /** * Read-only snapshot of the live world an {@link ObstacleResolver} needs to classify a {@link PlannedEdge}, @@ -21,8 +18,8 @@ public interface LiveScene { /** Whether {@code tile} is walk-reachable from the player right now (live-scene collision BFS). */ boolean isReachable(WorldPoint tile); - /** Transports whose origin is {@code tile} (stairs/ladders/shortcuts/teleports), or empty. */ - Set transportsAt(WorldPoint tile); + /** Whether a transport starts at {@code tile} (stairs/ladders/shortcuts/teleports). */ + boolean hasTransportAt(WorldPoint tile); /** The top interactable object on {@code tile} (door/gate/rockfall/…), or {@code null} if none. */ TileObject objectAt(WorldPoint tile); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java index b852876a287..cab98651f4d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java @@ -2,13 +2,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; -import java.util.Collections; import java.util.Map; -import java.util.Set; /** * Live-client implementation of {@link LiveScene} — the read side of the P2 obstacle plumbing @@ -39,14 +36,9 @@ public boolean isReachable(WorldPoint tile) { } @Override - public Set transportsAt(WorldPoint tile) { - final Map> transports = Rs2PathApi.getTransports(); - if (transports == null) { - return Collections.emptySet(); - } - final Set at = transports.get(tile); - return at == null ? Collections.emptySet() : at; - } + public boolean hasTransportAt(WorldPoint tile) { + return Rs2PathApi.hasCatalogTransportOrigin(tile); + } @Override public TileObject objectAt(WorldPoint tile) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java index 5da72edd3b1..8a060b96291 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java @@ -70,7 +70,7 @@ public static RockfallResult handleRockfall(List path, int index) { if (path == null || path.isEmpty() || index < 0 || index >= path.size()) { return RockfallResult.NOT_APPLICABLE; } - if (Rs2PathApi.getPathfinder() == null) return RockfallResult.NOT_APPLICABLE; + if (!Rs2PathApi.getActiveRouteStatus().isPresent()) return RockfallResult.NOT_APPLICABLE; if (index == path.size() - 1) return RockfallResult.NOT_APPLICABLE; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java index 2448abe27c9..c3f7efcf1a6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java @@ -1,9 +1,6 @@ package net.runelite.client.plugins.microbot.util.walker.obstacle; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; - -import java.util.Set; /** * Resolves a planned edge blocked because its far side is across a transport / agility shortcut (a stepping @@ -23,10 +20,9 @@ public boolean handles(PlannedEdge edge, LiveScene scene) { return false; } final WorldPoint origin = edge.from(); - final Set transports = scene.transportsAt(origin); - if (transports == null || transports.isEmpty()) { - return false; - } + if (!scene.hasTransportAt(origin)) { + return false; + } final WorldPoint player = scene.playerLocation(); // Applies only when the player is off the origin but can still reach it: then stepping onto it lets // the normal loop take the transport. When already on the origin, that loop owns it (not recovery); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java new file mode 100644 index 00000000000..a6abc0adc12 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java @@ -0,0 +1,522 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +import java.util.List; +import java.util.Map; + +/** + * The blocked-frontier cascade's pure decisions: WHERE the route is actually blocked, and which raw + * edge that frontier corresponds to. + * + *

Functional core of the recovery cascade — the same split {@link RouteRecovery} and + * {@code segment.SegmentGate} already use. The caller keeps every interaction (waiting on doors, + * mining, clicking); this only answers questions about the route and the reachable set, so the + * answers can be pinned in a decision table instead of rediscovered on a live walk. + */ +public final class FrontierDecision +{ + /** No route tile before the miss is blocked. */ + public static final int NO_EARLIER_BLOCKED_INDEX = -1; + + private FrontierDecision() + { + } + + /** + * The recovery scan anchor: the first route index whose raw mapping is at or past the player's + * own raw position. Route tiles mapped BEHIND the player are spent — the walk never needs to + * stand on them again — and recovery must not chase them. + * + *

The smoothed closest index cannot express "one raw tile past a door". At the Stronghold of + * Security's paired gates (2026-08-12) a moves-you gate carried the player one raw tile through; + * the next smoothed point was nine tiles out, so the closest smoothed index stayed on the + * near-side start tile, which now read unreachable through the auto-closed gate. Recovery chased + * it, clicked the same gate from the far side, and the gate carried the player straight back — + * a two-sided bounce that repeated every ~6 seconds for five minutes. + * + *

Unmapped entries ({@code smoothedToRaw[i] < 0}) stop the advance: no evidence of "behind" + * must not read as "spent". + */ + public static int forwardScanStartIndex(int[] smoothedToRaw, int startIndex, int playerRawIdx) + { + if (smoothedToRaw == null || startIndex < 0 || startIndex >= smoothedToRaw.length + || playerRawIdx <= 0) + { + return startIndex; + } + int index = startIndex; + while (index < smoothedToRaw.length - 1 + && smoothedToRaw[index] >= 0 + && smoothedToRaw[index] < playerRawIdx) + { + index++; + } + return index; + } + + /** + * The earliest route tile at or after {@code fromIndex} and before {@code missIndex} that the + * player cannot reach, or {@link #NO_EARLIER_BLOCKED_INDEX}. + * + *

Anti-end-camping rewind. The near-player reachability check skips far-away route tiles, so + * on a route whose tail folds back beside the player — Clock Tower — the miss fires on the GOAL + * (Euclidean-near, index at the end) while the REAL blocked frontier, the door tiles at + * mid-route, was never examined. Recovery then camps on the end: door scans probe the wrong raw + * segment and the recovery target anchors at the goal. The earliest unreachable tile is the first + * edge the walk genuinely cannot cross, which is where the obstacle really is. + * + *

Tiles on another plane are skipped rather than treated as blocked: a route that climbs a + * staircase legitimately contains tiles the player's plane cannot reach, and rewinding onto one + * would send recovery at a staircase that is working. + * + * @param reachable player-origin reachability; {@code null} disables the rewind entirely, because + * "no evidence" must not read as "everything is blocked" + */ + public static int earliestBlockedIndex(List path, + int fromIndex, + int missIndex, + int playerPlane, + Map reachable) + { + if (path == null || reachable == null) + { + return NO_EARLIER_BLOCKED_INDEX; + } + for (int index = Math.max(0, fromIndex); index < missIndex && index < path.size(); index++) + { + WorldPoint tile = path.get(index); + if (tile != null + && tile.getPlane() == playerPlane + && !reachable.containsKey(tile)) + { + return index; + } + } + return NO_EARLIER_BLOCKED_INDEX; + } + + /** + * Whether an unreachable route tile is too far ahead for fresh evidence to matter this pass. + * + *

Every tile past a closed frontier is unreachable, so on a gated route the unreachable + * branch runs for the WHOLE forward tail — dozens of tiles per pass, each paying the loop + * snapshot's client-thread hops and, whenever the player has moved since the last capture, a + * full-radius reachability BFS. Fresh evidence has exactly one consumer, the local-recovery + * branch, and that is gated to {@code nearGate}; a tile that no plausible position drift could + * bring inside that gate needs no fresh reads at all. Measured 2026-08-14 (Lumbridge→Varlamore): + * ~45s of a 288s walk stood still at obstacles, most of it in these hops — recaptures fired for + * tiles on the far side of an NPC travel. + * + *

{@code lastKnownPlayerLoc} is the pass-stale snapshot position, so {@code stalenessMargin} + * covers ground run since it was read: borderline tiles still take the fresh-capture path, and a + * null position (no snapshot yet) never skips. {@code distanceTo2D} ignores plane, which errs + * safe — an overhead tile reads as near and takes the fresh path. + */ + public static boolean shouldSkipFarUnreachableTile(WorldPoint tile, + WorldPoint lastKnownPlayerLoc, int nearGate, int stalenessMargin) + { + return tile != null && lastKnownPlayerLoc != null + && tile.distanceTo2D(lastKnownPlayerLoc) > nearGate + stalenessMargin; + } + + /** + * What a wait on a recently-attempted door concluded. + * + *

Every outcome but {@link #FALL_THROUGH} ends the pass — the walk goes round again and + * re-derives from wherever the door left the player. + */ + public enum DoorWaitOutcome + { + /** Edge opened and the follow-through click landed. */ + RESOLVED_FAST_CLICK(WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK), + /** Edge opened; no follow-through click was issued. */ + RESOLVED_AFTER_WAIT(WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT), + /** Edge did not open in the budget: go round and try again. */ + WAITING_RETRY(WalkExit.DOOR_EDGE_WAITING_RETRY), + /** A door NEAR this edge opened and the player moved through it. */ + RESOLVED_AFTER_NEARBY_WAIT(WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT), + /** A door near this edge did not open in the budget. */ + NEARBY_WAITING_RETRY(WalkExit.DOOR_EDGE_NEARBY_WAITING_RETRY), + /** + * A door near this edge opened but the player did not move. The wait proved nothing about + * THIS frontier — some other door resolved — so the cascade must carry on to the settle + * checks and the real recovery rather than reporting progress it did not make. + */ + FALL_THROUGH(null); + + private final WalkExit exit; + + DoorWaitOutcome(WalkExit exit) + { + this.exit = exit; + } + + /** The exit to record, or {@code null} for {@link #FALL_THROUGH}. */ + public WalkExit exit() + { + return exit; + } + + /** Whether this outcome ends the pass. */ + public boolean endsPass() + { + return this != FALL_THROUGH; + } + } + + /** + * Whether a follow-through click is worth issuing after a wait on THIS edge. + * + *

Split from {@link #afterEdgeWait} so the caller performs the click only when it is wanted: + * the click is an interaction and cannot live in a pure decision. + */ + public static boolean shouldFastClickAfterEdgeWait(boolean edgeResolved) + { + return edgeResolved; + } + + /** + * As {@link #shouldFastClickAfterEdgeWait}, for a door near — but not on — this edge. + * + *

Movement is required as well as resolution. A nearby door that opened while the player + * stayed put says nothing about the frontier in front of us. + */ + public static boolean shouldFastClickAfterNearbyWait(boolean nearbyResolved, boolean playerMoved) + { + return nearbyResolved && playerMoved; + } + + /** + * @param fastClicked whether the follow-through click landed; must be {@code false} when + * {@link #shouldFastClickAfterEdgeWait} said not to attempt one + */ + public static DoorWaitOutcome afterEdgeWait(boolean edgeResolved, boolean fastClicked) + { + if (!edgeResolved) + { + return DoorWaitOutcome.WAITING_RETRY; + } + return fastClicked ? DoorWaitOutcome.RESOLVED_FAST_CLICK : DoorWaitOutcome.RESOLVED_AFTER_WAIT; + } + + /** + * @param playerMoved whether the player's tile changed across the wait + * @param fastClicked whether the follow-through click landed; must be {@code false} when + * {@link #shouldFastClickAfterNearbyWait} said not to attempt one + */ + public static DoorWaitOutcome afterNearbyWait(boolean nearbyResolved, + boolean playerMoved, + boolean fastClicked) + { + if (!nearbyResolved) + { + return DoorWaitOutcome.NEARBY_WAITING_RETRY; + } + if (!playerMoved) + { + return DoorWaitOutcome.FALL_THROUGH; + } + return fastClicked + ? DoorWaitOutcome.RESOLVED_FAST_CLICK + : DoorWaitOutcome.RESOLVED_AFTER_NEARBY_WAIT; + } + + /** + * Why the cascade yields instead of acting on the blocked frontier, in precedence order. + * + *

All three mean "an action of ours is already in flight; probing again would fight it". + * They were three sequential {@code if}s whose ORDER was the policy and was documented nowhere. + */ + public enum FrontierYield + { + /** Nothing in flight: run the door and blocker handlers. */ + NONE(null), + /** + * A door interaction is still settling, or the per-pass door-skip is cooling down. Probing + * now re-enters the resolver mid-settle, which loops. + */ + DOOR_SETTLING(WalkExit.DOOR_SETTLING_YIELD), + /** + * A door opened moments ago and the player has not started through it. Let the one-shot + * traversal finish before falling back to path-adjacent probing or recovery clicks. + */ + DOOR_TRAVERSAL_PENDING(WalkExit.DOOR_TRAVERSAL_PENDING_YIELD), + /** A recovery interim click is still being walked. */ + INTERIM_IN_FLIGHT(WalkExit.INTERIM_IN_FLIGHT_RECOVERY); + + private final WalkExit exit; + + FrontierYield(WalkExit exit) + { + this.exit = exit; + } + + /** The exit to record, or {@code null} for {@link #NONE}. */ + public WalkExit exit() + { + return exit; + } + + public boolean yields() + { + return this != NONE; + } + } + + /** + * Whether to yield the frontier this pass, and why. + * + *

Precedence is settling → traversal-pending → interim, preserved from the original + * sequential ifs. Settling wins because it is the broadest "we just touched a door" window; + * asking the narrower questions first would let a probe through during it. + * + * @param recentDoorAgeMs ms since the last door attempt near this edge; NEGATIVE means + * there was none, and must not be read as "zero ms ago" + * @param playerMoving a player already moving is traversing the door they opened, so + * there is nothing to wait for — the yield is for the stationary case + * @param interimRecoveryActive a recovery interim click is still in flight + */ + public static FrontierYield yieldBeforeDoorActions(boolean doorInteractionSettling, + boolean doorEdgePassCoolingDown, + long recentDoorAgeMs, + long doorTraversalBlockMs, + boolean playerMoving, + boolean interimRecoveryActive) + { + if (doorInteractionSettling || doorEdgePassCoolingDown) + { + return FrontierYield.DOOR_SETTLING; + } + boolean pendingTraversal = recentDoorAgeMs >= 0 + && recentDoorAgeMs <= doorTraversalBlockMs + && !playerMoving; + if (pendingTraversal) + { + return FrontierYield.DOOR_TRAVERSAL_PENDING; + } + return interimRecoveryActive ? FrontierYield.INTERIM_IN_FLIGHT : FrontierYield.NONE; + } + + /** + * Clamps a recovery index so it can neither go backwards along the route nor off the end. + * + *

The floor is the later of the pass's route position and the frontier: recovering to a tile + * BEHIND the blockage would walk the player away from the goal, which is the retreat behaviour + * the walled-route net exists to refuse. + */ + public static int clampRecoveryIndex(int candidateIndex, int routePositionIndex, int frontierIndex, + int pathSize) + { + int floor = Math.max(routePositionIndex, frontierIndex); + return Math.min(Math.max(candidateIndex, floor), pathSize - 1); + } + + /** + * Walks the recovery index back along the route until it leaves a hazard, stopping at + * {@code minIndex}. + * + *

Recovery must not park the player next to an aggressive NPC. The planner avoids those, but + * this runtime fallback would otherwise strand the walk in melee. + * + *

Deliberately CAN return a hazardous index: if every tile back to the floor is dangerous the + * index stops at the floor rather than retreating past the frontier. Walking backwards off the + * route is the worse failure, and the caller's click decision still has its own guards. + */ + public static int stepBackFromDanger(List path, int recoverIndex, int minIndex, + java.util.function.Predicate dangerous) + { + if (path == null || dangerous == null) + { + return recoverIndex; + } + int safeIndex = recoverIndex; + while (safeIndex > minIndex + && safeIndex >= 0 && safeIndex < path.size() + && dangerous.test(path.get(safeIndex))) + { + safeIndex--; + } + return safeIndex; + } + + /** + * The final recovery click target, in precedence order. + * + *

Three candidates compete and the order is the policy: + * + *

    + *
  1. {@code base} — the furthest clickable route tile (or an interpolated point near the + * minimap edge when that tile is beyond the clip).
  2. + *
  3. {@code rawGated} — the furthest RAW-path point the walled-click net vouches for. Finer + * grained than the smoothed route, so it tracks the actual corridor.
  4. + *
  5. {@code walkToOrigin} — a transport or agility-shortcut origin resolved at the frontier. + * Wins outright: the transport only dispatches while the player STANDS on its origin, so + * clicking the far side of a shortcut loops on the near bank forever (the stepping-stone + * incident). Stepping onto the origin lets the normal transport handler cross next tick.
  6. + *
+ * + *

Note the asymmetry, preserved from the original: {@code rawGated} must clear the hazard + * predicate, {@code walkToOrigin} is not hazard-checked. A shortcut origin beside an aggressive + * NPC is therefore still chosen. That is existing behaviour, not an endorsement — changing it is + * a behaviour change and belongs in its own commit with its own live evidence. + * + * @param playerLoc a candidate equal to where we already stand is no recovery at all + */ + public static WorldPoint chooseRecoveryTarget(WorldPoint base, + WorldPoint rawGated, + WorldPoint walkToOrigin, + WorldPoint playerLoc, + java.util.function.Predicate dangerous) + { + WorldPoint chosen = base; + if (rawGated != null + && !rawGated.equals(playerLoc) + && (dangerous == null || !dangerous.test(rawGated))) + { + chosen = rawGated; + } + if (walkToOrigin != null && !walkToOrigin.equals(playerLoc)) + { + chosen = walkToOrigin; + } + return chosen; + } + + /** + * Which recovery-click outcomes end the pass, and with what exit. + * + *

Two of the five continue and they do so for different reasons: {@code CLICK} continues + * because the click is about to be issued, {@code NO_TARGET} because there is nothing worth + * clicking and the rejoin logic below should get its turn. {@code NO_TARGET} was never mentioned + * in the loop at all — it fell through the three {@code if}s by omission, which reads + * identically to a forgotten case. + * + *

The caller still performs {@code REPLAN_WALLED}'s side effects (cooldown stamp, replan); + * this only says what the pass reports. + * + * @return the exit to record, or {@code null} when the cascade continues + */ + public static WalkExit exitForRecoveryClick(RouteRecovery.RecoveryClickAction action) + { + if (action == null) + { + return null; + } + switch (action) + { + case YIELD_ACTION_IN_FLIGHT: + return WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION; + case REPLAN_WALLED: + return WalkExit.RECOVERY_TARGET_WALLED_REPLAN; + case WAIT_WALLED: + return WalkExit.RECOVERY_TARGET_WALLED_WAITING; + case CLICK: + case NO_TARGET: + default: + return null; + } + } + + /** + * Whether the canvas-click fallback is worth trying after the minimap click failed to land. + * + *

Last resort, deliberately narrow: only on the FINAL approach, when the goal is essentially + * underfoot and the minimap click may simply have missed the clip because everything is too + * close together. Widening either bound turns a rescue into a second click source competing with + * the minimap on ordinary walks. + * + *

Pure: the caller still runs the reachability probe and the click, both of which touch the + * client. This only answers whether they are worth spending. + */ + public static boolean shouldTrySceneClickFallback(WorldPoint playerLoc, + WorldPoint goal, + WorldPoint recoverTarget, + int arrivalDistance, + int finalAdjacentChebyshev, + int maxTargetDistance) + { + if (playerLoc == null || goal == null || recoverTarget == null) + { + return false; + } + int nearGoal = Math.max(2, arrivalDistance + finalAdjacentChebyshev); + return playerLoc.distanceTo2D(goal) <= nearGoal + && playerLoc.distanceTo2D(recoverTarget) <= maxTargetDistance; + } + + /** The raw-path edge a smoothed frontier index corresponds to. */ + public static final class FrontierEdge + { + private final int edgeIndex; + private final int rawStart; + private final int rawEndExclusive; + private final WorldPoint from; + private final WorldPoint to; + + FrontierEdge(int edgeIndex, int rawStart, int rawEndExclusive, WorldPoint from, WorldPoint to) + { + this.edgeIndex = edgeIndex; + this.rawStart = rawStart; + this.rawEndExclusive = rawEndExclusive; + this.from = from; + this.to = to; + } + + public int edgeIndex() + { + return edgeIndex; + } + + public int rawStart() + { + return rawStart; + } + + public int rawEndExclusive() + { + return rawEndExclusive; + } + + /** Raw tile the blocked edge leaves from, or {@code null} when the raw path cannot supply it. */ + public WorldPoint from() + { + return from; + } + + /** Raw tile the blocked edge leads to, or {@code null}. */ + public WorldPoint to() + { + return to; + } + } + + /** + * Maps the frontier index onto the raw path, which is what every door and obstacle handler in the + * cascade is addressed by. + * + *

The edge starts one smoothed index BEFORE the frontier — the blocked edge is the step INTO + * the unreachable tile, not the step out of it — clamped so it can never precede the route + * position the pass started from. + */ + public static FrontierEdge frontierEdge(List rawPath, + int[] smoothedToRaw, + int fromIndex, + int frontierIndex) + { + int rawSize = rawPath == null ? 0 : rawPath.size(); + int edgeIndex = Math.max(fromIndex, frontierIndex - 1); + int rawStart = smoothedToRaw != null && edgeIndex < smoothedToRaw.length + ? smoothedToRaw[edgeIndex] + : 0; + int rawEndExclusive = smoothedToRaw != null && frontierIndex < smoothedToRaw.length + ? smoothedToRaw[frontierIndex] + 1 + : rawSize; + WorldPoint from = rawStart >= 0 && rawStart < rawSize ? rawPath.get(rawStart) : null; + WorldPoint to = rawEndExclusive - 1 >= 0 && rawEndExclusive - 1 < rawSize + ? rawPath.get(rawEndExclusive - 1) + : null; + return new FrontierEdge(edgeIndex, rawStart, rawEndExclusive, from, to); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java index 7cb37b7275d..7ef59d6952d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java @@ -1,10 +1,8 @@ package net.runelite.client.plugins.microbot.util.walker.recovery; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.function.Predicate; @@ -213,21 +211,21 @@ public static boolean isLocalRecoveryCandidateOnForwardRoute(List ra * {@code maxEuclidean} tiles of the player), so recovery can walk the player ONTO it and let the normal * transport handler cross next tick. Returns {@code null} when none qualifies. *

- * Pure and fully injected ({@code reachable} tiles and the {@code transports} map are parameters), so it + * Pure and fully injected ({@code reachable} tiles and the transport-origin predicate are parameters), so it * is exercised headlessly by {@code RouteRecoveryTest} rather than requiring a live walk. Rationale: the * far-side fallback otherwise clicks the opposite bank, which the client cannot reach, so the player * loops on the near bank and the transport (which only dispatches while standing on its origin) never * fires. */ - public static WorldPoint findReachableTransportOriginAhead(List rawPath, - int startIndex, - WorldPoint playerLoc, - Set reachable, - Map> transports, - int maxEuclidean, - int forwardScanTiles) { - if (rawPath == null || rawPath.isEmpty() || playerLoc == null || reachable == null - || transports == null || transports.isEmpty() || startIndex < 0 || startIndex >= rawPath.size()) { + public static WorldPoint findReachableTransportOriginAhead(List rawPath, + int startIndex, + WorldPoint playerLoc, + Set reachable, + Predicate hasTransportOrigin, + int maxEuclidean, + int forwardScanTiles) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null || reachable == null + || hasTransportOrigin == null || startIndex < 0 || startIndex >= rawPath.size()) { return null; } int maxSq = maxEuclidean * maxEuclidean; @@ -243,8 +241,7 @@ public static WorldPoint findReachableTransportOriginAhead(List rawP if (euclideanSq(wp, playerLoc) > maxSq) { continue; // within minimap-click reach } - Set ts = transports.get(wp); - if (ts != null && !ts.isEmpty()) { + if (hasTransportOrigin.test(wp)) { return wp; // nearest reachable transport / shortcut origin ahead } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java new file mode 100644 index 00000000000..50645b7c211 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +/** + * What the walk loop does at the end of one iteration: finish, replan, give up, or go round again. + * + *

Pure and fully injected, so the interactions between the partial-retry budget, its refill rule + * and the tail-iteration exemption can be pinned in a decision table instead of re-discovered on a + * live walk. The caller still performs the actions — replanning, telemetry, clearing the target. + * + *

The partial branch is where this matters. A "partial path" is a route the pathfinder could not + * run all the way to the goal, which is every long or awkward walk, and on those routes the budget + * is armed for the entire journey. Getting the classification wrong there does not degrade the + * walk, it aborts it. + */ +public final class TailDecision +{ + /** Consecutive failures to advance on a partial route before the goal is called unreachable. */ + public static final int MAX_PARTIAL_RETRIES = 3; + + private TailDecision() + { + } + + public enum TailAction + { + /** Within the arrival threshold. */ + ARRIVED, + /** Partial route, and the iteration advanced it: replan, but do not spend a retry. */ + PARTIAL_PROGRESS_REPLAN, + /** Partial route and no progress: spend a retry and replan. */ + PARTIAL_RETRY_REPLAN, + /** Partial route, budget spent: the goal is unreachable. */ + PARTIAL_EXHAUSTED, + /** Go round again, charging one tail iteration. */ + CONTINUE, + /** Go round again without charging a tail iteration (a benign yield). */ + CONTINUE_TAIL_EXEMPT + } + + /** + * Route progress since the last retry means the walk is working, so the budget refills. + * + *

Standing somewhere new is required as well as the progress timestamp: the timestamp is also + * bumped when the route is merely REPLACED, and every retry replans, so the timestamp alone + * would let a retry refill the budget it just spent. Requiring movement is what still lets the + * budget drain when the target is genuinely unreachable and the player has stopped. + */ + public static boolean shouldRefillPartialRetryBudget(int retriesSpent, + boolean movedSinceLastRetry, + long routeProgressAdvancedAtMs, + long lastPartialRetryAtMs) + { + return retriesSpent > 0 + && movedSinceLastRetry + && routeProgressAdvancedAtMs > lastPartialRetryAtMs; + } + + /** + * @param retriesSpent budget already spent, AFTER any refill from + * {@link #shouldRefillPartialRetryBudget} + */ + public static TailAction decide(boolean withinFinishThreshold, + boolean partialPath, + WalkExit exit, + int retriesSpent, + int maxRetries) + { + if (withinFinishThreshold) + { + return TailAction.ARRIVED; + } + if (partialPath) + { + if (exit != null && exit.isProgress()) + { + return TailAction.PARTIAL_PROGRESS_REPLAN; + } + return retriesSpent < maxRetries + ? TailAction.PARTIAL_RETRY_REPLAN + : TailAction.PARTIAL_EXHAUSTED; + } + return exit != null && exit.isTailExempt() + ? TailAction.CONTINUE_TAIL_EXEMPT + : TailAction.CONTINUE; + } + + /** What to do about a route whose progress index has stopped advancing. */ + public enum StagnationAction + { + /** Progress is recent (or there is no route yet): nothing to do. */ + NONE, + /** Stagnant: spend one stagnation replan and restart the clock. */ + REPLAN, + /** Stagnant with the replan budget spent: end the walk honestly. */ + EXHAUSTED + } + + /** + * The oscillation bound the other two budgets cannot provide. The wall-clock budget is sized for + * whole journeys (minutes), and the exempt-run counter resets on any movement — so a walk that + * ping-pongs between two tiles forever (measured: 4+ minutes of door/recovery oscillation at the + * Tithe Farm door until a human cancelled it) trips neither. Movement is not progress; the route + * progress index is. When the index has not advanced for a full budget, the route is not working: + * replan it, and when replanning has been given its chances, call the goal unreachable instead of + * letting the loop run unbounded. + * + *

The budget must dwarf every legitimate index hold: ranged door waits (≤8s), transport + * settles (~2s), off-path deferrals (~10s) — 60s is over six times the largest. + * + * @param routeProgressAdvancedAtMs when the stabilized route index last advanced (0 = no route yet; + * the caller restarts this clock when it spends a REPLAN, so each + * replan gets a full budget even when the new route is identical) + */ + public static StagnationAction decideRouteStagnation(long routeProgressAdvancedAtMs, + long nowMs, + long stagnationBudgetMs, + int stagnationReplansSpent, + int maxStagnationReplans) + { + if (routeProgressAdvancedAtMs <= 0L || stagnationBudgetMs <= 0L + || nowMs - routeProgressAdvancedAtMs <= stagnationBudgetMs) + { + return StagnationAction.NONE; + } + return stagnationReplansSpent < maxStagnationReplans + ? StagnationAction.REPLAN + : StagnationAction.EXHAUSTED; + } + + /** + * Whether a continuation re-click at the route tail is churn rather than flow. Mid-route, + * clicking the next stretch while still moving is exactly how the walker chains minimap clicks — + * that must stay. But inside the final band the click in flight already ends at (or beside) the + * goal, and re-clicking every pass fights it: measured as ~10 clicks in 7 seconds on the last + * tile, each minimap click quantizing onto a neighbour of the goal and restarting the dance. + * Let the in-flight click land; a stationary miss gets one precise follow-up instead. + */ + public static boolean suppressTailReclick(boolean playerMoving, int distanceToGoal, int tailBandTiles) + { + return playerMoving && distanceToGoal >= 0 && distanceToGoal <= tailBandTiles; + } + + /** + * Whether the walk has run past its wall-clock budget. + * + *

The loop's iteration cap is not a bound on its own: several exit reasons decrement the tail + * counter, so a walk that keeps producing one of them goes round forever. Nothing else in the + * call chain imposes a time limit either. + * + *

Sized to catch a livelock, not a slow walk — a budget that aborts a working long journey + * would be a worse bug than the one it is guarding against. + */ + public static boolean isWallClockExhausted(long walkStartedAtMs, long nowMs, long budgetMs) + { + return walkStartedAtMs > 0L && budgetMs > 0L && nowMs - walkStartedAtMs > budgetMs; + } + + /** + * Companion bound to {@link #isWallClockExhausted}: an uninterrupted run of tail-exempt + * iterations means the loop is yielding without ever advancing, which the iteration cap cannot + * see because those iterations refund themselves. + */ + public static boolean isExemptRunTooLong(int consecutiveExemptIterations, int cap) + { + return cap > 0 && consecutiveExemptIterations > cap; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java new file mode 100644 index 00000000000..64be28deab0 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java @@ -0,0 +1,124 @@ +package net.runelite.client.plugins.microbot.util.walker.segment; + +/** + * Whether the obstacle handlers run for one route segment, and whether a door on it may be clicked + * from range. + * + *

Two independent reasons skip a segment — the window after a transport, and startup before the + * first movement click — and both were computed inline as boolean soup with the log reason derived + * from a ternary. The pair matters more than it looks: a skipped segment was never examined, + * so an obstacle on it is neither resolved nor ruled out, and that is precisely what makes reaching + * past it dangerous. + * + *

Pure and fully injected, so the interaction can be pinned in a decision table rather than + * rediscovered at Falador. + */ +public final class SegmentGate +{ + private SegmentGate() + { + } + + public enum SegmentAction + { + /** Examine this segment: run the door / blocker / rockfall / transport handlers. */ + RUN("run"), + /** + * Inside the post-transport window with no planned transport nearby. The scene has just + * changed under us and the handlers would thrash against a route we are about to re-derive. + */ + SKIP_POST_TRANSPORT_WINDOW("no_nearby_planned_transport"), + /** + * Startup, before the first movement click. Broad handlers here delay the first click for + * every segment on the route; the walk should start moving and examine obstacles en route. + */ + SKIP_STARTUP_PRECLICK("startup_before_first_click"); + + private final String wireReason; + + SegmentAction(String wireReason) + { + this.wireReason = wireReason; + } + + /** The exact reason string this decision has always been logged as. */ + public String wireReason() + { + return wireReason; + } + + public boolean isSkip() + { + return this != RUN; + } + } + + /** + * Post-transport skip wins over the startup skip when both apply, matching the original + * {@code skipPostTransport ? … : …} reason ternary. + * + * @param tileReachable whether the segment tile is reachable from the player right now; an + * unreachable tile is never skipped, because that is the case the handlers + * exist for + */ + public static SegmentAction decide(boolean recentTransportWindow, + boolean upcomingNearbyTransport, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight, + boolean tileReachable, + boolean startupBeforeFirstClick, + boolean immediateSegmentTransportStep, + int segmentIdx, + int routeStartIdx) + { + if (recentTransportWindow + && !upcomingNearbyTransport + && !recentDoorAttemptNearSegment + && !doorSettling + && !recoveryInFlight + && tileReachable) + { + return SegmentAction.SKIP_POST_TRANSPORT_WINDOW; + } + if (!immediateSegmentTransportStep + && skipStartupPreclick(startupBeforeFirstClick, segmentIdx, routeStartIdx, + recentDoorAttemptNearSegment, doorSettling, recoveryInFlight)) + { + return SegmentAction.SKIP_STARTUP_PRECLICK; + } + return SegmentAction.RUN; + } + + static boolean skipStartupPreclick(boolean startupBeforeFirstClick, + int segmentIdx, + int routeStartIdx, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight) + { + if (!startupBeforeFirstClick || routeStartIdx < 0 || segmentIdx < routeStartIdx) + { + return false; + } + return !recentDoorAttemptNearSegment && !doorSettling && !recoveryInFlight; + } + + /** + * Whether a door on this segment may be clicked from range. + * + *

"First handler to run this pass" is NOT the same as "nearest unresolved obstacle on the + * route". A segment that was SKIPPED was never examined, so an obstacle on it is neither resolved + * nor ruled out, and reaching past it is exactly the failure that ranged dispatch must avoid. + * + *

Measured at Falador: segments 11 and 12 skipped with {@code no_nearby_planned_transport}, + * then the door at (2985,3341) clicked from range while the door at (2981,3340) was still shut + * between us and it. The server began routing AROUND the building, dragging the player south to + * (2960,3330), and the traversal wait it could never satisfy timed out. Ten seconds and a U-turn. + */ + public static boolean mayDispatchDoorAtRange(boolean handlersAlreadyRanThisPass, + boolean anySegmentSkippedThisPass) + { + return !handlersAlreadyRanThisPass && !anySegmentSkippedThisPass; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java index b0bb74bbe78..44a81fe425e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java @@ -30,6 +30,35 @@ public static boolean shouldSkipStallAccounting(long leaguesPendingMaxAgeMs) { return !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON); } + /** + * Whether the pose-based movement flag may be credited as route progress. + * + *

{@code Rs2Player.isMoving()} compares the pose animation against the idle pose, so it reads + * TRUE while the player merely TURNS ON THE SPOT. Stall accounting credited that as progress and + * refreshed the clock, so a player wedged against a wall or a door who kept re-facing it could + * never be declared stuck — the one state the stall detector exists to catch. + * + *

Requiring a tile change outright would be worse: a walking step takes ~600ms and the check + * samples faster than that, so "same tile as last sample" is the normal state of a healthy walk. + * The question is not whether the tile changed since the last sample but whether it has changed + * at all RECENTLY — walking changes tile continuously, spinning never does. + * + * @param sinceTileChangeMs ms since the player last actually changed tile; negative when unknown, + * which is treated as "cannot disprove movement" and credits the pose + */ + public static boolean poseCountsAsProgress(boolean poseMoving, + boolean nearPath, + long sinceTileChangeMs, + long tileChangeWindowMs) { + if (!poseMoving || !nearPath) { + return false; + } + if (sinceTileChangeMs < 0L) { + return true; + } + return sinceTileChangeMs < tileChangeWindowMs; + } + /** * Computes the stall threshold by multiplying {@code baseMs} by the maximum applicable multiplier. * Result uses {@link Math#round(double)}. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java new file mode 100644 index 00000000000..361bd99e812 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java @@ -0,0 +1,182 @@ +package net.runelite.client.plugins.microbot.util.walker.state; + +/** + * Why one iteration of the {@code processWalk} tail loop ended. + * + *

This replaces a bare {@code String exitReason} that carried the loop's control flow through 47 + * assignment sites and was consumed by string equality and {@code startsWith} in eight places. Three + * downstream behaviours keyed off that string — whether the iteration counts as route progress + * (partial-retry budget), whether it is exempt from the tail-iteration cap, and whether a canvas + * nudge is owed after a door-like exit — and a value that no branch had classified simply fell + * through to the default in each. + * + *

That is not hypothetical. Two of the values below ({@link #DOOR_EDGE_RESOLVED_AFTER_WAIT} and + * {@link #DOOR_EDGE_WAITING_RETRY}) are produced inside a ternary and never appear in a search for + * {@code exitReason = "…"}, so an audit that enumerates the reasons by grepping the assignments + * misses them. Making the set an enum makes it enumerable, exhaustively switchable, and impossible + * to extend without deciding what the new value means. + * + *

Wire names are load-bearing

+ * {@link #wireName()} returns the exact string the old code logged. Live walker debugging in this + * repo is log-driven, and renaming an exit reason would blind the one diagnostic that works. Do not + * "tidy" these strings. + * + * @see net.runelite.client.plugins.microbot.util.walker.Rs2Walker + */ +public enum WalkExit +{ + // ---- loop completed normally ---- + + /** The segment loop ran to the end of the path without any handler acting. */ + END_OF_PATH("end-of-path", false, false, false), + + // ---- an obstacle handler acted (route progress) ---- + + DOOR_HANDLED("door-handled", true, false, true), + DOOR_HANDLED_BEFORE_MINIMAP_CLICK("door-handled-before-minimap-click", true, false, true), + DOOR_HANDLED_DURING_INTERIM("door-handled-during-interim", true, false, true), + DOOR_HANDLED_LOCAL_REACHABILITY("door-handled-local-reachability", true, false, true), + DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN("door-handled-local-reachability-raw-scan", true, false, true), + DOOR_HANDLED_NEARBY_ROUTE_DOOR("door-handled-nearby-route-door", true, false, true), + DOOR_HANDLED_PATH_ADJ_SCAN("door-handled-path-adj-scan", true, false, true), + PATH_BLOCKER_HANDLED("path-blocker-handled", true, false, false), + ROCKFALL_HANDLED("rockfall-handled", true, false, false), + TRANSPORT_HANDLED("transport-handled", true, false, false), + CURRENT_TILE_TRANSPORT_HANDLED("current-tile-transport-handled", true, false, false), + POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED("post-click-current-tile-transport-handled", true, false, false), + RAW_PATH_SCENE_OBJECT_HANDLED("raw-path-scene-object-handled", true, false, true), + POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED("post-click-raw-path-scene-object-handled", true, false, true), + + // ---- recovery acted, or resolved the blocked frontier ---- + // Recovery doing its job is progress. These were all non-progress, which is how a walk that was + // mining a rockfall, taking a shortcut or clicking its way back onto the route could spend its + // whole retry budget and report UNREACHABLE while advancing. + + /** A rockfall was mined or an on-origin transport/shortcut was taken at the blocked frontier. */ + FRONTIER_OBSTACLE_HANDLED("frontier-obstacle-handled", true, false, false), + /** Recovery took a transport (e.g. an agility shortcut) on the blocked edge. */ + TRANSPORT_HANDLED_LOCAL_REACHABILITY("transport-handled-local-reachability", true, false, false), + /** A recovery click was issued and movement was confirmed to start. */ + LOCAL_RECOVERY_CLICK("local-recovery-click", true, false, false), + LOCAL_REACHABILITY_MISS_NO_CLICK("local-reachability-miss-no-click", false, false, false), + /** The door-edge nudge acted. */ + RECENT_DOOR_EDGE_NUDGE("recent-door-edge-nudge", true, false, false), + /** A minimap click toward the door approach was issued; the player is walking to it. */ + DOOR_SUPPRESSED_APPROACH_CLICK("door-suppressed-approach-click", true, false, false), + DOOR_RECOVERY_SUPPRESSED("door-recovery-suppressed", false, false, false), + /** The pass was abandoned because the player MOVED mid-pass — movement is the definition of progress. */ + RECOVERY_POSITION_STALE("recovery-position-stale", true, false, false), + /** Yielded because a door open / walker-owned movement is still in flight. */ + RECOVERY_CLICK_PREEMPTED_BY_ACTION("recovery-click-preempted-by-action", true, false, false), + /** Genuinely walled: this is the "we are stuck" signal the retry budget exists for. */ + RECOVERY_TARGET_WALLED_REPLAN("recovery-target-walled-replan", false, false, false), + RECOVERY_TARGET_WALLED_WAITING("recovery-target-walled-waiting", false, false, false), + + // ---- door edge resolution around a recent attempt ---- + // "Resolved" means the door opened. Only the waiting-retry pair is a failure to advance. + + DOOR_EDGE_RESOLVED_FAST_CLICK("door-edge-resolved-fast-click", true, false, false), + DOOR_EDGE_RESOLVED_AFTER_WAIT("door-edge-resolved-after-wait", true, false, false), + DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT("door-edge-resolved-after-nearby-wait", true, false, false), + DOOR_EDGE_WAITING_RETRY("door-edge-waiting-retry", false, false, false), + DOOR_EDGE_NEARBY_WAITING_RETRY("door-edge-nearby-waiting-retry", false, false, false), + + // ---- yields while one of our own actions is still in flight ---- + // Waiting for an action we issued is not a failed attempt. Charging these meant three settle + // windows at one ordinary door could exhaust the budget and abort the walk. + + /** + * Yielded to a live interim waypoint. Three separate places in the loop do this, and until they + * were told apart a log line reading {@code interim-in-flight} could mean any of them — which + * twice made a real stall undiagnosable from the log. The suffix names the site; the shared + * {@code interim-in-flight} prefix keeps one grep matching all three. + */ + INTERIM_IN_FLIGHT_ROUTE("interim-in-flight:route", true, true, false), + /** The blocked-frontier recovery deferred to an interim it had already clicked. */ + INTERIM_IN_FLIGHT_RECOVERY("interim-in-flight:recovery", true, true, false), + /** Click selection found the player still travelling to the previous interim. */ + INTERIM_IN_FLIGHT_CLICK("interim-in-flight:click", true, true, false), + RECOVERY_MOVE_IN_FLIGHT("recovery-move-in-flight", true, true, false), + ROUTE_MOVE_IN_FLIGHT("route-move-in-flight", true, true, false), + DOOR_SETTLING_YIELD("door-settling-yield", true, false, false), + DOOR_TRAVERSAL_PENDING_YIELD("door-traversal-pending-yield", true, false, false), + TRANSPORT_SETTLING_YIELD("transport-settling-yield", true, false, false), + + // ---- route geometry / fold handling ---- + + ROUTE_FOLD_CONTINUATION_CLICK("route-fold-continuation-click", true, true, false), + ROUTE_FOLD_CONTINUATION_PENDING("route-fold-continuation-pending", false, false, false), + + // ---- the walk is not tracking the route ---- + + /** + * Off-path, but a recent click / route progress / busy state says the player may still be + * advancing, so the replan was deferred. Carries a detail string naming the deferral reason; + * see {@link #wireName(String)}. + */ + OFF_PATH_DEFERRED("off-path-deferred", false, true, false), + NOT_NEAR_PATH("not-near-path", false, false, false), + CLICK_FAILED_OFF_MINIMAP("click-failed-off-minimap", false, false, false), + PLAYER_LOCATION_NULL("player-location-null", false, false, false); + + private final String wireName; + private final boolean progress; + private final boolean tailExempt; + private final boolean doorLike; + + WalkExit(String wireName, boolean progress, boolean tailExempt, boolean doorLike) + { + this.wireName = wireName; + this.progress = progress; + this.tailExempt = tailExempt; + this.doorLike = doorLike; + } + + /** The exact string this reason has always been logged as. Never change these. */ + public String wireName() + { + return wireName; + } + + /** + * Log name including the deferral detail for {@link #OFF_PATH_DEFERRED}, which was previously + * built by string concatenation at the assignment site and parsed back apart downstream. + */ + public String wireName(String detail) + { + if (this != OFF_PATH_DEFERRED) + { + return wireName; + } + return wireName + ":" + (detail == null ? "" : detail); + } + + /** + * The iteration ended because the walker did something that advances the route, or + * because movement it owns is already in flight — progress, not a failed attempt. + * + *

The partial-retry budget exists for "the goal is unreachable and we are stuck". Spending it + * on these conflates the two: on a partial path the budget is armed for the entire walk, so an + * ordinary door can exhaust it far into a working route and report UNREACHABLE while the player + * is still advancing. See {@code movement.md} #25. + */ + public boolean isProgress() + { + return progress; + } + + /** + * Benign yields that must not consume the bounded tail-iteration budget, so long waits cannot + * exhaust it and EXIT a healthy walk. + */ + public boolean isTailExempt() + { + return tailExempt; + } + + /** A door-like exit owes the post-door canvas nudge and its minimap hold-off window. */ + public boolean isDoorLike() + { + return doorLike; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index dc734130d80..a60acf54bb6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -20,15 +20,33 @@ public final class WalkerRouteState { // ---- transport handoff: set when a transport (stairs, ladder, shortcut, teleport) is taken, read by // the post-transport settling/window logic in processWalk. ---- - /** Wall-clock ms when the last transport was handled; 0 when none this session. */ + /** + * Wall-clock ms when the last transport was handled; 0 when none this session. + * + *

This is the field every post-transport window check actually reads, so it is the one that + * decides whether handlers are suppressed. Clearing the locations below without clearing this + * leaves the window armed — see {@link #clearRecentTransportContext()}. + */ public volatile long lastTransportHandledAtMs = 0L; - /** Player tile immediately after the last transport handoff. */ - public volatile WorldPoint lastTransportHandledAtLocation = null; /** Origin tile of the last handled transport. */ public volatile WorldPoint lastTransportOriginLocation = null; /** Destination tile of the last handled transport. */ public volatile WorldPoint lastTransportDestinationLocation = null; + /** + * Ends the post-transport window: the handoff belongs to the route that took the transport. + * + *

Clear all of it together. Nulling only the locations leaves + * {@link #lastTransportHandledAtMs} set, and every window check keys off that timestamp — the + * window stays armed for its full duration while the destination it is supposed to be about is + * already gone. + */ + public void clearRecentTransportContext() { + lastTransportHandledAtMs = 0L; + lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; + } + // ---- route progress: tracks how far along the current route the player has advanced, used to detect // real forward progress (vs thrashing) and to decide when to reset on a new/changed route. ---- @@ -44,6 +62,10 @@ public final class WalkerRouteState { public volatile int routeProgressPathSize = -1; /** Wall-clock ms when route progress last advanced. */ public volatile long routeProgressAdvancedAtMs = 0L; + /** Stagnation replans this walk has spent (TailDecision.decideRouteStagnation). */ + public volatile int stagnationReplansSpent = 0; + /** Furthest raw-path index the player has stood at on the current route; -1 when none. */ + public volatile int rawProgressHighIdx = -1; // ---- interim target: a reachable point clicked toward when the true next tile is off the minimap; // held until the player gets close or progress stalls. ---- @@ -81,37 +103,59 @@ public final class WalkerRouteState { public volatile WorldPoint lastPosition = null; /** Wall-clock ms the player last changed tiles (or a click granted grace). */ public volatile long lastMovedTimeMs = 0L; + /** + * Wall-clock ms the player last actually CHANGED TILE — no click grace, no pose, no animation. + * + *

Distinct from {@link #lastMovedTimeMs}, which several places refresh to buy grace and which + * therefore cannot answer "is the player really covering ground". This one only ever moves when + * the observed tile differs from the previous sample, which is what makes it a usable check on + * the pose-based movement flag. + */ + public volatile long lastTileChangeAtMs = 0L; /** Rising-edge detection for animation progress without tile delta in the stuck check. */ public volatile boolean prevAnimatingForStuckCheck = false; /** Wall-clock ms of the last walled-recovery replan (cooldown selects replan vs wait). */ public volatile long lastWalledRecoveryReplanAtMs = 0L; /** Cooldown so partial-segment in-transit path recalculation does not spam. */ public volatile long lastPartialTransRecalcMs = 0L; + /** + * Best (smallest) partial-segment endpoint distance-to-goal accepted this walk session; + * MAX_VALUE until the first partial. Baseline for the partial-regression guard in + * processWalk: a fresh partial ending drastically farther from the goal than an earlier + * one is a budget/tiebreak artifact of an exhausted search, not a road, and following it + * flips the travel direction. + */ + public volatile int bestPartialDGoal = Integer.MAX_VALUE; + /** Consecutive regressed partials replanned instead of walked; bounds the guard's retry loop. */ + public volatile int partialRegressReplans = 0; + /** + * Set when a pass enters the local-reachability recovery gate; consumed at pass exit into a + * {@code recovery_gate_done} tmark. The gate's cascade (door scans, edge waits, recovery-target + * probes — each a client-thread hop) was the unattributed bulk of 9-12s pass_slow residuals at + * the Rogues' Den doorstep; this names it. + */ + public volatile long recoveryGateEnteredAtMs = 0L; + /** + * The route edge the walled-click net most recently refused BECAUSE a scene door sits on it + * ({@code walled_edge_not_learned}). Replanning cannot help there — the planner's graph crosses + * that door, so it returns the same route and the refusal loops (three identical replans over + * 24s at the Rogues' Den pub door). Recovery consumes this to approach the door instead. + */ + public volatile WorldPoint walledDoorEdgeFrom = null; + public volatile WorldPoint walledDoorEdgeTo = null; + public volatile long walledDoorEdgeAtMs = 0L; + /** Immutable caller intent for this walk; currentTarget may temporarily become an effective rim. */ + public volatile WorldPoint requestedGoal = null; + /** Sealed-goal rim retargets consumed this walk; bounds the chain (a rim tile can itself probe sealed). */ + public volatile int sealedRimRetargets = 0; + + // ---- door interaction (D3 slice 4: settle window, raw-scan focus, pass budget and the global + // cooldown migrated to DoorAttemptLedger; the diagnostics timestamps below remain). ---- - // ---- door interaction: settle windows, focused-door raw-scan state, attempt tracking and - // cooldowns shared by the door cascade, the recovery block and the movement-ownership check. ---- - - /** Path index of the door the raw scene scan is currently focused on; null when none. */ - public volatile Integer rawScanFocusedDoorIdx = null; - /** Wall-clock ms the focused door was selected. */ - public volatile long rawScanFocusedDoorSetAtMs = 0L; - /** Interaction attempts spent on the focused door so far. */ - public volatile int rawScanFocusedDoorAttempts = 0; - /** Door settle window ceiling; 0 when no settle is pending. */ - public volatile long doorInteractionSettleUntilMs = 0L; - /** When the current door settle window started, and the door's far-side tile — the early-exit signal. */ - public volatile long doorInteractionSettleStartedAtMs = 0L; - public volatile WorldPoint doorSettleFarSideWp = null; /** Wall-clock ms a door-edge pass was last skipped (per-edge cooldown diagnostics). */ public volatile long lastDoorEdgePassSkipAtMs = 0L; /** Cooldown for the expensive path-adjacent door scan on unreachable tiles. */ public volatile long lastDoorPathAdjAttemptAtMs = 0L; - /** Origin/destination/time of the last door interaction attempt (wrong-traversal detection reads these). */ - public volatile WorldPoint lastDoorAttemptFrom = null; - public volatile WorldPoint lastDoorAttemptTo = null; - public volatile long lastDoorAttemptAtMs = 0L; - /** Global door-interaction throttle: no door interaction may fire before this wall-clock ms. */ - public volatile long nextDoorInteractionAllowedAtMs = 0L; /** * When the walker first held off a door interaction because an option menu was open; 0 when no * such hold-off is active. Bounds the wait so an unanswered conversation cannot stall the walk. diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java new file mode 100644 index 00000000000..73dce392252 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java @@ -0,0 +1,643 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Emits local planner results for the opt-in dual-engine comparison harness. */ +public final class LocalPlannerComparisonMain +{ + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final boolean EMBEDDED_UPSTREAM = Boolean.getBoolean( + "microbot.planner.embedded-upstream"); + + private LocalPlannerComparisonMain() + { + } + + public static void main(String[] args) throws Exception + { + if (args.length != 2) + { + throw new IllegalArgumentException("expected "); + } + Path corpusPath = Path.of(args[0]).toAbsolutePath().normalize(); + Path outputPath = Path.of(args[1]).toAbsolutePath().normalize(); + PlannerCorpus corpus = readCorpus(corpusPath); + List results = new ArrayList<>(); + for (PlannerCase plannerCase : corpus.cases) + { + results.add(run(plannerCase)); + } + PlannerRun run = new PlannerRun( + corpus.schemaVersion, + EMBEDDED_UPSTREAM ? "shortest-path-upstream-embedded" : "microbot-local", + System.getProperty("microbot.planner.revision", "unknown"), + results); + Files.createDirectories(outputPath.getParent()); + Files.writeString(outputPath, GSON.toJson(run) + System.lineSeparator(), + StandardCharsets.UTF_8); + } + + private static PlannerCorpus readCorpus(Path path) throws IOException + { + PlannerCorpus corpus = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), + PlannerCorpus.class); + if (corpus == null || corpus.schemaVersion != 3 || corpus.cases == null) + { + throw new IllegalArgumentException("unsupported or incomplete planner corpus"); + } + return corpus; + } + + private static PlannerCaseResult run(PlannerCase plannerCase) throws Exception + { + if (!"STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !"EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode) + && !"BANK_AWARE_EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode)) + { + return PlannerCaseResult.unsupported(plannerCase.id, + "unsupported transport policy: " + plannerCase.policy.transportMode); + } + if (plannerCase.policy.cutoffMillis <= 0 || plannerCase.policy.cutoffMillis % 600L != 0) + { + throw new IllegalArgumentException( + "comparison cutoff must be a positive whole number of game ticks"); + } + + Catalog catalog = Catalog.from(plannerCase); + WorldPoint start = plannerCase.start.toWorldPoint(); + WorldPoint target = plannerCase.target.toWorldPoint(); + resetHeapPeaks(); + long heapBefore = usedHeap(); + SearchOutcome outcome = "BANK_AWARE_EXPLICIT_CATALOG".equals( + plannerCase.policy.transportMode) + ? searchWithBankDetours(plannerCase, catalog, start, target) + : search(newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, target, false); + long peakHeapDelta = Math.max(0L, peakHeap() - heapBefore); + WorldPoint endpoint = outcome.path.isEmpty() + ? null : outcome.path.get(outcome.path.size() - 1); + return PlannerCaseResult.supported( + plannerCase.id, + outcome.termination, + outcome.reached, + Point.from(endpoint), + outcome.path.size(), + outcome.cost, + outcome.nodesChecked, + outcome.transportsChecked, + outcome.elapsedNanos, + peakHeapDelta, + selectedTransports(outcome.edges, catalog), + outcome.bankVisited); + } + + private static PathfinderConfig newConfig( + PlannerCase plannerCase, Map> activeCatalog, + boolean useBankItems) throws Exception + { + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), activeCatalog, Collections.emptyList(), null, null); + config.getTransports().putAll(activeCatalog); + for (Map.Entry> entry : activeCatalog.entrySet()) + { + config.getTransportsPacked().put( + net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.packWorldPoint(entry.getKey()), + entry.getValue()); + } + setField(config, "calculationCutoffMillis", plannerCase.policy.cutoffMillis); + setField(config, "avoidWilderness", plannerCase.policy.avoidWilderness); + config.setUseBankItems(useBankItems); + return config; + } + + private static SearchOutcome search( + PathfinderConfig config, WorldPoint start, WorldPoint target, boolean bankVisited) + { + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2RoutePlanner planner = EMBEDDED_UPSTREAM + ? Rs2PathApi.upstreamPlanner() : Rs2PathApi.localPlanner(config); + Rs2RouteResult result = planner.plan( + request, Rs2PathApi.resolvePlanningSnapshot(request, config)); + List path = result.getPath(); + List pathEdges = result.getSteps(); + Rs2RouteMetrics metrics = result.getMetrics(); + WorldPoint endpoint = path.isEmpty() ? null : path.get(path.size() - 1); + boolean reached = endpoint != null && endpoint.equals(target); + long reconstructedCost = pathCost(pathEdges, request.getPolicy().orElseThrow()); + if (metrics.getPathCost() != reconstructedCost) + { + throw new IllegalStateException( + "local selected cost differs from reconstructed edge cost: " + start + " -> " + target); + } + return new SearchOutcome( + path, + pathEdges, + result.getTerminationReason().name(), + reached, + reconstructedCost, + metrics.getNodesChecked(), + metrics.getTransportsChecked(), + metrics.getSearchNanos(), + bankVisited); + } + + private static SearchOutcome searchWithBankDetours( + PlannerCase plannerCase, Catalog catalog, WorldPoint start, WorldPoint target) throws Exception + { + List definitions = plannerCase.bankLocations == null + ? Collections.emptyList() : plannerCase.bankLocations; + if (definitions.isEmpty()) + { + throw new IllegalArgumentException( + "bank-aware comparison requires at least one bank: " + plannerCase.id); + } + + SearchOutcome direct = search( + newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, target, false); + SearchOutcome chosen = direct; + long totalNodes = availableMetric(direct.nodesChecked); + long totalTransports = availableMetric(direct.transportsChecked); + long totalElapsed = availableMetric(direct.elapsedNanos); + for (Point definition : definitions) + { + WorldPoint bank = definition.toWorldPoint(); + SearchOutcome toBank = search( + newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, bank, false); + totalNodes += availableMetric(toBank.nodesChecked); + totalTransports += availableMetric(toBank.transportsChecked); + totalElapsed += availableMetric(toBank.elapsedNanos); + if (!toBank.reached) + { + continue; + } + SearchOutcome fromBank = search( + newConfig(plannerCase, catalog.withBankByOrigin, true), bank, target, true); + totalNodes += availableMetric(fromBank.nodesChecked); + totalTransports += availableMetric(fromBank.transportsChecked); + totalElapsed += availableMetric(fromBank.elapsedNanos); + if (!fromBank.reached) + { + continue; + } + SearchOutcome bankRoute = SearchOutcome.combine(toBank, fromBank); + if (!chosen.reached || bankRoute.cost < chosen.cost) + { + chosen = bankRoute; + } + } + return chosen.withMetrics(totalNodes, totalTransports, totalElapsed); + } + + private static long availableMetric(long value) + { + return value < 0L ? 0L : value; + } + + private static void setField(PathfinderConfig config, String name, Object value) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField(name); + field.setAccessible(true); + field.set(config, value); + } + + private static long pathCost(List path, Rs2RoutePolicy policy) + { + if (path == null) + { + return -1L; + } + long cost = 0L; + for (Rs2RouteStep edge : path) + { + if (edge.isTransport()) + { + Rs2TransportEdge transport = edge.getTransport().orElseThrow(); + cost += transport.getDuration(); + if (transport.isTeleport()) + { + cost += policy.getDistanceBeforeUsingTeleport(); + } + } + else + { + cost += net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.distanceBetween( + edge.getFrom(), edge.getTo()); + } + } + return cost; + } + + private static List selectedTransports( + List path, Catalog catalog) + { + List selected = new ArrayList<>(); + for (Rs2RouteStep edge : path) + { + if (!edge.isTransport()) + { + continue; + } + Rs2TransportEdge transport = edge.getTransport().orElseThrow(); + Object sourceIdentity = transport.getSourceIdentity(); + String id = sourceIdentity instanceof Transport + ? catalog.ids.get((Transport) sourceIdentity) + : null; + if (id == null) + { + throw new IllegalStateException("selected transport is not from the explicit corpus catalog: " + + transport); + } + selected.add(new SelectedTransport(id, Point.from(edge.getFrom()), Point.from(edge.getTo()), + transport.getType().name(), transport.getDuration())); + } + return Collections.unmodifiableList(selected); + } + + private static void resetHeapPeaks() + { + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP) + { + pool.resetPeakUsage(); + } + } + } + + private static long usedHeap() + { + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); + } + + private static long peakHeap() + { + long peak = 0L; + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP && pool.getPeakUsage() != null) + { + peak += Math.max(0L, pool.getPeakUsage().getUsed()); + } + } + return peak; + } + + private static final class SearchOutcome + { + private final List path; + private final List edges; + private final String termination; + private final boolean reached; + private final long cost; + private final long nodesChecked; + private final long transportsChecked; + private final long elapsedNanos; + private final boolean bankVisited; + + private SearchOutcome(List path, List edges, String termination, + boolean reached, long cost, long nodesChecked, long transportsChecked, + long elapsedNanos, boolean bankVisited) + { + this.path = List.copyOf(path); + this.edges = List.copyOf(edges); + this.termination = termination; + this.reached = reached; + this.cost = cost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.bankVisited = bankVisited; + } + + private static SearchOutcome combine(SearchOutcome toBank, SearchOutcome fromBank) + { + if (toBank.path.isEmpty() || fromBank.path.isEmpty() + || !toBank.path.get(toBank.path.size() - 1).equals(fromBank.path.get(0))) + { + throw new IllegalArgumentException("bank route legs are not contiguous"); + } + List path = new ArrayList<>(toBank.path); + path.addAll(fromBank.path.subList(1, fromBank.path.size())); + List edges = new ArrayList<>(toBank.edges); + edges.addAll(fromBank.edges); + return new SearchOutcome( + path, + edges, + fromBank.termination, + fromBank.reached, + Math.addExact(toBank.cost, fromBank.cost), + availableMetric(toBank.nodesChecked) + availableMetric(fromBank.nodesChecked), + availableMetric(toBank.transportsChecked) + + availableMetric(fromBank.transportsChecked), + availableMetric(toBank.elapsedNanos) + availableMetric(fromBank.elapsedNanos), + true); + } + + private SearchOutcome withMetrics(long nodes, long transports, long elapsed) + { + return new SearchOutcome(path, edges, termination, reached, cost, + nodes, transports, elapsed, bankVisited); + } + } + + private static final class PlannerCorpus + { + private int schemaVersion; + private List cases; + } + + private static final class PlannerCase + { + private String id; + private Point start; + private Point target; + private PlannerPolicy policy; + private List transports = Collections.emptyList(); + private List bankLocations = Collections.emptyList(); + private List inventoryItems = Collections.emptyList(); + private List equipmentItems = Collections.emptyList(); + private List bankItems = Collections.emptyList(); + } + + private static final class PlannerPolicy + { + private String transportMode; + private boolean avoidWilderness; + private long cutoffMillis; + } + + private static final class PlannerTransport + { + private String id; + private Point origin; + private Point destination; + private String type; + private int duration; + private String displayInfo; + private String availability = "ALWAYS"; + private String items; + } + + private static final class PlannerItem + { + private int id; + private int quantity; + } + + private static final class Catalog + { + private final Map> withoutBankByOrigin = new HashMap<>(); + private final Map> withBankByOrigin = new HashMap<>(); + private final IdentityHashMap ids = new IdentityHashMap<>(); + + private static Catalog from(PlannerCase plannerCase) throws Exception + { + Catalog catalog = new Catalog(); + List definitions = plannerCase.transports == null + ? Collections.emptyList() : plannerCase.transports; + if ("STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !definitions.isEmpty()) + { + throw new IllegalArgumentException("static-only case has a transport catalog: " + + plannerCase.id); + } + Set seenIds = new java.util.HashSet<>(); + for (PlannerTransport definition : definitions) + { + if (definition.id == null || !seenIds.add(definition.id)) + { + throw new IllegalArgumentException("missing or duplicate transport id in " + + plannerCase.id + ": " + definition.id); + } + if (definition.origin == null || definition.destination == null + || definition.type == null || definition.duration < 0) + { + throw new IllegalArgumentException("incomplete transport " + definition.id + + " in " + plannerCase.id); + } + WorldPoint origin = definition.origin.toWorldPoint(); + Transport transport = new Transport(origin, definition.destination.toWorldPoint(), + definition.displayInfo, TransportType.valueOf(definition.type), false, + definition.duration); + applyItemRequirements(transport, definition.items); + if (!"ALWAYS".equals(definition.availability) + && !"AFTER_BANK".equals(definition.availability)) + { + throw new IllegalArgumentException("unsupported transport availability for " + + definition.id + ": " + definition.availability); + } + if (hasRequiredItems(transport, plannerCase, true)) + { + catalog.withBankByOrigin + .computeIfAbsent(origin, ignored -> new java.util.LinkedHashSet<>()) + .add(transport); + } + if ("ALWAYS".equals(definition.availability) + && hasRequiredItems(transport, plannerCase, false)) + { + catalog.withoutBankByOrigin + .computeIfAbsent(origin, ignored -> new java.util.LinkedHashSet<>()) + .add(transport); + } + catalog.ids.put(transport, definition.id); + } + return catalog; + } + + private static void applyItemRequirements(Transport transport, String items) + throws Exception + { + if (items == null || items.isBlank()) + { + return; + } + Method setter = Transport.class.getDeclaredMethod( + "setItemRequirements", List.class); + setter.setAccessible(true); + setter.invoke(transport, TransportItemRequirement.parseRequirements(items)); + } + + private static boolean hasRequiredItems( + Transport transport, PlannerCase plannerCase, boolean includeBank) + { + Map available = availableItems(plannerCase, includeBank); + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + itemId -> available.getOrDefault(itemId, 0), + itemId -> available.getOrDefault(itemId, 0) > 0, + itemId -> available.getOrDefault(itemId, 0) > 0).isPresent(); + } + + private static Map availableItems( + PlannerCase plannerCase, boolean includeBank) + { + Map available = new HashMap<>(); + addItems(available, plannerCase.inventoryItems, "inventory"); + addItems(available, plannerCase.equipmentItems, "equipment"); + if (includeBank) + { + addItems(available, plannerCase.bankItems, "bank"); + } + return available; + } + + private static void addItems( + Map available, List items, String source) + { + if (items == null) + { + return; + } + for (PlannerItem item : items) + { + if (item == null || item.id <= 0 || item.quantity <= 0) + { + throw new IllegalArgumentException("invalid " + source + " item state"); + } + available.merge(item.id, item.quantity, Math::addExact); + } + } + + } + + private static final class Point + { + private int x; + private int y; + private int plane; + + private WorldPoint toWorldPoint() + { + return new WorldPoint(x, y, plane); + } + + private static Point from(WorldPoint point) + { + if (point == null) + { + return null; + } + Point value = new Point(); + value.x = point.getX(); + value.y = point.getY(); + value.plane = point.getPlane(); + return value; + } + } + + private static final class PlannerRun + { + private final int schemaVersion; + private final String engine; + private final String revision; + private final List cases; + + private PlannerRun(int schemaVersion, String engine, String revision, + List cases) + { + this.schemaVersion = schemaVersion; + this.engine = engine; + this.revision = revision; + this.cases = cases; + } + } + + private static final class PlannerCaseResult + { + private final String id; + private final boolean supported; + private final String unsupportedReason; + private final String termination; + private final boolean reached; + private final Point endpoint; + private final int pathLength; + private final long pathCost; + private final long nodesChecked; + private final long transportsChecked; + private final long elapsedNanos; + private final long peakHeapDeltaBytes; + private final List selectedTransports; + private final boolean bankVisited; + + private PlannerCaseResult(String id, boolean supported, String unsupportedReason, + String termination, boolean reached, Point endpoint, int pathLength, long pathCost, + long nodesChecked, long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + this.id = id; + this.supported = supported; + this.unsupportedReason = unsupportedReason; + this.termination = termination; + this.reached = reached; + this.endpoint = endpoint; + this.pathLength = pathLength; + this.pathCost = pathCost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.peakHeapDeltaBytes = peakHeapDeltaBytes; + this.selectedTransports = selectedTransports; + this.bankVisited = bankVisited; + } + + private static PlannerCaseResult unsupported(String id, String reason) + { + return new PlannerCaseResult(id, false, reason, null, false, null, + 0, -1L, -1L, -1L, -1L, -1L, Collections.emptyList(), false); + } + + private static PlannerCaseResult supported(String id, String termination, + boolean reached, Point endpoint, int pathLength, long pathCost, long nodesChecked, + long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + return new PlannerCaseResult(id, true, null, termination, reached, endpoint, + pathLength, pathCost, nodesChecked, transportsChecked, elapsedNanos, + peakHeapDeltaBytes, selectedTransports, bankVisited); + } + } + + private static final class SelectedTransport + { + private final String id; + private final Point from; + private final Point to; + private final String type; + private final int duration; + + private SelectedTransport(String id, Point from, Point to, String type, int duration) + { + this.id = id; + this.from = from; + this.to = to; + this.type = type; + this.duration = duration; + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicyTest.java new file mode 100644 index 00000000000..4e7544f9b40 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LoginStabilityPolicyTest.java @@ -0,0 +1,23 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class LoginStabilityPolicyTest { + @Test + public void oneFalseSampleThatRecoversDoesNotAbort() { + assertFalse(LoginStabilityPolicy.shouldExit(false, true, false)); + } + + @Test + public void stableFalseStateAborts() { + assertTrue(LoginStabilityPolicy.shouldExit(false, false, false)); + } + + @Test + public void cancellationOwnsItsOwnExitPath() { + assertFalse(LoginStabilityPolicy.shouldExit(false, false, true)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java new file mode 100644 index 00000000000..7f8146902c5 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java @@ -0,0 +1,107 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * The raw watermark that feeds the stagnation clock. + * + *

Seeded from the first post-restart live log (2026-08-12, Lovakengj → Varrock): the entire + * Varrock west approach — fifty tiles and three doors — sat inside the final smoothed segment, so + * the smoothed progress index held one value through ~50 seconds of honest walking against a 60s + * stagnation budget. The raw index advances tile by tile on exactly that walk; the Tithe Farm + * ping-pong (the incident the budget exists for) still cannot advance it more than once. + */ +public class RouteProgressWatermarkTest { + + private final WalkerRouteState routeState = Rs2Walker.routeStateForTesting(); + + private static final WorldPoint GOAL = new WorldPoint(3049, 3341, 0); + + /** Fifty collinear raw tiles; the smoothed path keeps only the endpoints. */ + private static List rawLine() { + List raw = new ArrayList<>(); + for (int i = 0; i <= 49; i++) { + raw.add(new WorldPoint(3000 + i, 3341, 0)); + } + return raw; + } + + private static List smoothedEndpoints() { + return Arrays.asList(new WorldPoint(3000, 3341, 0), GOAL); + } + + @Before + public void reset() { + Rs2Walker.resetWalkSessionState(); + } + + @Test + public void rawAdvanceKeepsTheClockAliveWhileTheSmoothedIndexHolds() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + + // First pass initializes tracking (routeChanged stamps unconditionally). + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(0)); + int smoothedIdxAtStart = routeState.routeProgressIdx; + + for (int i = 1; i <= 20; i++) { + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(i)); + assertNotEquals("tile " + i + ": a new furthest raw tile must stamp the clock", + 0L, routeState.routeProgressAdvancedAtMs); + assertEquals("the smoothed index is expected to hold still in this scenario", + smoothedIdxAtStart, routeState.routeProgressIdx); + } + assertEquals(20, routeState.rawProgressHighIdx); + } + + @Test + public void oscillationStampsAtMostOnce() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + + // Walk to tile 4, establishing the high-water mark. + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(0)); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(4)); + assertEquals(4, routeState.rawProgressHighIdx); + + // The Tithe ping-pong: bounce between tiles 2 and 4 forever. No pass may stamp. + for (int bounce = 0; bounce < 10; bounce++) { + WorldPoint at = raw.get(bounce % 2 == 0 ? 2 : 4); + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, at); + assertEquals("bounce " + bounce + ": oscillation must not feed the stagnation clock", + 0L, routeState.routeProgressAdvancedAtMs); + } + } + + @Test + public void aReplansNewRouteResetsTheHighWaterMark() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(30)); + assertEquals(30, routeState.rawProgressHighIdx); + + // Replan: a different (shorter) route. The stale mark of 30 must not gag the watermark. + List newRaw = raw.subList(28, 49); + List newSmoothed = Arrays.asList(newRaw.get(0), GOAL); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(newRaw, newSmoothed, 0, GOAL, newRaw.get(1)); + assertTrue("post-replan raw indices are small again and must still stamp", + routeState.rawProgressHighIdx >= 0 && routeState.rawProgressHighIdx <= 2); + + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(newRaw, newSmoothed, 0, GOAL, newRaw.get(5)); + assertNotEquals(0L, routeState.routeProgressAdvancedAtMs); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java new file mode 100644 index 00000000000..be0924e07ba --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java @@ -0,0 +1,51 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Rs2HotAirBalloonTest +{ + @Test + public void everyRegisteredDestinationHasTheCanonicalMapButton() + { + assertEquals(InterfaceID.ZepBalloonMap.BTN_CAST, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.CASTLE_WARS)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_GNO, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.GRAND_TREE)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_CRAFT, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.CRAFTING_GUILD)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_ENT, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.ENTRANA)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_TAV, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.TAVERLEY)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_VARR, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.VARROCK)); + assertEquals(-1, Rs2HotAirBalloon.destinationButton(null)); + } + + @Test + public void basketLookupAcceptsBaseAndUnlockedStationTransforms() + { + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BASKET_ENTRANA)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BASKET)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_ENTRANA)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_TAV)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_CAST)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_GNO)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_CRAFT)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_VARR)); + assertFalse(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BALLOON)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java new file mode 100644 index 00000000000..1bd980781c3 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java @@ -0,0 +1,1447 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.Client; +import net.runelite.api.WorldType; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.shortestpath.PlannerSelectionMode; +import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Node; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.VisitedTiles; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.EnumSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2PathApiPlanningTest +{ + /** Covers two sequential canary cutoffs and cold production-catalog initialization in a full suite. */ + private static final long ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS = 30L; + private static PathfinderConfig config; + + @BeforeClass + public static void createIsolatedConfig() throws Exception + { + config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + setCalculationCutoff(config); + } + + private static void setCalculationCutoff(PathfinderConfig pathfinderConfig) throws Exception + { + Field cutoff = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + cutoff.setAccessible(true); + cutoff.setLong(pathfinderConfig, 10_000L); + } + + private static void setPlannerMode( + PathfinderConfig pathfinderConfig, PlannerSelectionMode mode) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField("plannerSelectionMode"); + field.setAccessible(true); + field.set(pathfinderConfig, mode); + } + + private static PathfinderConfig f2pConfig() throws Exception + { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.noneOf(WorldType.class)); + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), client, null); + setCalculationCutoff(pathfinderConfig); + return pathfinderConfig; + } + + private static PathfinderConfig membersConfig() throws Exception + { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), client, null); + setCalculationCutoff(pathfinderConfig); + return pathfinderConfig; + } + + private static void restoreProperty(String key, String value) + { + if (value == null) + { + System.clearProperty(key); + } + else + { + System.setProperty(key, value); + } + } + + @Test + public void synchronousPlanReturnsImmutableCompletedRoute() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER); + + Rs2RouteResult result = Rs2PathApi.planWithConfig(request, config); + + assertTrue("isolated pathfinder search must terminate", result.isSearchCompleted()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertTrue("short Lumbridge walk must reach its exact target", result.isTargetReached(0)); + assertEquals(target, result.getEndpoint().orElse(null)); + assertEquals(result.getPath().size() - 1, result.getSteps().size()); + assertTrue(result.getSteps().stream().noneMatch(Rs2RouteStep::isTransport)); + assertTrue("search timing should be captured", result.getSearchNanos() > 0); + Rs2RouteMetrics metrics = result.getMetrics(); + assertEquals(result.getSearchNanos(), metrics.getSearchNanos()); + assertTrue("local planner must expose selected path cost", metrics.hasPathCost()); + assertEquals("ten-tile straight walk must cost ten", 10L, metrics.getPathCost()); + assertTrue("local planner must expose explored walking nodes", metrics.hasNodesChecked()); + assertTrue(metrics.getNodesChecked() > 0); + assertTrue("local planner must expose checked transport count", metrics.hasTransportsChecked()); + assertEquals(0L, metrics.getTransportsChecked()); + try + { + result.getPath().add(start); + fail("result path must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + try + { + result.getSteps().clear(); + fail("result steps must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void localPlannerReceivesAnExplicitImmutablePolicySnapshot() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest unresolved = Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER); + + assertTrue(unresolved.getPolicy().isEmpty()); + Rs2RouteRequest resolved = Rs2PathApi.resolvePolicy(unresolved, config); + Rs2RoutePolicy policy = resolved.getPolicy().orElseThrow(AssertionError::new); + + assertEquals(config.isUseBankItems(), policy.isUseBankItems()); + assertEquals(config.isAvoidWilderness(), policy.isAvoidWilderness()); + assertEquals(config.isAvoidDangerousNpcs(), policy.isAvoidDangerousNpcs()); + assertEquals(config.isIgnoreTeleportAndItems(), policy.isIgnoreTeleportAndItems()); + assertEquals(config.getCalculationCutoffMillis(), policy.getCalculationCutoffMillis()); + assertTrue(policy.getEnabledTransportTypes().contains(Rs2TransportType.TRANSPORT)); + try + { + policy.getEnabledTransportTypes().clear(); + fail("resolved transport policy must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + + Rs2RoutePlanner planner = Rs2PathApi.localPlanner(config); + assertEquals("microbot-local", planner.getEngineId()); + try + { + planner.plan(unresolved, Rs2PathApi.resolvePlanningSnapshot(resolved, config)); + fail("an engine must not receive a request backed by mutable globals"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @Test + public void pinnedUpstreamAdapterMatchesProductionBoundaryForStaticWalk() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, config); + + Rs2RouteResult local = Rs2PathApi.localPlanner(config).plan(request, snapshot); + Rs2RoutePlanner upstreamPlanner = Rs2PathApi.upstreamPlanner(); + Rs2RouteResult upstream = upstreamPlanner.plan(request, snapshot); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.compare( + upstreamPlanner.getEngineId(), + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + upstream); + + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertTrue(comparison.getShadowEngineId().contains(UpstreamRoutePlanner.REVISION)); + assertEquals(target, upstream.getEndpoint().orElse(null)); + assertEquals(10L, upstream.getMetrics().getPathCost()); + } + + @Test + public void packagedUpstreamCoreHasNoSecondRuneLitePluginOwner() + { + assertFalse(shortestpath.ShortestPathPlugin.class.isAnnotationPresent( + net.runelite.client.plugins.PluginDescriptor.class)); + assertTrue("upstream core must resolve the pinned root collision archive", + shortestpath.ShortestPathPlugin.class.getResource("/collision-map.zip") != null); + } + + @Test + public void pinnedUpstreamAdapterRetainsExactAmbiguousTransportIdentity() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport slow = new Transport( + origin, destination, "slow", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 9); + Transport fast = new Transport( + origin, destination, "fast", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1002, 3); + PathfinderConfig transportConfig = configWithTransports( + origin, new LinkedHashSet<>(List.of(slow, fast))); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, transportConfig); + + Rs2RouteResult local = Rs2PathApi.localPlanner(transportConfig).plan(request, snapshot); + Rs2RouteResult upstream = Rs2PathApi.upstreamPlanner().plan(request, snapshot); + + assertSame(fast, local.getTransportSteps().get(0).getTransport() + .orElseThrow(AssertionError::new).getSourceIdentity()); + assertSame(fast, upstream.getTransportSteps().get(0).getTransport() + .orElseThrow(AssertionError::new).getSourceIdentity()); + Pathfinder materialized = Rs2PathApi.materializeUpstreamRoute( + upstream, transportConfig); + List selections = + Rs2PathApi.getTransportSelections(materialized, upstream.getPath()); + assertEquals(1, selections.size()); + assertSame("materialization must preserve the exact executable catalog object", + fast, selections.get(0).getLocalExecutionTransport()); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, + Rs2PlannerShadowComparison.compare( + "upstream", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + upstream).getStatus()); + } + + @Test + public void pinnedUpstreamAdapterMatchesLocalPlannerForEdgevilleBankCanoeLeg() throws Exception + { + WorldPoint start = new WorldPoint(3094, 3492, 0); + WorldPoint target = new WorldPoint(3199, 3344, 0); + Map> canoes = new HashMap<>(); + for (Map.Entry> entry + : Transport.loadAllFromResources().entrySet()) + { + if (entry.getKey() == null) + { + continue; + } + Set selected = new LinkedHashSet<>(); + for (Transport transport : entry.getValue()) + { + if (transport.getType() == TransportType.CANOE) + { + selected.add(transport); + } + } + if (!selected.isEmpty()) + { + canoes.put(entry.getKey(), selected); + } + } + PathfinderConfig transportConfig = configWithTransportCatalog(canoes); + transportConfig.setUseBankItems(true); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER) + .withPurpose(Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK), + transportConfig); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, transportConfig); + + Rs2RouteResult local = Rs2PathApi.localPlanner(transportConfig).plan(request, snapshot); + Rs2RouteResult upstream = Rs2PathApi.upstreamPlanner().plan(request, snapshot); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, local.getTerminationReason()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, upstream.getTerminationReason()); + assertEquals("the real bank-to-target leg must have the same route cost", + local.getMetrics().getPathCost(), upstream.getMetrics().getPathCost()); + assertEquals("the real bank-to-target leg must select the same exact canoe edge", + local.getTransportSteps().stream() + .map(step -> step.getTransport().orElseThrow(AssertionError::new).getSourceIdentity()) + .collect(java.util.stream.Collectors.toList()), + upstream.getTransportSteps().stream() + .map(step -> step.getTransport().orElseThrow(AssertionError::new).getSourceIdentity()) + .collect(java.util.stream.Collectors.toList())); + } + + @Test + public void pinnedUpstreamAdapterConsumesImmutableCollisionOverride() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2PlanningSnapshot base = Rs2PathApi.resolvePlanningSnapshot(request, config); + Rs2PlanningSnapshot closedArea = new Rs2PlanningSnapshot( + base.getPolicy(), + base.getAdmittedTransports(), + (x, y, plane, flag) -> plane == start.getPlane() ? Boolean.FALSE : null, + Collections.emptySet(), + packed -> false); + + Rs2RouteResult result = Rs2PathApi.upstreamPlanner().plan(request, closedArea); + + assertFalse(result.isTargetReached(0)); + assertEquals(Rs2RouteTermination.SEARCH_EXHAUSTED, result.getTerminationReason()); + } + + @Test + public void shadowFailurePublishesOnlyTheFailureType() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2RouteResult local = Rs2PathApi.localPlanner(config).plan( + request, Rs2PathApi.resolvePlanningSnapshot(request, config)); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.failed( + "upstream", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + new IllegalStateException("sensitive runtime detail")); + + assertEquals(Rs2PlannerShadowComparison.Status.FAILED, comparison.getStatus()); + assertEquals("IllegalStateException", comparison.getFailureType()); + assertFalse(comparison.getFailureType().contains("sensitive")); + } + + @Test + public void selectedTransportIsPreservedAsOwnedImmutableStep() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 3218, 1); + Transport stairs = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + PathfinderConfig transportConfig = configWithTransport(origin, stairs); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(java.util.List.of(origin, destination), result.getPath()); + assertEquals(1, result.getSteps().size()); + Rs2RouteStep step = result.getSteps().get(0); + assertTrue(step.isTransport()); + Rs2TransportEdge edge = step.getTransport().orElseThrow(AssertionError::new); + assertEquals(Rs2TransportType.TRANSPORT, edge.getType()); + assertEquals(origin, edge.getOrigin()); + assertEquals(destination, edge.getDestination()); + assertEquals("Climb-up", edge.getAction()); + assertEquals("Staircase", edge.getTarget()); + assertEquals(16671, edge.getObjectId()); + assertFalse(edge.isTeleport()); + assertSame("the local adapter must retain exact source identity opaquely", + stairs, edge.getSourceIdentity()); + + Transport indistinguishableReplacement = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + transportConfig.getTransports().put(origin, Set.of(indistinguishableReplacement)); + assertEquals("catalog refresh must not alter the selected immutable edge", + "Staircase", edge.getTarget()); + assertEquals(16671, edge.getObjectId()); + } + + @Test + public void activeExecutorSelectionUsesExactPlannerChoiceWithoutCatalogRematch() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport slow = new Transport( + origin, destination, "slow shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 9); + Transport fast = new Transport( + origin, destination, "fast shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1002, 3); + PathfinderConfig transportConfig = configWithTransports( + origin, new LinkedHashSet<>(List.of(slow, fast))); + Pathfinder pathfinder = new Pathfinder(transportConfig, origin, Set.of(destination)); + pathfinder.run(); + Transport replacement = new Transport( + origin, destination, "replacement shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1003, 1); + transportConfig.getTransports().put(origin, Set.of(replacement)); + + List selections = + Rs2PathApi.getTransportSelections(pathfinder, pathfinder.getPath()); + + assertEquals(1, selections.size()); + Rs2PathApi.ActiveTransportSelection selected = selections.get(0); + assertEquals(0, selected.getPathIndex()); + assertSame("local execution adapter must retain the exact selected object", fast, + selected.getLocalExecutionTransport()); + assertEquals(Rs2TransportExecutor.OBJECT, selected.getExecutor()); + assertTrue(selected.isExecutable()); + assertEquals("fast shared edge", selected.getEdge().getDisplayInfo()); + assertTrue("a stale/different route must not inherit the selection", + Rs2PathApi.getTransportSelections(pathfinder, List.of(origin)).isEmpty()); + } + + @Test + public void balloonRouteRetainsItsDedicatedRuntimeExecutor() throws Exception + { + WorldPoint origin = new WorldPoint(2461, 3111, 0); + WorldPoint destination = new WorldPoint(3299, 3482, 0); + Transport balloon = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + PathfinderConfig transportConfig = configWithTransport(origin, balloon); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(List.of(origin, destination), result.getPath()); + Rs2TransportEdge edge = result.getSteps().get(0).getTransport() + .orElseThrow(AssertionError::new); + assertEquals(Rs2TransportType.HOT_AIR_BALLOON, edge.getType()); + assertEquals(Rs2TransportExecutor.HOT_AIR_BALLOON, edge.getExecutor()); + assertEquals("Varrock", edge.getDisplayInfo()); + } + + @Test + public void catalogQueriesHideConcreteMutableTransportGraph() + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3200, 3200, 1); + Transport stairs = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + Map> catalog = new HashMap<>(); + catalog.put(origin, new LinkedHashSet<>(List.of(stairs))); + + assertTrue(Rs2PathApi.hasCatalogTransportOrigin(catalog, origin)); + assertTrue(Rs2PathApi.hasCatalogTransportEdge(catalog, origin, destination)); + assertFalse(Rs2PathApi.hasCatalogTransportEdge( + catalog, origin, new WorldPoint(3201, 3200, 0))); + List edges = Rs2PathApi.getCatalogTransportEdges(catalog, origin); + assertEquals(1, edges.size()); + assertEquals(destination, edges.get(0).getDestination()); + assertEquals("Staircase", edges.get(0).getTarget()); + + catalog.get(origin).clear(); + assertEquals("the returned catalog view must not alias the mutable graph", 1, edges.size()); + try + { + edges.clear(); + fail("catalog edge views must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void bidirectionalTransportRouteRetainsSearchCost() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport tunnel = new Transport( + origin, destination, "Synthetic long-band transition", + TransportType.TRANSPORT, false, 7); + PathfinderConfig transportConfig = configWithTransport(origin, tunnel); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(java.util.List.of(origin, destination), result.getPath()); + assertTrue(result.getSteps().get(0).isTransport()); + assertEquals("selected transport duration must be the joined route cost", + 7L, result.getMetrics().getPathCost()); + } + + @Test + public void requestDefensivelyCopiesMultipleTargets() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint first = new WorldPoint(3232, 3218, 0); + WorldPoint second = new WorldPoint(3222, 3228, 0); + Set mutableTargets = new LinkedHashSet<>(Set.of(first, second)); + + Rs2RouteRequest request = Rs2RouteRequest.toAny(start, mutableTargets); + mutableTargets.clear(); + + assertEquals(Set.of(first, second), request.getTargets()); + assertEquals(Rs2RouteRequest.RefreshPolicy.IF_TRANSPORTS_EMPTY, request.getRefreshPolicy()); + assertFalse(request.getUseBankItems() != null); + } + + @Test + public void bankPolicyForcesRefreshWithoutExposingConfig() + { + Rs2RouteRequest request = Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withBankItems(true); + + assertEquals(Boolean.TRUE, request.getUseBankItems()); + assertEquals(Rs2RouteRequest.RefreshPolicy.ALWAYS, request.getRefreshPolicy()); + } + + @Test + public void publicPlanRestoresTemporaryBankPolicy() throws Exception + { + RecordingPathfinderConfig recording = new RecordingPathfinderConfig(); + setCalculationCutoff(recording); + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + WorldPoint refreshTarget = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + Rs2RouteResult result = Rs2PathApi.plan( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + refreshTarget) + .withRefreshTarget(refreshTarget) + .withBankItems(true)); + + assertTrue(result.isTargetReached(0)); + assertFalse("shared config must be restored after bank-aware planning", + recording.isUseBankItems()); + assertEquals("refresh must observe the temporary policy and then its restoration", + java.util.List.of(Boolean.TRUE, Boolean.FALSE), recording.refreshPolicies); + assertEquals("policy restoration must retain the caller's refresh target", + java.util.List.of(refreshTarget, refreshTarget), recording.refreshTargets); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void unchangedBankPolicyDoesNotPerformARedundantRestoreRefresh() throws Exception + { + RecordingPathfinderConfig recording = new RecordingPathfinderConfig(); + setCalculationCutoff(recording); + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + Rs2PathApi.plan( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshTarget(target) + .withBankItems(false)); + + assertEquals(java.util.List.of(Boolean.FALSE), recording.refreshPolicies); + assertEquals(java.util.List.of(target), recording.refreshTargets); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void namedRuntimePolicyOperationsOwnMutableConfiguration() + { + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + PathfinderConfig recording = mock(PathfinderConfig.class); + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3201, 3200, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + when(recording.isAvoidDangerousNpcs()).thenReturn(true); + when(recording.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(origin))).thenReturn(true); + when(recording.isUseSpiritTrees()).thenReturn(true); + when(recording.learnBlockedEdge(origin, destination, "stable failure")).thenReturn(true); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + + assertTrue(Rs2PathApi.shouldAvoidDangerousTile(origin)); + assertTrue(Rs2PathApi.isSpiritTreeTravelEnabled()); + assertTrue(Rs2PathApi.learnBlockedEdge(origin, destination, "stable failure")); + assertTrue(Rs2PathApi.refreshPlanningConfiguration()); + assertTrue(Rs2PathApi.invalidateTransportRefreshCache()); + assertTrue(Rs2PathApi.prepareInventoryOnlyRoute(target)); + + verify(recording).learnBlockedEdge(origin, destination, "stable failure"); + verify(recording).refresh((WorldPoint) null); + verify(recording).invalidateTransportRefreshCache(); + verify(recording).setUseBankItems(false); + verify(recording).refresh(target); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void teleportItemClassificationIncludesCatalogAndCompatibilityItems() + { + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + PathfinderConfig recording = mock(PathfinderConfig.class); + Transport teleport = new Transport( + new WorldPoint(3210, 3210, 0), + "Synthetic teleport", + TransportType.TELEPORTATION_ITEM, + false, + 0, + Set.of(Set.of(1234))); + Map> catalog = new HashMap<>(); + catalog.put(null, Set.of(teleport)); + when(recording.getAllTransports()).thenReturn(catalog); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + + assertTrue(Rs2PathApi.isTeleportItem(1234, 5678)); + assertTrue(Rs2PathApi.isTeleportItem(5678, 5678)); + assertFalse(Rs2PathApi.isTeleportItem(9999, 5678)); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void caveRouteSelectionChecksEveryRequestedTarget() + { + Pathfinder normal = mock(Pathfinder.class); + Pathfinder walkingOnly = mock(Pathfinder.class); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint firstTarget = new WorldPoint(3300, 3300, 0); + WorldPoint reachedSecondTarget = new WorldPoint(3202, 3200, 0); + when(normal.getPath()).thenReturn(List.of( + start, + new WorldPoint(3201, 3201, 0), + new WorldPoint(3202, 3201, 0), + new WorldPoint(3203, 3201, 0))); + when(walkingOnly.getPath()).thenReturn(List.of( + start, + new WorldPoint(3201, 3200, 0), + reachedSecondTarget)); + + Pathfinder selected = Rs2PathApi.selectCaveRoute( + normal, + walkingOnly, + new LinkedHashSet<>(List.of(firstTarget, reachedSecondTarget)), + 0); + + assertSame("a reachable non-first target must qualify the walking-only route", + walkingOnly, selected); + } + + @Test(expected = IllegalArgumentException.class) + public void activeRouteRejectsSynchronousShadowInvocation() + { + Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + new WorldPoint(3232, 3218, 0)), + false, + 0, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY); + } + + @Test + public void canarySelectsRouteShapeOnlySemanticMatchAndRejectsCostDivergence() + throws Exception + { + PathfinderConfig f2pConfig = f2pConfig(); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3202, 3200, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + f2pConfig); + Rs2RouteResult local = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, new WorldPoint(3201, 3200, 0), target), + List.of( + Rs2RouteStep.walk(start, new WorldPoint(3201, 3200, 0)), + Rs2RouteStep.walk(new WorldPoint(3201, 3200, 0), target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 2L, 3L, 0L)); + WorldPoint alternate = new WorldPoint(3201, 3201, 0); + Rs2RouteResult equalCostAlternate = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, alternate, target), + List.of(Rs2RouteStep.walk(start, alternate), Rs2RouteStep.walk(alternate, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(9L, 2L, 2L, 0L)); + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local); + + Rs2PlannerShadowComparison match = Rs2PlannerShadowComparison.compare( + "upstream", context, local, equalCostAlternate); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, match.getStatus()); + assertFalse(match.isPathMatches()); + assertTrue(Rs2PathApi.shouldSelectUpstream(match)); + Rs2PlannerShadowStats beforeInvalidDuration = Rs2PathApi.getShadowStats(); + try + { + Rs2PathApi.recordCanaryOutcome(match, 1L, Rs2RouteMetrics.UNAVAILABLE); + fail("unavailable canary duration must be rejected"); + } + catch (IllegalArgumentException expected) + { + // Expected. + } + Rs2PlannerShadowStats afterInvalidDuration = Rs2PathApi.getShadowStats(); + assertEquals(beforeInvalidDuration.getUpstreamCanarySelections(), + afterInvalidDuration.getUpstreamCanarySelections()); + assertEquals(beforeInvalidDuration.getCanaryPerformance().getPlanningSamples(), + afterInvalidDuration.getCanaryPerformance().getPlanningSamples()); + + Rs2RouteResult higherCost = new Rs2RouteResult( + start, + Set.of(target), + equalCostAlternate.getPath(), + equalCostAlternate.getSteps(), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(9L, 3L, 2L, 0L)); + Rs2PlannerShadowComparison divergence = Rs2PlannerShadowComparison.compare( + "upstream", context, local, higherCost); + assertEquals(Rs2PlannerShadowComparison.Status.DIVERGENCE, divergence.getStatus()); + assertFalse(Rs2PathApi.shouldSelectUpstream(divergence)); + } + + @Test + public void f2pCanaryEligibilityUsesResolvedWorldPolicy() throws Exception + { + PathfinderConfig f2pConfig = f2pConfig(); + Rs2RouteRequest f2p = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to( + new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3200, 0)), + f2pConfig); + assertTrue(Rs2PathApi.isF2pCanary( + PlannerSelectionMode.UPSTREAM_F2P_CANARY, f2p)); + + Client membersClient = mock(Client.class); + when(membersClient.getWorldType()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + PathfinderConfig membersConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), + membersClient, null); + setCalculationCutoff(membersConfig); + Rs2RouteRequest members = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to( + new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3200, 0)), + membersConfig); + assertFalse(Rs2PathApi.isF2pCanary( + PlannerSelectionMode.UPSTREAM_F2P_CANARY, members)); + assertFalse(Rs2PathApi.isF2pCanary(PlannerSelectionMode.SHADOW, f2p)); + } + + @Test + public void activeRouteRemainsCalculatingUntilSelectionFutureCompletes() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Pathfinder completed = new Pathfinder(config, start, target); + completed.run(); + Future selectionFuture = mock(Future.class); + when(selectionFuture.isDone()).thenReturn(false); + try + { + Rs2PathApi.setPathfinder(completed); + Rs2PathApi.setPathfinderFuture(selectionFuture); + assertTrue(Rs2PathApi.getActiveRouteStatus().isCalculating()); + assertTrue(Rs2PathApi.getActiveRoute().isEmpty()); + + when(selectionFuture.isDone()).thenReturn(true); + assertTrue(Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + } + } + + @Test + public void activeF2pCanarySelectsPinnedUpstreamRoute() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.clearProperty("microbot.test.walker.forceUpstreamPlannerFailure"); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + long routeGeneration = Rs2PathApi.getActiveRouteStatus().getGeneration(); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible(routeGeneration)); + // The canary runs the local and upstream planners sequentially. Each inherits the + // 10-second calculation cutoff, so the lifecycle bound must cover both under a busy suite. + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + assertEquals(before.getSubmitted() + 1, after.getSubmitted()); + assertEquals(before.getCompleted() + 1, after.getCompleted()); + assertEquals(before.getUpstreamCanarySelections() + 1, + after.getUpstreamCanarySelections()); + assertEquals(before.getLocalFallbackDivergences(), + after.getLocalFallbackDivergences()); + assertEquals(before.getLocalFallbackFailures(), after.getLocalFallbackFailures()); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertEquals(before.getCanaryPerformance().getUpstreamSearchSamples() + 1, + after.getCanaryPerformance().getUpstreamSearchSamples()); + assertTrue(after.getCanaryPerformance().getPlanningNanosTotal() + > before.getCanaryPerformance().getPlanningNanosTotal()); + assertTrue(after.getCanaryPerformance().getLocalSearchNanosTotal() + > before.getCanaryPerformance().getLocalSearchNanosTotal()); + assertTrue(after.getCanaryPerformance().getUpstreamSearchNanosTotal() + > before.getCanaryPerformance().getUpstreamSearchNanosTotal()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeF2pCanaryFallsBackOnForcedUpstreamFailure() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalTestMode = System.getProperty("microbot.test.mode"); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.setProperty("microbot.test.mode", "true"); + System.setProperty("microbot.test.walker.forceUpstreamPlannerFailure", "true"); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue("the local rollback route must remain executable", + Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + assertEquals(before.getFailures() + 1, after.getFailures()); + assertEquals(before.getLocalFallbackFailures() + 1, + after.getLocalFallbackFailures()); + assertEquals(before.getUpstreamCanarySelections(), + after.getUpstreamCanarySelections()); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertEquals(before.getCanaryPerformance().getUpstreamSearchSamples(), + after.getCanaryPerformance().getUpstreamSearchSamples()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.mode", originalTestMode); + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeF2pCaveCanaryCountsBothLocalCandidateSearches() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.clearProperty("microbot.test.walker.forceUpstreamPlannerFailure"); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + true, + 0)); + + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + Rs2PlannerShadowComparison comparison = Rs2PathApi.getLastShadowComparison() + .orElseThrow(AssertionError::new); + long localPlanningDelta = after.getCanaryPerformance().getLocalSearchNanosTotal() + - before.getCanaryPerformance().getLocalSearchNanosTotal(); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertTrue("cave timing must include the unselected local candidate search", + localPlanningDelta > comparison.getLocalSearchNanos()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeMembersRouteInF2pCanaryDoesNotPolluteExecutionEvidence() throws Exception + { + PathfinderConfig activeConfig = membersConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible()); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible( + Rs2PathApi.getActiveRouteStatus().getGeneration())); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + Rs2PlannerShadowStats afterRoute = Rs2PathApi.getShadowStats(); + assertEquals(before.getSubmitted(), afterRoute.getSubmitted()); + assertEquals(before.getCanaryPerformance().getPlanningSamples(), + afterRoute.getCanaryPerformance().getPlanningSamples()); + Rs2PathApi.recordShadowWalkerOutcome(WalkerState.ARRIVED, false, false); + Rs2PlannerShadowStats afterOutcome = Rs2PathApi.getShadowStats(); + assertEquals(before.getExecution().getArrived(), + afterOutcome.getExecution().getArrived()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + } + } + + @Test + public void activeLocalRouteDoesNotAdmitComparisonExecutionEvidence() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.LOCAL); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible()); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible( + Rs2PathApi.getActiveRouteStatus().getGeneration())); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(before.getSubmitted(), Rs2PathApi.getShadowStats().getSubmitted()); + assertEquals(before.getCanaryPerformance().getPlanningSamples(), + Rs2PathApi.getShadowStats().getCanaryPerformance().getPlanningSamples()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + } + } + + @Test + public void activeWalkerRoutePublishesPinnedUpstreamShadowEvidence() throws Exception + { + PathfinderConfig activeConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + setCalculationCutoff(activeConfig); + setPlannerMode(activeConfig, PlannerSelectionMode.SHADOW); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + Field lastComparison = Rs2PathApi.class.getDeclaredField("lastShadowComparison"); + lastComparison.setAccessible(true); + Object originalComparison = lastComparison.get(null); + Rs2PlannerShadowStats statsBefore = Rs2PathApi.getShadowStats(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + lastComparison.set(null, null); + + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + Rs2PlannerShadowComparison comparison = null; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (comparison == null && System.nanoTime() < deadline) + { + comparison = Rs2PathApi.getLastShadowComparison().orElse(null); + Thread.yield(); + } + + assertTrue("active route must publish a completed shadow comparison", + comparison != null); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertTrue(comparison.getShadowEngineId().contains(UpstreamRoutePlanner.REVISION)); + assertEquals(Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE, + comparison.getContext().getInvocation()); + assertTrue(comparison.getContext().getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY)); + assertTrue(comparison.getLocalSearchNanos() > 0L); + Rs2PlannerShadowStats statsAfter = Rs2PathApi.getShadowStats(); + assertEquals(statsBefore.getSubmitted() + 1, statsAfter.getSubmitted()); + assertEquals(statsBefore.getCompleted() + 1, statsAfter.getCompleted()); + assertEquals(statsBefore.getMatches() + 1, statsAfter.getMatches()); + assertEquals( + statsBefore.getCoverage().get(Rs2PlannerShadowContext.Coverage.ACTIVE_ROUTE) + .getMatches() + 1, + statsAfter.getCoverage().get(Rs2PlannerShadowContext.Coverage.ACTIVE_ROUTE) + .getMatches()); + assertEquals( + statsBefore.getCoverage().get( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY).getMatches() + 1, + statsAfter.getCoverage().get( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY).getMatches()); + assertEquals(0L, statsAfter.getPending()); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + Rs2PathApi.recordShadowWalkerOutcome(WalkerState.ARRIVED, true, true); + Rs2PlannerShadowStats executionAfter = Rs2PathApi.getShadowStats(); + assertEquals(statsAfter.getExecution().getArrived() + 1, + executionAfter.getExecution().getArrived()); + assertEquals(statsAfter.getExecution().getRecoveryArrived() + 1, + executionAfter.getExecution().getRecoveryArrived()); + Rs2PathApi.setPathfinder(null); + assertTrue("route replacement must invalidate the previous latest evidence", + Rs2PathApi.getLastShadowComparison().isEmpty()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + lastComparison.set(null, originalComparison); + } + } + + @Test + public void cancelAndClearActiveRouteOwnsConcretePlannerCancellation() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + Pathfinder active = mock(Pathfinder.class); + Future future = mock(Future.class); + when(future.isDone()).thenReturn(false); + try + { + Rs2PathApi.setPathfinder(active); + Rs2PathApi.setPathfinderFuture(future); + + Rs2PathApi.cancelAndClearActiveRoute(); + + verify(active).cancel(); + verify(future).cancel(true); + assertNull(Rs2PathApi.getPathfinder()); + assertNull(Rs2PathApi.getPathfinderFuture()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + } + } + + @Test + public void activeRouteStatusDefensivelySnapshotsCalculatingPlanner() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Pathfinder active = mock(Pathfinder.class); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3210, 3200, 0); + List partialPath = new ArrayList<>(List.of(start, new WorldPoint(3201, 3200, 0))); + Set targets = new LinkedHashSet<>(Set.of(target)); + when(active.isDone()).thenReturn(false); + when(active.getStart()).thenReturn(start); + when(active.getTargets()).thenReturn(targets); + when(active.getPath()).thenReturn(partialPath); + try + { + long before = Rs2PathApi.getActiveRouteStatus().getGeneration(); + Rs2PathApi.setPathfinder(active); + + Rs2ActiveRouteStatus status = Rs2PathApi.getActiveRouteStatus(); + + assertEquals(Rs2ActiveRouteStatus.Phase.CALCULATING, status.getPhase()); + assertTrue(status.isPresent()); + assertTrue(status.isCalculating()); + assertTrue(status.getGeneration() > before); + assertEquals(start, status.getStart().orElse(null)); + assertEquals(Set.of(target), status.getTargets()); + assertEquals(partialPath, status.getRawPath()); + assertEquals(status.getRawPath(), status.getWalkablePath()); + partialPath.clear(); + targets.clear(); + assertEquals(2, status.getRawPath().size()); + assertEquals(Set.of(target), status.getTargets()); + try + { + status.getRawPath().clear(); + fail("active route path must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + } + } + + @Test + public void activeRouteStatusPublishesReadyMetricsWithoutPlannerType() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Pathfinder active = new Pathfinder(config, start, target); + active.run(); + try + { + Rs2PathApi.setPathfinder(active); + + Rs2ActiveRouteStatus status = Rs2PathApi.getActiveRouteStatus(); + + assertTrue(status.isReady()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, + status.getTerminationReason().orElse(null)); + assertEquals(target, status.getEndpoint().orElse(null)); + Rs2RouteMetrics metrics = status.getMetrics().orElseThrow(AssertionError::new); + assertTrue(metrics.hasSearchNanos()); + assertTrue(metrics.getNodesChecked() > 0); + assertEquals(10L, metrics.getPathCost()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + } + } + + @Test + public void requestRejectsEmptyTargetsAndResultRejectsNegativeTolerance() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + try + { + Rs2RouteRequest.toAny(start, Collections.emptySet()); + fail("empty targets must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(start, new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + try + { + result.isTargetReached(-1); + fail("negative tolerance must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @Test + public void failedPlannerIsNotReportedAsCompleted() + { + PathfinderConfig failingConfig = mock(PathfinderConfig.class); + CollisionMap failingMap = mock(CollisionMap.class); + when(failingConfig.getMap()).thenReturn(failingMap); + when(failingConfig.getCalculationCutoffMillis()).thenReturn(10_000L); + when(failingConfig.getEnabledTransportTypes()).thenReturn(Collections.emptySet()); + when(failingConfig.getRestrictedPointsPacked()).thenReturn(Collections.emptySet()); + when(failingConfig.getTeleportationItemPolicy()).thenReturn( + net.runelite.client.plugins.microbot.shortestpath.TeleportationItem.NONE); + when(failingConfig.getLiveCollisionOverlay()).thenReturn( + new net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay()); + when(failingMap.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(failingConfig), anySet())).thenThrow(new IllegalStateException("synthetic planner failure")); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + new WorldPoint(3232, 3218, 0)), + failingConfig); + + assertEquals(Rs2RouteTermination.FAILED, result.getTerminationReason()); + assertFalse("a caught planner failure must not look completed", result.isSearchCompleted()); + assertEquals(Math.max(0, result.getPath().size() - 1), result.getSteps().size()); + } + + @Test + public void ownedItemRequirementDefensivelyCopiesAlternatives() + { + Map mutable = new HashMap<>(); + mutable.put(1, 2); + mutable.put(2, 2); + Rs2TransportItemRequirement requirement = new Rs2TransportItemRequirement(mutable); + mutable.clear(); + + assertEquals(Map.of(1, 2, 2, 2), requirement.getAlternatives()); + assertTrue(requirement.isSatisfiedBy(itemId -> itemId == 2 ? 2 : 0)); + assertFalse(requirement.isSatisfiedBy(itemId -> 1)); + try + { + requirement.getAlternatives().put(3, 2); + fail("owned item alternatives must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void routeMetricsDistinguishUnavailableFromZero() + { + Rs2RouteMetrics metrics = new Rs2RouteMetrics( + Rs2RouteMetrics.UNAVAILABLE, + Rs2RouteMetrics.UNAVAILABLE, + 0L, + Rs2RouteMetrics.UNAVAILABLE); + + assertFalse(metrics.hasSearchNanos()); + assertEquals(Rs2RouteMetrics.UNAVAILABLE, metrics.getSearchNanos()); + assertFalse(metrics.hasPathCost()); + assertEquals(Rs2RouteMetrics.UNAVAILABLE, metrics.getPathCost()); + assertTrue(metrics.hasNodesChecked()); + assertEquals(0L, metrics.getNodesChecked()); + assertFalse(metrics.hasTransportsChecked()); + try + { + new Rs2RouteMetrics(-2L, 0L, 0L, 0L); + fail("negative metrics other than UNAVAILABLE must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransport(WorldPoint origin, Transport transport) throws Exception + { + return configWithTransports(origin, Set.of(transport)); + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransports( + WorldPoint origin, Set transports) throws Exception + { + return configWithTransportCatalog(Map.of(origin, transports)); + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransportCatalog( + Map> catalog) throws Exception + { + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), catalog, + Collections.emptyList(), null, null); + setCalculationCutoff(pathfinderConfig); + + Field transportsField = PathfinderConfig.class.getDeclaredField("transports"); + transportsField.setAccessible(true); + Map> activeTransports = + (Map>) transportsField.get(pathfinderConfig); + activeTransports.putAll(catalog); + + Field packedField = PathfinderConfig.class.getDeclaredField("transportsPacked"); + packedField.setAccessible(true); + PrimitiveIntHashMap> packed = + (PrimitiveIntHashMap>) packedField.get(pathfinderConfig); + for (Map.Entry> entry : catalog.entrySet()) + { + packed.put(WorldPointUtil.packWorldPoint(entry.getKey()), entry.getValue()); + } + return pathfinderConfig; + } + + private static final class RecordingPathfinderConfig extends PathfinderConfig + { + private final java.util.List refreshPolicies = new ArrayList<>(); + private final java.util.List refreshTargets = new ArrayList<>(); + + private RecordingPathfinderConfig() + { + super(SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + } + + @Override + public void refresh(WorldPoint target) + { + refreshPolicies.add(isUseBankItems()); + refreshTargets.add(target); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java new file mode 100644 index 00000000000..97217dc3537 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java @@ -0,0 +1,213 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Rs2PlannerShadowContextTest +{ + @Test + public void classifiesReplanUndergroundTransportAndResolvedPolicyWithoutCoordinates() + { + WorldPoint start = new WorldPoint(2876, 9878, 0); + WorldPoint target = new WorldPoint(2820, 9882, 0); + Rs2RoutePolicy policy = policy(true, true); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPurpose(Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK) + .withPolicy(policy); + Rs2TransportEdge transport = new Rs2TransportEdge( + start, + target, + Rs2TransportType.TRANSPORT, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test", + "Climb", + "Stairs", + 1, + 1, + false, + false, + true, + 0, + "Coins", + 30, + Collections.emptyList(), + true, + true, + true, + null); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.transport(start, target, transport)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 1L, 2L, 1L, 4L)); + + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN, true, request, result); + + assertEquals(Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN, + context.getInvocation()); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.RECOVERY_REPLAN)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.UNDERGROUND_COORDINATES)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.WALKING_ONLY_SELECTED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.USES_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.MEMBERS_WORLD_POLICY)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_MEMBERS_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_ITEM_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_SKILL_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_QUEST_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_STATE_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ITEMS_ENABLED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.LIVE_COLLISION_ENABLED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.LIVE_COLLISION_CONSULTED)); + assertEquals(Set.of(Rs2TransportExecutor.OBJECT), context.getTransportExecutors()); + assertEquals(Set.of(Rs2TransportType.TRANSPORT), context.getTransportTypes()); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY)); + } + + @Test + public void f2pWalkingRouteDoesNotClaimMembersOrRequirementEvidence() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3223, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false, false)); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.walk(start, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 1L, 2L, 0L)); + + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE, false, request, result); + + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.MEMBERS_WORLD_POLICY)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_MEMBERS_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_SKILL_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_QUEST_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_STATE_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT)); + } + + @Test(expected = UnsupportedOperationException.class) + public void coverageIsImmutable() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false)); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.walk(start, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 10L, 11L, 0L)); + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + result); + + context.getCoverage().add(Rs2PlannerShadowContext.Coverage.USES_TRANSPORT); + } + + @Test + public void equalSemanticRoutesRetainExactShapeDifferenceAsDiagnostic() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3224, 3220, 0); + WorldPoint localMid = new WorldPoint(3223, 3219, 0); + WorldPoint shadowMid = new WorldPoint(3223, 3220, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false)); + Rs2RouteResult local = walkingResult(start, localMid, target); + Rs2RouteResult shadow = walkingResult(start, shadowMid, target); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.compare( + "candidate", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + shadow); + + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertFalse(comparison.isPathMatches()); + } + + private static Rs2RouteResult walkingResult( + WorldPoint start, WorldPoint middle, WorldPoint target) + { + return new Rs2RouteResult( + start, + Set.of(target), + List.of(start, middle, target), + List.of(Rs2RouteStep.walk(start, middle), Rs2RouteStep.walk(middle, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 2L, 3L, 0L)); + } + + private static Rs2RoutePolicy policy(boolean bankItems, boolean liveCollision) + { + return policy(bankItems, liveCollision, true); + } + + private static Rs2RoutePolicy policy( + boolean bankItems, boolean liveCollision, boolean membersWorld) + { + return new Rs2RoutePolicy( + bankItems, + true, + false, + false, + false, + membersWorld, + liveCollision, + 10_000L, + 0, + Rs2RoutePolicy.TeleportationItemMode.NONE, + EnumSet.allOf(Rs2TransportType.class), + Collections.emptySet()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerStaminaTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerStaminaTest.java index 9c6b8a1af74..e4610e16d49 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerStaminaTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerStaminaTest.java @@ -26,7 +26,7 @@ public class Rs2WalkerStaminaTest { public void thresholdAlwaysInConfiguredRange() { for (String name : SAMPLE_NAMES) { for (long seed : new long[]{FIXED_SEED_A, FIXED_SEED_B, 0L, 42L}) { - int v = Rs2Walker.computeStaminaThreshold(name, seed); + int v = Rs2WalkerMovement.computeStaminaThreshold(name, seed); assertTrue(name + "@" + seed + " → " + v + " < min", v >= Rs2Walker.STAMINA_THRESHOLD_MIN); assertTrue(name + "@" + seed + " → " + v + " > max", v <= Rs2Walker.STAMINA_THRESHOLD_MAX); } @@ -36,26 +36,26 @@ public void thresholdAlwaysInConfiguredRange() { @Test public void thresholdIsDeterministicPerNameAndSeed() { for (String name : SAMPLE_NAMES) { - int first = Rs2Walker.computeStaminaThreshold(name, FIXED_SEED_A); - int second = Rs2Walker.computeStaminaThreshold(name, FIXED_SEED_A); + int first = Rs2WalkerMovement.computeStaminaThreshold(name, FIXED_SEED_A); + int second = Rs2WalkerMovement.computeStaminaThreshold(name, FIXED_SEED_A); assertEquals(first, second); } } @Test public void thresholdIsCaseInsensitive() { - assertEquals(Rs2Walker.computeStaminaThreshold("Zezima", FIXED_SEED_A), - Rs2Walker.computeStaminaThreshold("ZEZIMA", FIXED_SEED_A)); - assertEquals(Rs2Walker.computeStaminaThreshold("Lynx Titan", FIXED_SEED_A), - Rs2Walker.computeStaminaThreshold("lynx titan", FIXED_SEED_A)); + assertEquals(Rs2WalkerMovement.computeStaminaThreshold("Zezima", FIXED_SEED_A), + Rs2WalkerMovement.computeStaminaThreshold("ZEZIMA", FIXED_SEED_A)); + assertEquals(Rs2WalkerMovement.computeStaminaThreshold("Lynx Titan", FIXED_SEED_A), + Rs2WalkerMovement.computeStaminaThreshold("lynx titan", FIXED_SEED_A)); } @Test public void differentInstallSeedsProduceDifferentThresholds() { int differing = 0; for (String name : SAMPLE_NAMES) { - int a = Rs2Walker.computeStaminaThreshold(name, FIXED_SEED_A); - int b = Rs2Walker.computeStaminaThreshold(name, FIXED_SEED_B); + int a = Rs2WalkerMovement.computeStaminaThreshold(name, FIXED_SEED_A); + int b = Rs2WalkerMovement.computeStaminaThreshold(name, FIXED_SEED_B); if (a != b) differing++; } assertTrue("install seed must meaningfully scatter thresholds across installs; only " + differing + " of " @@ -71,7 +71,7 @@ public void distributionIsBimodal() { int trials = 5_000; for (int i = 0; i < trials; i++) { String synthetic = Long.toHexString(nameGen.nextLong()); - int v = Rs2Walker.computeStaminaThreshold(synthetic, FIXED_SEED_A); + int v = Rs2WalkerMovement.computeStaminaThreshold(synthetic, FIXED_SEED_A); if (v >= Rs2Walker.STAMINA_HARDCORE_MIN && v <= Rs2Walker.STAMINA_HARDCORE_MAX) hardcore++; else if (v >= Rs2Walker.STAMINA_CASUAL_MIN && v <= Rs2Walker.STAMINA_CASUAL_MAX) casual++; } @@ -84,8 +84,8 @@ public void distributionIsBimodal() { @Test public void thresholdFallbackHandlesNullAndEmpty() { - int nullThreshold = Rs2Walker.computeStaminaThreshold(null, FIXED_SEED_A); - int emptyThreshold = Rs2Walker.computeStaminaThreshold("", FIXED_SEED_A); + int nullThreshold = Rs2WalkerMovement.computeStaminaThreshold(null, FIXED_SEED_A); + int emptyThreshold = Rs2WalkerMovement.computeStaminaThreshold("", FIXED_SEED_A); assertEquals(nullThreshold, emptyThreshold); assertTrue(nullThreshold >= Rs2Walker.STAMINA_THRESHOLD_MIN); assertTrue(nullThreshold <= Rs2Walker.STAMINA_THRESHOLD_MAX); @@ -98,7 +98,7 @@ public void populationSpreadCoversMultipleValuesPerBucket() { Random nameGen = new Random(42L); for (int i = 0; i < 1_000; i++) { String name = Long.toHexString(nameGen.nextLong()); - int v = Rs2Walker.computeStaminaThreshold(name, FIXED_SEED_A); + int v = Rs2WalkerMovement.computeStaminaThreshold(name, FIXED_SEED_A); if (v <= Rs2Walker.STAMINA_HARDCORE_MAX) { hardcoreValues.add(v); } else { 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 0649642c628..92d7c12bd9d 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 @@ -1,4 +1,5 @@ package net.runelite.client.plugins.microbot.util.walker; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; import net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; @@ -7,10 +8,8 @@ import net.runelite.api.WallObject; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.InterfaceID; -import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -22,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeoutException; import java.util.function.Predicate; import static org.junit.Assert.assertEquals; @@ -34,7 +34,6 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -49,18 +48,351 @@ */ public class Rs2WalkerUnitTest { + @Test + public void teleportItemLeafActionSupportsNestedUpstreamLabels() { + assertEquals("rimmington", + Rs2WalkerTransports.teleportItemLeafAction("Max cape: POH Portals: Rimmington")); + assertEquals("fishing guild", + Rs2WalkerTransports.teleportItemLeafAction("Max cape: Fishing Teleports: Fishing Guild")); + assertEquals("teleport", + Rs2WalkerTransports.teleportItemLeafAction("Quest point cape: Teleport")); + assertEquals("chronicle", Rs2WalkerTransports.teleportItemLeafAction("Chronicle")); + assertEquals("", Rs2WalkerTransports.teleportItemLeafAction(null)); + } + + @Test + public void teleportWildernessLimitIsInclusiveWithoutOffByOne() { + assertTrue(Rs2WalkerTransports.isTeleportAllowedAtWildernessLevel(20, 20)); + assertFalse(Rs2WalkerTransports.isTeleportAllowedAtWildernessLevel(21, 20)); + } + + @Test + public void quetzalDestinationLabelsUseCurrentLandingAndMapText() { + assertEquals("Quetzacalli Gorge", + Rs2WalkerTransports.quetzalMapLabelForDestination(new WorldPoint(1510, 3222, 0))); + assertEquals("Cam Torum", + Rs2WalkerTransports.quetzalMapLabelForDestination(new WorldPoint(1446, 3108, 0))); + } + + @Test + public void terminalTravelTransport_onlyMatchesShipNpcAndBoat() { + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.SHIP)); + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.NPC)); + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.BOAT)); + + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.CHARTER_SHIP)); + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.TRANSPORT)); + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(null)); + } + + /** + * The direct-travel early release (Mountain Guide burned the whole 5s dialogue wait standing + * at Auburn Valley): landed means moved-from-start AND at the destination, same plane. + */ + @Test + public void terminalLanding_requiresMovementPlusDestinationProximityOnTheSamePlane() { + WorldPoint dest = new WorldPoint(1700, 3141, 0); + WorldPoint origin = new WorldPoint(3280, 3412, 0); + + assertTrue("landed at the exact destination after travelling", + Rs2WalkerTransports.hasLandedAtTerminalDestination(dest, origin, dest)); + assertTrue("the landing AREA counts: the guide dropped the player 4 tiles from the tile", + Rs2WalkerTransports.hasLandedAtTerminalDestination(new WorldPoint(1704, 3141, 0), origin, dest)); + assertFalse("6 tiles out is not landed", + Rs2WalkerTransports.hasLandedAtTerminalDestination(new WorldPoint(1706, 3141, 0), origin, dest)); + assertFalse("still standing where the wait began proves nothing (short-crossing guard)", + Rs2WalkerTransports.hasLandedAtTerminalDestination(dest, dest, dest)); + assertFalse("a short crossing: stepping about near an origin beside the destination must " + + "not release — the player has not closed distance on the destination", + Rs2WalkerTransports.hasLandedAtTerminalDestination( + new WorldPoint(1704, 3143, 0), new WorldPoint(1704, 3142, 0), dest)); + assertFalse("wrong plane is not the destination", + Rs2WalkerTransports.hasLandedAtTerminalDestination(new WorldPoint(1700, 3141, 1), origin, dest)); + assertFalse(Rs2WalkerTransports.hasLandedAtTerminalDestination(null, origin, dest)); + assertFalse(Rs2WalkerTransports.hasLandedAtTerminalDestination(dest, origin, null)); + } + + @Test + public void terminalNpcInteractionCandidates_onlyFallbackForLegacyShipLabels() { + assertEquals(Arrays.asList("Musa Point", "Travel"), + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Musa Point")); + assertEquals(Collections.singletonList("Travel"), + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Travel")); + assertEquals(Collections.singletonList("Talk-to"), + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Talk-to")); + assertEquals(Collections.singletonList("Follow"), + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.NPC, "Follow")); + assertTrue(Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.NPC, null).isEmpty()); + } + + @Test + public void terminalTravelAttempt_isOncePerExactEdgeUntilWalkStateReset() { + Transport ship = portSarimToMusaShip(); + + assertTrue(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); + assertFalse(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); + + Rs2Walker.clearWalkerDedupeForTesting(); + assertTrue(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); + } + + @Test + public void terminalTravelLanding_acceptsExactOrImmediateContinuationOnly() { + Transport ship = portSarimToMusaShip(); + WorldPoint modernGroundLanding = new WorldPoint(2956, 3146, 0); + List modernPath = Arrays.asList( + ship.getOrigin(), + ship.getDestination(), + modernGroundLanding); + + assertTrue(Rs2WalkerTransports.hasReachedTerminalTravelLanding( + ship, modernPath, 1, ship.getDestination())); + assertTrue(Rs2WalkerTransports.hasReachedTerminalTravelLanding( + ship, modernPath, 1, modernGroundLanding)); + assertFalse("standing at the origin is not a completed trip", + Rs2WalkerTransports.hasReachedTerminalTravelLanding(ship, modernPath, 1, ship.getOrigin())); + + List loopingPath = Arrays.asList( + ship.getOrigin(), + ship.getDestination(), + new WorldPoint(2957, 3143, 1), + modernGroundLanding); + assertFalse("an arbitrary later path point must not prove terminal arrival", + Rs2WalkerTransports.hasReachedTerminalTravelLanding(ship, loopingPath, 1, modernGroundLanding)); + assertFalse(Rs2WalkerTransports.hasReachedTerminalTravelLanding( + ship, modernPath, 1, new WorldPoint(3200, 3200, 0))); + } + + private static Transport portSarimToMusaShip() { + return new Transport( + new WorldPoint(3029, 3217, 0), + new WorldPoint(2956, 3143, 1), + "Musa Point", + TransportType.SHIP, + false, + "Musa Point", + "Captain Tobias", + 3644, + 10); + } + + @Test + public void terminalTravelObjectCandidate_matchesConfiguredSemanticTargetNearOrigin() { + Transport ferry = new Transport( + new WorldPoint(3271, 3144, 0), + new WorldPoint(3148, 2843, 0), + "", + TransportType.BOAT, + true, + "Board", + "Ferry", + 41311, + 8); + + assertTrue(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ferry, + ferry.getOrigin(), + "Ferry", + new String[]{"Board"})); + assertTrue("nearby multi-tile object anchors remain eligible", + Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ferry, + new WorldPoint(3273, 3144, 0), + "Ferry", + new String[]{"Board"})); + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ferry, ferry.getOrigin(), "Boat", new String[]{"Board"})); + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ferry, ferry.getOrigin(), "Ferry", new String[]{"Travel"})); + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ferry, new WorldPoint(3275, 3144, 0), "Ferry", new String[]{"Board"})); + + Transport ordinaryObject = new Transport( + ferry.getOrigin(), ferry.getDestination(), "", TransportType.TRANSPORT, + true, "Board", "Ferry", 41311, 8); + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( + ordinaryObject, ferry.getOrigin(), "Ferry", new String[]{"Board"})); + } + + @Test + public void alKharidTollLanding_requiresExactSelectedDestination() { + Transport eastbound = new Transport( + new WorldPoint(3267, 3227, 0), + new WorldPoint(3268, 3227, 0), + "Gate", + TransportType.TRANSPORT, + false, + "Pay-toll(10gp)", + "Gate", + net.runelite.api.ObjectID.CITY_GATE_2786, + 2); + + assertTrue(Rs2WalkerTransports.hasReachedAlKharidTollDestination( + eastbound, eastbound.getDestination())); + assertFalse("the adjacent origin must never count as a crossing", + Rs2WalkerTransports.hasReachedAlKharidTollDestination(eastbound, eastbound.getOrigin())); + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination( + eastbound, new WorldPoint(3268, 3228, 0))); + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination(eastbound, null)); + } + + @Test + public void alKharidTollLanding_rejectsUnrelatedTransport() { + Transport door = new Transport( + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Door", + TransportType.TRANSPORT, + false, + "Open", + "Door", + 136); + + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination( + door, door.getDestination())); + } + + @Test + public void alKharidTollSegment_matchesOnlyCrossGateEdges() { + assertTrue(Rs2WalkerDoors.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3268, 3227, 0))); + assertTrue(Rs2WalkerDoors.isAlKharidTollGateSegment( + new WorldPoint(3268, 3228, 0), new WorldPoint(3267, 3228, 0))); + + assertFalse("an along-gate step is not a crossing", + Rs2WalkerDoors.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3267, 3228, 0))); + assertFalse(Rs2WalkerDoors.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3268, 3227, 1))); + assertFalse(Rs2WalkerDoors.isAlKharidTollGateSegment( + new WorldPoint(3152, 3363, 0), new WorldPoint(3153, 3363, 0))); + } + + @Test + public void alKharidTollObjectCandidate_requiresGateActionAndSelectedEdgeLocation() { + Transport payToll = alKharidGateTransport("Pay-toll(10gp)"); + + assertTrue(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Gate", + new String[]{"Open", "Pay-toll(10gp)"})); + assertFalse("a stale id collision must not make an unrelated object eligible", + Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Lever", + new String[]{"Pay-toll(10gp)"})); + assertFalse(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Gate", + new String[]{"Open"})); + assertFalse(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3269, 3227, 0), + "Gate", + new String[]{"Pay-toll(10gp)"})); + + Transport open = alKharidGateTransport("Open"); + assertTrue(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( + open, + new WorldPoint(3267, 3228, 0), + "City gate", + new String[]{"Open"})); + } + + private static Transport alKharidGateTransport(String action) { + return new Transport( + new WorldPoint(3267, 3227, 0), + new WorldPoint(3268, 3227, 0), + "Gate", + TransportType.TRANSPORT, + false, + action, + "Gate", + net.runelite.api.ObjectID.CITY_GATE_2786, + 2); + } + + @Test + public void canoeStationsSelectTheirOwnMapInterfaceAndUnknownIdsFailClosed() { + assertEquals(InterfaceID.CanoeMapLum.MAIN_MAP, Rs2WalkerTransports.canoeMapMainComponentId(12163)); + assertEquals(InterfaceID.CanoeMapLum.DESTINATIONS, + Rs2WalkerTransports.canoeMapDestinationsComponentId(39638)); + assertEquals(InterfaceID.CanoeMapDougne.MAIN_MAP, + Rs2WalkerTransports.canoeMapMainComponentId(60845)); + assertEquals(InterfaceID.CanoeMapDougne.DESTINATIONS, + Rs2WalkerTransports.canoeMapDestinationsComponentId(60849)); + assertEquals(-1, Rs2WalkerTransports.canoeMapMainComponentId(99999)); + assertEquals(-1, Rs2WalkerTransports.canoeMapDestinationsComponentId(99999)); + } + + @Test + public void recoveryReplanTestHookIsTestOnlyTargetBoundAndOneShot() { + String previousTestMode = System.getProperty("microbot.test.mode"); + WorldPoint previousTarget = Rs2Walker.currentTarget; + try { + System.clearProperty("microbot.test.mode"); + Rs2Walker.currentTarget = new WorldPoint(3029, 3217, 0); + assertFalse(Rs2Walker.requestRecoveryReplanForTest()); + assertFalse(Rs2Walker.consumeRecoveryReplanForTest()); + + System.setProperty("microbot.test.mode", "true"); + Rs2Walker.currentTarget = null; + assertFalse(Rs2Walker.requestRecoveryReplanForTest()); + + Rs2Walker.currentTarget = new WorldPoint(3029, 3217, 0); + assertTrue(Rs2Walker.requestRecoveryReplanForTest()); + assertTrue(Rs2Walker.consumeRecoveryReplanForTest()); + assertFalse("one request must be consumed exactly once", + Rs2Walker.consumeRecoveryReplanForTest()); + } finally { + Rs2Walker.clearWalkerDedupeForTesting(); + Rs2Walker.currentTarget = previousTarget; + if (previousTestMode == null) { + System.clearProperty("microbot.test.mode"); + } else { + System.setProperty("microbot.test.mode", previousTestMode); + } + } + } + + @Test + public void clientThreadTimeoutDetectionWalksTheCauseChain() { + assertTrue(Rs2Walker.isClientThreadReadTimeout( + new RuntimeException("outer", new RuntimeException( + "Timed out waiting for client thread", new TimeoutException())))); + assertFalse(Rs2Walker.isClientThreadReadTimeout( + new RuntimeException("ordinary failure"))); + assertFalse(Rs2Walker.isClientThreadReadTimeout(null)); + } + + @Test + public void collisionFreeRouteIndexFallbackIsBoundedAndDistanceTagged() { + WorldPoint origin = new WorldPoint(3200, 3200, 2); + Map nearby = Rs2WalkerDoors.nearbyTilesIgnoringCollision(origin, 2); + + assertEquals(25, nearby.size()); + assertEquals(Integer.valueOf(0), nearby.get(origin)); + assertEquals(Integer.valueOf(2), nearby.get(new WorldPoint(3202, 3202, 2))); + assertFalse(nearby.containsKey(new WorldPoint(3203, 3200, 2))); + assertTrue(Rs2WalkerDoors.nearbyTilesIgnoringCollision(null, 2).isEmpty()); + assertTrue(Rs2WalkerDoors.nearbyTilesIgnoringCollision(origin, -1).isEmpty()); + } + @Before public void resetTelemetry() { Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); - Rs2Walker.sessionBlacklistedDoors.clear(); + Rs2WalkerDoors.doorAttemptLedgerForTesting().clearBlacklist(); } @After public void tearDown() { Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); - Rs2Walker.sessionBlacklistedDoors.clear(); + Rs2WalkerDoors.doorAttemptLedgerForTesting().clearBlacklist(); } @Test @@ -78,7 +410,7 @@ public void adjacentTransportSuppression_onlyAdjacentSamePlaneTransports() { assertEquals(new HashSet<>(Arrays.asList( new WorldPoint(3123, 3360, 0), new WorldPoint(3123, 3361, 0))), - Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(door, null)); + Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(door, null)); } /** @@ -105,7 +437,7 @@ public void adjacentTransportSuppression_coversAgilityShortcuts() { new HashSet<>(Arrays.asList( new WorldPoint(3151, 3363, 0), new WorldPoint(3150, 3363, 0))), - Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(shortcut, null)); + Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(shortcut, null)); } @Test @@ -120,39 +452,7 @@ public void adjacentTransportSuppression_ignoresNonAdjacentTransports() { "Ladder", 133); - assertTrue(Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); - } - - @Test - public void canoeStationsSelectTheirOwnMapInterfaceAndUnknownIdsFailClosed() { - assertEquals(InterfaceID.CanoeMapLum.MAIN_MAP, Rs2Walker.canoeMapMainComponentId(12163)); - assertEquals(InterfaceID.CanoeMapLum.DESTINATIONS, - Rs2Walker.canoeMapDestinationsComponentId(39638)); - assertEquals(InterfaceID.CanoeMapDougne.MAIN_MAP, Rs2Walker.canoeMapMainComponentId(60845)); - assertEquals(InterfaceID.CanoeMapDougne.DESTINATIONS, - Rs2Walker.canoeMapDestinationsComponentId(60849)); - assertEquals(-1, Rs2Walker.canoeMapMainComponentId(99999)); - assertEquals(-1, Rs2Walker.canoeMapDestinationsComponentId(99999)); - } - - @Test - public void barrowsDigExecutorRequiresExactMoundMappingAndSpade() { - WorldPoint origin = new WorldPoint(3564, 3291, 0); - WorldPoint destination = new WorldPoint(3559, 9703, 3); - Transport valid = new Transport(origin, destination, "Ahrim's Barrow", - TransportType.TRANSPORT, true, "Dig", "Barrow", 0); - valid.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); - - assertTrue(Rs2Walker.isBarrowsDigTransport(valid)); - - Transport wrongDestination = new Transport(origin, new WorldPoint(3558, 9718, 3), "Wrong crypt", - TransportType.TRANSPORT, true, "Dig", "Barrow", 0); - wrongDestination.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); - Transport missingSpade = new Transport(origin, destination, "Missing spade", - TransportType.TRANSPORT, true, "Dig", "Barrow", 0); - - assertFalse(Rs2Walker.isBarrowsDigTransport(wrongDestination)); - assertFalse(Rs2Walker.isBarrowsDigTransport(missingSpade)); + assertTrue(Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); } @Test @@ -165,7 +465,7 @@ public void shouldRecalculatePathAfterTransport_includesOriginlessTeleport() { 20, Collections.emptyMap()); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockTeleport)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockTeleport)); } @Test @@ -240,7 +540,7 @@ public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport( "Door", 136); - assertFalse(Rs2Walker.shouldRecalculatePathAfterTransport(door)); + assertFalse(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(door)); } @Test @@ -255,7 +555,7 @@ public void isSettledNearAdjacentSamePlaneLanding_acceptsNearDestinationOffOrigi "Door", 136); - assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertTrue(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3154, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -274,7 +574,7 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsOriginTile() { "Door", 136); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3152, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -293,7 +593,7 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsTilesTooFarFromDestinat "Door", 136); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3155, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -312,7 +612,7 @@ public void isSettledNearAdjacentSamePlaneLanding_acceptsBoundedForwardAgilityOv "Stepping stone", 16533); - assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertTrue(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3149, 3363, 0), steppingStone.getDestination(), @@ -331,17 +631,17 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsReverseOrUnboundedAgili "Stepping stone", 16533); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3155, 3363, 0), steppingStone.getDestination(), 0)); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3147, 3363, 0), steppingStone.getDestination(), 0)); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3149, 3365, 0), steppingStone.getDestination(), @@ -360,7 +660,7 @@ public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() "Gangplank", 2082); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(ship)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(ship)); } @Test @@ -375,7 +675,7 @@ public void shouldRecalculatePathAfterTransport_includesSamePlaneCoordinateBandT "Ladder", 11806); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockSewerLadder)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockSewerLadder)); } @Test @@ -386,7 +686,7 @@ public void hasPendingRouteStepBeforeArrival_detectsTransportBeforeDestination() new WorldPoint(3222, 3473, 0), new WorldPoint(3229, 3473, 0)); - assertTrue(Rs2Walker.hasPendingRouteStepBeforeArrival( + assertTrue(Rs2WalkerMovement.hasPendingRouteStepBeforeArrival( path, new WorldPoint(3229, 3473, 0), 0, @@ -401,7 +701,7 @@ public void hasPendingRouteStepBeforeArrival_ignoresStepsInsideArrivalTolerance( new WorldPoint(3228, 3473, 0), new WorldPoint(3229, 3473, 0)); - assertFalse(Rs2Walker.hasPendingRouteStepBeforeArrival( + assertFalse(Rs2WalkerMovement.hasPendingRouteStepBeforeArrival( path, new WorldPoint(3229, 3473, 0), 2, @@ -629,10 +929,10 @@ public void rawPathScan_staleAnchorPastPlayerYieldsNothingForEveryPredicate() { } assertNotNull("anchored at the player, the scan must find a forward route point", - Rs2Walker.findFurthestRawPathPointMatching(raw, player, 10, 0, wp -> true)); + Rs2WalkerMovement.findFurthestRawPathPointMatching(raw, player, 10, 0, wp -> true)); assertNull("a stale anchor near the goal must yield nothing even for an always-true predicate", - Rs2Walker.findFurthestRawPathPointMatching(raw, player, 10, 38, wp -> true)); + Rs2WalkerMovement.findFurthestRawPathPointMatching(raw, player, 10, 38, wp -> true)); } /** @@ -647,7 +947,7 @@ public void routeClickReach_staysWithinSafeBandAndActuallyVaries() { int max = 10; java.util.Set seen = new HashSet<>(); for (int i = 0; i < 400; i++) { - int reach = Rs2Walker.routeClickReach(max); + int reach = Rs2WalkerMovement.routeClickReach(max); assertTrue("reach must never exceed the caller's minimap reach, got " + reach, reach <= max); assertTrue("reach must stay clear of the interim-close threshold, got " + reach, reach >= 7); seen.add(reach); @@ -659,7 +959,7 @@ public void routeClickReach_staysWithinSafeBandAndActuallyVaries() { @Test public void routeClickReach_degenerateBoundsAreSafe() { for (int max : new int[]{0, 1, 5, 7}) { - int reach = Rs2Walker.routeClickReach(max); + int reach = Rs2WalkerMovement.routeClickReach(max); assertEquals("a reach at/below the floor must pass through unchanged", max, reach); } } @@ -711,58 +1011,41 @@ public void rockfallGateStaysClosedAwayFromTheMine() { Rs2ObstacleHandler.isMotherlodeRockfallCandidate(varrock, null, 0)); } - /** - * A handled door/transport/blocker must NOT be charged against the partial-retry budget. - * - *

Regression for a walk to an underground goal that reported UNREACHABLE while still - * advancing. The path end sat 31 tiles short of the goal, so {@code partialPath} was true on - * every iteration and the budget was armed for the whole walk. Opening one door ended the - * iteration, landed in the partial branch and spent a retry; the next iteration spent the last - * one a second later without the player ever walking. Three retries were gone ~100 tiles into a - * route that was working, and the walker gave up on the surface having never reached the ladder. - */ - @Test - public void routeProgressExits_areNotChargedAgainstThePartialRetryBudget() { - for (String progress : new String[]{ - "door-handled", - "door-handled-local-reachability", - "door-handled-during-interim", - "door-handled-before-minimap-click", - "transport-handled", - "current-tile-transport-handled", - "post-click-current-tile-transport-handled", - "raw-path-scene-object-handled", - "post-click-raw-path-scene-object-handled", - "rockfall-handled", - "path-blocker-handled", - "interim-in-flight", - "recovery-move-in-flight", - "route-fold-continuation-click"}) { - assertTrue("'" + progress + "' means the walker advanced the route, so it must not spend " - + "a partial retry", Rs2Walker.isRouteProgressExit(progress)); - } - } + // The partial-retry budget classification moved to WalkExit; its cases, including this + // underground-goal regression, now live in WalkExitTest as explicit sets. /** - * The exemption must stay narrow: reasons that mean the walker failed to advance still have to - * consume the budget, otherwise a genuinely unreachable goal never terminates and the walk spins - * until the outer tail cap trips. + * How long the walker will actually tolerate a motionless player before recovering. + * + *

Pinned as wall-clock seconds rather than as multipliers, because the multipliers are not the + * thing anyone cares about — "how long does it sit there" is. It used to be up to 36s: a flat 12s + * grace after every successful click, refreshed each pass, and then a 12s base scaled as far as + * 2x. The grace is gone (tile changes already refresh the clock, so it only ever bound the case + * where the player was NOT moving) and the interim multiplier is 1.25 rather than 1.75. + * + *

The base stays 12s on purpose: the longest legitimate motionless stretch measured across + * four live farm runs is ~7.1s, waiting out a transport handoff. Cutting the base is how you get + * a walker that interrupts its own ships. */ @Test - public void nonProgressExits_stillConsumeThePartialRetryBudget() { - for (String stuck : new String[]{ - "end-of-path", - "not-near-path", - "player-location-null", - "click-failed-off-minimap", - "door-edge-waiting-retry", - "door-edge-nearby-waiting-retry", - "door-recovery-suppressed", - "local-reachability-miss-no-click", - null}) { - assertFalse("'" + stuck + "' is not route progress and must still spend a retry", - Rs2Walker.isRouteProgressExit(stuck)); - } + public void stallBudgetStaysWithinItsMeasuredEnvelope() { + long plain = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, false, false); + long withInterim = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, true, false); + long worst = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, true, true, true, true, true); + + assertEquals("plain stall budget", 12_000L, plain); + assertEquals("the common case: a sticky interim is live for most of a walk", 15_000L, withInterim); + assertEquals("worst case, everything applying at once", 24_000L, worst); + assertTrue("must stay clear of the ~7.1s transport handoff measured live", plain >= 10_000L); } /** @@ -942,7 +1225,7 @@ public void findFurthestRawPathPointMatching_keepsPrimaryClickOnForwardRawRoute( new WorldPoint(1009, 1000, 0), new WorldPoint(1008, 1000, 0)); - WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + WorldPoint target = Rs2WalkerMovement.findFurthestRawPathPointMatching( rawPath, player, 13, @@ -964,7 +1247,7 @@ public void findFurthestRawPathPointMatching_honorsCandidatePredicate() { allowed, blocked); - WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + WorldPoint target = Rs2WalkerMovement.findFurthestRawPathPointMatching( rawPath, player, 13, @@ -982,7 +1265,7 @@ public void findFurthestRawPathPointMatching_doesNotReturnCurrentTile() { player, new WorldPoint(3215, 3200, 0)); - WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + WorldPoint target = Rs2WalkerMovement.findFurthestRawPathPointMatching( rawPath, player, 10, @@ -1001,7 +1284,7 @@ public void findFurthestRawPathPointMatching_stopsBeforeRouteStepBudgetIsExceede new WorldPoint(3210, 3200, 0), new WorldPoint(3216, 3200, 0)); - WorldPoint target = Rs2Walker.findFurthestRawPathPointMatching( + WorldPoint target = Rs2WalkerMovement.findFurthestRawPathPointMatching( rawPath, player, 13, @@ -1105,12 +1388,12 @@ public void offPathRecalcDeferralReason_allowsRecalcWhenSignalsExpired() { @Test public void shortWalkDirectPathCeiling_flagsGateDetours() { assertTrue("the Shantay detour (700 tiles for a 30-tile hop) must escalate to the bank compare", - 700 > Rs2Walker.shortWalkDirectPathCeiling(30)); + 700 > Rs2WalkerMovement.shortWalkDirectPathCeiling(30)); assertTrue("an honest town wiggle (150 tiles for an 80-tile hop) must stay direct", - 150 <= Rs2Walker.shortWalkDirectPathCeiling(80)); + 150 <= Rs2WalkerMovement.shortWalkDirectPathCeiling(80)); assertEquals("tiny distances keep a floor so building detours don't trip it", - 60, Rs2Walker.shortWalkDirectPathCeiling(5)); - assertEquals(300, Rs2Walker.shortWalkDirectPathCeiling(100)); + 60, Rs2WalkerMovement.shortWalkDirectPathCeiling(5)); + assertEquals(300, Rs2WalkerMovement.shortWalkDirectPathCeiling(100)); } /** @@ -1174,13 +1457,13 @@ public void shouldDispatchTransportAtRange_decisionTable() { public void doorDialogueDeferActive_holdsOffButAlwaysReleases() { long max = 5_000L; assertTrue("hold off while the menu is fresh", - Rs2Walker.doorDialogueDeferActive(10_000L, 10_500L, max)); + Rs2WalkerDoors.doorDialogueDeferActive(10_000L, 10_500L, max)); assertTrue("still holding just inside the bound", - Rs2Walker.doorDialogueDeferActive(10_000L, 14_999L, max)); + Rs2WalkerDoors.doorDialogueDeferActive(10_000L, 14_999L, max)); assertFalse("nothing answered it — resume clicking rather than stall the walk", - Rs2Walker.doorDialogueDeferActive(10_000L, 15_000L, max)); + Rs2WalkerDoors.doorDialogueDeferActive(10_000L, 15_000L, max)); assertFalse("no hold-off recorded means no deferral", - Rs2Walker.doorDialogueDeferActive(0L, 99_999L, max)); + Rs2WalkerDoors.doorDialogueDeferActive(0L, 99_999L, max)); } @Test @@ -1259,13 +1542,13 @@ public void directMinimapTarget_usesEuclideanRatherThanChebyshevRange() { WorldPoint player = new WorldPoint(3289, 3476, 0); assertFalse("A diagonal endpoint can be Chebyshev-close but outside the circular minimap reach", - Rs2Walker.shouldAttemptDirectMinimapTarget( + Rs2WalkerMovement.shouldAttemptDirectMinimapTarget( new WorldPoint(3300, 3487, 0), player, 12)); assertTrue("A cardinal endpoint on the Euclidean boundary remains eligible", - Rs2Walker.shouldAttemptDirectMinimapTarget( + Rs2WalkerMovement.shouldAttemptDirectMinimapTarget( new WorldPoint(3301, 3476, 0), player, 12)); assertFalse("A different plane is never a direct minimap target", - Rs2Walker.shouldAttemptDirectMinimapTarget( + Rs2WalkerMovement.shouldAttemptDirectMinimapTarget( new WorldPoint(3289, 3476, 1), player, 12)); } @@ -1357,7 +1640,7 @@ public void wallDoorTouchesSegment_startingBesideDoorAndMovingAway_returnsFalse( @Test public void isDoorEdgeNudgeResolved_movesToWrongNeighbor_returnsFalse() { - assertFalse(Rs2Walker.isDoorEdgeNudgeResolved( + assertFalse(Rs2WalkerDoors.isDoorEdgeNudgeResolved( new WorldPoint(3240, 3301, 0), new WorldPoint(3239, 3302, 0), new WorldPoint(3240, 3301, 0), @@ -1366,16 +1649,63 @@ public void isDoorEdgeNudgeResolved_movesToWrongNeighbor_returnsFalse() { @Test public void isDoorEdgeNudgeResolved_crossesToDoorTarget_returnsTrue() { - assertTrue(Rs2Walker.isDoorEdgeNudgeResolved( + assertTrue(Rs2WalkerDoors.isDoorEdgeNudgeResolved( new WorldPoint(3240, 3301, 0), new WorldPoint(3241, 3302, 0), new WorldPoint(3240, 3301, 0), new WorldPoint(3241, 3302, 0))); } + /** + * The nudge now clicks a route point PAST the door, so a successful crossing keeps going. The + * live log's exact case: south door 3369->3368, player observed at 3365 — through the door and + * three tiles beyond — reported unresolved by the near-toWp rule. + */ + @Test + public void isDoorEdgeNudgeResolved_ranOnPastTheDoor_returnsTrue() { + assertTrue(Rs2WalkerDoors.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3365, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + /** + * A running player covers two tiles a tick and may NEVER be observed on toWp itself: a nudge + * starting on fromWp has beforeTo=1, so "afterTo < beforeTo" could only fire on exactly toWp. + * Observed live as 3369 -> 3367 -> 3365 with every poll reading unresolved. + */ + @Test + public void isDoorEdgeNudgeResolved_runningSkipsTheFarSideTile_returnsTrue() { + assertTrue(Rs2WalkerDoors.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3367, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + /** Walking parallel along the NEAR side of the wall is not a crossing, however far it gets. */ + @Test + public void isDoorEdgeNudgeResolved_parallelOnTheNearSide_returnsFalse() { + assertFalse(Rs2WalkerDoors.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3103, 3369, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + @Test + public void isDoorEdgeNudgeResolved_eastDoorCrossedAtSpeed_returnsTrue() { + assertTrue(Rs2WalkerDoors.isDoorEdgeNudgeResolved( + new WorldPoint(3240, 3301, 0), + new WorldPoint(3243, 3301, 0), + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3301, 0))); + } + @Test public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { - assertTrue(Rs2Walker.shouldClearInterimTarget( + assertTrue(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2889, 3396, 0), 1_000L, @@ -1385,7 +1715,7 @@ public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { @Test public void shouldClearInterimTarget_preclickDistanceStillKeepsCheckpoint() { - assertFalse(Rs2Walker.shouldClearInterimTarget( + assertFalse(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2884, 3396, 0), 1_000L, @@ -1395,14 +1725,14 @@ public void shouldClearInterimTarget_preclickDistanceStillKeepsCheckpoint() { @Test public void distanceToInterimOrMax_samePlaneReturnsDistance() { - assertEquals(8, Rs2Walker.distanceToInterimOrMax( + assertEquals(8, Rs2WalkerMovement.distanceToInterimOrMax( new WorldPoint(2850, 3506, 0), new WorldPoint(2849, 3498, 0))); } @Test public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { - assertTrue(Rs2Walker.shouldClearInterimTarget( + assertTrue(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, @@ -1412,7 +1742,7 @@ public void shouldClearInterimTarget_expiredCheckpoint_returnsTrue() { @Test public void shouldClearInterimTarget_staleProgress_returnsTrue() { - assertTrue(Rs2Walker.shouldClearInterimTarget( + assertTrue(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, @@ -1422,7 +1752,7 @@ public void shouldClearInterimTarget_staleProgress_returnsTrue() { @Test public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { - assertFalse(Rs2Walker.shouldClearInterimTarget( + assertFalse(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, @@ -1437,7 +1767,7 @@ public void shouldClearInterimTarget_activeFarCheckpoint_returnsFalse() { */ @Test public void shouldClearInterimTarget_movingAwayFromCheckpoint_returnsTrue() { - assertTrue(Rs2Walker.shouldClearInterimTarget( + assertTrue(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2973, 3350, 0), new WorldPoint(2960, 3343, 0), 1_000L, @@ -1449,7 +1779,7 @@ public void shouldClearInterimTarget_movingAwayFromCheckpoint_returnsTrue() { /** Rounding a wall costs a few tiles and must not abandon a checkpoint still being approached. */ @Test public void shouldClearInterimTarget_detourWithinMargin_returnsFalse() { - assertFalse(Rs2Walker.shouldClearInterimTarget( + assertFalse(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, @@ -1461,7 +1791,7 @@ public void shouldClearInterimTarget_detourWithinMargin_returnsFalse() { /** Unknown best distance leaves the abandon check inert — behaviour matches the 5-arg form. */ @Test public void shouldClearInterimTarget_unknownBestDistance_returnsFalse() { - assertFalse(Rs2Walker.shouldClearInterimTarget( + assertFalse(Rs2WalkerMovement.shouldClearInterimTarget( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, @@ -1472,12 +1802,13 @@ public void shouldClearInterimTarget_unknownBestDistance_returnsFalse() { @Test public void shouldYieldForActiveRecoveryInterim_recentProgress_returnsTrue() { - assertTrue(Rs2Walker.shouldYieldForActiveRecoveryInterim( + assertTrue(Rs2WalkerMovement.shouldYieldForActiveRecoveryInterim( new WorldPoint(2890, 3396, 0), new WorldPoint(2884, 3396, 0), 1_000L, 2_500L, 3_000L, + Integer.MAX_VALUE, 0L, 0L, false)); @@ -1485,12 +1816,13 @@ public void shouldYieldForActiveRecoveryInterim_recentProgress_returnsTrue() { @Test public void shouldYieldForActiveRecoveryInterim_staleProgress_returnsFalse() { - assertFalse(Rs2Walker.shouldYieldForActiveRecoveryInterim( + assertFalse(Rs2WalkerMovement.shouldYieldForActiveRecoveryInterim( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, 1_500L, 5_000L, + Integer.MAX_VALUE, 0L, 0L, false)); @@ -1498,17 +1830,32 @@ public void shouldYieldForActiveRecoveryInterim_staleProgress_returnsFalse() { @Test public void shouldYieldForActiveRecoveryInterim_recentRecoveryClick_returnsTrue() { - assertTrue(Rs2Walker.shouldYieldForActiveRecoveryInterim( + assertTrue(Rs2WalkerMovement.shouldYieldForActiveRecoveryInterim( new WorldPoint(2890, 3396, 0), new WorldPoint(2880, 3396, 0), 1_000L, 0L, 3_000L, + Integer.MAX_VALUE, 0L, 2_000L, false)); } + @Test + public void shouldYieldForActiveRecoveryInterim_movingAway_returnsFalse() { + assertFalse(Rs2WalkerMovement.shouldYieldForActiveRecoveryInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 4_900L, + 5_000L, + 5, + 0L, + 0L, + true)); + } + @Test public void shouldDeferRouteWorkForActiveInterim_movingFarCheckpoint_returnsTrue() { assertTrue(Rs2Walker.shouldDeferRouteWorkForActiveInterim( @@ -1517,6 +1864,7 @@ public void shouldDeferRouteWorkForActiveInterim_movingFarCheckpoint_returnsTrue 1_000L, 4_500L, 5_000L, + Integer.MAX_VALUE, 0L, true, 5)); @@ -1530,6 +1878,7 @@ public void shouldDeferRouteWorkForActiveInterim_recentProgressStoppedFar_return 1_000L, 4_900L, 5_000L, + Integer.MAX_VALUE, 0L, false, 5)); @@ -1543,6 +1892,7 @@ public void shouldDeferRouteWorkForActiveInterim_closeCheckpoint_returnsFalse() 1_000L, 4_500L, 5_000L, + Integer.MAX_VALUE, 0L, true, 5)); @@ -1556,23 +1906,38 @@ public void shouldDeferRouteWorkForActiveInterim_staleStoppedCheckpoint_returnsF 1_000L, 1_500L, 5_000L, + Integer.MAX_VALUE, 0L, false, 5)); } + @Test + public void shouldDeferRouteWorkForActiveInterim_movingAway_returnsFalse() { + assertFalse(Rs2Walker.shouldDeferRouteWorkForActiveInterim( + new WorldPoint(2890, 3396, 0), + new WorldPoint(2880, 3396, 0), + 1_000L, + 4_900L, + 5_000L, + 5, + 0L, + true, + 5)); + } + @Test public void interimPreclickTiles_runHandsOffEarlierThanWalk() { - assertEquals(6, Rs2Walker.interimPreclickTiles(false)); - assertEquals(8, Rs2Walker.interimPreclickTiles(true)); + assertEquals(6, Rs2WalkerMovement.interimPreclickTiles(false)); + assertEquals(8, Rs2WalkerMovement.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")); + assertEquals("stall_recovery_click", Rs2WalkerMovement.routeMovementClickPhase("stall recovery click")); + assertEquals("active_route_idle_nudge", Rs2WalkerMovement.routeMovementClickPhase("active route idle nudge")); + assertEquals("interim_close_route_click", Rs2WalkerMovement.routeMovementClickPhase("interim close route click")); + assertEquals("route_movement_click", Rs2WalkerMovement.routeMovementClickPhase("other")); } @Test @@ -1633,39 +1998,12 @@ public void walkStepPathReachesTarget_rejectsMissingPathOrEndpoint() { @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)); + assertFalse(Rs2WalkerMovement.shouldRunActiveRouteIdleNudge(true, true)); + assertTrue(Rs2WalkerMovement.shouldRunActiveRouteIdleNudge(true, false)); + assertFalse(Rs2WalkerMovement.shouldRunActiveRouteIdleNudge(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)); - } + // Startup-preclick skipping moved to SegmentGate; its cases live in SegmentGateTest. @Test public void rawPathForwardAnchorIndex_keepsFallbackAheadOfAnchor() { @@ -1700,7 +2038,7 @@ public void rawPathForwardAnchorIndex_keepsFallbackAheadOfAnchor() { @Test public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_returnsTrue() { - assertTrue(Rs2Walker.didTraverseInteractedDoor( + assertTrue(Rs2WalkerDoors.didTraverseInteractedDoor( new WorldPoint(2465, 3494, 0), new WorldPoint(2465, 3493, 0), new WorldPoint(2465, 3493, 0), @@ -1710,7 +2048,7 @@ public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_return @Test public void didTraverseInteractedDoor_movesWithoutCrossingObject_returnsFalse() { - assertFalse(Rs2Walker.didTraverseInteractedDoor( + assertFalse(Rs2WalkerDoors.didTraverseInteractedDoor( new WorldPoint(2465, 3494, 0), new WorldPoint(2465, 3495, 0), new WorldPoint(2465, 3493, 0), @@ -1720,7 +2058,7 @@ public void didTraverseInteractedDoor_movesWithoutCrossingObject_returnsFalse() @Test public void didTraverseInteractedDoor_crossesObjectButMovesAwayFromDestination_returnsFalse() { - assertFalse(Rs2Walker.didTraverseInteractedDoor( + assertFalse(Rs2WalkerDoors.didTraverseInteractedDoor( new WorldPoint(1987, 5568, 0), new WorldPoint(1986, 5568, 0), new WorldPoint(1987, 5568, 0), @@ -1730,7 +2068,7 @@ public void didTraverseInteractedDoor_crossesObjectButMovesAwayFromDestination_r @Test public void shouldBlacklistDoorAfterWrongTraversal_teleportAway_returnsTrue() { - assertTrue(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + assertTrue(Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(1987, 5568, 0), new WorldPoint(2435, 3519, 0), new WorldPoint(1987, 5568, 0), @@ -1740,7 +2078,7 @@ public void shouldBlacklistDoorAfterWrongTraversal_teleportAway_returnsTrue() { @Test public void shouldBlacklistDoorAfterWrongTraversal_startedFarFromDoor_returnsFalse() { assertFalse("movement from an earlier minimap click must not blacklist a valid gate", - Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(3270, 3320, 0), new WorldPoint(3275, 3325, 0), new WorldPoint(3262, 3322, 0), @@ -1749,7 +2087,7 @@ public void shouldBlacklistDoorAfterWrongTraversal_startedFarFromDoor_returnsFal @Test public void shouldBlacklistDoorAfterWrongTraversal_progressTowardEdge_returnsFalse() { - assertFalse(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + assertFalse(Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(2465, 3494, 0), new WorldPoint(2465, 3493, 0), new WorldPoint(2465, 3494, 0), @@ -1795,7 +2133,7 @@ public void shouldBlacklistDoorAfterWrongTraversal_sampledWhileMoving_returnsFal // and learn-persisted — the shop's front door as permanently blocked. A same-plane sample taken // while the player is walking is a point along the path, never a traversal verdict. assertFalse("mid-walk sample must not blacklist the door", - Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(3008, 3207, 0), // before: en route toward the door new WorldPoint(3012, 3211, 0), // after: still walking new WorldPoint(3012, 3204, 0), @@ -1807,7 +2145,7 @@ public void shouldBlacklistDoorAfterWrongTraversal_sampledWhileMoving_returnsFal public void shouldBlacklistDoorAfterWrongTraversal_settledWrongWayDisplacement_stillBlacklists() { // Same shape of movement, but the player has STOPPED: a door that displaced the player the wrong // way and left them settled there is a genuine wrong traversal — the original blacklist case. - assertTrue(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + assertTrue(Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(3011, 3205, 0), // started beside the edge new WorldPoint(3016, 3206, 0), // settled 5 tiles away on the wrong side new WorldPoint(3012, 3204, 0), @@ -1818,7 +2156,7 @@ public void shouldBlacklistDoorAfterWrongTraversal_settledWrongWayDisplacement_s @Test public void shouldBlacklistDoorAfterWrongTraversal_planeChangeTrustedEvenWhileMoving() { // A plane change cannot come from walking — the door acted. Trusted regardless of motion state. - assertTrue(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + assertTrue(Rs2WalkerDoors.shouldBlacklistDoorAfterWrongTraversal( new WorldPoint(3011, 3205, 0), new WorldPoint(3011, 3205, 1), new WorldPoint(3012, 3204, 0), @@ -1826,87 +2164,125 @@ public void shouldBlacklistDoorAfterWrongTraversal_planeChangeTrustedEvenWhileMo true)); } - @Test - public void markDoorEdgeAttemptThisPass_allowsFirstAttemptOnly() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] segment = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; + // --------------------------------------------------------------------------- + // #19 — Quest-lock dialogue heuristic + // --------------------------------------------------------------------------- - WorldPoint playerPos = new WorldPoint(2465, 3494, 0); - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); - assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); + @Test + public void questLock_nullAndEmpty_returnFalse() { + assertFalse(Rs2WalkerDoors.hasQuestLockKeywords(null)); + assertFalse(Rs2WalkerDoors.hasQuestLockKeywords("")); } @Test - public void markDoorEdgeAttemptThisPass_treatsReverseEdgeAsDuplicate() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] forward = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; - WorldPoint[] reverse = new WorldPoint[] { - new WorldPoint(2465, 3493, 0), - new WorldPoint(2465, 3494, 0) - }; + public void questLock_benignDialogueReturnsFalse() { + assertFalse(Rs2WalkerDoors.hasQuestLockKeywords("Hello there, adventurer!")); + assertFalse(Rs2WalkerDoors.hasQuestLockKeywords("Would you like to trade?")); + assertFalse(Rs2WalkerDoors.hasQuestLockKeywords("Click to continue")); + } - WorldPoint playerPos = new WorldPoint(2465, 3494, 0); - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, forward, playerPos)); - assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, reverse, playerPos)); + @Test + public void questLock_commonGatingPhrasesReturnTrue() { + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("You need to have completed Cook's Assistant.")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("You must first finish the quest.")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("You have not yet proven yourself.")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("You cannot enter until you're a member.")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("You can't enter without the key.")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("This area requires you to have level 50 Agility.")); } @Test - public void markDoorEdgeAttemptThisPass_allowsRetryAfterPlayerProgress() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] segment = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; + public void questLock_isCaseInsensitive() { + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("YOU MUST COMPLETE THE QUEST")); + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("you Need To finish first")); + } - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2465, 3494, 0))); - assertTrue("retry should be allowed after moving away from same-edge attempt tile", - Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2462, 3491, 0))); + @Test + public void questLock_detectsBareQuestMention() { + // The standalone "quest" keyword is a last-resort safety net — gate dialogues + // almost always include it even when phrasing is unusual. + assertTrue(Rs2WalkerDoors.hasQuestLockKeywords("Only those who have finished the holy quest may pass.")); } // --------------------------------------------------------------------------- - // #19 — Quest-lock dialogue heuristic + // Goal-tile object guard (D3 requirement #1 — the Gift of Peace lesson) // --------------------------------------------------------------------------- + // + // An object standing ON the walk target is the destination, not an obstacle en route. Seeded + // from the Stronghold corridor (2026-08-13): the goal chest was Open-clicked and its failed + // traversal waited out on three consecutive runs, ~9s each, before arrived-within-distance. @Test - public void questLock_nullAndEmpty_returnFalse() { - assertFalse(Rs2Walker.hasQuestLockKeywords(null)); - assertFalse(Rs2Walker.hasQuestLockKeywords("")); + public void goalTileChestIsNotAnObstacleWhenTheWalkMayFinishBesideIt() { + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5224, 0); + assertTrue(Rs2WalkerDoors.goalTileObjectIsNotAnObstacle(false, goal, 4, goal, beside, goal)); } @Test - public void questLock_benignDialogueReturnsFalse() { - assertFalse(Rs2Walker.hasQuestLockKeywords("Hello there, adventurer!")); - assertFalse(Rs2Walker.hasQuestLockKeywords("Would you like to trade?")); - assertFalse(Rs2Walker.hasQuestLockKeywords("Click to continue")); + public void aWallDoorOnTheGoalEdgeIsStillAnObstacle() { + // A door on the goal tile's EDGE may genuinely need opening to step onto the goal. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5223, 0); + assertFalse(Rs2WalkerDoors.goalTileObjectIsNotAnObstacle(true, goal, 4, goal, beside, goal)); } @Test - public void questLock_commonGatingPhrasesReturnTrue() { - assertTrue(Rs2Walker.hasQuestLockKeywords("You need to have completed Cook's Assistant.")); - assertTrue(Rs2Walker.hasQuestLockKeywords("You must first finish the quest.")); - assertTrue(Rs2Walker.hasQuestLockKeywords("You have not yet proven yourself.")); - assertTrue(Rs2Walker.hasQuestLockKeywords("You cannot enter until you're a member.")); - assertTrue(Rs2Walker.hasQuestLockKeywords("You can't enter without the key.")); - assertTrue(Rs2Walker.hasQuestLockKeywords("This area requires you to have level 50 Agility.")); + public void aDistanceZeroWalkStillAttemptsTheGoalTileObject() { + // The walk MUST end on the tile itself; if an openable object seals it, opening is honest. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5224, 0); + assertFalse(Rs2WalkerDoors.goalTileObjectIsNotAnObstacle(false, goal, 0, goal, beside, goal)); } @Test - public void questLock_isCaseInsensitive() { - assertTrue(Rs2Walker.hasQuestLockKeywords("YOU MUST COMPLETE THE QUEST")); - assertTrue(Rs2Walker.hasQuestLockKeywords("you Need To finish first")); + public void anObjectShortOfTheGoalIsStillAnObstacle() { + // Only the goal tile's own object is exempt; a chest two tiles early still blocks the route + // even when its near side is adjacent to it. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint doorTile = new WorldPoint(1905, 5225, 0); + WorldPoint besideDoor = new WorldPoint(1904, 5226, 0); + assertFalse(Rs2WalkerDoors.goalTileObjectIsNotAnObstacle(false, goal, 4, doorTile, besideDoor, doorTile)); } @Test - public void questLock_detectsBareQuestMention() { - // The standalone "quest" keyword is a last-resort safety net — gate dialogues - // almost always include it even when phrasing is unusual. - assertTrue(Rs2Walker.hasQuestLockKeywords("Only those who have finished the holy quest may pass.")); + public void aFarNearSideDoesNotQualifyForTheGoalSkip() { + // The skip is only honest when the walk can FINISH from the near side; a ranged detection + // several tiles out must still be handled as an obstacle if crossing is required later. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint farAway = new WorldPoint(1900, 5230, 0); + assertFalse(Rs2WalkerDoors.goalTileObjectIsNotAnObstacle(false, goal, 4, goal, farAway, goal)); + } + + // --------------------------------------------------------------------------- + // Walled-net door adjacency (D3 requirement #2 — the double-gate wing lesson) + // --------------------------------------------------------------------------- + + @Test + public void aGateWingParallelBesideTheEdgeCountsAsAdjacent() { + // Stronghold 2026-08-13 14:00: primary wing at (1875,5239); the slave wing's edge + // (1875,5240)->(1876,5240) was learned as walled while the primary was being opened. + assertTrue(Rs2WalkerDoors.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5239, 0), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); + } + + @Test + public void aGateSharingTheDiagonalEdgesCornerCountsAsAdjacent() { + // Same run: primary wing at (1903,5243); the diagonal step (1903,5242)->(1904,5243) learned. + assertTrue(Rs2WalkerDoors.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1903, 5243, 0), new WorldPoint(1903, 5242, 0), new WorldPoint(1904, 5243, 0))); + } + + @Test + public void aDoorTwoTilesAwayDoesNotSuppressLearning() { + assertFalse(Rs2WalkerDoors.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5237, 0), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); + } + + @Test + public void aDoorOnAnotherPlaneDoesNotSuppressLearning() { + assertFalse(Rs2WalkerDoors.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5239, 1), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); } // --------------------------------------------------------------------------- @@ -1916,20 +2292,20 @@ public void questLock_detectsBareQuestMention() { @Test public void sessionBlacklist_addAndMembership() { WorldPoint door = new WorldPoint(3210, 3220, 0); - assertFalse(Rs2Walker.sessionBlacklistedDoors.contains(door)); - Rs2Walker.sessionBlacklistedDoors.add(door); - assertTrue(Rs2Walker.sessionBlacklistedDoors.contains(door)); + assertFalse(Rs2WalkerDoors.doorAttemptLedgerForTesting().isDoorBlacklisted(door)); + Rs2WalkerDoors.doorAttemptLedgerForTesting().blacklistDoor(door); + assertTrue(Rs2WalkerDoors.doorAttemptLedgerForTesting().isDoorBlacklisted(door)); } @Test public void sessionBlacklist_worldPointEqualityDrivesMembership() { // Two WorldPoints built from the same coords must hash/equal the same way — // otherwise the blacklist guard at handleDoors entry would miss re-attempts. - Rs2Walker.sessionBlacklistedDoors.add(new WorldPoint(3210, 3220, 0)); - assertTrue(Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3220, 0))); - assertFalse(Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3221, 0))); + Rs2WalkerDoors.doorAttemptLedgerForTesting().blacklistDoor(new WorldPoint(3210, 3220, 0)); + assertTrue(Rs2WalkerDoors.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3220, 0))); + assertFalse(Rs2WalkerDoors.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3221, 0))); assertFalse("different plane must not collide", - Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3220, 1))); + Rs2WalkerDoors.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3220, 1))); } // --------------------------------------------------------------------------- @@ -1952,23 +2328,20 @@ public void telemetry_recordUnreachable_incrementsCounterAndSetsReason() { } @Test - public void telemetry_recordUnreachable_nullPathfinderDoesNotThrow() { + public void telemetry_recordUnreachable_nullMetricsDoesNotThrow() { Rs2Walker.Telemetry.recordUnreachable("partial-retries-exhausted", null, null, null, 0, 2, null); assertEquals(1, Rs2Walker.Telemetry.unreachableCount.get()); } @Test - public void telemetry_recordUnreachable_withPathfinderReadsStats() { - Pathfinder pathfinder = mock(Pathfinder.class); - Pathfinder.PathfinderStats stats = new Pathfinder.PathfinderStats(); - when(pathfinder.getStats()).thenReturn(stats); + public void telemetry_recordUnreachable_withRouteMetricsDoesNotLeakPlannerState() { + Rs2RouteMetrics metrics = new Rs2RouteMetrics(2_000_000L, 12L, 30L, 4L); Rs2Walker.Telemetry.recordUnreachable("no-walkable-path", new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3201, 0), - null, 0, 0, pathfinder); + null, 0, 0, metrics); - verify(pathfinder).getStats(); assertEquals(1, Rs2Walker.Telemetry.unreachableCount.get()); } @@ -2044,4 +2417,294 @@ public void walkUntil_failedConditionFallsBackToNormalWalkerResult() { public void walkUntil_rejectsNullCondition() { Rs2Walker.walkUntil(new WorldPoint(3200, 3200, 0), 2, null); } + + // ---- arrival beside an unwalkable target (false-success near interactables) --------------------- + + /** + * "Within distance of an object" was reported as ARRIVED on straight-line distance alone. With a + * wall between, the caller then interacted from the wrong side and failed while the walker claimed + * success — the silent-wrong-success case. + */ + @Test + public void hasReachableNeighbour_trueWhenWeCanStandBesideTheTarget() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 0), 1); // directly south of it + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_acceptsDiagonalNeighbours() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3201, 3201, 0), 1); + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** Near in a straight line, but every adjacent tile is on the far side of a wall. */ + @Test + public void hasReachableNeighbour_falseWhenOnlyDistantTilesAreReachable() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3205, 3200, 0), 5); + reachable.put(new WorldPoint(3200, 3205, 0), 5); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** The target's own tile being reachable is not the question — we must stand BESIDE it. */ + @Test + public void hasReachableNeighbour_targetTileItselfDoesNotCount() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(chest, 0); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** A neighbour on another plane is not somewhere we can stand to use it. */ + @Test + public void hasReachableNeighbour_ignoresOtherPlanes() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 1), 1); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_toleratesMissingInputs() { + assertFalse(Rs2Walker.hasReachableNeighbour(null, new java.util.HashMap<>())); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), null)); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), new java.util.HashMap<>())); + } + + // ---- walled route edge learning (the Sinclair Mansion deadlock) --------------------------------- + + private static java.util.Map reachableSet(WorldPoint... tiles) { + java.util.Map m = new java.util.HashMap<>(); + for (int i = 0; i < tiles.length; i++) { + m.put(tiles[i], i); + } + return m; + } + + /** + * The route steps out of the BFS at b -> that edge is what is actually walled, whatever the shipped + * map claims. Learning it is what turns a permanent refuse/replan oscillation into one replan. + */ + @Test + public void firstWalledRawEdge_findsTheStepThatLeavesTheBfs() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint a = new WorldPoint(2740, 3468, 0); + WorldPoint b = new WorldPoint(2740, 3467, 0); + java.util.List raw = java.util.Arrays.asList(p, a, b, new WorldPoint(2740, 3466, 0)); + WorldPoint[] edge = Rs2WalkerMovement.firstWalledRawEdge(raw, p, reachableSet(p, a), 12); + assertNotNull(edge); + assertEquals(a, edge[0]); + assertEquals(b, edge[1]); + } + + /** Every step reachable — nothing is walled, so nothing may be learned. */ + @Test + public void firstWalledRawEdge_allReachableLearnsNothing() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint a = new WorldPoint(2740, 3468, 0); + java.util.List raw = java.util.Arrays.asList(p, a); + assertNull(Rs2WalkerMovement.firstWalledRawEdge(raw, p, reachableSet(p, a), 12)); + } + + /** + * Beyond the BFS budget "not reachable" means far away, not walled. Learning there would block a + * perfectly good edge permanently — the failure mode the two-strike store exists to avoid. + */ + @Test + public void firstWalledRawEdge_ignoresStepsBeyondTheBfsBudget() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint far = new WorldPoint(2740, 3449, 0); + java.util.List raw = java.util.Arrays.asList(p, far); + assertNull(Rs2WalkerMovement.firstWalledRawEdge(raw, p, reachableSet(p), 12)); + } + + /** + * A tile sitting AT the BFS budget never had its neighbours enumerated, so the next route tile is + * missing for want of budget, not because anything blocks it. Convicting that edge writes a lie + * into the learned-blocked-edge store and routing believes it for the rest of the session. + * + *

Pinned from a real farm run at the Port Sarim / Land's End docks: a click to (2760,3238) was + * refused as walled and the edge (2759,3230)->(2759,3231) learned — nine seconds later the walker + * was standing on (2760,3238), having simply walked there. Chebyshev-near, step-far. + */ + @Test + public void firstWalledRawEdge_doesNotConvictTheBfsFrontierItself() { + WorldPoint player = new WorldPoint(2772, 3234, 0); + WorldPoint onFrontier = new WorldPoint(2759, 3230, 0); + WorldPoint beyond = new WorldPoint(2759, 3231, 0); + java.util.List raw = java.util.Arrays.asList(player, onFrontier, beyond); + + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(player, 0); + // Thirteen tiles away as the crow flies, but twenty STEPS around the dock buildings — exactly + // the budget, so the BFS stopped here and knows nothing about what lies past it. + reachable.put(onFrontier, 20); + + assertNull("a tile at the budget proves nothing about its neighbour", + Rs2WalkerMovement.firstWalledRawEdge(raw, player, reachable, 20)); + } + + /** An interior tile DID have its neighbours enumerated, so a missing neighbour is genuinely walled. */ + @Test + public void firstWalledRawEdge_stillConvictsAnEdgeLeavingTheBfsInterior() { + WorldPoint player = new WorldPoint(2772, 3234, 0); + WorldPoint interior = new WorldPoint(2770, 3234, 0); + WorldPoint walled = new WorldPoint(2769, 3234, 0); + java.util.List raw = java.util.Arrays.asList(player, interior, walled); + + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(player, 0); + reachable.put(interior, 2); + + WorldPoint[] edge = Rs2WalkerMovement.firstWalledRawEdge(raw, player, reachable, 20); + assertNotNull("the BFS had budget left at this tile and still could not reach the next one", edge); + assertEquals(interior, edge[0]); + assertEquals(walled, edge[1]); + } + + @Test + public void firstWalledRawEdge_toleratesMissingInputs() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + assertNull(Rs2WalkerMovement.firstWalledRawEdge(null, p, reachableSet(p), 12)); + assertNull(Rs2WalkerMovement.firstWalledRawEdge(java.util.Collections.emptyList(), p, reachableSet(p), 12)); + assertNull(Rs2WalkerMovement.firstWalledRawEdge(java.util.Arrays.asList(p), p, null, 12)); + } + + // ---- post-door route target (chain the click past the opened door) ------------------------------ + + /** + * After a door opens, the follow-through click should make route progress, not step one tile. + * Every candidate must sit in the player-origin reachability map — the tile the previous attempt + * at this feature clicked was one the walled-route net had just refused, precisely because the + * selection ran ungated. + */ + + private static java.util.List northRoute(int startY, int count) { + java.util.List route = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + route.add(new WorldPoint(3100, startY + i, 0)); + } + return route; + } + + @Test + public void postDoorTarget_picksTheFurthestReachableRoutePoint() { + java.util.List route = northRoute(3200, 8); // door edge 3201 -> 3202 + WorldPoint from = route.get(1); + WorldPoint to = route.get(2); + WorldPoint player = route.get(1); + java.util.Map reachable = + reachableSet(route.get(3), route.get(4), route.get(5)); + assertEquals(route.get(5), + Rs2WalkerDoors.selectPostDoorRouteTarget(route, from, to, player, reachable, 13)); + } + + /** An unreachable far candidate must not be clicked; the furthest REACHABLE one wins instead. */ + @Test + public void postDoorTarget_skipsTilesTheBfsCannotVouchFor() { + java.util.List route = northRoute(3200, 8); + WorldPoint from = route.get(1); + WorldPoint to = route.get(2); + WorldPoint player = route.get(1); + java.util.Map reachable = reachableSet(route.get(3), route.get(4)); + assertEquals(route.get(4), + Rs2WalkerDoors.selectPostDoorRouteTarget(route, from, to, player, reachable, 13)); + } + + /** Nothing reachable past the door: null, and the caller keeps the single-tile nudge. */ + @Test + public void postDoorTarget_nullWhenNothingPastTheDoorIsReachable() { + java.util.List route = northRoute(3200, 8); + java.util.Map reachable = reachableSet(route.get(0), route.get(1)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, route.get(1), route.get(2), + route.get(1), reachable, 13)); + } + + /** The edge must be ON the route: a route that merely passes nearby proves nothing beyond the door. */ + @Test + public void postDoorTarget_nullWhenTheEdgeIsNotOnTheRoute() { + java.util.List route = northRoute(3200, 8); + WorldPoint offRouteFrom = new WorldPoint(3105, 3201, 0); + WorldPoint offRouteTo = new WorldPoint(3105, 3202, 0); + java.util.Map reachable = reachableSet(route.get(4)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, offRouteFrom, offRouteTo, + route.get(1), reachable, 13)); + } + + /** Candidates stop at the Euclidean cap and at a plane change — the same rules as route clicks. */ + @Test + public void postDoorTarget_respectsTheCapAndThePlane() { + java.util.List route = northRoute(3200, 12); + WorldPoint player = route.get(1); + java.util.Map reachable = + reachableSet(route.get(3), route.get(9)); + // route.get(9) is 8 tiles from the player — inside a cap of 13, outside a cap of 6. + assertEquals(route.get(9), + Rs2WalkerDoors.selectPostDoorRouteTarget(route, route.get(1), route.get(2), player, reachable, 13)); + assertEquals(route.get(3), + Rs2WalkerDoors.selectPostDoorRouteTarget(route, route.get(1), route.get(2), player, reachable, 6)); + + java.util.List upstairs = new java.util.ArrayList<>(northRoute(3200, 4)); + upstairs.add(new WorldPoint(3100, 3204, 1)); + java.util.Map upstairsReachable = reachableSet(route.get(3)); + assertEquals(route.get(3), + Rs2WalkerDoors.selectPostDoorRouteTarget(upstairs, upstairs.get(1), upstairs.get(2), + upstairs.get(1), upstairsReachable, 13)); + } + + @Test + public void postDoorTarget_toleratesMissingInputs() { + java.util.List route = northRoute(3200, 4); + WorldPoint p = route.get(0); + java.util.Map reachable = reachableSet(route.get(3)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(null, p, route.get(1), p, reachable, 13)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, null, route.get(1), p, reachable, 13)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, p, null, p, reachable, 13)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, p, route.get(1), null, reachable, 13)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, p, route.get(1), p, null, 13)); + assertNull(Rs2WalkerDoors.selectPostDoorRouteTarget(route, p, route.get(1), p, + new java.util.HashMap<>(), 13)); + } + + // ---- zoom-aware minimap reach -------------------------------------------------------------------- + // + // The minimap shows 20*4/zoom tiles of radius. Reach follows what the USER's zoom makes visible + // in BOTH directions: zoomed out, big strides (capped at the reachability BFS horizon — beyond + // it a wall between could not be detected); zoomed in, SHORT strides. The first cut of this + // floored at the old flat 11, which quietly broke the zoomed-in half: an 11-tile stride on a + // minimap showing ~8 tiles of radius selects a point on or past the rim. + + private static final int MIN_REACH = 5; + private static final int CAP = 18; + private static final int FALLBACK = 11; + + @Test + public void zoomAwareReach_zoomedOutStridesFurtherUpToTheBfsHorizon() { + assertEquals(18, Rs2WalkerMovement.zoomAwareMinimapReach(4.0, MIN_REACH, CAP, FALLBACK)); // default: 20-2 -> cap + assertEquals(18, Rs2WalkerMovement.zoomAwareMinimapReach(2.0, MIN_REACH, CAP, FALLBACK)); // fully out: 38 -> cap + } + + @Test + public void zoomAwareReach_zoomedInStridesShorter() { + assertEquals(14, Rs2WalkerMovement.zoomAwareMinimapReach(5.0, MIN_REACH, CAP, FALLBACK)); // pinned-era zoom: 16-2 + assertEquals(11, Rs2WalkerMovement.zoomAwareMinimapReach(6.0, MIN_REACH, CAP, FALLBACK)); // 13-2 + // Fully zoomed in the visible radius is ~8: the stride must SHRINK below the old flat 11. + assertEquals(8, Rs2WalkerMovement.zoomAwareMinimapReach(8.0, MIN_REACH, CAP, FALLBACK)); + } + + @Test + public void zoomAwareReach_extremeZoomStopsAtTheFunctionalFloor() { + assertEquals(MIN_REACH, Rs2WalkerMovement.zoomAwareMinimapReach(16.0, MIN_REACH, CAP, FALLBACK)); // 5-2=3 -> floor + } + + @Test + public void zoomAwareReach_degenerateZoomFallsBackToTheFlatReach() { + assertEquals(FALLBACK, Rs2WalkerMovement.zoomAwareMinimapReach(0.0, MIN_REACH, CAP, FALLBACK)); + assertEquals(FALLBACK, Rs2WalkerMovement.zoomAwareMinimapReach(-1.0, MIN_REACH, CAP, FALLBACK)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java index 7e813a03831..a15e5465c45 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java @@ -44,15 +44,15 @@ public void aTargetBuriedInRockIsRejected() { map.isBlocked(BURIED_IN_ROCK.getX(), BURIED_IN_ROCK.getY(), 0)); assertFalse("a goal with no walkable tile within the arrival distance must be rejected " + "before the walk starts", - Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); + Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); } @Test public void legitimateUndergroundTargetsAreAccepted() { assertTrue("the last walkable tile in the corridor must still be accepted", - Rs2Walker.hasWalkableTileWithin(map, LAST_WALKABLE, 5)); + Rs2PathApi.hasWalkableTileWithin(map, LAST_WALKABLE, 5)); assertTrue("the Motherlode cave mouth is a valid destination and must not be rejected", - Rs2Walker.hasWalkableTileWithin(map, MLM_CAVE_MOUTH, 5)); + Rs2PathApi.hasWalkableTileWithin(map, MLM_CAVE_MOUTH, 5)); } /** @@ -65,22 +65,22 @@ public void unmappedRegionsAreNeverRejected() { assertFalse("precondition: this region genuinely has no collision data", map.hasRegion(offMap.getX(), offMap.getY())); assertTrue("no collision data must mean 'let the pathfinder try', not 'unreachable'", - Rs2Walker.hasWalkableTileWithin(map, offMap, 5)); + Rs2PathApi.hasWalkableTileWithin(map, offMap, 5)); assertTrue("a null map must never block a walk", - Rs2Walker.hasWalkableTileWithin(null, BURIED_IN_ROCK, 5)); + Rs2PathApi.hasWalkableTileWithin(null, BURIED_IN_ROCK, 5)); } /** A generous arrival distance reaches real ground, so the same goal becomes acceptable. */ @Test public void aLargeArrivalDistanceReachesWalkableGround() { - assertFalse(Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); + assertFalse(Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); assertTrue("with a 40 tile tolerance the corridor is inside the search box", - Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 40)); + Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 40)); } @Test public void theRejectionPointsAtTheNearestRealTile() { - WorldPoint nearest = Rs2Walker.nearestWalkableTile(map, BURIED_IN_ROCK, 48); + WorldPoint nearest = Rs2PathApi.nearestWalkableTile(map, BURIED_IN_ROCK, 48); assertNotNull("the warning must name a concrete tile so the coordinate can be corrected", nearest); assertFalse("the suggested tile must itself be walkable", diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java new file mode 100644 index 00000000000..ec7c232ae04 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java @@ -0,0 +1,115 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class TransportRouteAnalysisTest +{ + @Test + public void exactStepsRetainSelectedTransportIdentityAndOrder() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint directMiddle = new WorldPoint(3201, 3200, 0); + WorldPoint target = new WorldPoint(3202, 3200, 0); + WorldPoint bank = new WorldPoint(3199, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + Rs2TransportEdge directEdge = edge(directMiddle, target, "direct-selected"); + Rs2TransportEdge bankEdge = edge(null, landing, "bank-selected"); + + List directPath = new ArrayList<>(List.of(start, directMiddle, target)); + List directSteps = new ArrayList<>(List.of( + Rs2RouteStep.walk(start, directMiddle), + Rs2RouteStep.transport(directMiddle, target, directEdge))); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + directPath, + null, + bank, + List.of(start, bank), + List.of(bank, landing, target), + "test", + 2, + 3, + directSteps, + List.of(Rs2RouteStep.walk(start, bank)), + List.of( + Rs2RouteStep.transport(bank, landing, bankEdge), + Rs2RouteStep.walk(landing, target))); + + directPath.clear(); + directSteps.clear(); + + assertTrue(analysis.isDirectRouteStepsExact()); + assertTrue(analysis.isRouteToBankStepsExact()); + assertTrue(analysis.isRouteFromBankStepsExact()); + assertEquals(3, analysis.getDirectPath().size()); + assertSame(directEdge, analysis.getDirectTransportEdges().get(0)); + assertSame(bankEdge, analysis.getTransportEdgesFromBank().get(0)); + assertEquals(List.of(directEdge), analysis.getDirectTransportEdges()); + assertEquals(List.of(bankEdge), analysis.getBankingTransportEdges()); + + try + { + analysis.getRouteFromBankSteps().add(Rs2RouteStep.walk(landing, target)); + fail("exact route steps must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // Expected. + } + } + + @Test + public void legacyConstructorsDoNotPretendReconstructedEdgesAreExact() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + List.of(start, target), null, null, List.of(), List.of(), "legacy"); + + assertFalse(analysis.isDirectRouteStepsExact()); + assertFalse(analysis.isRouteToBankStepsExact()); + assertFalse(analysis.isRouteFromBankStepsExact()); + assertTrue(analysis.getDirectTransportEdges().isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void exactStepsMustDescribeEveryPathEdge() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + new TransportRouteAnalysis( + List.of(start, target), null, null, List.of(), List.of(), "invalid", 1, -1, + List.of(), List.of(), List.of()); + } + + private static Rs2TransportEdge edge(WorldPoint origin, WorldPoint destination, String displayInfo) + { + return new Rs2TransportEdge( + origin, + destination, + Rs2TransportType.TELEPORTATION_ITEM, + Rs2TransportExecutor.ITEM_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + displayInfo, + "Teleport", + "test item", + -1, + 1, + true, + false, + false, + 0, + "", + 0, + List.of()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java new file mode 100644 index 00000000000..1727d070665 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java @@ -0,0 +1,121 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; +import shortestpath.transport.TransportType; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class UpstreamRoutePlannerTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3200, 3200, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3201, 3200, 0); + + @Test + public void anchoredTypeProjectionIsExplicitAndComplete() + { + Map expected = new EnumMap<>(Rs2TransportType.class); + expected.put(Rs2TransportType.TRANSPORT, TransportType.TRANSPORT); + expected.put(Rs2TransportType.AGILITY_SHORTCUT, TransportType.AGILITY_SHORTCUT); + expected.put(Rs2TransportType.GRAPPLE_SHORTCUT, TransportType.GRAPPLE_SHORTCUT); + expected.put(Rs2TransportType.BOAT, TransportType.BOAT); + expected.put(Rs2TransportType.CANOE, TransportType.CANOE); + expected.put(Rs2TransportType.CHARTER_SHIP, TransportType.CHARTER_SHIP); + expected.put(Rs2TransportType.SHIP, TransportType.SHIP); + expected.put(Rs2TransportType.FAIRY_RING, TransportType.FAIRY_RING); + expected.put(Rs2TransportType.QUETZAL, TransportType.QUETZAL); + expected.put(Rs2TransportType.QUETZAL_WHISTLE, TransportType.QUETZAL_WHISTLE); + expected.put(Rs2TransportType.GNOME_GLIDER, TransportType.GNOME_GLIDER); + expected.put(Rs2TransportType.MINECART, TransportType.MINECART); + expected.put(Rs2TransportType.POH, TransportType.TRANSPORT); + expected.put(Rs2TransportType.SPIRIT_TREE, TransportType.SPIRIT_TREE); + expected.put(Rs2TransportType.TELEPORTATION_BOX, TransportType.TELEPORTATION_BOX); + expected.put(Rs2TransportType.TELEPORTATION_LEVER, TransportType.TELEPORTATION_LEVER); + expected.put(Rs2TransportType.TELEPORTATION_PORTAL, TransportType.TELEPORTATION_PORTAL); + expected.put(Rs2TransportType.TELEPORTATION_PORTAL_POH, TransportType.TELEPORTATION_PORTAL_POH); + expected.put(Rs2TransportType.TELEPORTATION_MINIGAME, TransportType.TELEPORTATION_MINIGAME); + expected.put(Rs2TransportType.TELEPORTATION_ITEM, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_SPELL, TransportType.TELEPORTATION_SPELL); + expected.put(Rs2TransportType.TELEPORTATION_SPELL_HOME, TransportType.TELEPORTATION_SPELL_HOME); + expected.put(Rs2TransportType.WILDERNESS_OBELISK, TransportType.WILDERNESS_OBELISK); + expected.put(Rs2TransportType.MAGIC_CARPET, TransportType.MAGIC_CARPET); + expected.put(Rs2TransportType.HOT_AIR_BALLOON, TransportType.HOT_AIR_BALLOON); + expected.put(Rs2TransportType.MAGIC_MUSHTREE, TransportType.MAGIC_MUSHTREE); + expected.put(Rs2TransportType.SEASONAL_TRANSPORT, TransportType.SEASONAL_TRANSPORTS); + expected.put(Rs2TransportType.NPC, TransportType.TRANSPORT); + + assertEquals("every supported boundary type must declare an anchored projection", + Rs2TransportType.values().length - 1, expected.size()); + for (Map.Entry entry : expected.entrySet()) + { + assertEquals(entry.getKey().name(), entry.getValue(), + UpstreamRoutePlanner.mapType(edge(ORIGIN, entry.getKey()))); + } + assertRejected(edge(ORIGIN, Rs2TransportType.UNKNOWN)); + } + + @Test + public void originlessProjectionOnlyAdmitsReviewedTeleportCategories() + { + Map expected = new EnumMap<>(Rs2TransportType.class); + expected.put(Rs2TransportType.QUETZAL_WHISTLE, TransportType.QUETZAL_WHISTLE); + expected.put(Rs2TransportType.SEASONAL_TRANSPORT, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_ITEM, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_MINIGAME, TransportType.TELEPORTATION_MINIGAME); + expected.put(Rs2TransportType.TELEPORTATION_SPELL, TransportType.TELEPORTATION_SPELL); + expected.put(Rs2TransportType.TELEPORTATION_SPELL_HOME, TransportType.TELEPORTATION_SPELL_HOME); + + for (Rs2TransportType type : Rs2TransportType.values()) + { + Rs2TransportEdge edge = edge(null, type); + if (expected.containsKey(type)) + { + assertEquals(type.name(), expected.get(type), UpstreamRoutePlanner.mapType(edge)); + } + else + { + assertRejected(edge); + } + } + } + + private static Rs2TransportEdge edge(WorldPoint origin, Rs2TransportType type) + { + return new Rs2TransportEdge( + origin, + DESTINATION, + type, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test", + "Use", + "test", + 1, + 1, + origin == null, + false, + false, + 0, + "", + 0, + Collections.emptyList()); + } + + private static void assertRejected(Rs2TransportEdge edge) + { + try + { + UpstreamRoutePlanner.mapType(edge); + fail("expected unsupported projection for " + edge.getType()); + } + catch (IllegalArgumentException expected) + { + // expected + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java new file mode 100644 index 00000000000..314f7a21939 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java @@ -0,0 +1,278 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The meaning of every walk-loop exit reason, held as data. + * + *

This began as a characterization against the string predicates {@link WalkExit} replaced, which + * is what made that refactor provably inert. Those predicates have now been deleted and their + * classification lives here instead: three explicit sets, checked exhaustively against every + * constant, so a reason cannot change meaning — or be added without one — unnoticed. + * + *

When a classification is deliberately corrected, the expectation moves here, and the + * diff to this file is the record of exactly what changed — which is precisely what the string + * version could never provide. Do not "fix" a failure by editing the enum until you have written + * down why the new answer is the right one. + */ +public class WalkExitTest +{ + /** + * The fourteen reasons whose route-progress classification was deliberately corrected once the + * enum made the set enumerable. Every one of them means the walker either just advanced the + * route or is waiting on movement it issued itself, yet all fourteen were charged against the + * partial-retry budget — three of them in a row on a partial route reported UNREACHABLE and + * aborted a walk that was working. + * + *

Kept as an explicit list rather than folded away, because this set is the + * behaviour change. Adding to it later means saying which reason and why. + */ + private static final Set RECLASSIFIED_AS_PROGRESS = new HashSet<>(Arrays.asList( + // recovery resolved the blocked frontier + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + // a click was issued and the player is walking + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK, + WalkExit.RECENT_DOOR_EDGE_NUDGE, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + // the door actually opened + WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT, + // waiting on an action we issued + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_TRAVERSAL_PENDING_YIELD, + WalkExit.TRANSPORT_SETTLING_YIELD, + WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION, + // the pass was abandoned because the player moved + WalkExit.RECOVERY_POSITION_STALE)); + + /** + * The full progress classification, as data. + * + *

This started as a comparison against the string predicates the enum replaced. Those have now + * been deleted, so the historical baseline lives here instead: every reason is either listed as + * progress or it is not, and a constant that changes side has to change this list too. + */ + private static final Set PROGRESS = new HashSet<>(Arrays.asList( + // an obstacle handler acted + WalkExit.DOOR_HANDLED, + WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK, + WalkExit.DOOR_HANDLED_DURING_INTERIM, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN, + WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR, + WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN, + WalkExit.PATH_BLOCKER_HANDLED, + WalkExit.ROCKFALL_HANDLED, + WalkExit.TRANSPORT_HANDLED, + WalkExit.CURRENT_TILE_TRANSPORT_HANDLED, + WalkExit.POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED, + WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.ROUTE_FOLD_CONTINUATION_CLICK, + // recovery resolved the blocked frontier, or issued movement + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK, + WalkExit.RECENT_DOOR_EDGE_NUDGE, + WalkExit.RECOVERY_POSITION_STALE, + // the door opened + WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT, + // waiting on an action we issued + WalkExit.INTERIM_IN_FLIGHT_ROUTE, + WalkExit.INTERIM_IN_FLIGHT_RECOVERY, + WalkExit.INTERIM_IN_FLIGHT_CLICK, + WalkExit.RECOVERY_MOVE_IN_FLIGHT, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_TRAVERSAL_PENDING_YIELD, + WalkExit.TRANSPORT_SETTLING_YIELD, + WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION)); + + @Test + public void progressClassificationIsExactlyThisSet() + { + for (WalkExit exit : WalkExit.values()) + { + assertEquals(exit.name() + " changed its route-progress meaning; if that is deliberate, " + + "move it in PROGRESS and say why in the commit", + PROGRESS.contains(exit), exit.isProgress()); + } + } + + /** Every reason listed as reclassified must in fact be progress; the list documents the change. */ + @Test + public void theReclassifiedReasonsAreAllProgress() + { + for (WalkExit exit : RECLASSIFIED_AS_PROGRESS) + { + assertTrue(exit.name() + " was reclassified as route progress and must report it", + exit.isProgress()); + assertTrue(exit.name() + " must also appear in the full PROGRESS set", + PROGRESS.contains(exit)); + } + } + + /** + * The budget must still drain on the reasons that genuinely mean "not advancing", or a truly + * unreachable goal never terminates and the walk spins until the tail cap trips. + */ + @Test + public void reasonsThatMeanStuckStillConsumeTheBudget() + { + for (WalkExit stuck : new WalkExit[]{ + WalkExit.END_OF_PATH, + WalkExit.NOT_NEAR_PATH, + WalkExit.PLAYER_LOCATION_NULL, + WalkExit.CLICK_FAILED_OFF_MINIMAP, + WalkExit.DOOR_EDGE_WAITING_RETRY, + WalkExit.DOOR_EDGE_NEARBY_WAITING_RETRY, + WalkExit.DOOR_RECOVERY_SUPPRESSED, + WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK, + WalkExit.RECOVERY_TARGET_WALLED_REPLAN, + WalkExit.RECOVERY_TARGET_WALLED_WAITING, + WalkExit.ROUTE_FOLD_CONTINUATION_PENDING}) + { + assertFalse(stuck.name() + " does not advance the route and must still spend a retry", + stuck.isProgress()); + } + } + + /** Benign yields that refund their own tail charge, so long waits cannot exhaust the cap. */ + private static final Set TAIL_EXEMPT = new HashSet<>(Arrays.asList( + WalkExit.INTERIM_IN_FLIGHT_ROUTE, + WalkExit.INTERIM_IN_FLIGHT_RECOVERY, + WalkExit.INTERIM_IN_FLIGHT_CLICK, + WalkExit.RECOVERY_MOVE_IN_FLIGHT, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + WalkExit.ROUTE_FOLD_CONTINUATION_CLICK, + WalkExit.OFF_PATH_DEFERRED)); + + /** Exits that owe the post-door canvas nudge and its minimap hold-off. */ + private static final Set DOOR_LIKE = new HashSet<>(Arrays.asList( + WalkExit.DOOR_HANDLED, + WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK, + WalkExit.DOOR_HANDLED_DURING_INTERIM, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN, + WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR, + WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN, + WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED)); + + @Test + public void tailExemptionIsExactlyThisSet() + { + for (WalkExit exit : WalkExit.values()) + { + assertEquals(exit.name() + " changed its tail-exemption meaning", + TAIL_EXEMPT.contains(exit), exit.isTailExempt()); + } + } + + @Test + public void doorLikeClassificationIsExactlyThisSet() + { + for (WalkExit exit : WalkExit.values()) + { + assertEquals(exit.name() + " changed its door-like meaning", + DOOR_LIKE.contains(exit), exit.isDoorLike()); + } + } + + /** + * The whole point of the enum is that the set of reasons is enumerable. Two of them + * ({@code door-edge-resolved-after-wait}, {@code door-edge-waiting-retry}) were produced inside a + * ternary and never appeared in a search for {@code exitReason = "…"}, so the reason set could not + * be recovered by reading the code. Pin the full set so a new value has to be added here too. + */ + @Test + public void theReasonSetIsComplete() + { + Set expected = new HashSet<>(Arrays.asList( + "end-of-path", + "door-handled", + "door-handled-before-minimap-click", + "door-handled-during-interim", + "door-handled-local-reachability", + "door-handled-local-reachability-raw-scan", + "door-handled-nearby-route-door", + "door-handled-path-adj-scan", + "path-blocker-handled", + "rockfall-handled", + "transport-handled", + "current-tile-transport-handled", + "post-click-current-tile-transport-handled", + "raw-path-scene-object-handled", + "post-click-raw-path-scene-object-handled", + "frontier-obstacle-handled", + "transport-handled-local-reachability", + "local-recovery-click", + "local-reachability-miss-no-click", + "recent-door-edge-nudge", + "door-suppressed-approach-click", + "door-recovery-suppressed", + "recovery-position-stale", + "recovery-click-preempted-by-action", + "recovery-target-walled-replan", + "recovery-target-walled-waiting", + "door-edge-resolved-fast-click", + "door-edge-resolved-after-wait", + "door-edge-resolved-after-nearby-wait", + "door-edge-waiting-retry", + "door-edge-nearby-waiting-retry", + "interim-in-flight:route", + "interim-in-flight:recovery", + "interim-in-flight:click", + "recovery-move-in-flight", + "route-move-in-flight", + "door-settling-yield", + "door-traversal-pending-yield", + "transport-settling-yield", + "route-fold-continuation-click", + "route-fold-continuation-pending", + "off-path-deferred", + "not-near-path", + "click-failed-off-minimap", + "player-location-null")); + + Set actual = new HashSet<>(); + for (WalkExit exit : WalkExit.values()) + { + actual.add(exit.wireName()); + } + assertEquals("the set of walker exit reasons changed", expected, actual); + assertEquals("wire names must be unique", WalkExit.values().length, actual.size()); + } + + /** The parameterized reason has to rebuild the exact string the log consumers expect. */ + @Test + public void offPathDeferredKeepsItsDetailSuffix() + { + assertEquals("off-path-deferred:recent-click", + WalkExit.OFF_PATH_DEFERRED.wireName("recent-click")); + assertEquals("off-path-deferred:", WalkExit.OFF_PATH_DEFERRED.wireName(null)); + assertTrue(WalkExit.OFF_PATH_DEFERRED.wireName("x").startsWith("off-path-deferred:")); + } + + /** A detail on any other reason is meaningless and must not corrupt its wire name. */ + @Test + public void detailIsIgnoredForNonParameterizedReasons() + { + assertEquals("door-handled", WalkExit.DOOR_HANDLED.wireName("ignored")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java new file mode 100644 index 00000000000..d9f4dd8abbd --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java @@ -0,0 +1,146 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.door.DoorAttemptLedger; +import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The post-transport window must not survive into the next walk. + * + *

While the window is armed the walker deliberately runs degraded: it skips the raw scene scan, + * skips the per-segment door / rockfall / transport handlers, disables ranged door dispatch for the + * whole pass, and bypasses the off-path recalc. That is correct for the seconds after a landing, and + * badly wrong for a walk that has only just started — a fresh walk would ignore the door in front of + * it and never replan when it drifted off route. + * + *

The leak was subtle because walk-session start did clear the transport context — but + * only the three location fields, not {@code lastTransportHandledAtMs}, which is the field every + * window check actually reads. So the window stayed armed for its full 15 seconds while the + * destination it describes was already null. + */ +public class WalkSessionStateResetTest +{ + private WalkerRouteState routeState; + + @Before + public void setUp() + { + routeState = Rs2Walker.routeStateForTesting(); + routeState.clearRecentTransportContext(); + } + + /** + * The regression itself. A walk that ends without clearing its target — an exception, the tail + * cap tripping, or an external cancellation — leaves the window armed; starting the next walk has + * to disarm it. + */ + @Test + public void startingAWalkEndsAnyPostTransportWindowLeftByThePreviousOne() + { + routeState.lastTransportHandledAtMs = System.currentTimeMillis(); + routeState.lastTransportOriginLocation = new WorldPoint(3200, 3200, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3200, 3210, 1); + + Rs2Walker.resetWalkSessionState(); + + assertEquals("the post-transport window must be disarmed at walk start; every window check " + + "reads this timestamp, so leaving it set suppresses the new walk's handlers", + 0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** + * Clearing the locations alone is what the bug was. Pin the timestamp explicitly so a future + * edit cannot reintroduce a partial clear that looks right and does nothing. + */ + @Test + public void clearingTheTransportContextClearsTheTimestampNotJustTheLocations() + { + routeState.lastTransportHandledAtMs = 1_234_567L; + routeState.lastTransportOriginLocation = new WorldPoint(1, 2, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3, 4, 0); + + routeState.clearRecentTransportContext(); + + assertEquals(0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** + * Walk start also withdraws the previous walk's door claim, for the same staleness reason — + * but deliberately NOT the per-edge cooldowns: hammering one door across two walks is still + * hammering. The two lifetimes used to live in two stores; the ledger keeps both facts and + * this test pins that the reset touches only the claim. + */ + @Test + public void startingAWalkDropsThePreviousWalksDoorClaimButKeepsTheEdgeCooldown() + { + DoorAttemptLedger ledger = Rs2WalkerDoors.doorAttemptLedgerForTesting(); + WorldPoint from = new WorldPoint(3010, 3204, 0); + WorldPoint to = new WorldPoint(3011, 3204, 0); + long now = System.currentTimeMillis(); + ledger.markAttempt(null, from, to, now); + + Rs2Walker.resetWalkSessionState(); + + assertNull("the latest claim belongs to the previous walk", ledger.latestAttempt()); + assertTrue("the anti-hammer cooldown must survive the walk boundary", + ledger.shouldThrottleAttempt(null, from, to, 2_500, now + 100)); + } + + /** + * The partial-regression baseline measures partial endpoints against the previous walk's goal; + * carried into a new walk it would read the new walk's honest first partial as a regression and + * burn the replan budget on it. + */ + @Test + public void startingAWalkResetsThePartialRegressionBaseline() + { + routeState.bestPartialDGoal = 124; + routeState.partialRegressReplans = 2; + routeState.recoveryGateEnteredAtMs = 123L; + routeState.walledDoorEdgeFrom = new net.runelite.api.coords.WorldPoint(2907, 3544, 0); + routeState.walledDoorEdgeTo = new net.runelite.api.coords.WorldPoint(2907, 3543, 0); + routeState.walledDoorEdgeAtMs = 456L; + routeState.requestedGoal = new net.runelite.api.coords.WorldPoint(2907, 3539, 0); + routeState.sealedRimRetargets = 2; + + Rs2Walker.resetWalkSessionState(); + + assertEquals("the baseline belongs to the previous walk's goal", + Integer.MAX_VALUE, routeState.bestPartialDGoal); + assertEquals(0, routeState.partialRegressReplans); + assertEquals("a stale recovery-gate entry would misattribute the new walk's first pass", + 0L, routeState.recoveryGateEnteredAtMs); + assertNull("a previous walk's door edge must not steer this walk's recovery", + routeState.walledDoorEdgeFrom); + assertNull(routeState.walledDoorEdgeTo); + assertEquals(0L, routeState.walledDoorEdgeAtMs); + assertNull("requested goal belongs to the previous walk", routeState.requestedGoal); + assertEquals("rim-retarget budget belongs to the walk that spent it", + 0, routeState.sealedRimRetargets); + } + + /** Route progress belongs to the route that made it. */ + @Test + public void startingAWalkResetsRouteProgress() + { + routeState.routeProgressIdx = 42; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + routeState.stagnationReplansSpent = 2; + + Rs2Walker.resetWalkSessionState(); + + assertEquals(-1, routeState.routeProgressIdx); + assertEquals(0L, routeState.routeProgressAdvancedAtMs); + assertEquals("a fresh walk owes a fresh stagnation budget", 0, routeState.stagnationReplansSpent); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicyTest.java new file mode 100644 index 00000000000..0b4f825dd07 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalledDoorClaimPolicyTest.java @@ -0,0 +1,79 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class WalledDoorClaimPolicyTest { + private static final long NOW = 10_000L; + private static final WorldPoint FROM = new WorldPoint(1770, 3589, 0); + private static final WorldPoint TO = new WorldPoint(1770, 3590, 0); + + @Test + public void adjacentClaimDispatchesDoorHandlerInsteadOfReplan() { + assertEquals(WalledDoorClaimPolicy.Decision.HANDLE_AT_EDGE, + decide(new WorldPoint(1771, 3589, 0), false, true)); + } + + @Test + public void distantReachableNearSideRequestsApproach() { + assertEquals(WalledDoorClaimPolicy.Decision.APPROACH, + decide(new WorldPoint(1778, 3589, 0), false, true)); + } + + @Test + public void movingClaimRetainsOwnershipWithoutAnotherClick() { + assertEquals(WalledDoorClaimPolicy.Decision.ACTION_IN_FLIGHT, + decide(new WorldPoint(1778, 3589, 0), true, true)); + } + + @Test + public void crossedAndExpiredClaimsReleaseOwnership() { + assertEquals(WalledDoorClaimPolicy.Decision.CROSSED, + decide(new WorldPoint(1770, 3590, 0), false, true)); + assertEquals(WalledDoorClaimPolicy.Decision.EXPIRED, + WalledDoorClaimPolicy.decide(FROM, TO, + NOW - WalledDoorClaimPolicy.FRESH_MS - 1, NOW, + new WorldPoint(1771, 3589, 0), false, true)); + } + + @Test + public void beingFarBeyondTheAxisDoesNotPretendTheDoorWasCrossed() { + assertEquals(WalledDoorClaimPolicy.Decision.APPROACH, + decide(new WorldPoint(1780, 3593, 0), false, true)); + } + + @Test + public void malformedOrUnreachableClaimsAreInvalid() { + assertEquals(WalledDoorClaimPolicy.Decision.INVALID, + decide(new WorldPoint(1778, 3589, 0), false, false)); + assertEquals(WalledDoorClaimPolicy.Decision.INVALID, + WalledDoorClaimPolicy.decide(FROM, new WorldPoint(1770, 3591, 0), + NOW - 1, NOW, new WorldPoint(1771, 3589, 0), false, true)); + } + + @Test + public void traversalEnvelopeIncludesDoorAndFirstLandingStepsOnly() { + WorldPoint doorFrom = new WorldPoint(2907, 3544, 0); + WorldPoint doorTo = new WorldPoint(2907, 3543, 0); + assertTrue(WalledDoorClaimPolicy.ownsTraversalEdge(doorFrom, doorTo, doorFrom, doorTo)); + assertTrue(WalledDoorClaimPolicy.ownsTraversalEdge(doorFrom, doorTo, doorTo, doorFrom)); + assertTrue(WalledDoorClaimPolicy.ownsTraversalEdge( + doorFrom, doorTo, doorTo, new WorldPoint(2906, 3543, 0))); + assertTrue(WalledDoorClaimPolicy.ownsTraversalEdge( + doorFrom, doorTo, doorTo, new WorldPoint(2906, 3542, 0))); + assertFalse(WalledDoorClaimPolicy.ownsTraversalEdge(doorFrom, doorTo, + new WorldPoint(2906, 3544, 0), new WorldPoint(2906, 3543, 0))); + assertFalse(WalledDoorClaimPolicy.ownsTraversalEdge(doorFrom, doorTo, + new WorldPoint(2907, 3543, 1), new WorldPoint(2906, 3542, 1))); + } + + private static WalledDoorClaimPolicy.Decision decide(WorldPoint player, + boolean moving, + boolean reachable) { + return WalledDoorClaimPolicy.decide(FROM, TO, NOW - 1, NOW, player, moving, reachable); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java index 0fc35b7b062..b60716217e5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java @@ -1,18 +1,30 @@ package net.runelite.client.plugins.microbot.util.walker.banking; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TerminalTravelMode; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteStep; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteTermination; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportItemRequirement; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportLoadout; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; +import net.runelite.client.plugins.microbot.util.walker.TransportRouteAnalysis; import org.junit.BeforeClass; import org.junit.Test; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; /** @@ -54,6 +66,216 @@ private static List matching(String menuFragment) { .collect(Collectors.toList()); } + private static Rs2TransportEdge owned(Transport transport) { + List requirements = transport.getItemRequirements().stream() + .map(requirement -> new Rs2TransportItemRequirement( + requirement.getAlternatives(), + requirement.getStaffAlternatives(), + requirement.getOffhandAlternatives(), + requirement.isRuneOnly())) + .collect(Collectors.toList()); + return new Rs2TransportEdge( + transport.getOrigin(), + transport.getDestination(), + Rs2TransportType.valueOf(transport.getType().name()), + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + transport.getDisplayInfo(), + transport.getAction(), + transport.getName(), + transport.getObjectId(), + transport.getDuration(), + TransportType.isTeleport(transport.getType(), transport.getOrigin()), + transport.isConsumable(), + transport.isMembers(), + transport.getMaxWildernessLevel(), + transport.getCurrencyName(), + transport.getCurrencyAmount(), + requirements); + } + + private static Rs2TransportEdge sourceAwareSpellEdge() { + Rs2TransportItemRequirement fire = new Rs2TransportItemRequirement( + Map.of(ItemID.FIRERUNE, 2), + Set.of(ItemID.TWINFLAME_STAFF), + Set.of(), + true); + Rs2TransportItemRequirement water = new Rs2TransportItemRequirement( + Map.of(ItemID.WATERRUNE, 2), + Set.of(ItemID.TWINFLAME_STAFF), + Set.of(), + true); + Rs2TransportItemRequirement law = new Rs2TransportItemRequirement( + Map.of(ItemID.LAWRUNE, 2), Set.of(), Set.of(), true); + Rs2TransportItemRequirement banana = new Rs2TransportItemRequirement( + Map.of(ItemID.BANANA, 1)); + return new Rs2TransportEdge( + null, + new WorldPoint(2771, 9102, 0), + Rs2TransportType.TELEPORTATION_SPELL, + Rs2TransportExecutor.SPELL_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + "Ape Atoll Teleport", + "Cast", + "", + -1, + 5, + true, + true, + true, + 20, + "", + 0, + List.of(fire, water, law, banana)); + } + + private static Rs2TransportEdge teleportEdge(WorldPoint destination) { + return new Rs2TransportEdge( + null, + destination, + Rs2TransportType.TELEPORTATION_ITEM, + Rs2TransportExecutor.ITEM_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test teleport", + "Teleport", + "test item", + -1, + 1, + true, + false, + false, + 0, + "", + 0, + List.of()); + } + + @Test + public void bankDistanceUsesExactSelectedRouteStepForImmediateTeleport() { + WorldPoint bank = new WorldPoint(3200, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + WorldPoint tail = new WorldPoint(3001, 3000, 0); + WorldPoint target = new WorldPoint(3002, 3000, 0); + Rs2TransportEdge selected = teleportEdge(landing); + List path = List.of(bank, landing, tail, target); + List steps = List.of( + Rs2RouteStep.transport(bank, landing, selected), + Rs2RouteStep.walk(landing, tail), + Rs2RouteStep.walk(tail, target)); + + assertEquals("the bank-leg metric must use the exact route edge rather than rematching the catalog", + 4, Rs2WalkerBankingPlanner.effectiveDistanceFromBank(path, steps, 205)); + } + + @Test + public void bankDistanceWithoutSelectedTransportKeepsRawDistance() { + WorldPoint bank = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + List path = List.of(bank, target); + + assertEquals(1, Rs2WalkerBankingPlanner.effectiveDistanceFromBank( + path, List.of(Rs2RouteStep.walk(bank, target)), 1)); + } + + @Test + public void exhaustedPartialRouteIsUnavailableRatherThanMaxValueDistance() { + WorldPoint start = new WorldPoint(2963, 3378, 0); + WorldPoint partial = new WorldPoint(2919, 3499, 0); + WorldPoint target = new WorldPoint(2907, 3539, 0); + assertEquals(-1, Rs2WalkerBankingPlanner.comparableRouteDistance( + List.of(start, partial), target, Rs2RouteTermination.SEARCH_EXHAUSTED, false)); + } + + @Test + public void reachedRouteHasFiniteComparableDistance() { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + assertEquals(2, Rs2WalkerBankingPlanner.comparableRouteDistance( + List.of(start, target), target, Rs2RouteTermination.TARGET_REACHED, true)); + } + + @Test + public void withdrawalPlanningUsesTheExactComparedBankRoute() { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint bank = new WorldPoint(3201, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + Rs2TransportEdge selected = teleportEdge(landing); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + List.of(start, bank), + null, + bank, + List.of(start, bank), + List.of(bank, landing), + "bank route selected", + 1, + 2, + List.of(Rs2RouteStep.walk(start, bank)), + List.of(Rs2RouteStep.walk(start, bank)), + List.of(Rs2RouteStep.transport(bank, landing, selected))); + + List required = + Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(analysis); + + assertEquals(1, required.size()); + assertSame("withdrawal planning must consume the transport selected by the compared bank leg", + selected, required.get(0)); + } + + @Test + public void sourceAwareSpellLoadoutWithdrawsAndEquipsOneCombinationStaff() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.TWINFLAME_STAFF ? 1 + : itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> 0, + ignored -> 0, + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> false); + + assertTrue(loadout.isSatisfiable()); + assertEquals(Map.of( + ItemID.TWINFLAME_STAFF, 1, + ItemID.LAWRUNE, 2, + ItemID.BANANA, 1), loadout.getWithdrawals()); + assertEquals(List.of(ItemID.TWINFLAME_STAFF), loadout.getEquipmentItemIds()); + assertFalse(loadout.getWithdrawals().containsKey(ItemID.FIRERUNE)); + assertFalse(loadout.getWithdrawals().containsKey(ItemID.WATERRUNE)); + } + + @Test + public void carriedUnequippedStaffCreatesEquipActionWithoutStaffWithdrawal() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + itemId -> itemId == ItemID.TWINFLAME_STAFF ? 1 : 0, + ignored -> 0, + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> false); + + assertTrue(loadout.isSatisfiable()); + assertEquals(Map.of(ItemID.LAWRUNE, 2, ItemID.BANANA, 1), loadout.getWithdrawals()); + assertEquals(List.of(ItemID.TWINFLAME_STAFF), loadout.getEquipmentItemIds()); + } + + @Test + public void missingRuneProviderMakesTheLoadoutExplicitlyUnavailable() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> 0, + ignored -> 0, + ignored -> 0, + ignored -> false); + + assertFalse(loadout.isSatisfiable()); + assertTrue(loadout.isEmpty()); + } + @Test public void itemGatedPlainTransportsNowQualifyForPlanning() { List itemGated = all.stream() @@ -72,6 +294,25 @@ public void itemGatedPlainTransportsNowQualifyForPlanning() { } } + @Test + public void itemGatedPlainTransportsSurviveTheActualPlanningFilter() { + Transport itemGated = all.stream() + .filter(t -> t.getType() == TransportType.TRANSPORT) + .filter(t -> t.getItemIdRequirements() != null && !t.getItemIdRequirements().isEmpty()) + .filter(t -> t.getCurrencyAmount() <= 0) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain an item-gated plain transport")); + + List filtered = Rs2WalkerBankingPlanner.applyTransportFiltering(List.of(itemGated)); + + assertEquals("the real banking filter must not discard the selected item-gated edge", + List.of(itemGated), filtered); + Rs2TransportEdge edge = owned(itemGated); + assertEquals("the immutable banking filter must retain the same selected edge", + List.of(edge), Rs2WalkerBankingPlanner.applyTransportEdgeFiltering(List.of(edge))); + assertTrue(Rs2WalkerBankingPlanner.planningCoversPlainTransportEdge(edge)); + } + /** A transport with no item and no currency requirement must stay out of planning. */ @Test public void unrestrictedTransportsAreStillIgnored() { @@ -118,6 +359,14 @@ public void pureCurrencyFaresEnterTheWithdrawalMap() { Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(java.util.List.of(one, one)); assertTrue("fares must sum across currency hops", summed.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0) >= one.getCurrencyAmount() * 2); + + Rs2TransportEdge edge = owned(one); + java.util.Map edgeSummed = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge, edge), ignored -> 0, ignored -> 0); + assertEquals("immutable selected edges must sum the same fares", + one.getCurrencyAmount() * 2, + edgeSummed.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); } /** @@ -135,12 +384,65 @@ public void unbankedPurchasableItemFallsBackToItsFare() { .orElseThrow(() -> new AssertionError("catalog should contain the Shantay ticket row")); java.util.Map map = - Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(java.util.List.of(ticketRow)); + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(ticketRow), ignored -> 0); assertEquals("the planner must withdraw exactly one 5-coin fare", 5, map.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); assertFalse("the unbankable ticket itself must not be requested", map.containsKey(1854)); + + Rs2TransportEdge edge = owned(ticketRow); + java.util.Map edgeMap = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge), ignored -> 0, ignored -> 0); + assertEquals("the immutable selected edge must preserve the purchasable fallback", + 5, edgeMap.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); + assertFalse(edgeMap.containsKey(1854)); + } + + @Test + public void legacyChargedItemVariantsRequestOnlyOneAlternative() { + Transport gamesNecklace = all.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM) + .filter(t -> t.getDisplayInfo() != null + && t.getDisplayInfo().startsWith("Games necklace:")) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain Games necklace teleports")); + + assertEquals("legacy semicolon variants must be represented as one OR requirement", + 1, gamesNecklace.getItemRequirements().size()); + java.util.Map requested = + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(gamesNecklace), ignored -> 0); + + assertEquals("bank planning must request one charged variant, not every charge state", + 1, requested.size()); + assertEquals(1, requested.values().iterator().next().intValue()); + } + + @Test + public void symbolicCanoeAxeCollectionChoosesOneBankedAlternative() { + Transport canoe = all.stream() + .filter(t -> t.getType() == TransportType.CANOE) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain River Lum canoes")); + int crystalAxe = net.runelite.api.gameval.ItemID.CRYSTAL_AXE; + + assertEquals(12, canoe.getItemRequirements().get(0).getItemIds().size()); + java.util.Map requested = + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(canoe), itemId -> itemId == crystalAxe ? 1 : 0); + + assertEquals("bank planning should request the available axe, not every symbolic variant", + java.util.Map.of(crystalAxe, 1), requested); + + Rs2TransportEdge edge = owned(canoe); + java.util.Map edgeRequested = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge), itemId -> itemId == crystalAxe ? 1 : 0, ignored -> 0); + assertEquals("immutable selected edges must preserve OR-alternative selection", + java.util.Map.of(crystalAxe, 1), edgeRequested); } /** Currency-bearing transports kept their existing eligibility. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java new file mode 100644 index 00000000000..d430cd3d13e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java @@ -0,0 +1,366 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Characterization of the ATTEMPTED facet of the door-attempt ledger (D3 slice 1). Every row pins a + * behaviour the two pre-ledger stores ({@code recentDoorAttemptByEdge} and + * {@code routeState.lastDoorAttempt*}) exhibited live — the fold must change where the facts live, + * not what they say. + */ +public class DoorAttemptLedgerTest +{ + private static final long COOLDOWN_MS = 2_500; + private static final long T0 = 1_000_000L; + + private final WorldPoint near = new WorldPoint(1875, 5240, 0); + private final WorldPoint far = new WorldPoint(1876, 5239, 0); + private final WorldPoint otherNear = new WorldPoint(1879, 5239, 0); + private final WorldPoint otherFar = new WorldPoint(1879, 5240, 0); + + private DoorAttemptLedger ledger; + + @Before + public void setUp() + { + ledger = new DoorAttemptLedger(); + } + + // ---- the anti-hammer cooldown (formerly recentDoorAttemptByEdge) ---- + + @Test + public void attemptThrottlesTheSameEdgeWithinTheCooldown() + { + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownIsDirectionBlind() + { + // The edge key normalizes direction: clicking the gate from the far side one second after + // clicking it from the near side is still hammering the same door. + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, far, near, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownExpires() + { + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + COOLDOWN_MS + 1)); + } + + @Test + public void aDifferentEdgeIsNeverThrottledByThisOne() + { + // Chaining is not hammering — the Stronghold's gates are three tiles apart and the walk + // must be free to attempt the NEXT gate immediately. + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, otherNear, otherFar, COOLDOWN_MS, T0 + 100)); + } + + @Test + public void tileKeyedAttemptsFeedTheCooldownButNeverBecomeTheClaim() + { + // A probe-only door (no resolved edge) has always been cooldown-tracked by its tile without + // becoming "the door the walker is working on". + WorldPoint doorTile = new WorldPoint(1859, 5239, 0); + ledger.markAttempt(doorTile, null, null, T0); + assertTrue(ledger.shouldThrottleAttempt(doorTile, null, null, COOLDOWN_MS, T0 + 100)); + assertNull(ledger.latestAttempt()); + } + + @Test + public void attemptTimesAreReadableForTheAgeHeuristics() + { + ledger.markAttempt(null, near, far, T0); + assertEquals(Long.valueOf(T0), ledger.attemptAtMs(near, far)); + assertEquals("age reads are direction-blind like the cooldown", + Long.valueOf(T0), ledger.attemptAtMs(far, near)); + assertNull(ledger.attemptAtMs(otherNear, otherFar)); + } + + // ---- the latest claim (formerly routeState.lastDoorAttempt*) ---- + + @Test + public void theLatestClaimAnswersTheActiveEdgeQuestionInBothDirections() + { + // The live-collision route validator asks "does the executor own this edge" without caring + // which way the crossing runs (fightarena_door1 lesson). + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 1_000); + assertNotNull(claim); + assertTrue(claim.matchesEdge(near, far)); + assertTrue(claim.matchesEdge(far, near)); + assertFalse(claim.matchesEdge(otherNear, otherFar)); + } + + @Test + public void theClaimGoesStale() + { + ledger.markAttempt(null, near, far, T0); + assertNull("a claim older than its window satisfies nothing", + ledger.latestAttempt(6_000, T0 + 6_001)); + assertNotNull("but the un-aged read still sees it (same-edge cooldown semantics)", + ledger.latestAttempt()); + } + + @Test + public void theSameEdgeCooldownCheckIsDirectionAware() + { + // shouldThrottleGlobalDoorInteraction's same-edge test was always directional — approaching + // the door from the other side is a new interaction context, not a re-click. + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(); + assertTrue(claim.isSameDirectedEdge(near, far)); + assertFalse(claim.isSameDirectedEdge(far, near)); + } + + @Test + public void aNewerAttemptReplacesTheClaim() + { + // Stronghold 2026-08-12: once gate 2 is attempted, gate 1 must no longer be "the door the + // walker is working on" — the victory-lap bug was exactly a stale claim outliving its door. + ledger.markAttempt(null, near, far, T0); + ledger.markAttempt(null, otherNear, otherFar, T0 + 500); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 600); + assertTrue(claim.matchesEdge(otherNear, otherFar)); + assertFalse(claim.matchesEdge(near, far)); + } + + // ---- the REFUSED facet: strike counting (formerly Rs2DoorHandler.registerDoorCrossFailure) ---- + // + // Seeded from the Tithe Farm incident (2026-08-12): Farm door 27445 refused to pass a seedless + // player, and with no strike-out the walker ping-ponged door->recovery for 4+ minutes until a + // human cancelled it. Three concluded-but-uncrossed attempts must strike the edge out. + + private static final long DECAY_MS = 300_000L; + private static final int STRIKE_LIMIT = 3; + + @Test + public void thirdConclusiveFailureStrikesOut() + { + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 12_000, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 24_000, DECAY_MS, STRIKE_LIMIT)); + // The strike-out consumed the entry: the edge starts fresh if it is ever attempted again. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 25_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes are direction-blind like every other edge fact: refusing to pass is a property of the door. */ + @Test + public void strikesAccumulateAcrossDirections() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(far, near, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A moving or cancelled sample proves only that the approach was in flight — the Wydin lesson. */ + @Test + public void inconclusiveSamplesNeverCount() + { + for (int i = 0; i < 10; i++) + { + assertEquals(DoorAttemptLedger.Strike.NOT_COUNTED, + ledger.registerCrossFailure(near, far, false, T0 + i, DECAY_MS, STRIKE_LIMIT)); + } + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 100, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes older than the decay window reset; two failures an hour apart are not a pattern. */ + @Test + public void staleStrikesDecay() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + // Third failure arrives after the decay window: the old two evaporate, count restarts at 1. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 1_000 + DECAY_MS + 1, DECAY_MS, STRIKE_LIMIT)); + } + + @Test + public void edgesStrikeIndependently() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(otherNear, otherFar, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A successful crossing forgives accumulated strikes (transient refusals must not accrue). */ + @Test + public void successfulCrossingClearsStrikes() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + ledger.clearCrossFailures(near, far); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + // ---- the tile facets: recently-opened suppression and the session blacklist ---- + + private static final long SUPPRESS_MS = 10_000L; + + /** Re-clicking a just-opened door closes it again — the original two-clicks-per-door bug. */ + @Test + public void aJustOpenedDoorSuppressesProbesOnItsSegment() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertTrue("segment ending beside the opened door must be suppressed", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + assertTrue(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void theSuppressionExpires() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertFalse(ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + assertFalse(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + } + + @Test + public void aFarAwayOpenedDoorSuppressesNothing() + { + ledger.markStationaryDoorOpened(new WorldPoint(1990, 5300, 0), T0); + assertFalse("suppression is local (within 2 tiles of a segment end), not global", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void blacklistedDoorsAreSessionPermanent() + { + WorldPoint doorTile = new WorldPoint(1907, 5223, 0); + assertFalse(ledger.isDoorBlacklisted(doorTile)); + ledger.blacklistDoor(doorTile); + assertTrue(ledger.isDoorBlacklisted(doorTile)); + assertFalse("plane is part of the tile identity", + ledger.isDoorBlacklisted(new WorldPoint(1907, 5223, 1))); + } + + // ---- the REFUSED facet: walk-scoped blocks ---- + + /** The museum lesson: a strike-out blocks the edge for THIS walk only; the next walk withdraws it. */ + @Test + public void walkScopedBlocksDrainOnceAndInOrder() + { + ledger.recordWalkScopedBlock(near, far); + ledger.recordWalkScopedBlock(far, near); + + java.util.List drained = ledger.drainWalkScopedBlocks(); + assertEquals(2, drained.size()); + assertEquals(near, drained.get(0)[0]); + assertEquals(far, drained.get(0)[1]); + assertEquals(far, drained.get(1)[0]); + assertEquals(near, drained.get(1)[1]); + assertTrue("a second drain must find nothing — blocks are withdrawn exactly once", + ledger.drainWalkScopedBlocks().isEmpty()); + } + + // ---- the pass budget (formerly processWalk's doorEdgesAttemptedThisTail map) ---- + + @Test + public void anEdgeIsClaimableOncePerPassFromTheSameStand() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + @Test + public void theReverseEdgeIsTheSameClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(toWp, fromWp, stand)); + } + + @Test + public void movingReArmsTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2465, 3494, 0))); + assertTrue("retry should be allowed after moving away from same-edge attempt tile", + ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2462, 3491, 0))); + } + + @Test + public void aNewPassAndAReleaseEachReArmTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand); + ledger.releaseEdgeThisPass(fromWp, toWp); + assertTrue("a released claim (no interaction happened) must be attemptable this pass", + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + ledger.beginTailPass(); + assertTrue("a new pass owes a fresh budget", ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + // ---- the settle window, global cooldown and raw-scan focus (walk-runtime facets) ---- + + @Test + public void theSettleWindowStoresAndEndsEarly() + { + WorldPoint farSide = new WorldPoint(1876, 5239, 0); + ledger.markSettling(farSide, T0, 900); + assertEquals(T0, ledger.settleStartedAtMs()); + assertEquals(T0 + 900, ledger.settleUntilMs()); + assertEquals(farSide, ledger.settleFarSide()); + ledger.endSettleEarly(); + assertEquals("early end clears the ceiling, not the start (heartbeat still reads it)", + 0L, ledger.settleUntilMs()); + assertNull(ledger.settleFarSide()); + assertEquals(T0, ledger.settleStartedAtMs()); + } + + @Test + public void theRawScanFocusIsABoundedCommitment() + { + ledger.setRawScanFocus(7, T0); + assertEquals(Integer.valueOf(7), ledger.rawScanFocusDoorIdx()); + assertEquals(T0, ledger.rawScanFocusSetAtMs()); + ledger.recordRawScanFocusAttempt(); + ledger.recordRawScanFocusAttempt(); + assertEquals(2, ledger.rawScanFocusAttempts()); + ledger.clearRawScanFocus(); + assertNull(ledger.rawScanFocusDoorIdx()); + assertEquals(0, ledger.rawScanFocusAttempts()); + } + + @Test + public void withdrawingTheClaimLeavesTheCooldownStanding() + { + // The crossed-axis clearing (conquered door) and the walk-start reset both withdraw the + // claim; neither may forgive the anti-hammer cooldown. Two lifetimes, one owner. + ledger.markAttempt(null, near, far, T0); + ledger.clearLatestAttempt(); + assertNull(ledger.latestAttempt()); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 100)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java index 6c85efd9c44..7fe2cb9861e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java @@ -134,4 +134,44 @@ public void getDoorActionReturnsHighestPriorityConfiguredMatch() { assertNull(Rs2DoorClassifier.getDoorAction(compWithActions("Examine", "Look-at"), doorActions)); assertNull(Rs2DoorClassifier.getDoorAction(null, doorActions)); } + + // ---- route-door classification (D3 requirement #3 — the Gift of Peace lesson) ---- + // + // An Open-actioned GameObject with a non-door name is scenery. The Stronghold's goal chest was + // Open-clicked as a route door en route (2026-08-13, 7-9s of failed traversal per encounter); + // the rule is name-or-traversal-verb because large double gates ARE GameObjects, so a flat name + // filter (the old segment-probe contains("door")) missed real doors while the flat action rule + // (the old segment-door site) admitted chests. + + @Test + public void anOpenActionedChestIsNotARouteDoor() { + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Gift of Peace", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Sarcophagus", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Cupboard", "Open")); + } + + @Test + public void aGameObjectGateIsARouteDoorByName() { + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Gate of War", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Temple door", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Curtain", "Open")); + } + + @Test + public void aTraversalVerbProvesDoorhoodWhateverTheName() { + // Field entrances, tollgates and the like carry inherently-traversal verbs. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Wheat", "Walk-through")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Ornate railing", "Pay-toll")); + assertFalse("Enter is scenery-shared, not traversal-proof", + Rs2DoorClassifier.isRouteDoorObject(false, "Cave entrance", "Enter")); + } + + @Test + public void aWallThatOpensIsADoorWhateverItsName() { + // Unchanged wall semantics: quest walls with odd names still open. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Oozing barrier", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Strange wall", "Push")); + assertFalse("an actionless, namelessly-non-door wall is still nothing", + Rs2DoorClassifier.isRouteDoorObject(true, "Wall", null)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java index 2d066ecf5cc..267d585c48b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java @@ -108,4 +108,68 @@ public void interactionRejectsNonPositiveRangeAndNullPlayer() { wp(3200, 3200), 0)); assertFalse(Rs2DoorGeometry.isDoorInteractionWithinRange(null, wp(3200, 3200), null, null, null, 2)); } + + // ---- playerBeyondWallFace -------------------------------------------------------------------- + // + // THE STRONGHOLD GATE BOUNCE (2026-08-12). A west-facing moves-you Gate of War at (1887,5244); + // the route step (1886,5244)->(1887,5243) crossed its face DIAGONALLY, and the gate deposited + // the player at (1887,5244) — off the planned to-tile — so the segment-based crossing test + // answered false while the raw scan's backtrack window kept re-finding the gate. Each re-click + // carried the player back through it. + + private static final int WEST = 1; + private static final int NORTH = 2; + private static final int EAST = 4; + private static final int SOUTH = 8; + + /** The bounce itself: carried past the face, even a tile off the planned to-tile, is crossed. */ + @Test + public void depositedBeyondTheFaceIsCrossedEvenOffThePlannedTile() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1887, 5244))); + } + + /** Approaching from the near side — including standing ON the approach tile — is not crossed. */ + @Test + public void approachingTheFaceIsNotCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1885, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1886, 5244))); + } + + /** Standing on the wall's own tile counts as its side of the face: the second Stronghold gate. */ + @Test + public void standingOnTheWallTileIsBeyondAWestFaceApproachedFromTheWest() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1904, 5242), + wp(1903, 5242), wp(1904, 5242))); + } + + /** The same boundary read from the other direction: crossing east-to-west is symmetric. */ + @Test + public void crossingIsSymmetricAcrossTheFace() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1886, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1888, 5244))); + } + + @Test + public void everyCardinalFaceDividesAlongItsOwnAxis() { + // East face of (10,10): boundary between x=10 and x=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(9, 10))); + // North face of (10,10): boundary between y=10 and y=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(NORTH, wp(10, 10), wp(10, 10), wp(10, 11))); + // South face of (10,10): boundary between y=9 and y=10. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 9))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 10))); + } + + /** A corner wall's face does not divide the plane along one axis: never claim crossed. */ + @Test + public void cornerWallsNeverReadAsCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(16, wp(10, 10), wp(9, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(128, wp(10, 10), wp(9, 10), wp(11, 10))); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java new file mode 100644 index 00000000000..6325d7df8ae --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java @@ -0,0 +1,49 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * The edge-scoped global door cooldown. The full window is anti-hammer for re-clicking ONE door; a + * different door immediately after a successful open is chaining, not hammering, and holding it for + * the full window serialised every pair of nearby doors at ~1.8s each. + */ +public class Rs2DoorHandlerTest { + + private static final long FULL = 1_800L; + private static final long CROSS = 600L; + private static final long CLICKED_AT = 100_000L; + private static final long NEXT_ALLOWED = CLICKED_AT + FULL; + + @Test + public void sameEdgeKeepsTheFullWindow() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + FULL, NEXT_ALLOWED, true, FULL, CROSS)); + } + + /** A different door owes one tick, no more — that is what a player chaining two doors looks like. */ + @Test + public void differentEdgeOwesOnlyTheCrossEdgeFloor() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 200L, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + CROSS, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, false, FULL, CROSS)); + } + + /** No window stamped (or long expired): nothing throttles either way. */ + @Test + public void expiredWindowThrottlesNothing() { + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 10_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT, 0L, false, FULL, CROSS)); + } + +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java index 0c9c5126545..fb2e1e3c83a 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java @@ -1,13 +1,16 @@ package net.runelite.client.plugins.microbot.util.walker.door; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2TerminalTravelMode; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; import org.junit.Test; +import java.util.Collections; + import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * Headless tests for {@link Rs2DoorProbe#isDoorLikeCatalogTransport} — whether a catalog transport is @@ -17,46 +20,86 @@ */ public class Rs2DoorProbeTest { - private static Transport transport(TransportType type, String name, String displayInfo, String action) { - Transport t = mock(Transport.class); - when(t.getType()).thenReturn(type); - when(t.getName()).thenReturn(name); - when(t.getDisplayInfo()).thenReturn(displayInfo); - when(t.getAction()).thenReturn(action); - return t; + private static Rs2TransportEdge transport( + Rs2TransportType type, String name, String displayInfo, String action) { + return new Rs2TransportEdge( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3200, 3201, 0), + type, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + displayInfo, + action, + name, + 1, + 1, + false, + false, + false, + 0, + "", + 0, + Collections.emptyList()); } @Test public void doorLikeByName() { assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Gate", null, "Open"))); + transport(Rs2TransportType.TRANSPORT, "Gate", null, "Open"))); } @Test public void doorLikeByDisplayInfo() { assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Anonymous object", "Large door", null))); + transport(Rs2TransportType.TRANSPORT, "Anonymous object", "Large door", null))); } @Test public void doorLikeByAction() { // Neutral name/display, but an "Open" action is a door-walk action -> classified door-like. assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Anonymous object", "Anonymous object", "Open"))); + transport(Rs2TransportType.TRANSPORT, "Anonymous object", "Anonymous object", "Open"))); } @Test public void genuineTransportIsNotDoorLike() { // A ladder with a Climb action is a real transport, not a door. assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Ladder", "Ladder", "Climb"))); + transport(Rs2TransportType.TRANSPORT, "Ladder", "Ladder", "Climb"))); } @Test public void nonTransportTypeIsNeverDoorLike() { // Only TRANSPORT-type rows are considered; an agility shortcut named "Gate" must not qualify. assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.AGILITY_SHORTCUT, "Gate", "Gate", "Open"))); + transport(Rs2TransportType.AGILITY_SHORTCUT, "Gate", "Gate", "Open"))); + } + + /** + * The regression this class exists for after the Ardougne stile. A Stile is named door-like and + * would classify as a door on its name alone — but it is crossed by climbing over it, and the + * door cascade can only wait for an edge to open. That wait timed out + * ({@code door_edge_post_unresolved}) and cost twenty seconds of refused clicks, a recovery + * wander and a replan before the transport handler crossed it in a single action. + */ + @Test + public void aMovesYouObstacleIsNotDoorLikeEvenWhenItsNameIs() { + assertFalse("a Climb-over stile belongs to the transport handler, not the door cascade", + Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Stile", "Stile", "Climb-over"))); + assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gate", "Gate", "Squeeze-through"))); + assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gangplank", "Gangplank", "Cross"))); + } + + /** Opening actions are untouched: a named gate you Open is still the door cascade's job. */ + @Test + public void anOpeningActionIsStillDoorLike() { + assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gate", "Gate", "Open"))); + assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Door", "Door", "Walk-through"))); } @Test diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java index e77d06f98e7..c797826da39 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -2,6 +2,7 @@ import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -33,4 +34,60 @@ public void shouldAcceptIdleDoorAwait_rejectsBeforeMinimumElapsed() { assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1200L, true)); assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 800L, true)); } + + // ---- door-open observation throttle ------------------------------------------------------------- + + /** + * An unlocked door opens within one game tick, so an observation before then can only report + * "still shut". The observation is a scene scan, not a field read, which is why it is rationed at + * all rather than run on every poll of the surrounding wait. + */ + @Test + public void shouldPollDoorOpen_notBeforeADoorCouldHaveOpened() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(0L, 10_000L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(100L, 10_000L)); + } + + @Test + public void shouldPollDoorOpen_onceTheFirstTickHasPassed() { + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(250L, 10_000L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(600L, 10_000L)); + } + + /** Rationed: a fresh observation is not worth a scene scan on every poll of the wait. */ + @Test + public void shouldPollDoorOpen_notMoreOftenThanTheInterval() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 0L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 100L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 250L)); + } + + // ---- traversal budget by click distance --------------------------------------------------------- + + /** Adjacent clicks keep the flat cap they were sized for — no behaviour change for the legacy band. */ + @Test + public void traversalBudget_adjacentClicksKeepTheLegacyCap() { + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(0)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(1)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(2)); + } + + /** + * A ranged click spends its first seconds being WALKED to the door, at one tile per 0.6s. The + * flat cap expired mid-approach — measured releasedBy=timeout at 11 tiles with the player still + * walking — which handed the recovery machinery its window and cost a second interaction. + */ + @Test + public void traversalBudget_rangedClicksAreGivenTheApproachTime() { + assertEquals(2_200L + 600L, Rs2WalkerAwaits.traversalBudgetMs(3)); + assertEquals(2_200L + 5 * 600L, Rs2WalkerAwaits.traversalBudgetMs(7)); + assertEquals(2_200L + 9 * 600L, Rs2WalkerAwaits.traversalBudgetMs(11)); + } + + /** The stall release bounds a wedged approach, but a hard ceiling still caps the worst case. */ + @Test + public void traversalBudget_isCapped() { + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(12)); + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(50)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java index e1e6d492e1d..ff0638ce1f5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java @@ -3,13 +3,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.ObjectID; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; -import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Set; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -39,9 +36,9 @@ public boolean isReachable(WorldPoint tile) { return true; } - public Set transportsAt(WorldPoint tile) { - return Collections.emptySet(); - } + public boolean hasTransportAt(WorldPoint tile) { + return false; + } public TileObject objectAt(WorldPoint tile) { return objects.get(tile); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java index 7f92fe1b363..4ce08410130 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java @@ -2,12 +2,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Arrays; import java.util.Collections; -import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -33,9 +31,9 @@ public boolean isReachable(WorldPoint tile) { return false; } - public Set transportsAt(WorldPoint tile) { - return Collections.emptySet(); - } + public boolean hasTransportAt(WorldPoint tile) { + return false; + } public TileObject objectAt(WorldPoint tile) { return null; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java index e427c0c6a78..55078a760b0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java @@ -2,7 +2,6 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Collections; @@ -12,7 +11,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; /** * Headless tests for {@link TransportResolver} — the stepping-stone recovery fix expressed in the P2 @@ -29,8 +27,7 @@ private static WorldPoint wp(int x, int y) { /** Scene with a transport origin at {@code origin}, given player tile and reachable set. */ private static LiveScene scene(WorldPoint player, WorldPoint origin, Set reachable) { - final Set t = new HashSet<>(Collections.singletonList(mock(Transport.class))); - return new LiveScene() { + return new LiveScene() { public WorldPoint playerLocation() { return player; } @@ -39,8 +36,8 @@ public boolean isReachable(WorldPoint tile) { return reachable.contains(tile); } - public Set transportsAt(WorldPoint tile) { - return origin.equals(tile) ? t : Collections.emptySet(); + public boolean hasTransportAt(WorldPoint tile) { + return origin.equals(tile); } public TileObject objectAt(WorldPoint tile) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java new file mode 100644 index 00000000000..f221f857b4e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java @@ -0,0 +1,612 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The frontier cascade's pure decisions, seeded with the incidents that produced them. + * + *

D2 slice 1 of the walker fix plan: these two answers used to be inline in a 1,600-line loop + * with no way to exercise them except by walking to Clock Tower. + */ +public class FrontierDecisionTest +{ + private static List route(int count, int plane) + { + List path = new ArrayList<>(); + for (int i = 0; i < count; i++) + { + path.add(new WorldPoint(3200, 3200 + i, plane)); + } + return path; + } + + private static Map reachable(WorldPoint... tiles) + { + Map map = new HashMap<>(); + for (int i = 0; i < tiles.length; i++) + { + map.put(tiles[i], i); + } + return map; + } + + // ---- forwardScanStartIndex ------------------------------------------------------------------ + // + // THE STRONGHOLD GATE BOUNCE (2026-08-12). A moves-you gate carried the player one raw tile + // through; the next smoothed point sat nine tiles out, so the closest smoothed index stayed on + // the near-side start tile — which now read unreachable through the auto-closed gate. Recovery + // chased the spent tile, clicked the same gate from the far side, and bounced every ~6s. + + /** Player one raw tile past the start: the anchor must advance off the spent tile. */ + @Test + public void anchorAdvancesPastRouteTilesTheRawPositionHasPassed() + { + int[] smoothedToRaw = {0, 9, 18, 27}; + assertEquals(1, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 1)); + // Deeper in: raw position 19 has spent indices 0..2. + assertEquals(3, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 19)); + } + + /** Standing at (or before) the start tile's raw position: nothing is spent. */ + @Test + public void anchorHoldsWhenTheRawPositionHasNotPassedTheStart() + { + int[] smoothedToRaw = {0, 9, 18}; + assertEquals(0, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 0)); + assertEquals(0, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, -1)); + } + + /** No evidence of "behind" must not read as "spent": an unmapped entry stops the advance. */ + @Test + public void unmappedEntriesStopTheAdvance() + { + int[] smoothedToRaw = {0, -1, 18}; + assertEquals(1, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 19)); + } + + /** Everything behind: the anchor clamps to the last index rather than running off the route. */ + @Test + public void anchorClampsToTheLastIndex() + { + int[] smoothedToRaw = {0, 9, 18}; + assertEquals(2, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 999)); + } + + // ---- earliestBlockedIndex ------------------------------------------------------------------- + + /** + * THE CLOCK TOWER INCIDENT. The route's tail folds back beside the player, so the reachability + * miss fires on a late index while the real blockage — a door at mid-route — sits earlier and was + * never examined. Recovery must rewind to the earliest blocked tile or it camps on the end, + * probing the wrong raw segment. + */ + @Test + public void rewindsToTheEarliestBlockedTileNotTheMissedOne() + { + List path = route(10, 0); + // Everything reachable except index 3 (the door) — the miss was reported at index 9. + List open = new ArrayList<>(path); + open.remove(3); + Map reach = reachable(open.toArray(new WorldPoint[0])); + + assertEquals(3, FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, reach)); + } + + /** Nothing before the miss is blocked: the miss index stands, no rewind. */ + @Test + public void noEarlierBlockageLeavesTheFrontierAlone() + { + List path = route(10, 0); + Map reach = reachable(path.toArray(new WorldPoint[0])); + + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, reach)); + } + + /** The scan starts at the pass's route position — tiles already walked are not re-examined. */ + @Test + public void doesNotRewindBehindTheRoutePosition() + { + List path = route(10, 0); + List open = new ArrayList<>(path); + open.remove(1); // blocked, but behind indexOfStartPoint + Map reach = reachable(open.toArray(new WorldPoint[0])); + + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 5, 9, 0, reach)); + } + + /** + * A route that climbs a staircase legitimately holds tiles the player's plane cannot reach. + * Treating those as blocked would send recovery at a staircase that is working perfectly. + */ + @Test + public void skipsTilesOnAnotherPlaneInsteadOfCallingThemBlocked() + { + List path = new ArrayList<>(route(4, 0)); + path.addAll(route(4, 1)); // indices 4-7 upstairs + Map reach = reachable(path.get(0), path.get(1), path.get(2), path.get(3)); + + // Upstairs tiles are absent from the reachable map but must NOT be chosen as the frontier. + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 8, 0, reach)); + } + + /** No reachability evidence must not read as "everything is blocked". */ + @Test + public void missingReachabilityDisablesTheRewind() + { + List path = route(10, 0); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, null)); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(null, 0, 9, 0, reachable())); + } + + /** A miss at the very start has nothing before it to rewind to. */ + @Test + public void missAtTheStartHasNoEarlierTile() + { + List path = route(10, 0); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 0, 0, reachable())); + } + + // ---- far-unreachable pre-gate --------------------------------------------------------------- + + /** + * The pre-gate skips fresh reads only for tiles the recovery gate could never consume: with + * nearGate 15 and margin 10, the boundary tile at 25 still takes the fresh-capture path and + * 26 is the first skip. On a gated route this is what keeps the pass from paying client-thread + * hops for the entire forward tail. + */ + @Test + public void tilesBeyondTheRecoveryGatePlusMarginSkipFreshReads() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + assertFalse("boundary tile must still take the fresh path", + FrontierDecision.shouldSkipFarUnreachableTile( + new WorldPoint(3225, 3200, 0), player, 15, 10)); + assertTrue(FrontierDecision.shouldSkipFarUnreachableTile( + new WorldPoint(3226, 3200, 0), player, 15, 10)); + assertTrue("a post-transport tile on another continent is the motivating case", + FrontierDecision.shouldSkipFarUnreachableTile( + new WorldPoint(1681, 3125, 0), player, 15, 10)); + } + + /** No position (or no tile) means no evidence of farness: never skip, take the fresh path. */ + @Test + public void missingPositionNeverSkips() + { + assertFalse(FrontierDecision.shouldSkipFarUnreachableTile( + new WorldPoint(1681, 3125, 0), null, 15, 10)); + assertFalse(FrontierDecision.shouldSkipFarUnreachableTile( + null, new WorldPoint(3200, 3200, 0), 15, 10)); + } + + /** + * distanceTo2D ignores plane, and that is the safe direction: the tile directly above the + * player (other side of a staircase) reads as near and keeps its fresh capture. + */ + @Test + public void anOverheadTileReadsAsNearAndIsNotSkipped() + { + assertFalse(FrontierDecision.shouldSkipFarUnreachableTile( + new WorldPoint(3201, 3200, 1), new WorldPoint(3200, 3200, 0), 15, 10)); + } + + // ---- door-attempt waits --------------------------------------------------------------------- + + @Test + public void edgeWaitMapsResolutionAndFollowThrough() + { + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_FAST_CLICK, + FrontierDecision.afterEdgeWait(true, true)); + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_AFTER_WAIT, + FrontierDecision.afterEdgeWait(true, false)); + assertEquals(FrontierDecision.DoorWaitOutcome.WAITING_RETRY, + FrontierDecision.afterEdgeWait(false, false)); + } + + /** The click is an interaction: it must only be attempted when the edge actually opened. */ + @Test + public void edgeWaitOnlyClicksWhenResolved() + { + assertTrue(FrontierDecision.shouldFastClickAfterEdgeWait(true)); + assertFalse(FrontierDecision.shouldFastClickAfterEdgeWait(false)); + } + + /** + * THE SUBTLE ONE. A door NEAR this edge opened but the player did not move: the wait proved + * nothing about the frontier in front of us, so the cascade must carry on to the settle checks + * and the real recovery. Every other outcome ends the pass. Reporting progress here would credit + * a route advance that never happened. + */ + @Test + public void nearbyWaitFallsThroughWhenResolvedButNobodyMoved() + { + FrontierDecision.DoorWaitOutcome outcome = + FrontierDecision.afterNearbyWait(true, false, false); + + assertEquals(FrontierDecision.DoorWaitOutcome.FALL_THROUGH, outcome); + assertFalse("fall-through must not end the pass", outcome.endsPass()); + assertNull("fall-through records no exit", outcome.exit()); + } + + @Test + public void nearbyWaitMapsTheResolvedAndMovedCases() + { + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_FAST_CLICK, + FrontierDecision.afterNearbyWait(true, true, true)); + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_AFTER_NEARBY_WAIT, + FrontierDecision.afterNearbyWait(true, true, false)); + assertEquals(FrontierDecision.DoorWaitOutcome.NEARBY_WAITING_RETRY, + FrontierDecision.afterNearbyWait(false, false, false)); + assertEquals("unresolved wins over movement", + FrontierDecision.DoorWaitOutcome.NEARBY_WAITING_RETRY, + FrontierDecision.afterNearbyWait(false, true, false)); + } + + /** A nearby door that opened while the player stood still says nothing about this frontier. */ + @Test + public void nearbyWaitRequiresMovementBeforeClicking() + { + assertTrue(FrontierDecision.shouldFastClickAfterNearbyWait(true, true)); + assertFalse(FrontierDecision.shouldFastClickAfterNearbyWait(true, false)); + assertFalse(FrontierDecision.shouldFastClickAfterNearbyWait(false, true)); + } + + /** Every outcome that records an exit must also end the pass, and vice versa. */ + @Test + public void onlyFallThroughContinuesTheCascade() + { + for (FrontierDecision.DoorWaitOutcome outcome : FrontierDecision.DoorWaitOutcome.values()) + { + assertEquals(outcome + " exit/endsPass must agree", + outcome.endsPass(), outcome.exit() != null); + } + } + + // ---- frontier yields ------------------------------------------------------------------------ + + private static final long BLOCK_MS = 2_200L; + + @Test + public void noYieldWhenNothingIsInFlight() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, false)); + } + + /** Settling is the broadest "we just touched a door" window, so it outranks the narrower two. */ + @Test + public void settlingOutranksTraversalAndInterim() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_SETTLING, + FrontierDecision.yieldBeforeDoorActions(true, false, 100L, BLOCK_MS, false, true)); + assertEquals("the pass-skip cooldown is the same window by another name", + FrontierDecision.FrontierYield.DOOR_SETTLING, + FrontierDecision.yieldBeforeDoorActions(false, true, 100L, BLOCK_MS, false, true)); + } + + @Test + public void traversalPendingOutranksInterim() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_TRAVERSAL_PENDING, + FrontierDecision.yieldBeforeDoorActions(false, false, 100L, BLOCK_MS, false, true)); + } + + /** + * A player already moving is walking through the door they just opened — there is nothing to + * wait for, and yielding would stall the pass behind their own successful traversal. + */ + @Test + public void aMovingPlayerIsNotWaitingToTraverse() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, 100L, BLOCK_MS, true, false)); + } + + /** A negative age means there was NO recent attempt; reading it as "0ms ago" would yield forever. */ + @Test + public void negativeAgeMeansNoRecentDoorNotAnInstantOne() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, false)); + } + + @Test + public void traversalWindowIsInclusiveAndExpires() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_TRAVERSAL_PENDING, + FrontierDecision.yieldBeforeDoorActions(false, false, BLOCK_MS, BLOCK_MS, false, false)); + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, BLOCK_MS + 1, BLOCK_MS, false, false)); + } + + @Test + public void interimYieldsWhenNothingDoorRelatedApplies() + { + assertEquals(FrontierDecision.FrontierYield.INTERIM_IN_FLIGHT, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, true)); + } + + @Test + public void everyYieldReasonCarriesAnExitAndNoneDoesNot() + { + for (FrontierDecision.FrontierYield yield : FrontierDecision.FrontierYield.values()) + { + assertEquals(yield + " exit/yields must agree", yield.yields(), yield.exit() != null); + } + } + + // ---- recovery target selection -------------------------------------------------------------- + + /** Recovering to a tile BEHIND the blockage walks the player away from the goal. */ + @Test + public void recoveryIndexNeverGoesBehindTheFrontierOrTheRoutePosition() + { + assertEquals(7, FrontierDecision.clampRecoveryIndex(3, 5, 7, 20)); + assertEquals(5, FrontierDecision.clampRecoveryIndex(2, 5, 4, 20)); + assertEquals(9, FrontierDecision.clampRecoveryIndex(9, 5, 7, 20)); + } + + @Test + public void recoveryIndexNeverRunsOffTheEnd() + { + assertEquals(19, FrontierDecision.clampRecoveryIndex(999, 0, 0, 20)); + } + + /** Recovery must not park the player next to an aggressive NPC — step back along the route. */ + @Test + public void stepsBackOutOfAHazard() + { + List path = route(10, 0); + java.util.Set hazards = new java.util.HashSet<>( + Arrays.asList(path.get(7), path.get(8), path.get(9))); + + assertEquals(6, FrontierDecision.stepBackFromDanger(path, 9, 2, hazards::contains)); + } + + /** + * If every tile back to the floor is hazardous the index stops AT the floor rather than + * retreating past the frontier — walking backwards off the route is the worse failure. + */ + @Test + public void stepBackStopsAtTheFloorEvenIfStillHazardous() + { + List path = route(10, 0); + assertEquals(4, FrontierDecision.stepBackFromDanger(path, 9, 4, tile -> true)); + } + + @Test + public void stepBackLeavesASafeIndexAlone() + { + List path = route(10, 0); + assertEquals(9, FrontierDecision.stepBackFromDanger(path, 9, 2, tile -> false)); + assertEquals(9, FrontierDecision.stepBackFromDanger(path, 9, 2, null)); + } + + /** + * THE STEPPING-STONE INCIDENT. A transport only dispatches while the player STANDS on its + * origin, so clicking the far side of a shortcut loops on the near bank forever. The origin + * therefore outranks both the route tile and the raw-gated point. + */ + @Test + public void walkToOriginWinsOverEveryOtherCandidate() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint origin = new WorldPoint(3210, 3210, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(origin, + FrontierDecision.chooseRecoveryTarget(base, raw, origin, player, tile -> false)); + } + + @Test + public void rawGatedBeatsTheBaseWhenItIsUsable() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(raw, + FrontierDecision.chooseRecoveryTarget(base, raw, null, player, tile -> false)); + } + + /** A candidate equal to where we already stand is no recovery at all. */ + @Test + public void candidatesAtThePlayersOwnTileAreIgnored() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(base, + FrontierDecision.chooseRecoveryTarget(base, player, player, player, tile -> false)); + } + + @Test + public void rawGatedIsRejectedWhenHazardous() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(base, + FrontierDecision.chooseRecoveryTarget(base, raw, null, player, raw::equals)); + } + + /** + * Documents an ASYMMETRY carried over from the original rather than endorsing it: the raw-gated + * candidate is hazard-checked, the shortcut origin is not. Changing that is a behaviour change + * and needs its own commit and its own live evidence — pinned here so it cannot drift silently. + */ + @Test + public void walkToOriginIsNotHazardChecked() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint origin = new WorldPoint(3210, 3210, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(origin, + FrontierDecision.chooseRecoveryTarget(base, null, origin, player, tile -> true)); + } + + // ---- recovery click outcome + scene fallback ------------------------------------------------ + + @Test + public void blockedClickOutcomesEndThePass() + { + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.YIELD_ACTION_IN_FLIGHT)); + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_TARGET_WALLED_REPLAN, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.REPLAN_WALLED)); + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_TARGET_WALLED_WAITING, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.WAIT_WALLED)); + } + + /** + * Two outcomes continue, for different reasons: CLICK because the click is about to happen, + * NO_TARGET because there is nothing worth clicking and the rejoin logic should get its turn. + * NO_TARGET was never mentioned in the loop — it fell through by omission, which reads exactly + * like a forgotten case. + */ + @Test + public void clickAndNoTargetBothContinueTheCascade() + { + assertNull(FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.CLICK)); + assertNull(FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.NO_TARGET)); + assertNull(FrontierDecision.exitForRecoveryClick(null)); + } + + /** Every action is classified — a new one must not default into "continue" unnoticed. */ + @Test + public void everyRecoveryClickActionIsClassified() + { + for (RouteRecovery.RecoveryClickAction action : RouteRecovery.RecoveryClickAction.values()) + { + boolean continues = action == RouteRecovery.RecoveryClickAction.CLICK + || action == RouteRecovery.RecoveryClickAction.NO_TARGET; + assertEquals(action + " classification", + continues, FrontierDecision.exitForRecoveryClick(action) == null); + } + } + + /** The canvas fallback is a last resort for the final approach, not a second click source. */ + @Test + public void sceneFallbackOnlyOnTheFinalApproach() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goalNear = new WorldPoint(3201, 3200, 0); + WorldPoint goalFar = new WorldPoint(3230, 3200, 0); + WorldPoint recover = new WorldPoint(3205, 3200, 0); + + assertTrue(FrontierDecision.shouldTrySceneClickFallback(player, goalNear, recover, 0, 1, 15)); + assertFalse("goal still far: the minimap owns this", + FrontierDecision.shouldTrySceneClickFallback(player, goalFar, recover, 0, 1, 15)); + } + + @Test + public void sceneFallbackRejectsADistantRecoveryTarget() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goal = new WorldPoint(3201, 3200, 0); + WorldPoint farTarget = new WorldPoint(3230, 3200, 0); + + assertFalse(FrontierDecision.shouldTrySceneClickFallback(player, goal, farTarget, 0, 1, 15)); + } + + /** The near-goal bound never drops below 2 tiles, however tight the caller's arrival distance. */ + @Test + public void sceneFallbackKeepsAMinimumNearGoalBound() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goal = new WorldPoint(3202, 3200, 0); + WorldPoint recover = new WorldPoint(3203, 3200, 0); + + assertTrue(FrontierDecision.shouldTrySceneClickFallback(player, goal, recover, 0, 0, 15)); + } + + @Test + public void sceneFallbackToleratesMissingInputs() + { + WorldPoint p = new WorldPoint(3200, 3200, 0); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(null, p, p, 0, 1, 15)); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(p, null, p, 0, 1, 15)); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(p, p, null, 0, 1, 15)); + } + + // ---- frontierEdge --------------------------------------------------------------------------- + + /** + * The blocked edge is the step INTO the unreachable tile, so it starts one smoothed index before + * the frontier — addressing the raw segment the door actually sits on. + */ + @Test + public void edgeStartsOneIndexBeforeTheFrontier() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4, 8, 12, 16}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 0, 3); + + assertEquals(2, edge.edgeIndex()); + assertEquals(8, edge.rawStart()); + assertEquals(13, edge.rawEndExclusive()); + assertEquals(raw.get(8), edge.from()); + assertEquals(raw.get(12), edge.to()); + } + + /** The edge can never precede the route position the pass started from. */ + @Test + public void edgeIsClampedToTheRoutePosition() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4, 8, 12, 16}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 3, 1); + + assertEquals("clamped to fromIndex, not frontier-1", 3, edge.edgeIndex()); + } + + /** A frontier past the mapping table falls back to the whole remaining raw path. */ + @Test + public void frontierBeyondTheMappingUsesTheRawTail() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 0, 5); + + assertEquals(20, edge.rawEndExclusive()); + assertEquals(raw.get(19), edge.to()); + } + + @Test + public void toleratesMissingInputs() + { + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(null, null, 0, 2); + assertEquals(0, edge.rawStart()); + assertEquals(0, edge.rawEndExclusive()); + assertNull(edge.from()); + assertNull(edge.to()); + + FrontierDecision.FrontierEdge empty = + FrontierDecision.frontierEdge(Arrays.asList(), new int[]{0}, 0, 0); + assertNull(empty.from()); + assertNull(empty.to()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java index eede470f62c..67234381341 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java @@ -1,25 +1,22 @@ package net.runelite.client.plugins.microbot.util.walker.recovery; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Arrays; -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; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; /** * Headless scenario tests for {@link RouteRecovery} decisions — the foundation of the walker test harness. *

* Each test constructs a stuck situation entirely in memory — a raw path, the set of tiles reachable from - * the player, and the transports map — with no live client, and asserts the recovery decision. This is what + * the player, and a transport-origin predicate — with no live client, and asserts the recovery decision. This is what * turns "verify a recovery change with a 5-minute live walk" into "verify it in milliseconds", which is the * prerequisite for safely rewriting the walker's recovery/executor rather than patching it live. New * recovery decisions are extracted into {@code RouteRecovery} as pure functions and exercised here. @@ -38,10 +35,8 @@ private static List steppingStonePath() { wp(3153, 3363), wp(3152, 3363), wp(3151, 3363), wp(3150, 3363), wp(3149, 3363)); } - private static Map> transportAt(WorldPoint origin) { - Map> t = new HashMap<>(); - t.put(origin, new HashSet<>(Arrays.asList(mock(Transport.class)))); - return t; + private static Predicate transportAt(WorldPoint origin) { + return origin::equals; } @Test @@ -67,7 +62,7 @@ public void returnsNullWhenNoTransportOnRoute() { reachable.add(player); assertNull(RouteRecovery.findReachableTransportOriginAhead( - path, 0, player, reachable, new HashMap<>(), 15, 40)); + path, 0, player, reachable, ignored -> false, 15, 40)); } @Test diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java new file mode 100644 index 00000000000..eccb393b561 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java @@ -0,0 +1,198 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision.TailAction; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Decision table for the end of a walk-loop iteration. + * + *

These interactions used to be inline in {@code processWalk} and could only be verified by + * walking around in-game, which is how a partial route came to report UNREACHABLE while the player + * was still advancing. + */ +public class TailDecisionTest +{ + private static final int MAX = TailDecision.MAX_PARTIAL_RETRIES; + + @Test + public void arrivalWinsOverEverythingElse() + { + assertEquals(TailAction.ARRIVED, + TailDecision.decide(true, true, WalkExit.NOT_NEAR_PATH, MAX, MAX)); + assertEquals(TailAction.ARRIVED, + TailDecision.decide(true, false, WalkExit.END_OF_PATH, 0, MAX)); + } + + @Test + public void completeRouteContinues() + { + assertEquals(TailAction.CONTINUE, + TailDecision.decide(false, false, WalkExit.END_OF_PATH, 0, MAX)); + } + + @Test + public void completeRouteExemptsBenignYieldsFromTheIterationCap() + { + assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, + TailDecision.decide(false, false, WalkExit.INTERIM_IN_FLIGHT_ROUTE, 0, MAX)); + assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, + TailDecision.decide(false, false, WalkExit.OFF_PATH_DEFERRED, 0, MAX)); + } + + /** + * The regression the whole exercise started from: on a partial route an iteration that advanced + * the walk must not spend budget, no matter how much is already spent. + */ + @Test + public void partialRouteDoesNotSpendBudgetOnAnIterationThatAdvanced() + { + for (WalkExit progress : new WalkExit[]{ + WalkExit.DOOR_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT}) + { + assertEquals(progress.name() + " advanced the route and must not spend a retry", + TailAction.PARTIAL_PROGRESS_REPLAN, + TailDecision.decide(false, true, progress, MAX, MAX)); + } + } + + @Test + public void partialRouteSpendsBudgetWhenItDidNotAdvance() + { + assertEquals(TailAction.PARTIAL_RETRY_REPLAN, + TailDecision.decide(false, true, WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK, 0, MAX)); + assertEquals(TailAction.PARTIAL_RETRY_REPLAN, + TailDecision.decide(false, true, WalkExit.NOT_NEAR_PATH, MAX - 1, MAX)); + } + + /** The budget must still terminate, or a genuinely unreachable goal never gives up. */ + @Test + public void partialRouteGivesUpOnceTheBudgetIsSpent() + { + assertEquals(TailAction.PARTIAL_EXHAUSTED, + TailDecision.decide(false, true, WalkExit.NOT_NEAR_PATH, MAX, MAX)); + assertEquals(TailAction.PARTIAL_EXHAUSTED, + TailDecision.decide(false, true, WalkExit.DOOR_RECOVERY_SUPPRESSED, MAX + 1, MAX)); + } + + @Test + public void budgetRefillsOnlyWhenTheWalkBothMovedAndAdvancedSinceTheLastRetry() + { + assertTrue("moved and route progressed after the last retry — the walk is working", + TailDecision.shouldRefillPartialRetryBudget(2, true, 500L, 400L)); + assertFalse("standing still: the route timestamp alone is also bumped by a mere replan, " + + "so a retry could refill the budget it just spent", + TailDecision.shouldRefillPartialRetryBudget(2, false, 500L, 400L)); + assertFalse("no route progress since the last retry", + TailDecision.shouldRefillPartialRetryBudget(2, true, 300L, 400L)); + assertFalse("nothing spent, nothing to refill", + TailDecision.shouldRefillPartialRetryBudget(0, true, 500L, 400L)); + } + + @Test + public void wallClockBudgetIgnoresWalksThatHaveNotStartedOrHaveNoBudget() + { + assertFalse(TailDecision.isWallClockExhausted(0L, 10_000_000L, 1_000L)); + assertFalse(TailDecision.isWallClockExhausted(1_000L, 10_000_000L, 0L)); + } + + @Test + public void wallClockBudgetTripsOnlyAfterTheBudgetElapses() + { + assertFalse(TailDecision.isWallClockExhausted(1_000L, 1_000L + 300_000L, 300_000L)); + assertTrue(TailDecision.isWallClockExhausted(1_000L, 1_001L + 300_000L, 300_000L)); + } + + /** + * The tail cap cannot see this state: every exempt iteration refunds its own charge, so the + * counter never rises and the loop can yield forever. + */ + @Test + public void exemptRunIsBoundedSeparatelyFromTheIterationCap() + { + assertFalse(TailDecision.isExemptRunTooLong(24, 24)); + assertTrue(TailDecision.isExemptRunTooLong(25, 24)); + assertFalse("a disabled cap must not fire", TailDecision.isExemptRunTooLong(1_000, 0)); + } + + // --- Route stagnation: the oscillation bound ------------------------------------------------- + // + // Seeded from the Tithe Farm incident (2026-08-12): the walker ping-ponged between two tiles for + // 4+ minutes. The wall-clock budget (observe-only, sized for whole journeys) and the exempt-run + // counter (resets on any movement) both missed it; the signal that never lied was the route + // progress index, which sat at 7/10 the entire time. + + private static final long STAGNATION_BUDGET = 60_000L; + + @Test + public void recentProgressIsNotStagnation() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(100_000L, 100_000L + STAGNATION_BUDGET, STAGNATION_BUDGET, 0, 2)); + } + + @Test + public void noRouteYetIsNotStagnation() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(0L, 10_000_000L, STAGNATION_BUDGET, 0, 2)); + } + + @Test + public void aDisabledBudgetNeverFires() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(100_000L, 10_000_000L, 0L, 0, 2)); + } + + /** One millisecond past the budget: replan while replans remain, exhaust when they are spent. */ + @Test + public void stagnationSpendsReplansThenExhausts() + { + long stale = 100_000L; + long now = stale + STAGNATION_BUDGET + 1; + assertEquals(TailDecision.StagnationAction.REPLAN, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 0, 2)); + assertEquals(TailDecision.StagnationAction.REPLAN, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 1, 2)); + assertEquals(TailDecision.StagnationAction.EXHAUSTED, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 2, 2)); + } + + // --- Tail re-click suppression ---------------------------------------------------------------- + // + // Seeded from the distance=0 dither (2026-08-12): ~10 re-clicks in 7 seconds on the last tile, + // each minimap click quantizing onto a neighbour of the goal while the player was already moving. + + /** Moving inside the band: the click in flight already ends at the goal — leave it alone. */ + @Test + public void movingInsideTheBandSuppressesTheReclick() + { + assertTrue(TailDecision.suppressTailReclick(true, 0, 5)); + assertTrue(TailDecision.suppressTailReclick(true, 5, 5)); + } + + /** Mid-route chaining while moving is how the walker flows; only the tail band suppresses. */ + @Test + public void movingBeyondTheBandStillChains() + { + assertFalse(TailDecision.suppressTailReclick(true, 6, 5)); + } + + /** A stationary player near the goal needs the follow-up click — never suppress it. */ + @Test + public void stationaryPlayersAreNeverSuppressed() + { + assertFalse(TailDecision.suppressTailReclick(false, 0, 5)); + assertFalse(TailDecision.suppressTailReclick(false, 3, 5)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java new file mode 100644 index 00000000000..c1288faa444 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java @@ -0,0 +1,146 @@ +package net.runelite.client.plugins.microbot.util.walker.segment; + +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate.SegmentAction; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Decision table for whether one route segment's obstacle handlers run. + * + *

These conditions were inline boolean soup in {@code processWalk}, and the interaction between + * them — a skipped segment silently withdrawing the right to click a door at range — is what + * produced the Falador U-turn. + */ +public class SegmentGateTest +{ + /** Steady state, nothing special: examine the segment. */ + private static SegmentAction decide(boolean recentTransportWindow, + boolean upcomingNearbyTransport, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight, + boolean tileReachable, + boolean startupBeforeFirstClick, + boolean immediateSegmentTransportStep, + int segmentIdx, + int routeStartIdx) + { + return SegmentGate.decide(recentTransportWindow, upcomingNearbyTransport, + recentDoorAttemptNearSegment, doorSettling, recoveryInFlight, tileReachable, + startupBeforeFirstClick, immediateSegmentTransportStep, segmentIdx, routeStartIdx); + } + + @Test + public void steadyStateRunsTheHandlers() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, false, false, 5, 5)); + } + + @Test + public void postTransportWindowSkipsWhenNoTransportIsComingUp() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, false, false, 5, 5)); + } + + /** The window must not hide the transport it is a window for. */ + @Test + public void aPlannedTransportNearbyOverridesThePostTransportSkip() + { + assertEquals(SegmentAction.RUN, + decide(true, true, false, false, false, true, false, false, 5, 5)); + } + + /** An unreachable segment tile is the case the handlers exist for, so it is never skipped. */ + @Test + public void anUnreachableSegmentIsNeverSkippedByTheTransportWindow() + { + assertEquals(SegmentAction.RUN, + decide(true, false, false, false, false, false, false, false, 5, 5)); + } + + @Test + public void doorWorkInFlightOverridesThePostTransportSkip() + { + assertEquals("a recent door attempt near this segment must still be examined", + SegmentAction.RUN, decide(true, false, true, false, false, true, false, false, 5, 5)); + assertEquals("a settling door must still be examined", + SegmentAction.RUN, decide(true, false, false, true, false, true, false, false, 5, 5)); + assertEquals("recovery movement in flight must still be examined", + SegmentAction.RUN, decide(true, false, false, false, true, true, false, false, 5, 5)); + } + + @Test + public void startupSkipsSegmentsUntilTheFirstMovementClick() + { + assertEquals(SegmentAction.SKIP_STARTUP_PRECLICK, + decide(false, false, false, false, false, true, true, false, 5, 5)); + } + + /** A transport we are standing next to is taken at startup rather than deferred. */ + @Test + public void anImmediateTransportStepIsNotSkippedAtStartup() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, true, true, 5, 5)); + } + + @Test + public void startupSkipDoesNotApplyBehindTheRouteStartOrOutsideStartup() + { + assertEquals("segments behind the route start are not startup-skipped", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 3, 5)); + assertEquals("a negative route start means we do not know where the route begins", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 5, -1)); + assertEquals("not in startup", + SegmentAction.RUN, decide(false, false, false, false, false, true, false, false, 8, 5)); + } + + @Test + public void startupSkipYieldsToDoorWorkInFlight() + { + assertEquals(SegmentAction.RUN, + decide(false, false, true, false, false, true, true, false, 8, 5)); + } + + /** Both apply: the post-transport reason wins, matching the original reason ternary. */ + @Test + public void postTransportReasonWinsWhenBothSkipsApply() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, true, false, 5, 5)); + } + + /** Log consumers key off these strings; they must not drift. */ + @Test + public void wireReasonsAreStable() + { + assertEquals("no_nearby_planned_transport", + SegmentAction.SKIP_POST_TRANSPORT_WINDOW.wireReason()); + assertEquals("startup_before_first_click", SegmentAction.SKIP_STARTUP_PRECLICK.wireReason()); + assertFalse(SegmentAction.RUN.isSkip()); + assertTrue(SegmentAction.SKIP_POST_TRANSPORT_WINDOW.isSkip()); + assertTrue(SegmentAction.SKIP_STARTUP_PRECLICK.isSkip()); + } + + /** + * The Falador invariant. A skipped segment was never examined, so the first segment that DOES run + * is not the nearest unresolved obstacle just because it is the first one handled — and only the + * nearest may be clicked at range. + */ + @Test + public void aSkippedSegmentWithdrawsTheRightToClickADoorAtRange() + { + assertTrue("first handler this pass, nothing skipped before it", + SegmentGate.mayDispatchDoorAtRange(false, false)); + assertFalse("an earlier segment was skipped and never examined", + SegmentGate.mayDispatchDoorAtRange(false, true)); + assertFalse("something already handled this pass, so this is not the nearest", + SegmentGate.mayDispatchDoorAtRange(true, false)); + assertFalse(SegmentGate.mayDispatchDoorAtRange(true, true)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java new file mode 100644 index 00000000000..fd38ecda653 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java @@ -0,0 +1,73 @@ +package net.runelite.client.plugins.microbot.util.walker.stall; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The walker's stall detector, and specifically what it is allowed to call "movement". + */ +public class Rs2WalkerStallPolicyTest +{ + private static final long WINDOW = 2_500L; + + /** + * The bug this exists for. {@code Rs2Player.isMoving()} compares the pose animation against the + * idle pose, so it reads TRUE while the player merely turns on the spot. Crediting that as + * progress refreshed the stall clock, so a player wedged against a wall or a door who kept + * re-facing it could never be declared stuck — the exact state the detector exists to catch. + */ + @Test + public void turningOnTheSpotIsNotProgress() + { + assertFalse("pose says moving, but no tile has changed in ten seconds", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 10_000L, WINDOW)); + } + + /** + * And the reason it cannot simply require a tile change: a walking step is ~600ms while the check + * samples faster, so "same tile as the last sample" is the normal state of a healthy walk. + */ + @Test + public void walkingBetweenTilesIsStillProgress() + { + assertTrue("mid-step, tile changed 400ms ago", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 400L, WINDOW)); + assertTrue("just inside the window", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW - 1, WINDOW)); + assertFalse("just outside it", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW, WINDOW)); + } + + /** An unknown tile-change time must not manufacture a stall. */ + @Test + public void unknownTileChangeTimeCreditsThePose() + { + assertTrue(Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, -1L, WINDOW)); + } + + /** Both original conditions still gate it: off-path movement was never route progress. */ + @Test + public void poseAndNearPathAreStillBothRequired() + { + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(false, true, 100L, WINDOW)); + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(true, false, 100L, WINDOW)); + } + + /** The threshold takes the largest applicable multiplier, not their product. */ + @Test + public void thresholdUsesTheLargestMultiplierNotTheProduct() + { + assertEquals(24_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + true, true, true, true, true)); + assertEquals(12_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, false, false)); + assertEquals("an interim waypoint alone", 21_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, true, false)); + } +} 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 ed56823ef22..a2d2b893527 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 @@ -75,6 +75,7 @@ net.runelite.client.plugins.microbot.api.tileobject.Rs2TileObjectCache#getStream net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -198,6 +199,7 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(T net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -272,6 +274,7 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWallObject net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.GameObject#sizeY(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.coords.WorldPoint#fromLocal(Client, LocalPoint): WorldPoint +net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasAction(ObjectComposition, String, boolean): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeY(): int @@ -316,7 +319,9 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#localPointFro net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.Client#getTickCount(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#blocksLineOfSight(): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#blocksLineOfSight(): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getActions(): String[] -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getCanonicalLocation(): WorldPoint -> net.runelite.api.Tile#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getCanonicalLocation(): WorldPoint -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getId(): int -> net.runelite.api.TileObject#getId(): int @@ -496,9 +501,9 @@ net.runelite.client.plugins.microbot.util.magic.Rs2Magic#canCast(MagicAction): b net.runelite.client.plugins.microbot.util.magic.Rs2Magic#cast(MagicAction, String, int): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#castOn(MagicAction, Actor): boolean -> net.runelite.api.Actor#getLocalLocation(): LocalPoint net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$canCast$1(MagicAction, Widget): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$castOn$4(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$14(Rectangle, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$15(Rectangle, Rectangle, Widget): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$castOn$5(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$15(Rectangle, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$16(Rectangle, Rectangle, Widget): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#npcContact(String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#quickCanCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.magic.Rs2Magic#quickCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle @@ -685,6 +690,12 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int) net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isBankBoothInternal(WorldPoint): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView @@ -699,7 +710,7 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boo net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$32(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$33(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] @@ -710,6 +721,7 @@ net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(St net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -725,19 +737,9 @@ net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapDrawWidget net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.Client#getTopLevelWorldView(): WorldView 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): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint, WorldPoint): WorldPoint -> net.runelite.api.CollisionData#getFlags(): int[][] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint, WorldPoint): WorldPoint -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] @@ -749,102 +751,25 @@ 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#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 -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleFairyRing(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMasterScrollBook(String): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObject(Transport, TileObject, String): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean -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#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#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$13(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$14(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$192(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$161(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$163(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$139(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$146(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$146(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$112(int, boolean, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$114(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(TileObject): boolean -> 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$119(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$122(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$153(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$154(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$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#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): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$5(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#mergePathAdjCandidate(Map, TileObject, WorldPoint, String, int, int, WorldPoint, WorldPoint, int): void -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoint, int, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoint, int, int): WalkerState -> net.runelite.api.WorldView#isInstance(): boolean -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#resolveProbeGameObject(WorldPoint): TileObject -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint 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 -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView @@ -854,8 +779,6 @@ 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 @@ -866,15 +789,120 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTranspo 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.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleDoors(List, int, boolean): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#handleStrongholdOfSecurityAnswer(TileObject, String): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#lambda$resolveProbeGameObject$3(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#lambda$resolveProbeGameObject$4(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#lambda$sceneDoorAdjacentToEdge$0(WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#markNearbyDoorFamilyOpened(TileObject, WorldPoint, String, int): void -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#resolveProbeGameObject(WorldPoint): TileObject -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean, List): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerDoors#waitForDoorInteractionProgress(WorldPoint, WorldPoint, WorldPoint, List, String, TileObject): void -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#staminaThreshold(): int -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#staminaThreshold(): int -> net.runelite.api.Player#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#walkFastCanvasOnScreenOnly(WorldPoint, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerMovement#walkFastCanvasOnScreenOnly(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#adjacentSamePlaneTransportSuppressionPoints(Transport, TileObject): Set -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findClickableCharterWidget(Widget, Widget): Widget -> net.runelite.api.widgets.Widget#getParent(): Widget +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#getFirstWidgetAction(Widget): String -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleCanoe(Transport): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleFairyRing(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMasterScrollBook(String): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObject(Transport, TileObject, String): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#hasWidgetActions(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getIndex(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$108(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$111(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$113(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleFairyRing$135(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleMinigameTeleport$102(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleMinigameTeleport$104(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$73(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$79(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$79(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$80(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$80(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$42(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$44(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$44(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$46(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$47(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$48(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$49(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$51(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$51(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$52(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleWildernessObelisk$87(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleWildernessObelisk$88(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#doorCompositionSpecifiesOnlyCloseOrShut(ObjectComposition): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#getDoorAction(ObjectComposition, List): String -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#pickWalkDoorAction(ObjectComposition): String -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorInteractionWithinRange(TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorOnSegment(TileObject, WorldPoint, WorldPoint): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, Set, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isDoorCandidateOnSegment(DoorProbeContext, DoorAttemptLedger, TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, DoorAttemptLedger, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$5(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.TileObject#getId(): int