diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md
index ca216f344e6..44e477993ce 100644
--- a/docs/AGENT_SERVER.md
+++ b/docs/AGENT_SERVER.md
@@ -273,6 +273,52 @@ Body: `{"name": "Banker", "action": "Bank"}` or `{"id": 3010, "action": "Attack"
}
```
+#### GET /objects/neighbours
+
+Same filters as `GET /objects`, plus the walkability of the tiles surrounding each match. Use it to
+catalogue obstacles that sit *between* two walkable tiles — Motherlode rockfalls, rubble, rock
+slides — where modelling the obstacle as a transport in `transports.tsv` needs both of its open
+ends, which the object's own position does not give you.
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `id` / `name` / `nameContains` | - | Same filters as `GET /objects` |
+| `maxDistance` | `20` | Max tile distance (scene radius is ~104) |
+| `radius` | `1` | Neighbour ring size, clamped to 1-5 |
+| `limit` | config max | Max results |
+
+`sides` pairs opposing neighbours on each axis, so a blocking obstacle's two endpoints can be read
+straight off the response. Walkability comes from the **live scene** collision flags, not the
+bundled collision map, so it reflects what the player can actually do right now — which is the
+point when the bundled map has an obstacle baked in as a permanent wall.
+
+```bash
+curl -s -H "X-Agent-Token: $(cat ~/.runelite/.agent-token)" \
+ "http://127.0.0.1:8081/objects/neighbours?id=26679&maxDistance=104&limit=200"
+```
+
+```json
+{
+ "count": 1, "total": 1, "radius": 1,
+ "objects": [
+ {
+ "id": 26679, "name": "Rockfall", "type": "GAME", "reachable": true,
+ "position": {"x": 3742, "y": 5668, "plane": 0},
+ "selfWalkable": false,
+ "neighbours": [
+ {"position": {"x": 3741, "y": 5668, "plane": 0}, "dx": -1, "dy": 0, "walkable": true}
+ ],
+ "sides": {
+ "eastWest": {"a": {"...": "dx=-1"}, "b": {"...": "dx=+1"}, "bothWalkable": true},
+ "northSouth": {"a": {"...": "dy=-1"}, "b": {"...": "dy=+1"}, "bothWalkable": false}
+ }
+ }
+ ]
+}
+```
+
+The axis whose `bothWalkable` is `true` gives the transport's origin/destination pair.
+
#### POST /objects/interact
Body: `{"name": "Oak tree", "action": "Chop down"}`
diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md
index 4a658235f41..fef8aa39832 100644
--- a/docs/entity-guides/movement.md
+++ b/docs/entity-guides/movement.md
@@ -1,5 +1,16 @@
# Movement Gotchas
+> **How to read these.** Each entry records a failure mode observed while working on the walker.
+> Two caveats for reviewers:
+>
+> - **"Pattern to follow" blocks are illustrative.** They may use shortened or pseudo-code names
+> (`atTransportDestination`, `isRouteActive`, `clickOptimisticRecoveryTarget`, …) that do not exist
+> verbatim in the source. The **"Where this applies"** line is the authoritative list of real symbols.
+> - **Some entries record approaches that were tried and reverted** (#19, #20). They are kept
+> deliberately so the reverted idea is not reintroduced from an older note. Where an entry cites a
+> measured log line, that measurement is the evidence; where it does not, treat it as a hypothesis
+> that happened to hold at the time of writing.
+
## 1. Do not recurse on failed minimap clicks without changing the click target
`Rs2Walker.processWalk` holds the walker lock while processing a path. If a minimap click is rejected because the calculated point is outside the minimap clip, immediately recursing with the same target can spin forever while still holding the lock. Shrink the click target toward the player or otherwise change the condition before retrying.
@@ -77,9 +88,11 @@ waitForDoorInteractionProgress(fromWp, toWp);
**Defensive check:** In live testing, a door should produce one interaction followed by movement/path progress, not repeated `Raw path door handler resolved obstacle` messages every tick while the player is moving.
+If a previous minimap click is still moving the player, do not attribute that movement to a new gate interaction. A valid gate can be blacklisted when the walker clicks an interim waypoint, notices the gate while movement is still in flight, then compares the pre/post positions from the old minimap movement against the gate edge. Wrong-traversal blacklisting should only run when the player started near the attempted door edge.
+
## 5. Suppress the inverse adjacent transport after crossing a same-plane door
-Some doors are represented in `transports.tsv` as two adjacent same-plane transports, one for each direction. After the walker clicks one side and arrives on the other, immediately accepting the inverse transport can bounce the player back through the same door instead of letting the next minimap step continue away from it. Mark both tiles of a successful adjacent same-plane transport as recently handled for a short window.
+Some doors are represented in `transports.tsv` as two adjacent same-plane transports, one for each direction. After the walker clicks one side and arrives on the other, immediately accepting the inverse transport can bounce the player back through the same door instead of letting the next minimap step continue away from it. Mark both tiles of a successful adjacent same-plane transport as recently handled for a short window. **This is necessary but not sufficient — see #22:** suppression must also run when the landing check *fails*, because an adjacent transport requires landing on the exact destination tile and a crossing can succeed while that check reports failure.
**Why this matters:** Leaving Draynor Manor through the east/back door can alternate between `3123,3360,0` and `3123,3361,0`, repeatedly logging raw-path/current-tile transport handling and burning the route timeout before walking back to Draynor.
@@ -235,9 +248,9 @@ if (isStuckTooLong()) {
For long open routes, retarget before the player fully stops when they are already close to the interim checkpoint, and keep normal minimap clicks slightly inside the observed minimap edge. This reduces visible stop/start pauses and outside-clip fallback clicks without reintroducing rapid click thrash.
-## 12. Do not let optimistic recovery override unresolved door blockers
+## 12. Do not let local recovery override unresolved door blockers
-Unreachable-tile recovery is useful for outdoor false negatives, but in tight rooms it can fight the door resolver. If a route edge still has a door-like scene object on or adjacent to the raw path, suppress broad minimap recovery and let the door scanners retry after their normal cooldowns. Do not permanently blacklist a path-adjacent fallback door just because one attempt traversed the wrong way; in small door clusters the same object may be the correct blocker again once the player has moved to the other side.
+Local-reachability recovery is useful for outdoor false negatives, but in tight rooms it can fight the door resolver. If a route edge still has a door-like scene object on or adjacent to the raw path, suppress broad minimap recovery and let the door scanners retry after their normal cooldowns. Do not permanently blacklist a path-adjacent fallback door just because one attempt traversed the wrong way; in small door clusters the same object may be the correct blocker again once the player has moved to the other side.
**Why this matters:** In POH-style tight rooms with several doors close together, a fallback door click can move the player away from the intended route. If that door tile is session-blacklisted and optimistic recovery keeps clicking route tiles beyond the blocker, the walker loops around the room until a user manually opens the final door.
@@ -255,7 +268,7 @@ clickOptimisticRecoveryTarget();
**Where this applies:** `Rs2Walker.processWalk` unreachable-tile handling, `tryResolvePathAdjacentBlocker`, and any fallback that issues minimap recovery clicks after door/path-adjacent scans fail.
-**Defensive check:** Reproduce a route through a small room with three nearby doors and a POH portal. The walker should retry the route-door blocker and avoid repeated `unreachable optimistic recovery` loops around the room; it should not need the user to manually open the final door.
+**Defensive check:** Reproduce a route through a small room with three nearby doors and a POH portal. The walker should retry the route-door blocker and avoid repeated `route-backed local recovery click` loops around the room; it should not need the user to manually open the final door.
## 13. Stall recalculation must also issue fresh movement
@@ -286,12 +299,287 @@ if (isStuckTooLong()) {
After a handled transport, avoid expensive path-adjacent or raw transport scans on ordinary open-ground segments unless a nearby planned transport or recent door attempt exists. Those scans are recovery tools, and on long outdoor routes a no-op scan can add several seconds before the next minimap click.
-For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off at a moderate remaining distance keeps movement continuous without rapid re-clicking. If an interim clears as close and no nearby route door/transport is pending, issue the next route-aligned continuation click immediately instead of waiting for idle-nudge recovery.
+For long-route minimap walking, let the next checkpoint selection happen before the current minimap target is fully consumed. Waiting until the player is only a few tiles from the interim makes the walker visibly stop before issuing the next click; handing off at a moderate remaining distance keeps movement continuous without rapid re-clicking. If an interim clears as close and no nearby route door/transport is pending, issue the next route-aligned continuation click immediately instead of waiting for idle-nudge recovery. Stale-progress checks must also count distance progress toward the actual interim checkpoint, not only smoothed path index progress; sparse smoothed paths can otherwise clear valid raw-path checkpoints as stale while the player is still walking toward them.
+
+Before the first movement click, avoid timeout-backed speculative segment scans for doors, blockers, rockfalls, or transports. Let the first minimap click move the player toward the route; actual visible doors can still be handled by the dedicated pre-click route-door guard, and steady-state handling can resolve later obstacles once movement has started. Spending startup time scanning every nearby raw segment creates a visible cold start.
Continuation clicks that keep an active route moving should be tail-exempt like `interim-in-flight`; otherwise very long routes can exhaust `MAX_PROCESS_WALK_TAIL_ITERATIONS` while still making progress and trigger an unnecessary auto-retry.
Sticky interim targets should also clear when route-index progress goes stale. If the player keeps moving but the closest path index does not advance for `INTERIM_PROGRESS_TIMEOUT_MS`, treat the checkpoint as stale and select a fresh route-aligned target instead of waiting for max-age expiry.
-When a route-following minimap click is outside the minimap clip, fallback clicks must stay on the raw path. A generic "reachable tile closer to target" fallback can select a tile far away from the route in open areas, especially near the final destination.
+When a route-following minimap click is outside the minimap clip, fallback clicks must stay on the raw path. This includes the "clicked but no movement yet" retry after a route click and route-backed direct short-walks near the final destination. A generic "reachable tile closer to target" fallback can select a tile far away from the route in open areas, especially near fences and the final destination. Raw fallback must be anchored from the stabilized route index, not a fresh nearest-raw-tile lookup, or the walker can snap backward to an already-travelled branch.
+
+For adjacent same-plane shortcuts, do not treat any movement away from the origin as success. Some shortcuts, such as stepping stones, can fail and place the player on a fallback tile; once the player is settled away from the expected destination, stop the landing wait and replan from the actual tile. If the player is settled within one tile of the expected destination and is no longer on the origin, accept the landing instead of waiting for an exact tile that server pathing may not choose.
+
+## 14. Keep local recovery clicks route-backed and paced
+
+Local-reachability recovery is a fallback for bounded reachability false negatives, not a license to pick an arbitrary minimap point. Recovery clicks should use the same route-backed raw-path fallback as normal route movement, then store the actual clicked target as the sticky interim checkpoint. After issuing the click, wait only for movement to start or the checkpoint/goal to be reached; do not wait for a full idle cycle before returning to the walk loop.
+
+**Why this matters:** Bounded local reachability can report a valid route tile as unreachable even though the server pathfinder can still walk toward it. Arbitrary or repeated recovery clicks can pull the player away from the planned route and create visible stop-start movement.
+
+**Pattern to follow:**
+
+```java
+int reach = STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN;
+WorldPoint clickTarget = clampToEuclideanRadius(playerLoc, recoverTarget, reach - 1);
+WorldPoint clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, reach - 1, false, rawAnchorIndex);
+if (clickedTarget != null) {
+ interimTargetWp = clickedTarget;
+ waitForMovementStartAfterRecovery(target, playerLoc, clickedTarget, target, finishThreshold);
+}
+```
+
+**Where this applies:** `Rs2Walker.processWalk` local-reachability recovery, route-backed direct short-walks, and any fallback that runs after local reachability says a route tile is not currently reachable.
+
+**Defensive check:** Exercise a long outdoor route and a route with gates or transports. Recovery logs should select route-backed points, and there should be no long idle pause between `route-backed local recovery click` and the next route-aligned movement.
+
+After recovery sets a sticky interim target, do not issue another optimistic recovery while that interim is still active and movement/progress is fresh. Yield as `interim-in-flight` or another tail-exempt movement state until the checkpoint is close, stale, or expired. Otherwise the walker can loop through several recovery clicks while the player is already moving, especially when local reachability reports the next smoothed tile as unreachable.
+
+## 15. Keep primary route clicks on the raw route
+
+When a shortest-path raw route exists, choose the next minimap target from that raw route before applying smoothed-waypoint wall-distance nudges, bounded reachability shortcuts, or generic directional fallbacks. Sideways reachable tiles can be valid server walk targets while still being the wrong side of a fence, gate, or corridor corner. Generic fallback is only appropriate when no route-backed raw point is available.
+
+**Why this matters:** Tight routes near gates and fences can fail when the walker clicks a tile left or right of the drawn route line. The game then chooses its own path around the obstacle, which fights the planned door/transport handlers and produces stop-start recovery.
+
+**Pattern to follow:**
+
+```java
+WorldPoint rawTarget = findFurthestRawPathPointMatching(rawPath, playerLoc, reach, rawAnchor,
+ Rs2Walker::isKnownWalkableOrUnloaded);
+WorldPoint clickTarget = rawTarget != null ? rawTarget : getPointWithWallDistance(smoothedTarget);
+```
+
+**Where this applies:** `Rs2Walker.processWalk`, recovery clicks after false unreachable reports, route continuation clicks, direct short-walk fallbacks, and any minimap click helper used while a shortest-path raw route exists.
+
+**Defensive check:** Unit-test raw-route target selection with a route that doubles back near an earlier branch; the selected tile must stay at or ahead of the anchored raw route index.
+
+## 16. Yield before scans while an interim route click is in flight
+
+Sticky interim targets only reduce click thrash if they are checked before broad route scans. When a fresh interim checkpoint is still far away and the player is moving or has very recent checkpoint progress, yield before raw-scene scans, current-tile transport scans, direct short-walk attempts, and path segment scans. Resume normal route work once the checkpoint is close, stale, expired, on another plane, or movement has genuinely stopped.
+
+**Why this matters:** Long post-transport routes can otherwise run several no-op scans and select several small follow-up clicks while the previous minimap flag is still carrying the player. That looks like stop-start walking and burns tail iterations even though the route is making progress.
+
+**Pattern to follow:**
+
+```java
+if (shouldYieldForActiveRouteInterim(playerLoc, path, nowMs)) {
+ exitReason = "interim-in-flight";
+ continue;
+}
+```
+
+**Where this applies:** `Rs2Walker.processWalk` before broad raw-scene handling, current-tile transport handling, direct short-walk handling, and the main path loop.
+
+**Defensive check:** Start a long route immediately after a transport. While the player is still moving toward the active minimap checkpoint, the walker should log a tail-exempt `interim-in-flight` yield instead of repeated `post_transport_path_selected` clicks for nearby path indices.
+
+## 17. Dispatch immediate planned transports before idle nudges
+
+When the current route index is an explicit shortest-path transport edge, let the segment transport handler run even during startup, before route idle nudges or stall recovery clicks. Keep startup door/rockfall probes skipped, but do not postpone teleports, ships, ladders, or other catalog-backed route transports that start at the player's current segment.
+
+**Why this matters:** Originless spell/item teleports can be the first edge in a route. If startup logic suppresses the segment handler and idle recovery runs first, the walker can sit on the origin, spam no-op route nudges, trigger stall recovery, and only then cast the teleport.
+
+**Pattern to follow:**
+
+```java
+boolean immediateTransport = hasImmediatePlannedTransportStep(path, routeStartIdx, playerLoc);
+if (shouldRunActiveRouteIdleNudge(idleNudgeDue, immediateTransport)) {
+ tryIssueRouteRecoveryClick(rawPath, path, target, "active route idle nudge");
+}
+```
+
+**Where this applies:** `Rs2Walker.processWalk` startup handling, active route idle nudges, stall recovery clicks, and segment transport dispatch.
+
+**Defensive check:** Start a route whose first planned edge is Home Teleport. Logs should show `transport_handoff_enter` for `TELEPORTATION_SPELL` without several `active route idle nudge: clicked=false` entries first.
+
+## 18. Model conditional paid gates as transports plus blocked edges
+
+When a gate can be crossed by either a quest unlock or a currency payment, do not model the gate tiles as quest-only restrictions. A tile restriction cannot express "quest complete OR pay coins", and it can prevent the walker from reaching the paid transport origin. Model each crossing as explicit paid/free transports and add a blocked edge override so normal walking cannot bypass the unavailable transport.
+
+**Why this matters:** The Lumbridge-to-Al Kharid toll gate can be opened freely after Prince Ali Rescue or paid through with 10 coins. A quest-only restriction blocks the paid route for accounts without the quest, while no edge block can let the pathfinder walk through the gate without paying.
+
+**Pattern to follow:**
+
+```java
+// Wrong: restrict both sides of the gate to one quest state.
+3267 3227 0 Prince Ali Rescue
+
+// Right: transport data decides availability; blocked_edges prevents free walking.
+3267 3227 0 3268 3227 0 Pay-toll(10gp);Gate;2786 ... 10 Coins
+3267 3227 0 3268 3227 0 Open;Gate;2786 ... Prince Ali Rescue
+```
+
+**Where this applies:** `transports.tsv`, `blocked_edges.tsv`, `restrictions.tsv`, `PathfinderConfig`, and `Rs2Walker.handleTransports`.
+
+**Defensive check:** Add resource tests that assert both paid and quest-free transports load, the gate tiles are not quest-only restricted, and the gate edge is blocked without a usable transport.
+
+## 19. Reachability-gate the FALLBACK click paths, and allow bidirectional rejoin
+
+A tile being *walkable* (`Rs2Tile.isWalkable`, i.e. not `BLOCK_MOVEMENT_FULL`) is not the same as being *reachable from the player*. Next to a wall the two diverge hard: a tile flush on the far side of a castle wall is walkable and Euclidean-close, but only reachable via a long detour. This matters for click targets that are **invented** rather than taken from the route:
+
+- `getPointWithWallDistance` picks a neighbour reachable *from the target*, from an unordered set, so its nudge can land on the far side of a wall or inside a building. Pass the player position so it prefers a neighbour reachable from and nearest to the player, and verify the result with `Rs2Tile.isTileReachable` before clicking it.
+- The scaled-radius directional fallback in `walkMiniMapToward` invents a geometric point toward an off-clip target. Only click it if it is actually reachable, otherwise a fresh teleport produces clicks far off the route.
+
+Forward-only anchoring (introduced to stop the walker snapping back to already-travelled branches) overcorrects when the player is pushed *off* the path: if nothing ahead is reachable, forward-only recovery returns nothing and the walker stalls. Recovery must therefore be **bidirectional**: scan a bounded index window on both sides of the anchor and rejoin via the nearest reachable raw point, preferring the furthest-forward one so normal progress is never sacrificed. This is a rejoin path for the off-path case only; steady-state progress stays forward-only.
+
+**Do NOT extend this to primary selection.** An earlier revision selected the primary click from the player's bounded reachable set. That was reverted: a BFS bounded by click radius can exclude legitimate around-a-corner points and shorten clicks for no benefit, because a point taken from the raw route is safe by construction regardless of how the server walks to it (see #20). Reachability gating belongs only on targets that are *not* route points.
+
+**Why this matters:** Walking past the Varrock West Bank, the wall-distance nudge and a stale-anchor fallback produced clicks that were off the planned route, so the game improvised a detour. The route-membership rule in #20 fixes the primary path; these guards fix the invented-target paths.
+
+**Pattern to follow:**
+
+```java
+// Primary: an on-route point (see #20) — no reachability gate needed.
+WorldPoint rawRouteTarget = selectRouteClickTarget(rawPath, playerLoc, reach, rawAnchorIndex);
+if (rawRouteTarget != null) {
+ clickTarget = rawRouteTarget;
+} else {
+ // Invented target: now reachability matters.
+ clickTarget = getPointWithWallDistance(targetWp, playerLoc);
+ if (!Rs2Tile.isTileReachable(clickTarget)) {
+ WorldPoint rejoin = findReachableRejoinRawPathPoint(rawPath, playerLoc, reach, rawAnchorIndex);
+ if (rejoin != null) clickTarget = rejoin; // step back onto the line if needed
+ }
+}
+if (!reachable.contains(scaledFallback)) continue; // directional guesses too
+```
+
+**Where this applies:** `Rs2Walker.findReachableRejoinRawPathPoint`, `Rs2Walker.walkMiniMapToward`, `getPointWithWallDistance` consumers. **Not** `selectRouteClickTarget`.
+
+**Defensive check:** Unit-test the rejoin core with an injected reachability predicate: (a) player pushed one tile off-path with nothing ahead reachable must return a point *behind* the anchor; (b) with points ahead reachable it must return the furthest-forward one inside the reach circle; (c) nothing reachable must return null so stall/recalc can take over.
+
+## 20. Keep minimap click targets on the raw route — do NOT clamp them to line-of-sight
+
+A minimap click is resolved by the **game's own pathing**, so line of sight is irrelevant to walking: a player clicks past a corner, through a doorway, or around a building and the server routes them there. The invariant that matters is that the click target sits **on the raw route** — then wherever the server chooses to walk, we arrive on the planned path. Do not select the click by clamping a far smoothed waypoint to a Euclidean radius either; that is what produced an off-route target in the first place.
+
+**Why this matters:** Walking past the Varrock West Bank, selection returned null on a stale anchor (see #24), so the caller clamped a far smoothed waypoint to a ~10 tile radius and clicked `(3176,3428)` — a tile that is *not* on the raw route. The game pathed there its own way, deviating wide and backtracking.
+
+**Do not "fix" this with line-of-sight.** An earlier revision clamped clicks to a straight walkable line. It removed the symptom but made the walker advance corner-to-corner — stopping at every corner to re-aim, because it would only click as far as it could see in a straight line. That is a visible behavioural tell and it bought no correctness: an on-route target is safe regardless of how the server walks to it. It was reverted.
+
+**Pattern to follow:**
+
+```java
+// Furthest forward point ON THE RAW ROUTE within minimap reach. No LOS requirement.
+WorldPoint forward = findFurthestRawPathPointMatching(rawPath, playerLoc, maxEuclidean,
+ rawAnchorIndex, Rs2Walker::isKnownWalkableOrUnloaded);
+```
+
+**Where this applies:** `Rs2Walker.selectRouteClickTarget`, `findFurthestVisibleKnownRawPathPoint`, and any minimap click selection while a raw route exists. Pending doors/gates are handled by `handlePendingDoorBeforeRouteClick` — not by shortening the click.
+
+**Defensive check:** `RouteClickTargetRegressionTest` asserts the historic `(3176,3428)` is not on the raw route, and that the route offers several on-route candidates within minimap reach so a click is never artificially shortened to the nearest corner.
+
+## 21. Route continuation is one-shot — keep the idle-nudge window short
+
+`tryIssueRouteContinuationClick` only runs on the single pass where `clearInterimTargetIfReachedOrExpired` returns `true` (the transition where the interim checkpoint is cleared). If any guard blocks it on that exact pass — `isInteracting`, `isAnimating`, door/transport settling, or its own unresolved-door / upcoming-transport checks — the opportunity is lost and **nothing retries it**. The route then only resumes via the active-route idle nudge, so that stationary window is the visible dead stop between hops. Keep it to a few ticks; do not treat it as a rare recovery path, because in practice it is what continues normal routes.
+
+**Why this matters:** A 41-tile walk from `(3183,3435)` to `(3173,3399)` clicked correctly, walked to its checkpoint, then sat fully stopped for the whole `2500ms` idle window before issuing the next click — roughly a third of the total walk time spent standing still, once per hop.
+
+**Pattern to follow:**
+
+```java
+// One-shot: only fires on the pass that clears the interim checkpoint.
+if (clearedInterimTarget && !Rs2Player.isInteracting() && !Rs2Player.isAnimating()
+ && tryIssueRouteContinuationClick(rawPath, path, target)) { ... }
+
+// So the nudge window is the real continuation path — keep it a few ticks, not seconds.
+private static final long ACTIVE_ROUTE_IDLE_NUDGE_MS = 1_200L;
+```
+
+**Where this applies:** `Rs2Walker.tryIssueRouteContinuationClick`, `clearInterimTargetIfReachedOrExpired`, `shouldIssueActiveRouteIdleNudge`, and `ACTIVE_ROUTE_IDLE_NUDGE_MS` / `ACTIVE_ROUTE_IDLE_NUDGE_COOLDOWN_MS`.
+
+**Defensive check:** On a multi-hop route, the gap between `first_minimap_click` / route-fallback clicks and the next `active route idle nudge` should be roughly the walk time plus a few ticks — not walk time plus a full idle window. A repeating ~2.5s stationary gap per hop means continuation is being missed and only the nudge is driving the route.
+
+## 22. Suppress the inverse adjacent transport even when the landing check fails
+
+Gotcha #5 suppresses the inverse of an adjacent same-plane transport after a crossing, but only on the success path. Adjacent same-plane transports require landing on the *exact* destination tile, and agility shortcuts routinely deposit the player a tile off it — so a crossing can physically succeed while the landing check reports failure. On that path nothing was suppressed, leaving the inverse entry immediately eligible. Record the suppression whenever the player is no longer on the origin, regardless of the landing verdict; the landing result itself should stay unchanged so the route still replans.
+
+**Why this matters:** Near `(3150..3151, 3363)` the walker crossed an `AGILITY_SHORTCUT`, landed a tile off the catalogued destination, logged `post-handleObject landing unresolved`, then immediately took the opposing entry back across and stranded itself — roughly 19s of a 2m26s walk spent bouncing and recovering.
+
+**Pattern to follow:**
+
+```java
+boolean landed = waitForPostHandleObjectLanding(transport, destWait, maxInclusive);
+if (!landed) {
+ WorldPoint after = Rs2Player.getWorldLocation();
+ // Off the origin => we crossed, even though the strict landing check failed.
+ if (isAdjacentSamePlaneTransport(transport) && after != null && !after.equals(transport.getOrigin())) {
+ markAdjacentSamePlaneTransportHandled(transport, object);
+ }
+}
+```
+
+**Where this applies:** `Rs2Walker.handleTransports` object interactions, `waitForPostHandleObjectLanding` callers, `markAdjacentSamePlaneTransportHandled`, and any adjacent same-plane transport regardless of `TransportType` — suppression is deliberately type-agnostic, so shortcuts and doors are covered alike.
+
+**Defensive check:** A crossed shortcut should produce at most one `post-handleObject landing unresolved` followed by route progress away from the shortcut — not a second interaction with the opposing entry at an adjacent destination tile (`dest=3150,3363` then `dest=3151,3363`).
+
+## 23. Never hash a raw cooldown timer into the transport cache key
+
+The transport refresh verification hash must contain values that change only when a transport's *usability* changes. A `COOLDOWN_MINUTES` varplayer compares against wall-clock minutes, so its raw value advances continuously — hashing it guarantees cache misses forever. Worse, casting Home Teleport writes `LAST_HOME_TELEPORT`, which is one of the hashed varplayers, so **the teleport invalidated the transport cache simply by being used**, forcing a full re-evaluation of every transport (~2.6s) immediately after the teleport it had just performed. Hash the condition *verdict* (`TransportVarPlayer.matches`) plus the condition identity instead of the raw value: the verdict flips exactly once, when the cooldown expires and the transport actually becomes usable.
+
+This is the same rule as #19's skill fix: only inputs that can flip a transport's usability may participate. Timers, regenerating stats, and other monotonically-drifting values must not.
+
+**Why this matters:** A Camelot -> Draynor walk beginning with a Home Teleport logged
+`transport_handoff ... ms=16562` with `refresh_transports cache_miss reason=verify ... changed=varplayers` firing inside that window — about 2.9s of a 16.5s teleport was the teleport invalidating its own cache.
+
+**Pattern to follow:**
+
+```java
+// Wrong: raw value ticks every minute, and casting the spell writes it.
+h = 31 * h + Microbot.getVarbitPlayerValue(id);
+
+// Right: only a usability flip participates.
+boolean satisfied = new TransportVarPlayer(id, value, operator).matches(actualValue);
+h = 31 * h + id; h = 31 * h + operatorOrdinal; h = 31 * h + value;
+h = 31 * h + (satisfied ? 1 : 0);
+```
+
+**Where this applies:** `PathfinderConfig.hashVarplayerConditionVerdicts`, `PathfinderConfig.hashVarbitConditionVerdicts`, `computeTransportRefreshVerificationHash`, the transport refresh snapshot, and any future cache key covering varbits/varplayers/skills. Varbits get the identical treatment: a raw varbit moving without flipping a condition verdict must not invalidate, which is what produced the ~2.4s dead time between pressing go and the first action.
+
+**Defensive check:** `refresh_transports cache_miss reason=verify ... changed=varplayers` should not appear immediately after a teleport. Unit-test that two different raw values inside the same cooldown window hash identically, and that crossing the threshold does not.
+
+## 24. Vary click reach, not click direction
+
+Route selection otherwise always returns the furthest candidate inside a fixed radius, so every click covers the same tile span — a deterministic signature. Jitter the reach per click instead (`routeClickReach`), which only changes how far **along** the route the target sits. Do **not** offset the target sideways: a lateral tile offset leaves the planned route, which is what produces clicks on the wrong side of a fence or inside a building (#15), and route membership is the property that keeps the server's own pathing on our route (#20). Lateral randomness belongs *inside* the tile, in click-point jitter, where it changes the pixel without changing the destination.
+
+Two bounds are load-bearing. The floor must stay clear of `INTERIM_CLOSE_TILES`, or a short click lands inside the interim-close threshold, clears the checkpoint immediately and produces click thrash with visible stop-start movement. The ceiling is the caller's reach, already tuned to the minimap clip; above it clicks simply fall outside the clip and take the fallback path. A shortened reach must also never be the reason selection fails — retry at full reach before falling through, or the click drops onto the off-route wall-nudge clamp (#19).
+
+**Pattern to follow:**
+
+```java
+int jitteredReach = routeClickReach(maxEuclidean);
+WorldPoint selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, rawAnchorIndex);
+if (selected == null && jitteredReach < maxEuclidean) {
+ selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, rawAnchorIndex);
+}
+```
+
+**Where this applies:** `Rs2Walker.routeClickReach`, `Rs2Walker.selectRouteClickTarget`, and any future click-target randomisation.
+
+**Defensive check:** Unit-test that the jittered reach never exceeds the caller's reach, never drops to the interim-close threshold, actually varies across calls, and passes a degenerate reach through unchanged.
+
+## 25. Never charge a retry budget for an iteration that made progress
+
+A "give up after N attempts" counter must count *failures to advance*, not loop iterations. Two things break it otherwise.
+
+First, **a partial path is a persistent condition, not an event.** `partialPath` is recomputed every iteration as "the path end is further than `distance` from the goal", so any goal the pathfinder cannot fully reach — a tile short of an underground destination, a gated area, a dynamic-region target — makes it true from the first snapshot to the last. The budget is then armed for the entire walk rather than for a stuck stretch.
+
+Second, **the path loop exits for good reasons too.** Opening a door, taking a transport, clearing a rockfall, or having movement already in flight all end the iteration and land in the same branch as "we failed to move". Charging those spends the budget while the walker is doing exactly what it should.
+
+Together they are quietly fatal on long multi-leg routes. Observed: a walk to an underground goal whose path end sat 31 tiles short burned retry 1 at the end of the first segment, walked ~100 tiles over 46 seconds, then spent retries 2 and 3 within one second of opening a single door — the second without the player moving at all. It reported `UNREACHABLE` standing at the entrance, having never reached the ladder. Nothing in the log said "stuck", because it never was.
+
+So: refill the budget when route progress has advanced since the last charge, and exempt exit reasons that denote work done. Keep the exemption **narrow** — reasons meaning the walker failed to advance (`not-near-path`, `click-failed-off-minimap`, waiting/retry states) must still consume it, or a genuinely unreachable goal never terminates and instead spins until the outer tail cap trips.
+
+**Where this applies:** `Rs2Walker.processWalk` partial-retry branch, `Rs2Walker.isRouteProgressExit`, and any future attempt-capped recovery loop.
+
+**Defensive check:** Unit-test both directions of the classifier — every progress reason exempt, every stuck reason still charged, `null` treated as not-progress.
+
+## 26. Telemetry must report measurements, not placeholders
+
+`recordUnreachable` was called with the player's location as `pathEndpoint` and a literal `0` as `pathSize`. The emitted line read as *"the pathfinder returned an empty path ending at your feet"* — a completely different and much scarier failure than the real one, and it sent diagnosis after a phantom pathfinder bug.
+
+It also corrupted the derived field. `endpointToTarget` is computed from `pathEndpoint`, so instead of the true 31-tile shortfall it printed `6346`: the raw y-delta between a surface tile and an underground one. Both are plane 0 — underground is the same plane at `y + 6400` — so `WorldPoint.distanceTo` does not bail out, it just returns a meaningless number. Any distance compared across that boundary is nonsense, and any threshold against it can never be met.
+
+Pass the real endpoint and the real size. If a value is genuinely unavailable, log it as absent rather than substituting a plausible-looking stand-in.
+
+**Where this applies:** `Rs2Walker.Telemetry.*`, `WebWalkLog`, and any diagnostic that derives a field from an argument.
-For adjacent same-plane shortcuts, do not treat any movement away from the origin as success. Some shortcuts, such as stepping stones, can fail and place the player on a fallback tile; once the player is settled away from the expected destination, stop the landing wait and replan from the actual tile.
+**Defensive check:** When a log line drives a diagnosis, confirm at the call site that each value is measured rather than hardcoded before trusting it.
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java
index 9994f5eb40f..9b2a503ed75 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java
@@ -3,7 +3,7 @@
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.coords.WorldPoint;
-import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin;
+import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi;
import net.runelite.client.plugins.microbot.util.Global;
import net.runelite.client.plugins.microbot.agentserver.handler.ScriptHeartbeatRegistry;
import net.runelite.client.plugins.microbot.util.antiban.SessionFatigue;
@@ -56,7 +56,7 @@ public void shutdown() {
ScriptHeartbeatRegistry.remove(this.getClass().getName());
if (mainScheduledFuture != null && !mainScheduledFuture.isDone()) {
mainScheduledFuture.cancel(true);
- ShortestPathPlugin.exit();
+ Rs2PathApi.exit();
if (Microbot.getClientThread().scheduledFuture != null)
Microbot.getClientThread().scheduledFuture.cancel(true);
initialPlayerLocation = null;
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java
index e19fb0b08c5..664688620a8 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ObjectHandler.java
@@ -36,6 +36,10 @@ protected void handleRequest(HttpExchange exchange) throws IOException {
case "/interact":
handleInteract(exchange);
break;
+ case "/neighbours":
+ case "/neighbors":
+ handleNeighbours(exchange);
+ break;
default:
sendJson(exchange, 404, errorResponse("Unknown path: /objects" + sub));
}
@@ -133,6 +137,147 @@ private void handleInteract(HttpExchange exchange) throws IOException {
sendJson(exchange, 200, response);
}
+ /**
+ * Like the plain query, but also reports the walkability of the tiles surrounding each match.
+ *
+ *
Exists to catalogue obstacles that sit between two walkable tiles — Motherlode
+ * rockfalls, rubble, rock slides. Modelling one as a transport needs both sides, which the
+ * object's own position does not give you. {@code sides} pairs opposing neighbours on each axis
+ * so a blocking obstacle's two open ends can be read straight off the response.
+ *
+ *
Walkability comes from the live scene collision flags, not the bundled collision
+ * map, so it reflects what the player can actually do right now. The flag array is read once per
+ * request rather than per tile, since each {@code Rs2Tile.isWalkable} call hops the client thread.
+ */
+ private void handleNeighbours(HttpExchange exchange) throws IOException {
+ try {
+ requireGet(exchange);
+ } catch (HttpMethodException e) {
+ sendJson(exchange, 405, errorResponse(e.getMessage()));
+ return;
+ }
+
+ Map params = parseQuery(exchange.getRequestURI());
+ String name = params.get("name");
+ String nameContains = params.get("nameContains");
+ int id = getIntParam(params, "id", -1);
+ int maxDistance = getIntParam(params, "maxDistance", 20);
+ int limit = getIntParam(params, "limit", defaultLimit);
+ int radius = Math.max(1, Math.min(5, getIntParam(params, "radius", 1)));
+
+ var query = Microbot.getRs2TileObjectCache().query();
+ if (id >= 0) {
+ query = query.withId(id);
+ }
+ if (name != null && !name.isEmpty()) {
+ query = query.withName(name);
+ } else if (nameContains != null && !nameContains.isEmpty()) {
+ query = query.withNameContains(nameContains);
+ }
+ query = query.within(maxDistance);
+
+ List objects = query.toListOnClientThread();
+ List limited = objects.stream().limit(limit).collect(Collectors.toList());
+
+ List