diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index fef8aa3983..8f8ce8e49f 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -583,3 +583,62 @@ Pass the real endpoint and the real size. If a value is genuinely unavailable, l **Where this applies:** `Rs2Walker.Telemetry.*`, `WebWalkLog`, and any diagnostic that derives a field from an argument. **Defensive check:** When a log line drives a diagnosis, confirm at the call site that each value is measured rather than hardcoded before trusting it. + +## 27. Gate direct minimap clicks by Euclidean distance + +`WorldPoint.distanceTo` / `distanceTo2D` use Chebyshev distance, but the usable minimap area is +approximately circular. A diagonally offset endpoint can therefore pass a short-walk distance check +while lying outside the minimap clip. Before trying a direct endpoint click, apply an explicit +Euclidean-radius check. When a raw route exists and the endpoint is outside that radius, select a +forward route tile immediately; this is normal continuation, not an exceptional fallback. + +**Why this matters:** Near the end of a long route, the walker tried an endpoint that was diagonally +within 13 Chebyshev tiles, predictably failed the circular minimap clip, and then logged a forward +raw-route tile as a seemingly random fallback. + +**Pattern to follow:** + +```java +if (shouldAttemptDirectMinimapTarget(end, player, maxEuclidean) && walkMiniMap(end)) { + return true; +} +WorldPoint continuation = findFurthestVisibleKnownRawPathPoint(rawPath, player, maxEuclidean, anchor); +return continuation != null && walkMiniMap(continuation); +``` + +**Where this applies:** `Rs2Walker.tryDirectShortWalk`, route-backed final clicks, and any helper +that converts a Chebyshev proximity check into a minimap click. + +**Defensive check:** Test a diagonal delta such as `(11, 11)` against a 12-tile minimap radius; it +must be rejected even though its Chebyshev distance is only 11. + +## 28. Distinguish spatial proximity from route proximity + +A route that detours around a mountain, fence, or building can pass close to itself. A future +smoothed waypoint may then be only a few world tiles from the player while remaining hundreds of raw +route steps ahead. Do not treat that waypoint as the immediate locally unreachable blocker and do +not anchor recovery to it. Bound local recovery candidates by forward raw-route distance; when the +candidate belongs to a distant route fold, continue from the stabilized current route frontier. + +**Why this matters:** South of White Wolf Mountain, the route from Catherby toward Taverley first +travels around the mountain and later returns near `(2867,3442)`. Local recovery selected that +spatially close future branch from around `(2855,3440)`, causing every minimap click to fight the +mountain collision and route the player back west indefinitely. + +**Pattern to follow:** + +```java +if (!isLocalRecoveryCandidateOnForwardRoute(rawPath, smoothedToRaw, + routeStartIdx, candidateIdx, maxRawSteps)) { + issueRouteContinuationFromCurrentFrontier(); + return; +} +recoverToward(candidate); +``` + +**Where this applies:** `Rs2Walker.processWalk` local-reachability recovery, route progress +stabilization, and any fallback that chooses a waypoint using world distance on a self-near route. + +**Defensive check:** Construct a raw route whose final branch returns near its beginning. A +spatially close waypoint beyond the raw-step budget must be rejected while an ordinary nearby +forward waypoint remains eligible. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index 607bc2c990..4e2bd42f5b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -152,7 +152,8 @@ private List buildHandlers(int maxResults) { new QuestHelperHandler(gson), new StateMachineDebugHandler(gson), new ProfileHandler(gson), - new DynamicScriptDeployHandler(gson) + new DynamicScriptDeployHandler(gson), + new LiveCollisionHandler(gson) ); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java new file mode 100644 index 0000000000..b024f0560e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LiveCollisionHandler.java @@ -0,0 +1,81 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import lombok.extern.slf4j.Slf4j; +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.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Diagnostics for the hybrid live-collision overlay: {@code GET /live-collision[?x=&y=&plane=]}. + *

+ * Reports whether the overlay is enabled and captured, the snapshot's scene base, and — for a tile + * (the player's by default, or one given by query params) — the overlay's own edge readings + * ({@code overlayRaw}: {@code true}/{@code false}/{@code null} = defer to static), the resolved edges + * the pathfinder uses, and the static-only edges. Where {@code resolved} differs from {@code static}, + * live collision is changing routing. Exists to verify Stage 1/2 against a running client, since the + * overlay and pathfinder are otherwise not observable over HTTP. + */ +@Slf4j +public class LiveCollisionHandler extends AgentHandler { + + public LiveCollisionHandler(Gson gson) { + super(gson); + } + + @Override + public String getPath() { + return "/live-collision"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException { + try { + requireGet(exchange); + } catch (HttpMethodException e) { + sendJson(exchange, 405, errorResponse(e.getMessage())); + return; + } + + final PathfinderConfig config = ShortestPathPlugin.getPathfinderConfig(); + if (config == null) { + sendJson(exchange, 503, errorResponse("Pathfinder not initialised yet")); + return; + } + + final Map params = parseQuery(exchange.getRequestURI()); + final WorldPoint tile = resolveTile(params); + if (tile == null) { + sendJson(exchange, 400, errorResponse("No tile: pass x, y (and optional plane) or log in")); + return; + } + + final Map response = new LinkedHashMap<>( + config.liveCollisionDiagnostics(tile.getX(), tile.getY(), tile.getPlane())); + sendJson(exchange, 200, response); + } + + private WorldPoint resolveTile(Map params) { + if (params.get("x") != null && params.get("y") != null) { + try { + final int x = Integer.parseInt(params.get("x")); + final int y = Integer.parseInt(params.get("y")); + final int plane = getIntParam(params, "plane", 0); + return new WorldPoint(x, y, plane); + } catch (NumberFormatException e) { + return null; + } + } + if (!Microbot.isLoggedIn()) { + return null; + } + return Rs2Player.getWorldLocation(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index f239ab1530..35649c9806 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -867,4 +867,15 @@ default boolean spiritTreeFarmingGuild() { default boolean reloadTransportDefinitions() { return false; } + + @ConfigItem( + keyName = "useLiveCollision", + name = "Live collision (experimental)", + description = "Overlay RuneLite's live scene collision on the static map inside the loaded scene, so pathfinding reflects opened/closed doors, gates and temporary objects. Out-of-scene routing still uses the static map. Experimental — leave OFF unless testing.", + position = 1, + section = sectionDeveloper + ) + default boolean useLiveCollision() { + return false; + } } 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 3f4c1437d1..65fce6f2c9 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 @@ -51,6 +51,10 @@ 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.shortestpath.pathfinder.live.LiveCollisionCapture; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; +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; @@ -563,9 +567,159 @@ void handlePendingLoginRefresh() { } } + // Scene base of the last live-collision capture, so the snapshot is only rebuilt when the scene + // actually reloads rather than every tick (avoids the per-tick allocation the design warns against). + private int lastLiveCaptureBaseX = Integer.MIN_VALUE; + private int lastLiveCaptureBaseY = Integer.MIN_VALUE; + // Set by object spawn/despawn events so a mid-scene change (door, temp object) triggers one recapture + // on the next tick. Debounced to at most one rebuild per tick regardless of how many objects changed. + private volatile boolean liveCollisionDirty = false; + // Kept set until the active route has actually been checked against the newest successful capture. + // This is intentionally separate from capture dirtiness: cooldown/pathfinder gates defer validation + // without losing it. + private boolean liveRouteValidationPending = false; + // Cooldown so a run of live changes cannot spam route recalculation. + private long lastLiveRecalcMs = 0L; + private static final long LIVE_RECALC_COOLDOWN_MS = 3000L; + // Only the next few tiles of the route matter — far-ahead changes are re-checked as the player nears + // them, and may clear before then. + private static final int LIVE_RECALC_LOOKAHEAD = 15; + + /** Flags the live-collision snapshot for rebuild. Cheap (a volatile write); fired from object events. */ + private void markLiveCollisionDirty() { + liveCollisionDirty = true; + } + + @Subscribe + public void onGameObjectSpawned(net.runelite.api.events.GameObjectSpawned event) { + markLiveCollisionDirty(); + } + + @Subscribe + public void onGameObjectDespawned(net.runelite.api.events.GameObjectDespawned event) { + markLiveCollisionDirty(); + } + + @Subscribe + public void onWallObjectSpawned(net.runelite.api.events.WallObjectSpawned event) { + markLiveCollisionDirty(); + } + + @Subscribe + public void onWallObjectDespawned(net.runelite.api.events.WallObjectDespawned event) { + markLiveCollisionDirty(); + } + + /** + * Keeps the shared live-collision overlay in step with the config flag and the loaded scene, and + * proactively recalculates the walker's route when a mid-scene change has blocked the road ahead. + * Runs on the client thread from {@link #onGameTick}. Rebuilds the immutable snapshot only when the + * scene base changes (a reload) or an object changed since the last rebuild. + */ + void refreshLiveCollision() { + if (pathfinderConfig == null) { + return; + } + final LiveCollisionOverlay overlay = pathfinderConfig.getLiveCollisionOverlay(); + final boolean enabled = config.useLiveCollision(); + if (enabled != overlay.isEnabled()) { + overlay.setEnabled(enabled); + lastLiveCaptureBaseX = Integer.MIN_VALUE; // force a capture on enable, drop snapshot on disable + liveCollisionDirty = enabled; + liveRouteValidationPending = false; + } + if (!enabled) { + liveCollisionDirty = false; + liveRouteValidationPending = false; + return; + } + + final WorldView wv = client.getTopLevelWorldView(); + if (wv == null) { + return; + } + final int baseX = wv.getBaseX(); + final int baseY = wv.getBaseY(); + final boolean baseChanged = baseX != lastLiveCaptureBaseX || baseY != lastLiveCaptureBaseY; + final boolean captureNeeded = baseChanged || liveCollisionDirty + || (overlay.current() == null && !wv.isInstance()); + if (captureNeeded) { + final LiveCollisionSnapshot snapshot = LiveCollisionCapture.captureOnClientThread(); + if (snapshot == null) { + overlay.clear(); + if (wv.isInstance()) { + // Instances intentionally use static collision. Latch this scene so we do not retry + // every tick; leaving it changes the base or produces a non-instance capture request. + lastLiveCaptureBaseX = baseX; + lastLiveCaptureBaseY = baseY; + liveCollisionDirty = false; + liveRouteValidationPending = false; + } else { + // Collision data can be briefly unavailable during loading. Keep the capture pending + // and do not claim this scene base was successfully captured. + liveCollisionDirty = true; + } + return; + } + + overlay.set(snapshot); + lastLiveCaptureBaseX = baseX; + lastLiveCaptureBaseY = baseY; + liveCollisionDirty = false; + // The newly trusted interior can invalidate a route which was calculated while those tiles + // were outside the old scene, so recenter captures need the same validation as object changes. + liveRouteValidationPending = true; + } + + if (liveRouteValidationPending && validateRouteAgainstLiveCollision(overlay)) { + liveRouteValidationPending = false; + } + } + + /** + * If the walker is mid-route and live collision now blocks a walking step within the look-ahead, + * restart pathfinding so it routes around before stalling into the block. Cooldown-gated. Openable + * doors and transports are skipped by {@link LiveRouteValidator} (door edges are unknown in the + * overlay, transport jumps are non-adjacent), so this does not fight the runtime door handler. + */ + private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) { + if (overlay.current() == null || Rs2Walker.getCurrentTarget() == null) { + return true; + } + final long now = System.currentTimeMillis(); + if (now - lastLiveRecalcMs < LIVE_RECALC_COOLDOWN_MS) { + return false; + } + final Pathfinder pf = ShortestPathPlugin.pathfinder; + if (pf == null || !pf.isDone()) { + return false; + } + final List path = pf.getPath(); + if (path == null || path.size() < 2) { + return true; + } + final WorldPoint me = Rs2Player.getWorldLocation(); + if (me == null) { + return false; + } + + 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); + if (blocked >= 0) { + lastLiveRecalcMs = now; + log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating", + path.get(blocked), path.get(blocked + 1)); + Rs2Walker.recalculatePath(); + } + return true; + } + @Subscribe public void onGameTick(GameTick tick) { handlePendingLoginRefresh(); + refreshLiveCollision(); if (Rs2Walker.getCurrentTarget() != null) { return; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index f5365853f5..f737df1964 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -5,6 +5,8 @@ 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.shortestpath.pathfinder.live.LiveCollisionOverlay; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot; import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; @@ -19,15 +21,50 @@ public class CollisionMap { private final SplitFlagMap collisionData; + /** + * Shared live-collision overlay. When enabled and covering {@code (x, y, z)}, its edges win over the + * static map; otherwise every read falls through to {@code collisionData}. Defaults to a disabled + * holder, so a {@link #CollisionMap(SplitFlagMap)} behaves exactly as the static-only map — the whole + * existing test suite is unaffected. + */ + private final LiveCollisionOverlay overlay; + + /** + * Snapshot pinned for the duration of one search, so a mid-search swap on the client thread cannot + * mix two scenes into a single path. Refreshed via {@link #beginSearch()}. + */ + private LiveCollisionSnapshot pinnedSnapshot; + public byte[] getPlanes() { return collisionData.getRegionMapPlaneCounts(); } public CollisionMap(SplitFlagMap collisionData) { + this(collisionData, new LiveCollisionOverlay()); + } + + public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { this.collisionData = collisionData; + this.overlay = overlay; + } + + /** + * Pins the overlay's current snapshot for the upcoming search. Called once at the start of a + * pathfind so every {@link #get} within that search sees one immutable view; between searches the + * client thread is free to swap in a newer snapshot. + */ + public void beginSearch() { + pinnedSnapshot = overlay.current(); } private boolean get(int x, int y, int z, int flag) { + final LiveCollisionSnapshot live = pinnedSnapshot; + if (live != null) { + final Boolean liveEdge = live.edge(x, y, z, flag); + if (liveEdge != null) { + return liveEdge; + } + } return collisionData.get(x, y, z, flag); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index 47d76b138b..c7b10f56d9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -735,6 +735,9 @@ public void run() { WebWalkLog.pf("run_start src={} dst={} cutoffMs={}", WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); joinedPath = null; + // Pin the live-collision snapshot for this whole search so a mid-search swap on the client + // thread cannot mix two scenes into one path. No-op when live collision is disabled. + map.beginSearch(); try { stats.start(); computeNetworkLandmarks(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 9bb584e261..d7b7b7aa98 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -11,6 +11,8 @@ import net.runelite.client.plugins.itemcharges.ItemChargeConfig; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.shortestpath.*; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.policy.TransportRequirementPolicy; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; @@ -74,6 +76,13 @@ public class PathfinderConfig { private final SplitFlagMap mapData; private final ThreadLocal map; + /** + * One shared overlay behind every per-thread {@link CollisionMap}, so the client thread can swap in a + * fresh live snapshot that all pathfinding threads pick up. Disabled until the + * {@code useLiveCollision} config flag turns it on. + */ + @Getter + private final LiveCollisionOverlay liveCollisionOverlay = new LiveCollisionOverlay(); /** * All transports by origin {@link WorldPoint}. The null key is used for transports centered on the player. */ @@ -190,7 +199,7 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr List restrictions, Client client, ShortestPathConfig config) { this.mapData = mapData; - this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData)); + this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData, this.liveCollisionOverlay)); this.allTransports = Collections.synchronizedMap(new HashMap<>()); replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); @@ -212,6 +221,57 @@ public CollisionMap getMap() { return map.get(); } + /** + * Diagnostics for the live-collision overlay at one tile, for the agent server's + * {@code /live-collision} endpoint. Reads only immutable data (the static map and the pinned + * snapshot), so it is safe to call from any thread. + *

+ * {@code overlayRaw} is the snapshot's own answer ({@code true}/{@code false}/{@code null} where + * {@code null} means "unknown, defer to static") — proving capture and the flag translation. + * {@code resolved} is what the pathfinder actually uses (overlay where known, else static), and + * {@code static} is the static-only reading. Where they differ, the overlay is changing routing. + */ + public Map liveCollisionDiagnostics(int x, int y, int z) { + final Map out = new LinkedHashMap<>(); + out.put("enabled", liveCollisionOverlay.isEnabled()); + + final LiveCollisionSnapshot snapshot = liveCollisionOverlay.current(); + out.put("snapshotPresent", snapshot != null); + if (snapshot != null) { + out.put("baseX", snapshot.getBaseX()); + out.put("baseY", snapshot.getBaseY()); + out.put("planeCount", snapshot.getPlaneCount()); + } + out.put("tile", Map.of("x", x, "y", y, "plane", z)); + + final CollisionMap staticMap = new CollisionMap(mapData); + final CollisionMap resolvedMap = new CollisionMap(mapData, liveCollisionOverlay); + resolvedMap.beginSearch(); + out.put("static", edgeReadout(staticMap, x, y, z)); + out.put("resolved", edgeReadout(resolvedMap, x, y, z)); + + if (snapshot != null) { + final Map raw = new LinkedHashMap<>(); + raw.put("n", snapshot.edge(x, y, z, LiveCollisionSnapshot.FLAG_NORTH)); + raw.put("e", snapshot.edge(x, y, z, LiveCollisionSnapshot.FLAG_EAST)); + raw.put("s", snapshot.edge(x, y - 1, z, LiveCollisionSnapshot.FLAG_NORTH)); + raw.put("w", snapshot.edge(x - 1, y, z, LiveCollisionSnapshot.FLAG_EAST)); + out.put("overlayRaw", raw); + } + return out; + } + + private static Map edgeReadout(CollisionMap m, int x, int y, int z) { + final Map e = new LinkedHashMap<>(); + e.put("n", m.n(x, y, z)); + e.put("e", m.e(x, y, z)); + e.put("s", m.s(x, y, z)); + e.put("w", m.w(x, y, z)); + e.put("blocked", m.isBlocked(x, y, z)); + e.put("hasRegion", m.hasRegion(x, y)); + return e; + } + public void refresh(WorldPoint target) { calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java new file mode 100644 index 0000000000..2143cc9091 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -0,0 +1,275 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.CollisionData; +import net.runelite.api.CollisionDataFlag; +import net.runelite.api.GameObject; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.WallObject; +import net.runelite.api.WorldView; +import net.runelite.client.plugins.microbot.Microbot; + +import java.util.Arrays; +import java.util.BitSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Builds a {@link LiveCollisionSnapshot} from the live RuneLite scene. + *

+ * {@link #capture()} must run on the client thread — it reads {@code WorldView.getCollisionMaps()} — and + * hands the result to an immutable snapshot the off-thread pathfinder can then read lock-free. The + * translation from RuneLite's per-tile {@link CollisionDataFlag} bitmask to the can-north / can-east edge + * model mirrors the canonical logic in {@code Rs2Tile.getReachableTilesFromTileInternal}: a tile is + * standable unless {@link CollisionDataFlag#BLOCK_MOVEMENT_FULL} is set, and a directional wall bit blocks + * the corresponding edge. An edge is only recorded when both tiles it connects were captured, so the + * north/east rim of the scene is left unknown and falls back to the static map. + *

+ * Instances are deliberately not captured in this stage: the scene→world mapping for rotated and repeated + * template chunks needs its own handling, and an approximate mapping would fabricate phantom walls. Inside + * an instance {@link #capture()} returns {@code null}, so the overlay covers nothing and the pathfinder + * uses the static map — the same behaviour as today. + */ +@Slf4j +public final class LiveCollisionCapture { + + /** + * Actions that mark a wall object as a door the walker opens at runtime. Mirrors the door-action set + * in {@code Rs2Walker.handleDoors}. Any edge touching such a door is left unknown in the + * snapshot so it falls back to the static map (which treats the doorway as passable) and the existing + * runtime door subsystem keeps owning it — otherwise a closed door's live-blocked edge would make the + * pathfinder route around an openable door. + */ + private static final Set DOOR_ACTIONS = new HashSet<>(Arrays.asList( + "open", "go-through", "walk-through", "pass", "pick-lock", "pay-toll")); + + /** + * Tiles this close to the edge of the loaded scene are left unknown (deferred to the static map), + * because RuneLite's collision for the scene's outer ring is incomplete while adjacent chunks are + * only partially loaded — verified live, where the border produced a uniform band of false walls out + * to about five tiles while the deep interior matched reality. The scene always loads centred on the + * player (~tile 52 of 104), so the trusted interior still covers everything near the player, and by + * the time the player nears the border the scene has recentred. Eight gives headroom over the ~5 + * tiles of artifact observed. + */ + private static final int SCENE_BORDER_MARGIN = 8; + + private LiveCollisionCapture() { + } + + /** + * Reads the live scene on the client thread and returns an immutable snapshot, or {@code null} when + * there is nothing to capture (no world view, no collision data, or an instanced scene). + */ + public static LiveCollisionSnapshot capture() { + return Microbot.getClientThread().runOnClientThreadOptional(LiveCollisionCapture::captureOnClientThread) + .orElse(null); + } + + /** + * Same as {@link #capture()} but for callers that are already on the client thread (e.g. a + * {@code GameTick} subscriber), avoiding a redundant thread hop. + */ + public static LiveCollisionSnapshot captureOnClientThread() { + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); + if (wv == null) { + return null; + } + // See class javadoc: instances need dedicated scene->world chunk mapping; skip for now. + if (wv.isInstance()) { + return null; + } + + final CollisionData[] collisionMaps = wv.getCollisionMaps(); + if (collisionMaps == null || collisionMaps.length == 0) { + return null; + } + + final int planeCount = collisionMaps.length; + final int[][][] flagsByPlane = new int[planeCount][][]; + for (int z = 0; z < planeCount; z++) { + final CollisionData plane = collisionMaps[z]; + flagsByPlane[z] = plane != null ? plane.getFlags() : null; + } + + return build(wv.getBaseX(), wv.getBaseY(), planeCount, flagsByPlane, findDoorEdges(wv, planeCount)); + } + + /** + * Scans the loaded scene for objects owned by the runtime door handler. Wall objects contribute + * only their oriented door edge; game-object doors contribute the edges touching their footprint + * because they do not expose a wall orientation. + */ + private static LiveCollisionDoorMask findDoorEdges(WorldView wv, int planeCount) { + final Tile[][][] tiles = wv.getScene().getTiles(); + if (tiles == null) { + return null; + } + final LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(planeCount); + // A scene can reference the same object id many times (and multi-tile game objects can appear + // on more than one Tile). Resolve each definition at most once per capture. + final Map wallDoorIds = new HashMap<>(); + final Map gameObjectDoorIds = new HashMap<>(); + final int planes = Math.min(planeCount, tiles.length); + for (int z = 0; z < planes; z++) { + final Tile[][] plane = tiles[z]; + if (plane == null) { + continue; + } + for (int sx = 0; sx < SCENE_SIZE && sx < plane.length; sx++) { + final Tile[] col = plane[sx]; + if (col == null) { + continue; + } + for (int sy = 0; sy < SCENE_SIZE && sy < col.length; sy++) { + final Tile tile = col[sy]; + if (tile == null) { + continue; + } + final WallObject wall = tile.getWallObject(); + if (wall != null && wallDoorIds.computeIfAbsent( + wall.getId(), LiveCollisionCapture::isOpenableDoor)) { + doorEdges.markWall(z, sx, sy, wall.getOrientationA(), wall.getOrientationB()); + } + final GameObject[] gameObjects = tile.getGameObjects(); + if (gameObjects == null) { + continue; + } + for (GameObject gameObject : gameObjects) { + if (gameObject == null) { + continue; + } + final Point min = gameObject.getSceneMinLocation(); + final Point max = gameObject.getSceneMaxLocation(); + if (min == null || max == null || min.getX() != sx || min.getY() != sy) { + continue; // process a multi-tile object once, at its scene-min tile + } + if (!gameObjectDoorIds.computeIfAbsent( + gameObject.getId(), LiveCollisionCapture::isOpenableGameObjectDoor)) { + continue; + } + doorEdges.markGameObject(z, min.getX(), min.getY(), max.getX(), max.getY()); + } + } + } + } + return doorEdges; + } + + private static boolean isOpenableDoor(int objectId) { + final ObjectComposition comp = resolvedComposition(objectId); + if (comp == null) { + return false; + } + return hasDoorAction(comp); + } + + private static boolean isOpenableGameObjectDoor(int objectId) { + final ObjectComposition comp = resolvedComposition(objectId); + return comp != null + && comp.getName() != null + && comp.getName().toLowerCase(Locale.ENGLISH).contains("door") + && hasDoorAction(comp); + } + + private static ObjectComposition resolvedComposition(int objectId) { + final ObjectComposition comp = Microbot.getClient().getObjectDefinition(objectId); + return comp != null && comp.getImpostorIds() != null ? comp.getImpostor() : comp; + } + + private static boolean hasDoorAction(ObjectComposition comp) { + final String[] actions = comp.getActions(); + if (actions == null) { + return false; + } + for (String action : actions) { + if (action != null && DOOR_ACTIONS.contains(action.toLowerCase(Locale.ENGLISH))) { + return true; + } + } + return false; + } + + /** + * Pure translation, split out so it can be unit-tested without a client. {@code flagsByPlane[z]} is + * the {@code int[SCENE_SIZE][SCENE_SIZE]} returned by {@code CollisionData.getFlags()}, indexed + * {@code [sceneX][sceneY]}, or {@code null} for a plane with no data. + */ + public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane) { + return build(baseX, baseY, planeCount, flagsByPlane, null); + } + + /** + * As {@link #build(int, int, int, int[][][])} but exempting edges owned by the runtime door handler. + */ + public static LiveCollisionSnapshot build(int baseX, int baseY, int planeCount, int[][][] flagsByPlane, + LiveCollisionDoorMask doorEdges) { + final int cells = planeCount * SCENE_SIZE * SCENE_SIZE; + final BitSet northKnown = new BitSet(cells); + final BitSet northValue = new BitSet(cells); + final BitSet eastKnown = new BitSet(cells); + final BitSet eastValue = new BitSet(cells); + + for (int z = 0; z < planeCount; z++) { + final int[][] flags = flagsByPlane[z]; + if (flags == null) { + continue; + } + for (int sx = 0; sx < SCENE_SIZE; sx++) { + for (int sy = 0; sy < SCENE_SIZE; sy++) { + final int index = (z * SCENE_SIZE + sy) * SCENE_SIZE + sx; + final int data = flags[sx][sy]; + final boolean walkableHere = standable(data); + + // North edge: known only when both endpoints are trusted interior tiles (away from + // the unreliable scene border) and the runtime door handler does not own this edge. + if (interior(sx, sy) && interior(sx, sy + 1) + && (doorEdges == null || !doorEdges.exemptsNorth(z, sx, sy))) { + northKnown.set(index); + final int north = flags[sx][sy + 1]; + final boolean open = walkableHere + && standable(north) + && (data & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0 + && (north & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0; + if (open) { + northValue.set(index); + } + } + + // East edge: same, for the east neighbour. + if (interior(sx, sy) && interior(sx + 1, sy) + && (doorEdges == null || !doorEdges.exemptsEast(z, sx, sy))) { + eastKnown.set(index); + final int east = flags[sx + 1][sy]; + final boolean open = walkableHere + && standable(east) + && (data & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0 + && (east & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0; + if (open) { + eastValue.set(index); + } + } + } + } + } + + return new LiveCollisionSnapshot(baseX, baseY, planeCount, northKnown, northValue, eastKnown, eastValue); + } + + /** A scene tile is trusted only when it is at least {@link #SCENE_BORDER_MARGIN} tiles from the edge. */ + private static boolean interior(int sx, int sy) { + return sx >= SCENE_BORDER_MARGIN && sx < SCENE_SIZE - SCENE_BORDER_MARGIN + && sy >= SCENE_BORDER_MARGIN && sy < SCENE_SIZE - SCENE_BORDER_MARGIN; + } + + private static boolean standable(int data) { + return (data & CollisionDataFlag.BLOCK_MOVEMENT_FULL) == 0; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java new file mode 100644 index 0000000000..637861d46f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionDoorMask.java @@ -0,0 +1,122 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import java.util.BitSet; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Edges owned by the walker's runtime door handler. Marked edges are left unknown in the live snapshot, + * so they fall back to the static map and remain available to the door interaction pipeline. + */ +public final class LiveCollisionDoorMask { + private static final int PLANE_STRIDE = SCENE_SIZE * SCENE_SIZE; + + private final int planeCount; + private final BitSet north; + private final BitSet east; + + public LiveCollisionDoorMask(int planeCount) { + this.planeCount = Math.max(0, planeCount); + final int cells = this.planeCount * PLANE_STRIDE; + this.north = new BitSet(cells); + this.east = new BitSet(cells); + } + + /** + * Marks the precise wall edge(s) represented by RuneLite wall orientations. Diagonal wall + * orientations map to the two cardinal edges which bound that corner, matching the pathfinder's + * cardinal-edge representation. + */ + public void markWall(int plane, int sceneX, int sceneY, int orientationA, int orientationB) { + markOrientation(plane, sceneX, sceneY, orientationA); + markOrientation(plane, sceneX, sceneY, orientationB); + } + + /** + * Game-object doors have no wall-edge orientation. Conservatively exempt every edge touching their + * footprint, while keeping that broader fallback limited to the game object itself. + */ + public void markGameObject(int plane, int minSceneX, int minSceneY, int maxSceneX, int maxSceneY) { + for (int x = minSceneX; x <= maxSceneX; x++) { + for (int y = minSceneY; y <= maxSceneY; y++) { + markCardinal(plane, x, y, -1, 0); + markCardinal(plane, x, y, 1, 0); + markCardinal(plane, x, y, 0, -1); + markCardinal(plane, x, y, 0, 1); + } + } + } + + public boolean exemptsNorth(int plane, int sceneX, int sceneY) { + return valid(plane, sceneX, sceneY) && north.get(index(plane, sceneX, sceneY)); + } + + public boolean exemptsEast(int plane, int sceneX, int sceneY) { + return valid(plane, sceneX, sceneY) && east.get(index(plane, sceneX, sceneY)); + } + + private void markOrientation(int plane, int sceneX, int sceneY, int orientation) { + switch (orientation) { + case 1: // west + markCardinal(plane, sceneX, sceneY, -1, 0); + break; + case 2: // north + markCardinal(plane, sceneX, sceneY, 0, 1); + break; + case 4: // east + markCardinal(plane, sceneX, sceneY, 1, 0); + break; + case 8: // south + markCardinal(plane, sceneX, sceneY, 0, -1); + break; + case 16: // north-west + markCardinal(plane, sceneX, sceneY, -1, 0); + markCardinal(plane, sceneX, sceneY, 0, 1); + break; + case 32: // north-east + markCardinal(plane, sceneX, sceneY, 0, 1); + markCardinal(plane, sceneX, sceneY, 1, 0); + break; + case 64: // south-east + markCardinal(plane, sceneX, sceneY, 1, 0); + markCardinal(plane, sceneX, sceneY, 0, -1); + break; + case 128: // south-west + markCardinal(plane, sceneX, sceneY, 0, -1); + markCardinal(plane, sceneX, sceneY, -1, 0); + break; + default: + break; + } + } + + private void markCardinal(int plane, int sceneX, int sceneY, int dx, int dy) { + int edgeX = sceneX; + int edgeY = sceneY; + final BitSet edges; + if (dx != 0) { + if (dx < 0) { + edgeX--; + } + edges = east; + } else { + if (dy < 0) { + edgeY--; + } + edges = north; + } + if (valid(plane, edgeX, edgeY)) { + edges.set(index(plane, edgeX, edgeY)); + } + } + + private boolean valid(int plane, int sceneX, int sceneY) { + return plane >= 0 && plane < planeCount + && sceneX >= 0 && sceneX < SCENE_SIZE + && sceneY >= 0 && sceneY < SCENE_SIZE; + } + + private static int index(int plane, int sceneX, int sceneY) { + return plane * PLANE_STRIDE + sceneY * SCENE_SIZE + sceneX; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java new file mode 100644 index 0000000000..39e9338bff --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionOverlay.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +/** + * Shared, atomically-swappable holder for the current {@link LiveCollisionSnapshot}. + *

+ * One instance is referenced by every {@code CollisionMap} — which is a {@code ThreadLocal} over a shared + * immutable {@code SplitFlagMap}, so the live overlay must live outside those per-thread instances. The + * client thread swaps the snapshot via {@link #set}; the off-thread pathfinder reads it as a single + * volatile load through {@link #current()}. Because a snapshot is immutable, a reader that pins the + * reference for the duration of one search sees a consistent view even if the client thread swaps in a + * newer one mid-search. + *

+ * When {@link #isEnabled()} is {@code false} — the default, and whenever the config flag is off — there is + * no snapshot and callers behave exactly as the static-only map does today. + */ +public final class LiveCollisionOverlay { + private volatile boolean enabled; + private volatile LiveCollisionSnapshot current; + + /** + * @return the current snapshot, or {@code null} when disabled or nothing has been captured yet. + * Callers doing more than one edge read should pin this into a local so the whole search sees one + * immutable view. + */ + public LiveCollisionSnapshot current() { + return enabled ? current : null; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) { + current = null; + } + } + + /** Swaps in a freshly captured snapshot. No effect while disabled. */ + public void set(LiveCollisionSnapshot snapshot) { + if (enabled) { + current = snapshot; + } + } + + public void clear() { + current = null; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java new file mode 100644 index 0000000000..c7f51c049d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionSnapshot.java @@ -0,0 +1,83 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import java.util.BitSet; + +import static net.runelite.api.Constants.SCENE_SIZE; + +/** + * Immutable snapshot of the live collision of a single loaded scene, addressed in world + * coordinates and expressed in the same edge model as + * {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap}: two flags per tile, + * flag {@code 0} = can move north, flag {@code 1} = can move east. South and west are read from the + * neighbour's north/east, exactly as {@code CollisionMap} does, so an overlay built from this stays + * consistent with the static map it sits on top of. + *

+ * Each edge carries a validity bit as well as a value: an edge is only known when the capture + * could see both tiles it connects. Edges on the north/east rim of the scene, whose neighbour lies + * outside the captured 104×104, are left unknown so the caller falls back to the static map + * there rather than fabricating a wall at the scene boundary. + *

+ * Built once on the client thread by {@link LiveCollisionCapture} and never mutated, so the off-thread + * pathfinder can read it lock-free. + */ +public final class LiveCollisionSnapshot { + /** North edge, flag 0 in the shared edge model. */ + public static final int FLAG_NORTH = 0; + /** East edge, flag 1 in the shared edge model. */ + public static final int FLAG_EAST = 1; + + private static final int PLANE_STRIDE = SCENE_SIZE * SCENE_SIZE; + + private final int baseX; + private final int baseY; + private final int planeCount; + private final BitSet northKnown; + private final BitSet northValue; + private final BitSet eastKnown; + private final BitSet eastValue; + + LiveCollisionSnapshot(int baseX, int baseY, int planeCount, + BitSet northKnown, BitSet northValue, + BitSet eastKnown, BitSet eastValue) { + this.baseX = baseX; + this.baseY = baseY; + this.planeCount = planeCount; + this.northKnown = northKnown; + this.northValue = northValue; + this.eastKnown = eastKnown; + this.eastValue = eastValue; + } + + /** + * @return {@link Boolean#TRUE}/{@link Boolean#FALSE} when {@code (x, y, z)} is inside this snapshot + * and the edge is known, or {@code null} when the tile is outside the scene or the edge sits on the + * unknown rim — the caller falls back to the static map. Returns the cached {@code Boolean} + * constants, so this allocates nothing on the pathfinder hot path. + */ + public Boolean edge(int x, int y, int z, int flag) { + final int lx = x - baseX; + final int ly = y - baseY; + if (lx < 0 || lx >= SCENE_SIZE || ly < 0 || ly >= SCENE_SIZE || z < 0 || z >= planeCount) { + return null; + } + final int index = z * PLANE_STRIDE + ly * SCENE_SIZE + lx; + final BitSet known = flag == FLAG_EAST ? eastKnown : northKnown; + if (!known.get(index)) { + return null; + } + final BitSet value = flag == FLAG_EAST ? eastValue : northValue; + return value.get(index) ? Boolean.TRUE : Boolean.FALSE; + } + + public int getBaseX() { + return baseX; + } + + public int getBaseY() { + return baseY; + } + + public int getPlaneCount() { + return planeCount; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java new file mode 100644 index 0000000000..88341e6174 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java @@ -0,0 +1,77 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.live; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; + +import java.util.List; + +/** + * Validates the walking steps of an in-progress route against a {@link CollisionMap}, so the walker can + * proactively recalculate when live collision has blocked the road ahead (a door closed, a temporary + * object appeared) rather than walking into it and stalling. + *

+ * Only walking edges between adjacent same-plane tiles are checked. Transport jumps (non-adjacent, or + * cross-plane — stairs, ladders, the wilderness ditch "Cross") are skipped: those are graph edges the + * pathfinder adds independently of collision, and openable doors are already left unknown in the overlay + * so their steps read as passable and the runtime door handler owns them. + */ +public final class LiveRouteValidator { + + private LiveRouteValidator() { + } + + /** + * @return the index of the path tile nearest the player (same plane), or 0 when the path is empty. + * Used as the start of the look-ahead so a player slightly off the exact path tile is handled. + */ + public static int nearestIndex(List path, WorldPoint player) { + if (path == null || path.isEmpty() || player == null) { + return 0; + } + int best = 0; + int bestDist = Integer.MAX_VALUE; + for (int i = 0; i < path.size(); i++) { + final WorldPoint p = path.get(i); + if (p.getPlane() != player.getPlane()) { + continue; + } + final int d = Math.abs(p.getX() - player.getX()) + Math.abs(p.getY() - player.getY()); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + /** + * @return the first path index at or after {@code fromIndex}, within {@code lookahead} steps, whose + * walking step to the next tile is no longer traversable per {@code map}; or {@code -1} when the near + * route is clear. Caller must have pinned the map's snapshot ({@link CollisionMap#beginSearch()}). + */ + public static int firstBlockedStep(List path, int fromIndex, int lookahead, CollisionMap map) { + if (path == null || map == null) { + return -1; + } + int checked = 0; + for (int i = Math.max(0, fromIndex); i + 1 < path.size() && checked < lookahead; i++, checked++) { + final WorldPoint a = path.get(i); + final WorldPoint b = path.get(i + 1); + if (a.getPlane() != b.getPlane()) { + continue; // transport (stairs/ladder/teleport) + } + final int dx = b.getX() - a.getX(); + final int dy = b.getY() - a.getY(); + if (dx == 0 && dy == 0) { + continue; + } + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { + continue; // non-adjacent: a transport jump, not a walking step + } + if (!map.canStep(a.getX(), a.getY(), a.getPlane(), dx, dy)) { + return i; + } + } + return -1; + } +} 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 0851d6b491..d98e8be50b 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 @@ -169,6 +169,10 @@ public static WorldPoint getCurrentTarget() { 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; + // 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; @@ -178,6 +182,13 @@ public static WorldPoint getCurrentTarget() { private static final int UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES = 2; private static final int UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES = 10; private static final int STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN = 10; + /** + * 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 + * local blocker. The bounded reachability sample is roughly 39 tiles, so 48 preserves ordinary + * 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; private static final int UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES = 24; /** @@ -326,6 +337,13 @@ private static void markWalkSessionStart(WorldPoint target) { WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); } + private static void clearRecentTransportContext() { + lastTransportHandledAtMs = 0L; + lastTransportHandledAtLocation = null; + lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; + } + private static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { if (firstMovementClickMarked) { return; @@ -726,6 +744,7 @@ static boolean isRouteProgressExit(String exitReason) { case "path-blocker-handled": case "interim-in-flight": case "recovery-move-in-flight": + case "route-fold-continuation-click": return true; default: return false; @@ -898,10 +917,7 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); recentCurrentTileTransportByEdge.clear(); - lastTransportHandledAtMs = 0L; - lastTransportHandledAtLocation = null; - lastTransportOriginLocation = null; - lastTransportDestinationLocation = null; + clearRecentTransportContext(); resetRouteProgress(); } @@ -1535,7 +1551,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { setTarget(target); - tryIssueRouteRecoveryClick(rawPath, path, target, "stall recovery click"); + tryIssueRouteRecoveryClick(rawPath, path, target, distance, "stall recovery click"); continue; } else { setTarget(target); @@ -1543,7 +1559,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } if (shouldRunActiveRouteIdleNudge(shouldIssueActiveRouteIdleNudge, immediateRouteTransportPending)) { - if (tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge")) { + if (tryIssueRouteRecoveryClick(rawPath, path, target, distance, "active route idle nudge")) { lastAttemptedMinimapClick = null; lastAttemptedMinimapClickOk = false; lastAttemptedMinimapClickAtMs = 0L; @@ -1601,7 +1617,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part && !Rs2Player.isAnimating() && !isDoorInteractionSettling() && !isTransportInteractionSettling() - && tryIssueRouteContinuationClick(rawPath, path, target)) { + && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { walkerDiag("tail exempt exitReason=interim-close-route-click tailBefore=%d", processWalkTail); processWalkTail--; continue; @@ -1921,6 +1937,23 @@ && shouldSkipStartupPreclickSegmentHandlers( if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { + boolean candidateOnCurrentRouteFrontier = isLocalRecoveryCandidateOnForwardRoute( + rawPath, + smoothedToRaw, + indexOfStartPoint, + 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); + if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { + exitReason = "route-fold-continuation-click"; + } else { + exitReason = "route-fold-continuation-pending"; + } + break; + } log.info("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", currentWorldPoint, i, path.size(), playerLoc, target); @@ -2164,9 +2197,15 @@ && walkFastCanvas(recoverTarget)) { } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); int dist2d = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); - if (dist2d > nextWalkingDistance) { + boolean approachPlannedTransportOrigin = shouldApproachPlannedTransportOrigin( + hasExplicitTransportStep(path, i), + currentWorldPoint, + Rs2Player.getWorldLocation(), + RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); + if (dist2d > nextWalkingDistance || approachPlannedTransportOrigin) { tmarkPostTransport("post_transport_click_eligibility", target, - "i=" + i + " dist2d=" + dist2d + " threshold=" + nextWalkingDistance); + "i=" + i + " dist2d=" + dist2d + " threshold=" + nextWalkingDistance + + (approachPlannedTransportOrigin ? " reason=transport_approach" : "")); // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). @@ -2549,6 +2588,14 @@ && walkFastCanvas(recoverTarget)) { } } } + // A route scan can legitimately find no new click while the server is still completing + // 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) + && Rs2Player.isMoving()) { + exitReason = "route-move-in-flight"; + } WorldPoint pathLastForFinish = path.get(path.size() - 1); int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); int finalDist = Rs2Player.getWorldLocation().distanceTo(target); @@ -2650,6 +2697,8 @@ && walkFastCanvas(recoverTarget)) { // 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); processWalkTail--; @@ -3324,6 +3373,35 @@ private static int rawPathStepDistance(WorldPoint previous, WorldPoint current) return Math.max(1, previous.distanceTo2D(current)); } + static boolean isLocalRecoveryCandidateOnForwardRoute(List rawPath, + int[] smoothedToRaw, + int routeStartIdx, + int candidateIdx, + int maxRawRouteSteps) { + if (rawPath == null || rawPath.isEmpty() || smoothedToRaw == null + || routeStartIdx < 0 || candidateIdx < routeStartIdx + || routeStartIdx >= smoothedToRaw.length || candidateIdx >= smoothedToRaw.length + || maxRawRouteSteps < 0) { + return false; + } + int rawStart = smoothedToRaw[routeStartIdx]; + int rawCandidate = smoothedToRaw[candidateIdx]; + if (rawStart < 0 || rawCandidate < rawStart + || rawStart >= rawPath.size() || rawCandidate >= rawPath.size()) { + return false; + } + + int routeSteps = 0; + for (int i = rawStart; i < rawCandidate; i++) { + int step = rawPathStepDistance(rawPath.get(i), rawPath.get(i + 1)); + if (step > maxRawRouteSteps - routeSteps) { + return false; + } + routeSteps += step; + } + return true; + } + static int rawPathForwardAnchorIndex(List rawPath, WorldPoint playerLoc, int rawAnchorIndex) { if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { return -1; @@ -3382,14 +3460,16 @@ private static boolean shouldIssueActiveRouteIdleNudge() { private static boolean tryIssueRouteRecoveryClick(List rawPath, List path, WorldPoint target, + int configuredDistance, String logLabel) { - return tryIssueRouteMovementClick(rawPath, path, target, logLabel, + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, logLabel, STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN, true); } private static boolean tryIssueRouteContinuationClick(List rawPath, List path, - WorldPoint target) { + WorldPoint target, + int configuredDistance) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null || path == null || path.isEmpty()) { return false; @@ -3411,13 +3491,14 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { return false; } - return tryIssueRouteMovementClick(rawPath, path, target, "interim close route click", + return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", NORMAL_MINIMAP_REACH_EUCLIDEAN, false); } private static boolean tryIssueRouteMovementClick(List rawPath, List path, WorldPoint target, + int configuredDistance, String logLabel, int maxEuclidean, boolean markRecoveryCooldown) { @@ -3425,12 +3506,15 @@ private static boolean tryIssueRouteMovementClick(List rawPath, if (playerLoc == null || path == null || path.isEmpty()) { return false; } + if (routeArrivalSatisfied(playerLoc, target, path, configuredDistance)) { + return false; + } int targetIdx = stabilizeRouteProgressIndex(path, getClosestTileIndex(path, playerLoc), target, playerLoc); int startIdx = Math.max(0, targetIdx); int[] routeSmoothedToRaw = mapSmoothedToRaw(path, rawPath); int rawAnchorIndex = rawIndexForSmoothedIndex(targetIdx, routeSmoothedToRaw, rawPath); - // Use the SAME line-of-sight-first selector as the main route loop. Recovery/idle-nudge and + // Use the same route-backed selector as the main route loop. Recovery/idle-nudge and // interim continuation clicks previously had their own walkable-only selection, so which // policy applied depended on which path happened to fire (timing-dependent, e.g. a slow // pathfinder makes the idle nudge issue the first click instead of the main loop). @@ -3504,6 +3588,19 @@ private static boolean tryIssueRouteMovementClick(List rawPath, return true; } + static boolean routeArrivalSatisfied(WorldPoint playerLoc, + WorldPoint target, + List path, + int configuredDistance) { + if (playerLoc == null || target == null || path == null || path.isEmpty() + || playerLoc.getPlane() != target.getPlane()) { + return false; + } + WorldPoint pathEnd = path.get(path.size() - 1); + int finishThreshold = tightFinishThreshold(target, pathEnd, configuredDistance); + return playerLoc.distanceTo2D(target) <= finishThreshold; + } + static String routeMovementClickPhase(String logLabel) { if ("stall recovery click".equals(logLabel)) { return "stall_recovery_click"; @@ -4277,14 +4374,43 @@ private static boolean clickRouteBackedShortWalk(List rawPath, WorldPoint playerLoc, int maxEuclidean, int rawAnchorIndex) { - WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, end, playerLoc, - maxEuclidean, false, rawAnchorIndex); - if (clickedTarget != null) { + 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) { @@ -4605,6 +4731,7 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, // single log line shows which handler dominates instead of needing another repro round. final long scanStartMs = System.currentTimeMillis(); int scannedIdx = 0; + long snapshotMs = 0L; long transportMs = 0L; long doorMs = 0L; long doorCandidateMs = 0L; @@ -4614,8 +4741,15 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, // handlerRange) and only match objects within one tile of a probe, so a handlerRange+2 // radius is a superset of what the per-probe queries could have found. final int snapshotRadius = handlerRange + 2; + long snapshotStartedAt = System.currentTimeMillis(); rawScanWallSnapshot = Rs2GameObject.getWallObjects(o -> true, playerLoc, snapshotRadius); rawScanGameObjectSnapshot = Rs2GameObject.getGameObjects(o -> true, playerLoc, snapshotRadius); + rawScanDoorLocationSnapshot = Microbot.getClientThread() + .invoke(Rs2Walker::captureRawScanDoorLocationsOnClientThread); + snapshotMs = System.currentTimeMillis() - snapshotStartedAt; + rawScanDoorCompositionCache = new IdentityHashMap<>(); + rawScanDoorSegmentCache = new HashMap<>(); + rawScanDoorInteractionWaitMs = 0L; try { for (int i = start; i < endExclusive; i++) { WorldPoint currentWorldPoint = rawPath.get(i); @@ -4626,7 +4760,10 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } scannedIdx++; - if (allowTransportHandlers && hasExplicitTransportStep(rawPath, i)) { + if (allowTransportHandlers + && hasExplicitTransportStep(rawPath, i) + && isRawTransportOriginNearPlayer( + rawPath, i, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { WorldPoint before = Rs2Player.getWorldLocation(); WorldPoint expectedDestination = i + 1 < rawPath.size() ? rawPath.get(i + 1) : null; long t = System.currentTimeMillis(); @@ -4675,12 +4812,18 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } finally { rawScanWallSnapshot = null; rawScanGameObjectSnapshot = null; + rawScanDoorLocationSnapshot = null; + rawScanDoorCompositionCache = null; + rawScanDoorSegmentCache = null; long totalMs = System.currentTimeMillis() - scanStartMs; if (totalMs >= SLOW_RAW_SCENE_SCAN_LOG_MS) { - log.info("[Walker] slow raw scene scan: total={}ms idx={} doors={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", - totalMs, scannedIdx, doorMs, doorCandidateMs, rockfallMs, transportMs, + long doorWaitMs = rawScanDoorInteractionWaitMs; + long doorProbeMs = Math.max(0L, doorMs - doorWaitMs); + log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorProbe={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", + totalMs, scannedIdx, snapshotMs, doorProbeMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, resolved, allowTransportHandlers); } + rawScanDoorInteractionWaitMs = 0L; } } @@ -4700,6 +4843,32 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, */ private static volatile List rawScanWallSnapshot = null; private static volatile List rawScanGameObjectSnapshot = null; + /** Immutable locations copied on the client thread for off-thread snapshot filtering. */ + private 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; + /** Interaction/edge-resolution wait contained inside {@link #handleDoors}; excluded from probe cost. */ + private static volatile long rawScanDoorInteractionWaitMs = 0L; + + 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; + } /** * Exact-tile match first, then a one-tile adjacency fallback — the same preference order the @@ -5341,6 +5510,21 @@ private static boolean handleDoors(List path, int index, boolean all 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 = findDoorNearSegment(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; @@ -5525,23 +5709,84 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp } final int searchDistance = 10; + List wallSnapshot = rawScanWallSnapshot; + List gameObjectSnapshot = rawScanGameObjectSnapshot; + if (wallSnapshot != null || gameObjectSnapshot != null) { + Map locations = rawScanDoorLocationSnapshot; + if (locations == null) { + return null; + } + Map> segmentCache = rawScanDoorSegmentCache; + String segmentKey = fromWp.getX() + "," + fromWp.getY() + "," + fromWp.getPlane() + + ">" + toWp.getX() + "," + toWp.getY() + "," + toWp.getPlane(); + if (segmentCache != null && segmentCache.containsKey(segmentKey)) { + return segmentCache.get(segmentKey).orElse(null); + } + List candidates = new ArrayList<>( + (wallSnapshot != null ? wallSnapshot.size() : 0) + + (gameObjectSnapshot != null ? gameObjectSnapshot.size() : 0)); + if (wallSnapshot != null) { + candidates.addAll(wallSnapshot); + } + if (gameObjectSnapshot != null) { + candidates.addAll(gameObjectSnapshot); + } + TileObject match = candidates.stream() + .filter(o -> isDoorCandidateOnSegment(o, locations.get(o), + playerLoc, fromWp, toWp, + doorActions, searchDistance)) + .min(Comparator.comparingInt(o -> locations.get(o).distanceTo2D(playerLoc))) + .orElse(null); + if (segmentCache != null) { + segmentCache.put(segmentKey, Optional.ofNullable(match)); + } + return match; + } return Rs2GameObject.getAll(o -> { - if (o == null || o.getWorldLocation() == null) return false; - WorldPoint loc = o.getWorldLocation(); - if (loc.getPlane() != playerLoc.getPlane()) return false; - if (loc.distanceTo2D(playerLoc) > searchDistance) return false; - if (sessionBlacklistedDoors.contains(loc)) return false; - if (!(o instanceof WallObject) && !(o instanceof GameObject)) return false; - if (isCatalogTransportObject(o) && !isDoorLikeSceneObject(o)) return false; - if (!isDoorOnSegment(o, fromWp, toWp)) return false; - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); - if (!isDoorComposition(comp, doorActions)) return false; - return true; + return isDoorCandidateOnSegment(o, o.getWorldLocation(), + playerLoc, fromWp, toWp, + doorActions, searchDistance); }, playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); } + private static boolean isDoorCandidateOnSegment(TileObject object, + WorldPoint objectLocation, + WorldPoint playerLoc, + WorldPoint fromWp, + WorldPoint toWp, + List doorActions, + int searchDistance) { + if (object == null || objectLocation == null) { + return false; + } + WorldPoint loc = objectLocation; + if (loc.getPlane() != playerLoc.getPlane() + || loc.distanceTo2D(playerLoc) > searchDistance + || sessionBlacklistedDoors.contains(loc) + || (!(object instanceof WallObject) && !(object instanceof GameObject)) + || (isCatalogTransportObject(object) && !isDoorLikeSceneObject(object)) + || !isDoorOnSegment(object, fromWp, toWp)) { + return false; + } + ObjectComposition comp = resolveRawScanDoorComposition(object); + return isDoorComposition(comp, doorActions); + } + + private static ObjectComposition resolveRawScanDoorComposition(TileObject object) { + if (object == null) { + return null; + } + Map> compositionCache = rawScanDoorCompositionCache; + if (compositionCache == null) { + return Rs2GameObject.convertToObjectComposition(object); + } + return compositionCache.computeIfAbsent(object, + ignored -> Optional.ofNullable(Rs2GameObject.convertToObjectComposition(object))) + .orElse(null); + } + private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, boolean allowSegmentProbe) { if (object == null || probe == null) return false; @@ -5553,7 +5798,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + ObjectComposition comp = resolveRawScanDoorComposition(object); if (!isDoorComposition(comp, doorActions)) return false; String action = getDoorAction(comp, doorActions); @@ -6274,11 +6519,20 @@ private static boolean hasImmediatePlannedTransportStep(List path, return false; } WorldPoint origin = path.get(routeStartIdx); - if (origin == null || origin.getPlane() != playerLoc.getPlane() - || origin.distanceTo2D(playerLoc) > HANDLER_RANGE) { - return false; - } - return hasExplicitTransportStep(path, routeStartIdx); + return hasExplicitTransportStep(path, routeStartIdx) + && isTransportOriginNearPlayer( + origin, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); + } + + 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); } /** @@ -6463,8 +6717,15 @@ private static boolean isCatalogTransportObject(TileObject object) { } private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { + long startedAt = System.currentTimeMillis(); AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); + 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) { @@ -8108,6 +8369,10 @@ public static void setTarget(WorldPoint target, String clearReasonWhenNull) { 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()) { @@ -8726,8 +8991,27 @@ static boolean isSettledNearAdjacentSamePlaneLanding(Transport transport, return false; } int destinationDistance = playerLoc.distanceTo2D(destWait); - return destinationDistance <= Math.max(1, maxInclusive) - && playerLoc.distanceTo2D(origin) > 0; + 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; } /** @@ -8810,7 +9094,11 @@ private static boolean handleObject(Transport transport, TileObject tileObject, if (tdObj.getPlane() == plObj.getPlane()) { if (transport.getType() == TransportType.AGILITY_SHORTCUT) { Rs2Player.waitForAnimation(); - sleepUntil(() -> isPlayerWithinChebyshevInclusive(tdObj, 2), 10000); + 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 @@ -8950,6 +9238,11 @@ private static boolean handleRockfallInRawSegment(List rawPath, int private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { 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)) { + continue; + } if (handleTransports(rawPath, ri)) { return true; } @@ -8957,6 +9250,27 @@ private static boolean handleTransportsInRawSegment(List rawPath, in return false; } + 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); + } + + 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(); lastTransportHandledAtMs = handoffStartedAt; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java new file mode 100644 index 0000000000..af32d75c80 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -0,0 +1,372 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.CollisionDataFlag; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionCapture; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionDoorMask; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveRouteValidator; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static net.runelite.api.Constants.SCENE_SIZE; +import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_EAST; +import static net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot.FLAG_NORTH; +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; + +/** + * Stage 1 hybrid live-collision coverage: the pure flag translation in {@link LiveCollisionCapture#build}, + * the snapshot's edge/validity semantics, and that {@link CollisionMap} lets an enabled overlay win inside + * the captured scene while falling back to the static map everywhere else. With no overlay, a + * {@code CollisionMap} must read byte-for-byte as the static-only map — the guard that keeps every other + * shortestpath test deterministic. + */ +public class LiveCollisionTest { + + private static final int BASE_X = 3200; + private static final int BASE_Y = 3200; + + private static SplitFlagMap staticFlags; + + @BeforeClass + public static void loadCollisionMap() { + staticFlags = SplitFlagMap.fromResources(); + } + + // ---- pure translation (no client, no static map) ---- + + private static int[][][] openScene() { + // One plane, all tiles standable, no walls -> every interior edge open. + return new int[][][]{new int[SCENE_SIZE][SCENE_SIZE]}; + } + + @Test + public void openScene_interiorEdgesOpen_rimUnknown() { + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, openScene()); + + // interior tile: both edges known and open + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_NORTH)); + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_EAST)); + + // north rim: north neighbour is outside the scene -> unknown -> null (static fallback) + assertNull(snap.edge(BASE_X + 10, BASE_Y + SCENE_SIZE - 1, 0, FLAG_NORTH)); + // east rim: east neighbour outside -> unknown + assertNull(snap.edge(BASE_X + SCENE_SIZE - 1, BASE_Y + 10, 0, FLAG_EAST)); + + // entirely outside the scene -> null + assertNull(snap.edge(BASE_X - 5, BASE_Y + 10, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + 10, BASE_Y + 10, 1, FLAG_NORTH)); // plane 1 not captured + } + + @Test + public void wallBitBlocksTheEdge() { + int[][][] flags = openScene(); + flags[0][10][10] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + flags[0][20][20] |= CollisionDataFlag.BLOCK_MOVEMENT_EAST; + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 20, BASE_Y + 20, 0, FLAG_EAST)); + // the untouched edge of the same tile is still open + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 10, BASE_Y + 10, 0, FLAG_EAST)); + } + + @Test + public void wallIsSymmetric_neighbourSouthBitAlsoBlocks() { + // A south wall on the north tile must block the same edge, since s(x,y)=n(x,y-1). + int[][][] flags = openScene(); + flags[0][30][31] |= CollisionDataFlag.BLOCK_MOVEMENT_SOUTH; // south wall on the northern tile + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // north edge of (30,30) connects (30,30)<->(30,31); the south wall on (30,31) closes it + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 30, BASE_Y + 30, 0, FLAG_NORTH)); + } + + @Test + public void sceneBorderIsUnknown_soBoundaryArtifactsFallBackToStatic() { + // RuneLite's collision for the scene's outer ring is unreliable (verified live: a uniform band + // of false walls ~5 tiles deep). The capture leaves that border unknown -> static fallback. + int[][][] flags = openScene(); + // put a "wall" both deep in the interior and out at the border + flags[0][50][50] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; // interior + flags[0][2][50] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; // border (sx=2, within margin 8) + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // interior wall is captured + assertEquals(Boolean.FALSE, snap.edge(BASE_X + 50, BASE_Y + 50, 0, FLAG_NORTH)); + // border tile is unknown regardless of its flags -> defer to static + assertNull(snap.edge(BASE_X + 2, BASE_Y + 50, 0, FLAG_NORTH)); + // a tile just inside the margin is known again + assertEquals(Boolean.TRUE, snap.edge(BASE_X + 8, BASE_Y + 50, 0, FLAG_NORTH)); + // a tile just outside the margin is unknown + assertNull(snap.edge(BASE_X + 7, BASE_Y + 50, 0, FLAG_NORTH)); + } + + @Test + public void wallDoorExemptsOnlyItsOrientedEdge() { + // A closed north-facing wall door owns only the north edge. Other live-blocked edges touching + // the same tile must remain authoritative instead of being erased by a tile-wide exemption. + int[][][] flags = openScene(); + final int dx = 50, dy = 50; + flags[0][dx][dy] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH + | CollisionDataFlag.BLOCK_MOVEMENT_EAST + | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH + | CollisionDataFlag.BLOCK_MOVEMENT_WEST; + + LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(1); + doorEdges.markWall(0, dx, dy, 2, 0); // orientation 2 = north + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorEdges); + + // The physical doorway falls back to static so the runtime door handler can open it. + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_NORTH)); + // Unrelated edges around the same tile retain their live walls. + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); + + // a tile two away from the door is unaffected and still overlaid normally + assertEquals(Boolean.TRUE, snap.edge(BASE_X + dx + 2, BASE_Y + dy + 2, 0, FLAG_NORTH)); + } + + @Test + public void gameObjectDoorExemptsEdgesTouchingItsFootprint() { + int[][][] flags = openScene(); + final int dx = 50, dy = 50; + flags[0][dx][dy] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH + | CollisionDataFlag.BLOCK_MOVEMENT_EAST + | CollisionDataFlag.BLOCK_MOVEMENT_SOUTH + | CollisionDataFlag.BLOCK_MOVEMENT_WEST; + + LiveCollisionDoorMask doorEdges = new LiveCollisionDoorMask(1); + doorEdges.markGameObject(0, dx, dy, dx, dy); + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, doorEdges); + + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy, 0, FLAG_EAST)); + assertNull(snap.edge(BASE_X + dx, BASE_Y + dy - 1, 0, FLAG_NORTH)); + assertNull(snap.edge(BASE_X + dx - 1, BASE_Y + dy, 0, FLAG_EAST)); + } + + @Test + public void wallDoorCardinalOrientationsMapToSharedEdgeCoordinates() { + final int x = 50, y = 50; + LiveCollisionDoorMask edges = new LiveCollisionDoorMask(1); + + edges.markWall(0, x, y, 1, 0); // west is neighbour's east edge + assertTrue(edges.exemptsEast(0, x - 1, y)); + assertFalse(edges.exemptsEast(0, x, y)); + + edges = new LiveCollisionDoorMask(1); + edges.markWall(0, x, y, 8, 0); // south is neighbour's north edge + assertTrue(edges.exemptsNorth(0, x, y - 1)); + assertFalse(edges.exemptsNorth(0, x, y)); + + edges = new LiveCollisionDoorMask(1); + edges.markWall(0, x, y, 4, 2); // east + north, including orientationB + assertTrue(edges.exemptsEast(0, x, y)); + assertTrue(edges.exemptsNorth(0, x, y)); + } + + @Test + public void nullDoorMask_behavesLikeNoDoors() { + int[][][] flags = openScene(); + flags[0][60][60] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + + LiveCollisionSnapshot withNull = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags, null); + LiveCollisionSnapshot fourArg = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + assertEquals(Boolean.FALSE, withNull.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH)); + assertEquals(fourArg.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH), + withNull.edge(BASE_X + 60, BASE_Y + 60, 0, FLAG_NORTH)); + } + + @Test + public void fullBlockClosesAllFourEdgesAroundTheTile() { + int[][][] flags = openScene(); + final int fx = 40, fy = 40; + flags[0][fx][fy] |= CollisionDataFlag.BLOCK_MOVEMENT_FULL; + + LiveCollisionSnapshot snap = LiveCollisionCapture.build(BASE_X, BASE_Y, 1, flags); + + // the full tile's own outgoing north/east edges are closed + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy, 0, FLAG_EAST)); + // and the incoming edges: south neighbour's north, west neighbour's east + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx, BASE_Y + fy - 1, 0, FLAG_NORTH)); + assertEquals(Boolean.FALSE, snap.edge(BASE_X + fx - 1, BASE_Y + fy, 0, FLAG_EAST)); + } + + // ---- CollisionMap integration against the real static map ---- + + @Test + public void noOverlay_readsIdenticalToStaticMap() { + CollisionMap plain = new CollisionMap(staticFlags); + CollisionMap withEmptyOverlay = new CollisionMap(staticFlags, new LiveCollisionOverlay()); + withEmptyOverlay.beginSearch(); // overlay disabled -> pins null + + // sweep a mapped area and confirm every edge matches + for (int x = 3200; x < 3260; x++) { + for (int y = 3200; y < 3260; y++) { + assertEquals(plain.n(x, y, 0), withEmptyOverlay.n(x, y, 0)); + assertEquals(plain.e(x, y, 0), withEmptyOverlay.e(x, y, 0)); + assertEquals(plain.isBlocked(x, y, 0), withEmptyOverlay.isBlocked(x, y, 0)); + } + } + } + + @Test + public void overlayBlocksAnOpenStaticEdge_andFallsBackOutsideScene() { + CollisionMap staticMap = new CollisionMap(staticFlags); + + // find an interior mapped tile with an open north edge in Lumbridge + int tx = -1, ty = -1; + for (int x = 3210; x < 3250 && tx < 0; x++) { + for (int y = 3210; y < 3250; y++) { + if (staticFlags.hasRegion(x, y) && staticMap.n(x, y, 0)) { + tx = x; + ty = y; + break; + } + } + } + assertTrue("expected an open north edge in Lumbridge", tx > 0); + + // snapshot centred so the target tile is interior (not on the unknown rim) + final int baseX = tx - 52; + final int baseY = ty - 52; + int[][][] flags = openScene(); + flags[0][tx - baseX][ty - baseY] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.set(LiveCollisionCapture.build(baseX, baseY, 1, flags)); + + CollisionMap live = new CollisionMap(staticFlags, overlay); + live.beginSearch(); + + // overlay wins inside the scene + assertTrue("precondition: static edge open", staticMap.n(tx, ty, 0)); + assertFalse("overlay must block the edge", live.n(tx, ty, 0)); + + // a tile far outside the snapshot falls back to the static map + int farX = baseX + 5000; + int farY = baseY + 5000; + assertEquals(staticMap.n(farX, farY, 0), live.n(farX, farY, 0)); + assertEquals(staticMap.e(farX, farY, 0), live.e(farX, farY, 0)); + } + + // ---- Stage 3: route validation (LiveRouteValidator) ---- + + /** Overlay-blocks the north edge of {@code (tx,ty)} and returns a CollisionMap with it pinned. */ + private CollisionMap mapBlockingNorthEdge(int tx, int ty) { + final int baseX = tx - 52, baseY = ty - 52; + int[][][] flags = openScene(); + flags[0][tx - baseX][ty - baseY] |= CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.set(LiveCollisionCapture.build(baseX, baseY, 1, flags)); + CollisionMap map = new CollisionMap(staticFlags, overlay); + map.beginSearch(); + return map; + } + + /** A tile with a run of open north edges from y-2..y+1, so multi-step north paths are clear in static. */ + private int[] findOpenNorthEdgeTile() { + CollisionMap staticMap = new CollisionMap(staticFlags); + for (int x = 3200; x < 3260; x++) { + for (int y = 3210; y < 3250; y++) { + if (staticFlags.hasRegion(x, y) + && staticMap.n(x, y - 2, 0) && staticMap.n(x, y - 1, 0) + && staticMap.n(x, y, 0) && staticMap.n(x, y + 1, 0)) { + return new int[]{x, y}; + } + } + } + throw new AssertionError("no run of open north edges found in Lumbridge"); + } + + @Test + public void routeValidator_nearestIndex() { + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), new WorldPoint(3200, 3201, 0), new WorldPoint(3200, 3202, 0)); + assertEquals(1, LiveRouteValidator.nearestIndex(path, new WorldPoint(3200, 3201, 0))); + assertEquals(2, LiveRouteValidator.nearestIndex(path, new WorldPoint(3204, 3202, 0))); + assertEquals(0, LiveRouteValidator.nearestIndex(path, new WorldPoint(3200, 3201, 1))); // other plane ignored + } + + @Test + public void routeValidator_detectsLiveBlockedWalkingStep() { + int[] t = findOpenNorthEdgeTile(); + int tx = t[0], ty = t[1]; + List path = Arrays.asList( + new WorldPoint(tx, ty, 0), new WorldPoint(tx, ty + 1, 0), new WorldPoint(tx, ty + 2, 0)); + + CollisionMap clear = new CollisionMap(staticFlags); + clear.beginSearch(); + assertEquals("clear route", -1, LiveRouteValidator.firstBlockedStep(path, 0, 15, clear)); + + CollisionMap blocked = mapBlockingNorthEdge(tx, ty); + assertEquals("first step now blocked", 0, LiveRouteValidator.firstBlockedStep(path, 0, 15, blocked)); + } + + @Test + public void routeValidator_skipsTransportJumps() { + CollisionMap clear = new CollisionMap(staticFlags); + clear.beginSearch(); + // a non-adjacent jump (walk transport) then a cross-plane jump (stairs) are not walking steps + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3260, 3260, 0), + new WorldPoint(3260, 3260, 1)); + assertEquals(-1, LiveRouteValidator.firstBlockedStep(path, 0, 15, clear)); + } + + @Test + public void routeValidator_respectsLookahead() { + int[] t = findOpenNorthEdgeTile(); + int tx = t[0], ty = t[1]; + // blocked edge is the step from index 2 -> 3 + List path = Arrays.asList( + new WorldPoint(tx, ty - 2, 0), new WorldPoint(tx, ty - 1, 0), + new WorldPoint(tx, ty, 0), new WorldPoint(tx, ty + 1, 0)); + CollisionMap blocked = mapBlockingNorthEdge(tx, ty); + + assertEquals("lookahead too short to see the block", -1, + LiveRouteValidator.firstBlockedStep(path, 0, 2, blocked)); + assertEquals("longer lookahead finds it at index 2", 2, + LiveRouteValidator.firstBlockedStep(path, 0, 3, blocked)); + } + + @Test + public void disabledOverlaySnapshot_isIgnored() { + int[][][] flags = openScene(); + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + // set() while disabled must not take effect + overlay.set(LiveCollisionCapture.build(3148, 3148, 1, flags)); + assertNull(overlay.current()); + + CollisionMap staticMap = new CollisionMap(staticFlags); + CollisionMap live = new CollisionMap(staticFlags, overlay); + live.beginSearch(); + for (int x = 3200; x < 3210; x++) { + for (int y = 3200; y < 3210; y++) { + assertEquals(staticMap.n(x, y, 0), live.n(x, y, 0)); + assertEquals(staticMap.e(x, y, 0), live.e(x, y, 0)); + } + } + } +} 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 69f2f8db07..406c566909 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 @@ -130,6 +130,66 @@ public void shouldRecalculatePathAfterTransport_includesOriginlessTeleport() { assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockTeleport)); } + @Test + public void rawTransportDispatch_allowsImmediateOriginlessTeleportEdge() { + List rawPath = Arrays.asList( + new WorldPoint(2610, 3100, 0), + new WorldPoint(3213, 3424, 0), + new WorldPoint(3214, 3424, 0)); + + assertTrue(Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 0, new WorldPoint(2610, 3100, 0), 2)); + } + + @Test + public void rawTransportDispatch_defersFutureTransportUntilApproach() { + List rawPath = Arrays.asList( + new WorldPoint(2610, 3100, 0), + new WorldPoint(2611, 3100, 0), + new WorldPoint(2623, 3093, 0), + new WorldPoint(3303, 3333, 0)); + + assertFalse("A future edge inside the broad handler window must not start a long run", + Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 2, new WorldPoint(2610, 3100, 0), 2)); + assertTrue("The same edge becomes dispatchable once the route has approached it", + Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 2, new WorldPoint(2622, 3093, 0), 2)); + } + + @Test + public void rawTransportDispatch_rejectsOtherPlane() { + List rawPath = Arrays.asList( + new WorldPoint(2775, 3234, 1), + new WorldPoint(2772, 3234, 0)); + + assertFalse(Rs2Walker.isRawTransportOriginNearPlayer( + rawPath, 0, new WorldPoint(2775, 3234, 0), 2)); + } + + @Test + public void plannedTransportApproach_clicksUntilDispatchRange() { + WorldPoint player = new WorldPoint(2760, 3229, 0); + WorldPoint charterOrigin = new WorldPoint(2760, 3238, 0); + + assertTrue("A planned transport nine tiles ahead still needs a route approach click", + Rs2Walker.shouldApproachPlannedTransportOrigin( + true, charterOrigin, player, 2)); + assertFalse("Once beside the transport, its handler owns the next action", + Rs2Walker.shouldApproachPlannedTransportOrigin( + true, charterOrigin, new WorldPoint(2760, 3236, 0), 2)); + } + + @Test + public void plannedTransportApproach_rejectsOrdinaryAndOtherPlaneSteps() { + WorldPoint player = new WorldPoint(2760, 3229, 0); + + assertFalse(Rs2Walker.shouldApproachPlannedTransportOrigin( + false, new WorldPoint(2760, 3238, 0), player, 2)); + assertFalse(Rs2Walker.shouldApproachPlannedTransportOrigin( + true, new WorldPoint(2760, 3238, 1), player, 2)); + } + @Test public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport() { Transport door = new Transport( @@ -202,6 +262,54 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsTilesTooFarFromDestinat 0)); } + @Test + public void isSettledNearAdjacentSamePlaneLanding_acceptsBoundedForwardAgilityOvershoot() { + Transport steppingStone = new Transport( + new WorldPoint(3154, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Stepping stone", + TransportType.AGILITY_SHORTCUT, + false, + "Jump-onto", + "Stepping stone", + 16533); + + assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3149, 3363, 0), + steppingStone.getDestination(), + 0)); + } + + @Test + public void isSettledNearAdjacentSamePlaneLanding_rejectsReverseOrUnboundedAgilityMovement() { + Transport steppingStone = new Transport( + new WorldPoint(3154, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Stepping stone", + TransportType.AGILITY_SHORTCUT, + false, + "Jump-onto", + "Stepping stone", + 16533); + + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3155, 3363, 0), + steppingStone.getDestination(), + 0)); + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3147, 3363, 0), + steppingStone.getDestination(), + 0)); + assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + steppingStone, + new WorldPoint(3149, 3365, 0), + steppingStone.getDestination(), + 0)); + } + @Test public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() { Transport ship = new Transport( @@ -590,7 +698,8 @@ public void routeProgressExits_areNotChargedAgainstThePartialRetryBudget() { "rockfall-handled", "path-blocker-handled", "interim-in-flight", - "recovery-move-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)); } @@ -990,6 +1099,63 @@ public void clampToEuclideanRadius_keepsInRangeTarget() { assertEquals(target, Rs2Walker.clampToEuclideanRadius(player, target, 10)); } + @Test + 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( + new WorldPoint(3300, 3487, 0), player, 12)); + assertTrue("A cardinal endpoint on the Euclidean boundary remains eligible", + Rs2Walker.shouldAttemptDirectMinimapTarget( + new WorldPoint(3301, 3476, 0), player, 12)); + assertFalse("A different plane is never a direct minimap target", + Rs2Walker.shouldAttemptDirectMinimapTarget( + new WorldPoint(3289, 3476, 1), player, 12)); + } + + @Test + public void localRecoveryCandidate_rejectsSpatiallyNearFutureRouteFold() { + List raw = new java.util.ArrayList<>(); + int x = 2855; + int y = 3440; + raw.add(new WorldPoint(x, y, 0)); + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(--x, y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(x, ++y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(++x, y, 0)); + } + for (int i = 0; i < 15; i++) { + raw.add(new WorldPoint(x, --y, 0)); + } + // The future branch returns spatially close to the player, but remains 60 raw route steps + // ahead. Its proximity must not override the immediate route frontier. + int[] smoothedToRaw = {0, 6, 60}; + + assertTrue(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 1, 48)); + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 2, 48)); + } + + @Test + public void localRecoveryCandidate_rejectsTransportJumpAndBackwardCandidate() { + List raw = Arrays.asList( + new WorldPoint(2808, 3436, 0), + new WorldPoint(2809, 3436, 0), + new WorldPoint(2900, 3400, 0)); + int[] smoothedToRaw = {0, 1, 2}; + + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 0, 2, 48)); + assertFalse(Rs2Walker.isLocalRecoveryCandidateOnForwardRoute( + raw, smoothedToRaw, 1, 0, 48)); + } + // --------------------------------------------------------------------------- // Raw-path wall-door segment probing // --------------------------------------------------------------------------- @@ -1214,6 +1380,31 @@ public void routeMovementClickPhase_labelsContinuationSeparatelyFromRecovery() { assertEquals("route_movement_click", Rs2Walker.routeMovementClickPhase("other")); } + @Test + public void routeArrivalSatisfied_preventsRecoveryClickAtGoal() { + WorldPoint goal = new WorldPoint(3304, 3336, 0); + List path = Arrays.asList( + new WorldPoint(3300, 3333, 0), + goal); + + assertTrue(Rs2Walker.routeArrivalSatisfied(goal, goal, path, 10)); + } + + @Test + public void routeArrivalSatisfied_usesTightFinalApproachThreshold() { + WorldPoint goal = new WorldPoint(3304, 3336, 0); + List path = Arrays.asList( + new WorldPoint(3300, 3333, 0), + goal); + + assertTrue(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3303, 3336, 0), goal, path, 10)); + assertFalse(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3302, 3336, 0), goal, path, 10)); + assertFalse(Rs2Walker.routeArrivalSatisfied( + new WorldPoint(3304, 3336, 1), goal, path, 10)); + } + @Test public void shouldRunActiveRouteIdleNudge_waitsForImmediateTransport() { assertFalse(Rs2Walker.shouldRunActiveRouteIdleNudge(true, true)); diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 2a5b2499b6..7c4c55b8c7 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 @@ -798,34 +798,34 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObj net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$45(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$46(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$47(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$48(WorldPoint, TileObject): int -> 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$191(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$160(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$162(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$138(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$144(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$144(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -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$handleFairyRing$194(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$163(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$165(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$141(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$147(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$147(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$24(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(int, boolean, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(int, boolean, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$119(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$122(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$124(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$125(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$152(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -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$handleTransports$120(int, boolean, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$120(int, boolean, boolean, 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$handleTransports$123(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$124(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$127(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$128(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$155(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$156(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$34(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$35(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$73(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$76(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