From da89e59310fa8f14a7cc081b993ae511ca0b0e74 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 23 Jul 2026 02:25:32 +0100 Subject: [PATCH 01/53] feat(death): add Rs2Death API for grave and Death's Office recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds util/death/Rs2Death, a static facade for handling a normal death: locate the grave (an NPC, ids 9856-10367), loot free and paid items, and optionally fall back to Death's Office once the grave expires. Death's Office recovery is opt-in — its fee is unreadable before it is charged, so it is never entered without an explicit flag. Scripts poll hasDeathToHandle() and drive recoverItems(budget[, office]), or compose the primitives directly. No automatic behaviour and no config coupling: callers pass plain scalars, matching Rs2Bank/Rs2Walker. Also reads the "Items Kept on Death" panel for the game's own numbers (getPredictedGraveFee, getRiskValue, getItemsKeptOnDeath) and estimates the office fee from wiki prices per the confirmed per-unit 100k rule. Verified against a live client: grave/office interface groups and components, the entrance object and reclaim dialogue, the GRAVESTONE_* varbit encodings (VISIBLE is non-zero not boolean; DURATION is ticks), and that both grave and office charge on per-unit value, not stack or cumulative. Details and footguns in docs/entity-guides/death.md. Wires onActorDeath/onVarbitChanged in MicrobotPlugin and regenerates the client-thread guardrail baseline for the two event handlers. Co-Authored-By: Claude Opus 4.8 --- docs/entity-guides/README.md | 1 + docs/entity-guides/death.md | 430 +++++++++ .../plugins/microbot/MicrobotPlugin.java | 8 + .../util/death/DeathsOfficeLocation.java | 60 ++ .../plugins/microbot/util/death/Rs2Death.java | 842 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 5 + 6 files changed, 1346 insertions(+) create mode 100644 docs/entity-guides/death.md create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index 09f9b68668b..53e2d3e2af9 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -10,6 +10,7 @@ Each guide lists known pitfalls when working with one specific game entity type. |--------|------|--------------| | Items (inventory, bank, ground, equipment, shops) | [items.md](items.md) | Any code calling `Rs2Inventory`, `Rs2Bank`, `Rs2Equipment`, `Rs2GroundItem`, `Rs2Shop`, or `Rs2DepositBox` interaction helpers, or any helper that takes a list of item names and applies a single action to all of them | | Movement (walker, minimap, pathing) | [movement.md](movement.md) | Any code calling or modifying `Rs2Walker`, `Rs2MiniMap`, shortest-path marker handling, or minimap/canvas walk-click logic | +| Death (graves, Death's Office, recovery) | [death.md](death.md) | Any code calling or modifying `Rs2Death`, `DeathRecoveryEvent`, `DeathEvent`, or handling graves, retrieval fees, and post-death item recovery | ## Format diff --git a/docs/entity-guides/death.md b/docs/entity-guides/death.md new file mode 100644 index 00000000000..d3896d279d4 --- /dev/null +++ b/docs/entity-guides/death.md @@ -0,0 +1,430 @@ +# Death Handling Gotchas + +Rules for working with `Rs2Death`, graves, and Death's Office. + +## Wiring it into a script + +There is no config interface, mode enum, or options object — scripts read their own config values and +call the statics with plain scalars, the same way they call `Rs2Bank` or `Rs2Walker`. + +```java +// walks to the grave, empties it, closes the interface. Death's Office is NOT visited. +if (Rs2Death.hasDeathToHandle()) { + Rs2Death.recoverItems(config.deathBudget()); // 0 = free items only, MAX_VALUE = pay anything + return State.BANK; // re-gear with whatever the script already does +} + +// opt in to the Death's Office trip as well, if the script wants expired items back +Rs2Death.recoverItems(config.deathBudget(), config.useDeathsOffice()); + +// or price the office yourself before committing — the trip is free, only the reclaim costs +if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) { + Rs2Death.reclaimAll(config.maxOfficeFee()); // ceiling checked against estimateReclaimFee() + Rs2Death.closeInterfaces(); // reclaimAll() with no argument = no ceiling +} +``` + +Or drive the steps yourself when the script wants its own logic in between: + +```java +if (Rs2Death.hasGrave()) { + Rs2Death.walkToGrave(); + Rs2Death.openGrave(); + Rs2Death.lootGraveFreeItems(); + + if (Rs2Death.getGraveFee() < myThreshold) { + Rs2Death.lootGravePaidItems(myThreshold); + } +} +``` + +Banking, re-gearing, and walking back are deliberately *not* in this API — scripts already have their own +banking state, so bolting a second one on here would only fight it. + +The typical flow an author builds around it: + +**recover → bank → resupply from an inventory setup → back to the grind** + +`Rs2Death` owns only the first step. The rest is the script's existing banking and `Rs2InventorySetup` +logic, which is why nothing here deposits, withdraws, or re-gears. Scripts that re-stock from a setup +mostly do not care what actually came back from the grave, which sidesteps rule 5 entirely. + +## 0. Prefer the game's own numbers over estimating + +The "Items Kept on Death" panel (`InterfaceID.Deathkeep`, group **4**, reached from the worn equipment +tab) publishes what the game has already calculated. Read it instead of computing anything: + +| Component | Live content | +|---|---| +| `KEPT` (4.6) | item slots + caption `Items that are KEPT:` | +| `GRAVE` (4.7) | item slots + caption `Items that go to your GRAVESTONE: (Fee: None)` | +| `VALUE` (4.18) | `Guide risk value:
111,716` | +| 4.14–4.17 | scenario toggles: Protect Item / PK Skull / Killed by a player / Wilderness beyond level 20 | + +`getPredictedGraveFee()` and `getRiskValue()` read these directly, so they are **authoritative** — the +per-unit valuation, ironman rate, and any discounted-death allowance are already applied. That beats any +GE-price arithmetic, which is why `estimateReclaimFee()` exists only for Death's Office, where the game +publishes nothing. + +Two things to watch: + +- The captions live **inside** the item containers as ordinary children, not in their own components, so + item slots and the label share a container. Skip entries whose item id is `-1`. +- The panel reflects whichever **scenario the toggles are set to**, not necessarily the player's real + situation. It answers "what would happen under these conditions". + +**Where this applies:** `Rs2Death.getItemsKeptOnDeath`, `getItemsSentToGrave`, `getPredictedGraveFee`, +`getRiskValue`. + +## 1. A grave is an NPC, not a game object + +Graves respond to `Rs2NpcCache`, not `Rs2GameObject`. Their ids run contiguously from +`NpcID.GRAVESTONE_DEFAULT` (9856) to `NpcID.GRAVESTONE_ANGEL_255` (10367) — 516 ids covering every +player-name and cosmetic permutation. + +**Why this matters:** searching for a grave with the object helpers silently finds nothing, and the +failure looks identical to "no grave exists", so handling reports success and the items rot. + +**Pattern to follow:** + +```java +// Wrong — graves are not objects +Rs2GameObject.interact("Grave", "Loot"); + +// Right — match the id range against the NPC cache +Microbot.getRs2NpcCache().query() + .where(npc -> npc.getId() >= NpcID.GRAVESTONE_DEFAULT && npc.getId() <= NpcID.GRAVESTONE_ANGEL_255) + .nearest(deathLocation, SCENE_RADIUS); +``` + +Do not enumerate the ids into a list, and do not match on name — anchor the search on the recorded death +location so another player's grave in the same area is never targeted. + +Verified live at a real grave: NPC id **9856** (`GRAVESTONE_DEFAULT`), name **`Grave`**, standing on the +death tile. Individual item slots carry `Take` / `Examine`; the section buttons carry `Take-All`. + +**Where this applies:** `Rs2Death.getGrave`, `Rs2Death.openGrave`. + +## 2. Death handling is never automatic + +There is no blocking event and no default-on behaviour. A script must poll +`Rs2Death.hasDeathToHandle()` and act on it itself — see the wiring example above. + +**Why this matters:** recovery spends the account's coins on retrieval fees and walks it across the map. +Doing that to a script that never asked for it is worse than leaving the items where they are. + +## 3. Bank *after* collecting, never before + +A player who just died keeps at most a few items, so the inventory is effectively empty and always has +room for the grave's contents. Banking first is a wasted trip that burns grave timer. + +**Why this matters:** this is the reverse of the usual "make space before looting" instinct, and the +instinct is wrong here specifically because death already emptied the inventory. `Rs2Death` does no +banking at all for this reason — the script does it afterwards, with the banking logic it already has. + +## 4. A PvP death may leave no grave at all + +Dying to another player in the Wilderness hands your tradeables straight to the killer. Untradeables go +to a grave below level 20, or are destroyed above it (unless locked with a Trouver parchment). So after a +PvP death there may be nothing to recover anywhere. + +**Why this matters:** "no grave standing" is not the same as "the grave expired into Death's Office". +Treating them as the same sends the script across the map to an empty office, and if it then walks back +to the death spot it re-enters the Wilderness and dies to the same player again — a die-return-die loop. + +**Pattern to follow:** `hasGraveExpired()` requires a grave to have actually been *seen* since the death +(`GRAVESTONE_VISIBLE` going **non-zero**, tracked via `Rs2Death.onVarbitChanged` — see rule 8, it is not +a boolean). Never derive it from `lastDeathTime != null && !hasGrave()`. + +Check `Rs2Death.getDeathWildernessLevel()` before walking back to a death location. + +**Where this applies:** `Rs2Death.hasGraveExpired`, `Rs2Death.handleActorDeath`. + +## 5. Supply loss is situational, not universal + +On an ordinary PvM death, food and potions go into the grave like everything else and come back normally. +Two cases break that: + +- **Wilderness / PvP death.** Food, potions, and phoenix necklaces cannot be graved or dropped — they are + deleted outright. +- **Dying again while a grave already holds supplies.** Cooked food and potions in the existing grave + drop to the ground beneath it and despawn after an hour, and unstackable resources already in the grave + (bones, ores, pure essence, unpowered orbs, planks) are pushed on to Death's Office. Only one + inventory's worth of those persists per grave. + +**Why this matters:** do not write a blanket "supplies are lost on death" assumption either way. A single +PvM death recovers fine; a Wilderness death does not; a second death on top of an uncollected grave +quietly relocates the first death's consumables. + +In practice most scripts sidestep this entirely by re-stocking from an inventory setup rather than +depending on what came back — see the expected flow at the top of this guide. + +## 6. Never compute the retrieval fee yourself + +Read `getGraveFee()` / `getReclaimFee()` from the live interface. The posted fee already accounts for the +per-item tiers (free under 100k, then 1k / 10k / 100k, capped at 500k total), the 50% ironman discount, +and per-boss discounted deaths — Zulrah is free for the first 50 kills, Desert Treasure II bosses and +Yama and Doom of Mokhaiotl and Fortis Colosseum all have their own 75%-off allowances. + +**Why this matters:** any fee calculated from item values will be wrong for a large and growing set of +content, and wrong in the expensive direction. + +**The fee is never charged to carried coins.** It comes out of **Death's Coffer if it holds anything, +and the bank otherwise**. Do not gate a reclaim on `Rs2Inventory` coins — a freshly respawned player is +usually carrying nothing, so that check refuses reclaims the account can easily afford. + +**The two schedules are unrelated — never reuse one for the other.** A grave charges flat coin amounts by +tier with a hard cap; the office charges an uncapped percentage. Numbers that look interchangeable at the +bottom bracket (100k x 1% = the grave's flat 1,000) diverge fast: a 1m item costs 10,000 at a grave and +50,000 at the office. + +**Both test unit price, not stack or cumulative value.** Confirmed in game for each: + +- *Grave:* 740 noted coal worth 111,000 in total at 150 each showed `(Fee: None)` — over the 100k stack + threshold, but a single coal is not, so free. +- *Office:* a reclaim of 862 coal + 875 iron ore + 142 steel bars — **~307,000 in total, nothing worth + 100k each** — cost **0**. Bank was 90,702 before and after. That single result rules out both a + cumulative charge (would have billed ~15k on the 307k) and a per-stack threshold (would have billed the + 125k coal slot). + +So the two schedules share the **same 100k per-unit threshold**; they differ only in the fee. Every +stackable item under 100k each is free from both, regardless of stack size. An earlier note here claimed +the office charges on cumulative value — that was wrong, and the test above disproves it. + +One half is still unobserved: that an item **over** 100k is billed at exactly 5% (office) or the flat tier +(grave) is taken from the wiki, not yet seen in game. + +For reference, the tiers the interface already applies for you: + +| Source | Regular | Ironman | +|---|---|---| +| Gravestone, per item | free <100k, then 1k / 10k / 100k by 100k–1m / 1m–10m / 10m+ tiers, **capped at 500k total** | 50% off | +| Death's Office | flat **5%** of value, items 100k+, no cap | 2.5% | + +## 7. Abandoned items are deferred, not destroyed + +Items left behind because of a zero or exceeded budget stay in the grave for its remaining life, then +move to Death's Office and keep there indefinitely, reclaimable at 5% of value (2.5% ironman). + +**Why this matters:** `lootGravePaidItems` returning `false` is a normal, intended outcome — do not treat +it as an error or retry it. `recoverItems` deliberately still returns `true` in that case. + +Note the difference between two similarly-named things: **Death's Office** is where unclaimed items go. +**Death's Coffer** is a separate credit pot you deposit items into (for 105% of GE price) to pay future +fees from. This API does not touch the coffer. + +## 8. The grave varbits are not what their names suggest + +Both were verified against a live grave, and both had my first implementation wrong: + +- **`GRAVESTONE_VISIBLE` (10464) is not a boolean.** It reads **0** with no grave and a steady **133** + with one standing — constant across repeated samples, so neither a flag nor a countdown. Only zero + versus non-zero is meaningful. Testing `== 1` reports "no grave" while a grave is standing, silently + disabling the whole recovery path. +- **`GRAVESTONE_DURATION` (10465) counts game ticks, not seconds.** Observed decrementing 1461 → 1377 + over roughly 50 seconds, starting from 1500 (1500 × 0.6s = 900s = the nominal 15 minutes). Reading it + as seconds overstates remaining time by 40%. + +```java +// Wrong +hasGrave() -> getVarbitValue(GRAVESTONE_VISIBLE) == 1 +getGraveTimeRemaining() -> Duration.ofSeconds(getVarbitValue(GRAVESTONE_DURATION)) + +// Right +hasGrave() -> getVarbitValue(GRAVESTONE_VISIBLE) != 0 +getGraveTimeRemaining() -> Duration.ofMillis(getVarbitValue(GRAVESTONE_DURATION) * 600L) +``` + +The varbit also drops to zero identically whether the grave was emptied or timed out into Death's Office, +so it cannot distinguish those two on its own. + +**Why this matters:** clearing the recorded death when the varbit hits zero permanently disables the +Death's Office path — the very state that path needs to detect is the state that erases it. + +**Pattern to follow:** combine it with the "a grave was seen" flag from rule 4, and clear the record only +once handling has completed (`Rs2Death.clearDeathState`). A script that loots its own grave by hand must +call `clearDeathState()` itself, or handling will later walk to an empty Death's Office. + +**Where this applies:** `Rs2Death.hasGraveExpired`, `Rs2Death.recoverItems`. + +## 9. The grave interface is group 672, and its FEE is prose + +Verified live with a grave open. The loaded group is `InterfaceID.GravestoneGeneric` (0x02a0 = **672**), +not `GravestoneRetrieval` (602): + +| Component | Live text / action | +|---|---| +| `FRAME` (672.2) | `Gravestone (2/120)` | +| `FREE_CONTAINER_TEXT0` (672.5) | `Free to reclaim:` | +| `FREEBUTTON` (672.8) | action `Take-All` | +| `FEE` (672.12) | `Fee: Paid` | +| `PAYBUTTON` (672.15) | action `Take-All` | +| `INFO` (672.18) | `Death's Coffer: Empty
Discard items to reduce a fee.` | + +**The `FEE` component is a sentence, not a number.** With the pay section settled it reads `Fee: Paid`, +which contains no digits, so any digit-scan parse returns `0`. + +**Why this matters:** `0` here means *nothing is owed*, **not** *there is nothing to claim*. Skipping the +`PAYBUTTON` click on a zero fee abandons items that cost nothing to take: + +```java +// Wrong — never clicks PAYBUTTON when the fee reads "Fee: Paid" +int fee = getGraveFee(); +if (fee <= 0) return true; +... +clickAndSettle(PAYBUTTON); + +// Right — the fee only gates, it never cancels the claim +int fee = getGraveFee(); +if (fee > 0) { + if (fee > budget) return false; + if (coinsCarried() < fee) return false; +} +clickAndSettle(PAYBUTTON); +``` + +**Hazard:** `INCINERATOR` (672.17) sits in the bottom-right of the pay section and **destroys items**. +Never click by position in this interface — always target the named component. + +**Where this applies:** `Rs2Death.getGraveFee`, `Rs2Death.lootGravePaidItems`. + +## 10. `/widgets/list` under-reports; use `/widgets/search` + +When debugging interfaces through the agent server, `/widgets/list` reported only group 164 while the +grave interface was open, and `/widgets/search` found group 672 fully populated at the same moment. + +**Why this matters:** concluding "the interface is not loaded" from `/widgets/list` sends you looking for +the wrong group entirely. Confirm with a search or a direct `describe` before believing it. + +## 11. The Death's Office reclaim has no spending limit, and cannot have one + +Verified live with an item waiting. `InterfaceID.DeathOffice` (669) is the right group — title +`Death's Office Item Retrieval (1/120)` — but the cost is never on screen before it is charged: + +| Component | Actions | With an item present | +|---|---|---| +| 669.1 idx=1 | — | `Death's Office Item Retrieval (1/120)` | +| 669.1 idx=11 | `Close` | visible | +| 669.3 (`ITEMS`) | `Select`, `Examine` | the item | +| 669.6/7/8/9 | `1` `5` `X` `All` | **hidden** until an item is selected | +| 669.10 (`TAKEALL`) | `Take-All` | visible | +| 669.11 (`INFO`) | — | `Select an item to retrieve.
Death's Coffer: 0` — **identical to empty** | + +The group has no `FEE` component, `INFO` does not change when items are waiting, the quantity buttons +stay hidden until selection, and `Take-All` never selects. So `reclaimAll()` takes **no budget** — a cap +would be fiction, and `getReclaimFee()` was removed rather than left returning a permanent `0` for +callers to trust. + +So the office trip is **opt-in**, not budget-controlled: `recoverItems(budget)` never goes there, and +`recoverItems(budget, true)` does. Whether an account should spend an unknowable amount to recover +expired items is a script-writer decision, not something this API should make on their behalf. The +default is off because the items keep at Death's Office indefinitely, so declining costs nothing and +stays reversible by hand. + +When the grave has expired and the office was not requested, `recoverItems` clears the death record and +returns `true` — otherwise `hasDeathToHandle()` would keep reporting a death the caller has already +decided to ignore, and the script would spin on it forever. + +**Why this matters:** the grave and the office are not symmetric. A grave publishes its fee in `FEE` +(672.12) and can be budgeted properly; the office cannot. Do not assume a limit that worked at the grave +carries over. + +**Contrast:** at a grave the worst case is the 500k cap. At the office it is an uncapped 5% of value, so +a 10M-gear death costs 500k there with nothing to stop it. + +`estimateReclaimFee()` exists for scripts that want a ceiling. It reads the open interface and charges 5% +on each item whose **unit** price is 100,000 or more, and nothing on the rest — the office's actual rule, +confirmed by the free ~307k reclaim above. So a resource-stack death now estimates near zero, matching the +game, instead of the wild overestimate the old cumulative version produced. + +Prices come from `getItemPriceWithSource(id, true)`, **not** `getItemPrice(id)`. The latter follows the +player's "Use wiki item prices" RuneLite setting, so with that toggle off it silently returns the +once-a-day Jagex guide price instead of the wiki feed that tracks the market. No separate HTTP client is +needed — RuneLite already maintains this data. + +It is still an estimate, for two reasons: it ignores the ironman half rate (reads high for them), and the +5% rate on items **over** 100k is taken from the wiki, not yet observed in game — only the free case below +100k is proven. Three more limits to respect: + +1. The interface must already be open, so you cannot price the office before travelling. The trip is + free, so estimate on arrival and walk away if it is too dear. +2. Wiki prices are periodically refreshed, not tick-live, and drift while you play — an 18x Earth rune + stack was quoted at 90 gp and then 108 gp within one session, a 20% move on a trivial item. +3. The feed need not match the game's own valuation. + +So `reclaimAll(maxEstimatedFee)` is a guard rail, not a guarantee. Leave headroom, and use the +no-argument `reclaimAll()` when the script genuinely does not care. + +**Where this applies:** `Rs2Death.reclaimAll`, `Rs2Death.recoverItems`, `Rs2Death.estimateReclaimFee`. + +## 12. Death's Office needs the entrance object, then a dialogue — not an NPC click + +Death stands inside **Death's Domain**, an instanced region (12633). Walking to the entrance coordinate +is not enough — the NPC is never in the scene until you step through the object. + +Verified in-game at Lumbridge: the object is `Death's Domain`, id **38426** +(`gameval.ObjectID1.DEATH_OFFICE_ACCESS_GRAVE`), at **(3238, 3192, 0)**, with the action +**`Enter Death's Domain`**. + +Note the id lives in `ObjectID1.java`, the overflow file — grepping only `gameval/ObjectID.java` misses +it. The legacy alias is `net.runelite.api.ObjectID.DEATHS_DOMAIN`. + +**The interface opens through dialogue, not a menu action on Death.** Verified in game: stepping through +the object auto-walks the player to Death and starts the conversation, so there is no "Collect"/"Talk-to" +click to make. Advance Death's lines, then choose **`Yes, have you got anything for me?`** (group 219): + +``` +219.1 idx=1 'How does that work?' +219.1 idx=2 'What is this place?' +219.1 idx=3 'Yes, have you got anything for me?' <- the reclaim option +219.1 idx=4 'More options...' +``` + +Match on **text**, not the index. The `More options...` entry means the list can grow and shift, so a +hardcoded "option 3" would eventually pick the wrong line. `Rs2Dialogue.clickOption("have you got +anything for me")` does a case-insensitive substring match and resolves the key press itself. + +**Sequence:** `walkToDeathsOffice()` → `enterDeathsOffice()` → `openDeathsOffice()` (drives the dialogue) +→ `reclaimAll()`. + +The other seven entrance coordinates come from the wiki's map pins (available in the page's raw +wikitext, not the rendered table). Lumbridge calibrates them: the pin says (3238, 3194) against a real +object at (3238, 3192), so expect ~2 tiles of error. That is harmless here — the walk only has to load +the object into the scene, and `enterDeathsOffice()` then matches it by **id**, never by coordinate. +Resolve entrances by id rather than pinning exact tiles. + +**Where this applies:** `Rs2Death.enterDeathsOffice`, `Rs2Death.isInDeathsOffice`, +`DeathsOfficeLocation`. + +## 13. Always close the retrieval interface + +The grave timer pauses while its interface is open. Leaving it up after a partial claim silently freezes +the countdown and confuses any later timing logic. + +An interface left open by accident holds the timer indefinitely and makes `getGraveTimeRemaining()` look +stuck. Close it unless you are pausing on purpose. + +**Where this applies:** `Rs2Death.closeInterfaces`. + +## 14. The grave interface does not close on the last item + +Use the `GRAVESTONE_VISIBLE` varbit to confirm a grave was emptied, not the interface's visibility. + +**Why this matters:** waiting on `!isGraveOpen()` reports failure on a fully successful loot whenever the +interface lingers. + +## 15. The grave timer is not wall-clock + +The nominal 15 minutes pauses **while logged out**, **while the grave interface is open**, and **while the +player stands idle**. The idle pause engages after a few ticks, not instantly — a sample taken right after +stopping still shows the countdown moving, which is why an early reading looks like idle does not pause it. +It does; give it a moment. + +**Why this matters:** do not compute remaining time from `getLastDeathTime()` — read +`Rs2Death.getGraveTimeRemaining()`, which reflects the real `GRAVESTONE_DURATION` varbit. + +## 16. `DeathEvent` and `Rs2Death` are different things + +`DeathEvent` is a blocking event handling the one-off first-death Death's Domain tutorial (varp 4517, +region 12633), exiting via the portal. It normally fires once per account and stays automatic. `Rs2Death` +handles every normal death afterwards and is opt-in. Do not merge them. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index f0f37a9cfb5..ffafb969af5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -21,6 +21,7 @@ import net.runelite.client.plugins.microbot.ui.MicrobotPluginListPanel; import net.runelite.client.plugins.microbot.ui.MicrobotTopLevelConfigPanel; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.death.Rs2Death; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.huntkit.Rs2HuntKit; import net.runelite.client.plugins.microbot.util.inventory.Rs2Gembag; @@ -366,6 +367,7 @@ public void onVarbitChanged(VarbitChanged event) Rs2Player.handlePotionTimers(event); Rs2Player.handleTeleblockTimer(event); Rs2RunePouch.onVarbitChanged(event); + Rs2Death.onVarbitChanged(event); } @Subscribe @@ -374,6 +376,12 @@ public void onAnimationChanged(AnimationChanged event) Rs2Player.handleAnimationChanged(event); } + @Subscribe + public void onActorDeath(ActorDeath event) + { + Rs2Death.handleActorDeath(event); + } + @Subscribe(priority = 999) private void onMenuEntryAdded(MenuEntryAdded event) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java new file mode 100644 index 00000000000..28790e42b69 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java @@ -0,0 +1,60 @@ +package net.runelite.client.plugins.microbot.util.death; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.util.Arrays; +import java.util.Comparator; + +/** + * Death's Office entrances, one beside each major respawn point. Each is marked by a tombstone icon on + * the minimap and leads to the same office, so the nearest is always the right choice. + *

+ * Entry is through a {@code Death's Domain} object + * ({@link net.runelite.api.gameval.ObjectID1#DEATH_OFFICE_ACCESS_GRAVE}, id 38426) with the action + * {@code Enter Death's Domain} — see {@link Rs2Death#enterDeathsOffice()}. + *

+ * {@link #LUMBRIDGE} is verified in-game. The other seven come from the wiki's map pins, which the + * Lumbridge entry calibrates as accurate to about two tiles — the pin there reads (3238, 3194) against + * an actual object at (3238, 3192). + *

+ * That margin does not matter in practice: {@link Rs2Death#walkToDeathsOffice()} only has to get close + * enough for the entrance object to load into the scene, and + * {@link Rs2Death#enterDeathsOffice()} then finds it by id rather than by coordinate. The + * {@code landmark} field records what each point is meant to sit beside. + */ +@Getter +@RequiredArgsConstructor +public enum DeathsOfficeLocation { + /** Verified in-game: the {@code Death's Domain} object sits here. */ + LUMBRIDGE(new WorldPoint(3238, 3192, 0), "Graveyard by the church"), + FALADOR(new WorldPoint(2964, 3331, 0), "White Knights' Castle Crypt"), + EDGEVILLE(new WorldPoint(3096, 3475, 0), "Edgeville Mausoleum"), + SEERS_VILLAGE(new WorldPoint(2715, 3466, 0), "Graveyard by the church"), + FEROX_ENCLAVE(new WorldPoint(3127, 3630, 0), "Ferox Enclave"), + KOUREND_CASTLE(new WorldPoint(1622, 3663, 0), "Kourend Castle"), + PRIFDDINAS(new WorldPoint(3256, 6118, 0), "Hefin district, north of the bank"), + CIVITAS_ILLA_FORTIS(new WorldPoint(1654, 3135, 0), "West of the Sunrise Palace"); + + private final WorldPoint entrance; + + /** What the entrance sits next to, for verifying the coordinate above. */ + private final String landmark; + + /** + * @return the entrance closest to the player, or {@code null} when the player's position is + * unavailable. + */ + public static DeathsOfficeLocation getNearest() { + return getNearest(Rs2Player.getWorldLocation()); + } + + public static DeathsOfficeLocation getNearest(WorldPoint from) { + if (from == null) return null; + return Arrays.stream(values()) + .min(Comparator.comparingInt(location -> location.entrance.distanceTo(from))) + .orElse(null); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java new file mode 100644 index 00000000000..e812a1821ab --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -0,0 +1,842 @@ +package net.runelite.client.plugins.microbot.util.death; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Player; +import net.runelite.api.annotations.Component; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ActorDeath; +import net.runelite.api.events.VarbitChanged; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID1; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.settings.Rs2Settings; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +import java.awt.event.KeyEvent; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Death recovery for a normal death: locating the grave, looting it (paying the retrieval fee when + * required), and falling back to Death's Office once the grave has expired. + *

+ * Nothing here runs on its own. A script polls from its loop and decides what to do: + *

+ * if (Rs2Death.hasDeathToHandle()) {
+ *     Rs2Death.recoverItems(config.deathBudget());   // grave only; 0 = free items only
+ *     return State.BANK;                             // script re-gears however it already does
+ * }
+ * 
+ * Death's Office is opt-in, because its fee is uncapped and cannot be read before it is charged: + *
+ * Rs2Death.recoverItems(config.deathBudget(), true);
+ * 
+ * A script that wants a ceiling there can price the office on arrival — the trip costs nothing, only the + * reclaim does: + *
+ * if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) {
+ *     Rs2Death.reclaimAll(config.maxOfficeFee());   // or reclaimAll() for no ceiling
+ *     Rs2Death.closeInterfaces();
+ * }
+ * 
+ * The ceiling is checked against {@link #estimateReclaimFee()}, since the office never shows the real + * fee before charging it. + *

+ * Or drive the steps directly — {@link #walkToGrave()}, {@link #openGrave()}, + * {@link #getGraveFee()}, {@link #lootGraveFreeItems()}, {@link #lootGravePaidItems(int)} — when the + * script wants its own logic between them. + *

+ * Items left behind are not destroyed; they keep in Death's Office and can be reclaimed later at 5% of + * value (2.5% for ironmen). + *

+ * The first-death Death's Domain tutorial is not handled here — that stays with + * {@link net.runelite.client.plugins.microbot.util.events.DeathEvent}, which normally only fires once + * per account. + */ +@Slf4j +public class Rs2Death { + + /** + * Grave NPC ids run contiguously from {@code GRAVESTONE_DEFAULT} to {@code GRAVESTONE_ANGEL_255} + * (516 ids covering every player-name/cosmetic permutation), so match on the range rather than + * enumerating them. + */ + private static final int GRAVE_NPC_ID_MIN = NpcID.GRAVESTONE_DEFAULT; + private static final int GRAVE_NPC_ID_MAX = NpcID.GRAVESTONE_ANGEL_255; + + private static final String GRAVE_LOOT_ACTION = "Loot"; + + /** Death's reclaim dialogue choice, verified in game. Matched as a substring, so it tolerates + * reordering and the trailing punctuation ("Yes, have you got anything for me?"). */ + private static final String DEATH_RECLAIM_OPTION = "have you got anything for me"; + + /** Verified in-game against the Lumbridge entrance object. */ + private static final String ENTER_DEATHS_DOMAIN_ACTION = "Enter Death's Domain"; + + /** Death's Domain is its own region; the same one {@code DeathEvent} watches. */ + private static final int DEATH_DOMAIN_REGION_ID = 12633; + + private static final int ENTER_TIMEOUT_MS = 10_000; + + /** {@code GRAVESTONE_DURATION} is measured in game ticks, so convert before reporting a Duration. */ + private static final long GAME_TICK_MS = 600L; + + /** + * Death's Office charges 5% of each qualifying item's value, halved for ironmen. Estimates use the + * full rate. + *

+ * Same 100k unit-price threshold as a grave (confirmed in game), but a different fee: a grave + * charges flat coin amounts by tier (1,000 / 10,000 / 100,000 for 100k–1m / 1m–10m / 10m+) capped at + * 500,000, whereas the office charges an uncapped 5%. Do not reuse one fee for the other. + */ + private static final long DEATHS_OFFICE_FEE_PERCENT = 5L; + + /** Items whose unit price is below this reclaim free — confirmed in game, grave and office. */ + private static final long DEATHS_OFFICE_FREE_THRESHOLD = 100_000L; + + + /** Graves are lootable from up to 7 tiles with line of sight. */ + private static final int GRAVE_INTERACT_DISTANCE = 7; + + /** Widest a loaded scene can be, used as the search radius when locating the grave. */ + private static final int SCENE_RADIUS = 104; + + private static final int INTERFACE_TIMEOUT_MS = 5_000; + private static final int LOOT_TIMEOUT_MS = 3_000; + + private static final Pattern DIGITS = Pattern.compile("[\\d,]+"); + + /** Captures whatever follows "Fee:" in the Items Kept on Death caption, e.g. "(Fee: None)". */ + private static final Pattern FEE_LABEL = Pattern.compile("(?i)fee:\\s*([^)<]+)"); + + + @Getter + private static volatile WorldPoint lastDeathLocation; + + @Getter + private static volatile Instant lastDeathTime; + + /** + * Whether a grave was ever seen standing since the last recorded death. Without this a PvP death — + * which hands the tradeables to the killer and spawns no grave at all — looks identical to a grave + * that expired into Death's Office. + */ + private static volatile boolean graveSeen; + + // region state + + /** + * Records the local player's death. Wired from {@code MicrobotPlugin#onActorDeath}. + */ + public static void handleActorDeath(ActorDeath event) { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + if (localPlayer == null || event.getActor() != localPlayer) return; + + lastDeathLocation = localPlayer.getWorldLocation(); + lastDeathTime = Instant.now(); + graveSeen = false; + log.info("Local player died at {} (wilderness level {})", + lastDeathLocation, Rs2Pvp.getWildernessLevelFrom(lastDeathLocation)); + } + + /** + * Notes that a grave actually appeared. Wired from {@code MicrobotPlugin#onVarbitChanged}. + */ + public static void onVarbitChanged(VarbitChanged event) { + // Non-zero, not == 1: the varbit reads 133 with a grave standing. Matching on 1 never fires, + // which would leave graveSeen false forever and permanently disable the Death's Office fallback. + if (event.getVarbitId() == VarbitID.GRAVESTONE_VISIBLE && event.getValue() != 0) { + graveSeen = true; + } + } + + /** + * Forgets the recorded death. Called automatically once items are recovered; scripts that collect + * their own grave manually should call this so recovery does not later walk to an empty + * Death's Office. + */ + public static void clearDeathState() { + lastDeathLocation = null; + lastDeathTime = null; + graveSeen = false; + } + + /** + * @return {@code true} while the local player is playing the death animation. This is only true for + * the brief window before the respawn — use {@link #hasGrave()} to detect the aftermath. + */ + public static boolean isDead() { + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> { + Player local = Microbot.getClient().getLocalPlayer(); + return local != null && local.isDead(); + }) + .orElse(false); + } + + public static boolean hasDiedRecently(long withinMs) { + Instant died = lastDeathTime; + return died != null && Duration.between(died, Instant.now()).toMillis() <= withinMs; + } + + /** + * @return {@code true} if the player currently has an uncollected grave somewhere in the world. + *

+ * {@code GRAVESTONE_VISIBLE} is not a boolean despite the name. Observed live: {@code 0} with + * no grave, and a steady {@code 133} with one standing — held constant across repeated samples, so + * it is neither a flag nor a countdown. Whatever it encodes, only zero versus non-zero is + * meaningful; testing {@code == 1} reports "no grave" while a grave is standing. + */ + public static boolean hasGrave() { + return Microbot.getVarbitValue(VarbitID.GRAVESTONE_VISIBLE) != 0; + } + + /** + * Remaining grave time. + *

+ * {@code GRAVESTONE_DURATION} counts game ticks, not seconds — verified live, decrementing + * 1461 to 1377 over roughly 50 seconds, and starting from 1500 ticks (1500 × 0.6s = 900s = the + * nominal 15 minutes). Reading it as seconds overstates the remaining time by 40%. + *

+ * The underlying timer pauses while logged out, while the grave interface is open, and while the + * player stands idle — the idle pause engages after a few ticks rather than immediately, which is why + * a sample taken right after stopping still shows it decrementing. A grave therefore routinely + * outlives fifteen minutes of wall-clock time, so read this varbit rather than timing from the death. + */ + public static Duration getGraveTimeRemaining() { + int ticks = Math.max(0, Microbot.getVarbitValue(VarbitID.GRAVESTONE_DURATION)); + return Duration.ofMillis(ticks * GAME_TICK_MS); + } + + /** + * @return {@code true} when a grave was seen standing after the last death but is no longer there, + * meaning the items have moved on to Death's Office. + *

+ * Requires the grave to have actually appeared. A PvP death in the Wilderness hands the tradeables + * straight to the killer and may spawn no grave at all — without that check this would report an + * expired grave and send the script across the map to an empty Death's Office. + *

+ * Stays {@code true} until {@link #clearDeathState()} runs, which is why recovery clears the record + * on success: {@code GRAVESTONE_VISIBLE} drops to zero identically whether the grave expired or was + * emptied, so the varbit alone cannot tell the two apart. + */ + public static boolean hasGraveExpired() { + return graveSeen && !hasGrave(); + } + + /** + * Wilderness level of the spot the player died at, or {@code 0} if that was outside the Wilderness + * or no death is recorded. + *

+ * Scripts should check this before walking back: returning to a deep-Wilderness death spot is how a + * script ends up in a die-return-die loop against the same player killer. + */ + public static int getDeathWildernessLevel() { + WorldPoint deathLocation = lastDeathLocation; + return deathLocation == null ? 0 : Rs2Pvp.getWildernessLevelFrom(deathLocation); + } + + // endregion + + // region items kept on death + + /** + * @return {@code true} when the "Items Kept on Death" panel is open. Reached from the worn + * equipment tab; this API only reads it, it does not open it. + */ + public static boolean isItemsKeptOnDeathOpen() { + return Rs2Widget.isWidgetVisible(InterfaceID.Deathkeep.KEPT); + } + + /** + * The items the player would keep if they died right now — normally the three most valuable, four + * with Protect Item. + *

+ * Reflects whichever scenario the panel's toggles are set to (Protect Item, PK skull, killed by a + * player, deep Wilderness), so it answers "what happens under these conditions", not necessarily + * "what happens on my next death". + */ + public static List getItemsKeptOnDeath() { + return readDeathkeepItems(InterfaceID.Deathkeep.KEPT); + } + + /** + * The items that would go to the gravestone — everything not kept, minus anything the death would + * destroy outright. + */ + public static List getItemsSentToGrave() { + return readDeathkeepItems(InterfaceID.Deathkeep.GRAVE); + } + + /** + * The gravestone fee the game itself has calculated for the current loadout, read straight off + * the panel rather than estimated. Authoritative: it already accounts for the per-unit valuation, + * ironman rates, and any discounted-death allowance. + *

+ * Verified in game — 740 noted coal worth 111,000 in total at 150 each reported {@code Fee: None}, + * because a grave tests each item's unit price, not its stack value. + * + * @return the fee in coins, or {@code 0} when the panel reads "None" or is closed. + */ + public static int getPredictedGraveFee() { + String label = findDeathkeepLabel(InterfaceID.Deathkeep.GRAVE); + if (label == null) return 0; + + Matcher matcher = FEE_LABEL.matcher(label); + return matcher.find() ? parseFeeText(matcher.group(1)) : 0; + } + + /** + * The game's own "Guide risk value" for the current loadout — what the panel reports the player is + * risking, in coins. + * + * @return the risk value, or {@code 0} when the panel is closed. + */ + public static int getRiskValue() { + return parseFee(Rs2Widget.getWidget(InterfaceID.Deathkeep.VALUE)); + } + + /** + * Reads the item slots out of one of the panel's containers. The container also holds its own + * caption as a plain child, so entries without an item id are skipped. + */ + private static List readDeathkeepItems(@Component int componentId) { + Widget container = Rs2Widget.getWidget(componentId); + if (container == null) return Collections.emptyList(); + + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + List items = new ArrayList<>(); + Widget[] children = container.getDynamicChildren(); + if (children == null) return items; + + for (int slot = 0; slot < children.length; slot++) { + int itemId = children[slot].getItemId(); + if (itemId <= 0) continue; + items.add(new Rs2ItemModel(itemId, Math.max(1, children[slot].getItemQuantity()), slot)); + } + return items; + }).orElseGet(Collections::emptyList); + } + + /** + * Finds the caption inside a panel container. It sits alongside the item slots rather than in its own + * component, so it has to be picked out by content. + */ + private static String findDeathkeepLabel(@Component int componentId) { + Widget container = Rs2Widget.getWidget(componentId); + if (container == null) return null; + + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget[] children = container.getDynamicChildren(); + if (children == null) return null; + + for (Widget child : children) { + String text = child.getText(); + if (text != null && !text.isEmpty()) return text; + } + return null; + }).orElse(null); + } + + // endregion + + // region grave + + /** + * Finds the player's grave in the loaded scene. When a death location is known, prefers the grave + * closest to it so a nearby player's grave is never targeted by mistake. + */ + public static Rs2NpcModel getGrave() { + WorldPoint anchor = lastDeathLocation != null ? lastDeathLocation : Rs2Player.getWorldLocation(); + if (anchor == null) return null; + + return Microbot.getRs2NpcCache().query() + .where(npc -> npc.getId() >= GRAVE_NPC_ID_MIN && npc.getId() <= GRAVE_NPC_ID_MAX) + .nearest(anchor, SCENE_RADIUS); + } + + /** + * Walks to the recorded death location. The grave only spawns into the scene once nearby, so this + * relies on the location captured by {@link #handleActorDeath(ActorDeath)} rather than on finding + * the NPC first. + */ + public static boolean walkToGrave() { + Rs2NpcModel grave = getGrave(); + if (grave != null) { + return Rs2Walker.walkTo(grave.getWorldLocation(), GRAVE_INTERACT_DISTANCE); + } + + WorldPoint deathLocation = lastDeathLocation; + if (deathLocation == null) { + log.warn("Cannot walk to grave: no death location recorded"); + return false; + } + return Rs2Walker.walkTo(deathLocation, GRAVE_INTERACT_DISTANCE); + } + + public static boolean isGraveOpen() { + return Rs2Widget.isWidgetVisible(InterfaceID.GravestoneGeneric.CONTENT); + } + + /** + * Opens the grave retrieval interface. {@link Rs2NpcModel#click(String)} matches the action against + * the NPC composition case-insensitively and logs the available actions when it misses, so a casing + * change in a game update surfaces as a warning rather than a silent no-op. + */ + public static boolean openGrave() { + if (isGraveOpen()) return true; + + Rs2NpcModel grave = getGrave(); + if (grave == null) { + log.warn("Cannot open grave: no grave NPC in the loaded scene"); + return false; + } + + if (!grave.click(GRAVE_LOOT_ACTION)) return false; + return Global.sleepUntil(Rs2Death::isGraveOpen, INTERFACE_TIMEOUT_MS); + } + + /** + * @return the coin cost to reclaim the paid half of the grave, or {@code 0} when nothing is + * outstanding. The in-game fee is tiered per item and capped at 500,000. + *

+ * The {@code FEE} component is prose, not a bare number — verified live as {@code "Fee: Paid"} + * with the pay section settled. Anything without digits reads as {@code 0}, which means "nothing + * owed", not "there is nothing to claim". Do not use a zero here to skip clicking + * {@code PAYBUTTON}. + */ + public static int getGraveFee() { + return parseFee(Rs2Widget.getWidget(InterfaceID.GravestoneGeneric.FEE)); + } + + /** + * Claims the half of the grave that costs nothing. Items behind a fee are untouched and stay put. + */ + public static boolean lootGraveFreeItems() { + if (!isGraveOpen()) return false; + clickAndSettle(InterfaceID.GravestoneGeneric.FREEBUTTON); + return true; + } + + /** + * Claims the items behind the retrieval fee, when the account can afford it and the fee fits the + * budget. Everything lands in the inventory — this interface has no send-to-bank option. + * + * The fee is charged to Death's Coffer if it holds anything, and to the bank otherwise — never to + * carried coins. The player does not need to be holding gold, which matters because a freshly + * respawned one generally is not. + * + * @param budget the highest fee to pay, or {@link Integer#MAX_VALUE} for no limit. + * @return {@code false} when the paid half was deliberately left behind, which is a normal outcome + * rather than an error — the items keep in Death's Office. + */ + public static boolean lootGravePaidItems(int budget) { + if (!isGraveOpen()) return false; + + // A zero fee is not a reason to skip the claim. The FEE component is prose, not a number — + // verified live reading "Fee: Paid" — so getGraveFee() legitimately reports 0 when nothing is + // outstanding. Returning early there would abandon items that cost nothing to take. + // + // Deliberately no carried-coin check: the fee comes out of Death's Coffer first and the bank + // second, never the inventory. Gating on coins in the backpack refuses reclaims the account can + // comfortably afford — a freshly respawned player is usually carrying nothing at all. + int fee = getGraveFee(); + if (fee > 0 && fee > budget) { + log.info("Grave fee {} is over the {} budget, leaving the paid items to Death's Office", + fee, budget); + return false; + } + + clickAndSettle(InterfaceID.GravestoneGeneric.PAYBUTTON); + + // The varbit is the authoritative signal: the interface can linger open after the last item is + // claimed, so closing is not proof the grave was emptied. + return Global.sleepUntil(() -> !hasGrave(), LOOT_TIMEOUT_MS); + } + + // endregion + + // region death's office + + public static boolean isDeathsOfficeOpen() { + return Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ITEMS_CONTAINER) + || Rs2Widget.isWidgetVisible(InterfaceID.GravestoneRetrieval.ITEMS_CONTAINER); + } + + /** + * Walks to the nearest Death's Office entrance. + */ + public static boolean walkToDeathsOffice() { + DeathsOfficeLocation location = DeathsOfficeLocation.getNearest(); + if (location == null) { + log.warn("Cannot walk to Death's Office: no reachable entrance found"); + return false; + } + log.info("Walking to Death's Office via {}", location); + return Rs2Walker.walkTo(location.getEntrance(), 6); + } + + /** + * @return {@code true} when the player is inside Death's Domain, the instanced room holding Death + * and the retrieval interface. + */ + public static boolean isInDeathsOffice() { + WorldPoint location = Rs2Player.getWorldLocation(); + return location != null && location.getRegionID() == DEATH_DOMAIN_REGION_ID; + } + + /** + * Steps through the {@code Death's Domain} object into the office. Death stands inside the instance, + * so walking to the entrance is not enough on its own — without this the NPC is never in the scene + * and {@link #openDeathsOffice()} finds nothing. + */ + public static boolean enterDeathsOffice() { + if (isInDeathsOffice()) return true; + + Rs2TileObjectModel entrance = Microbot.getRs2TileObjectCache().query() + .withId(ObjectID1.DEATH_OFFICE_ACCESS_GRAVE) + .nearest(); + if (entrance == null) { + log.warn("Cannot enter Death's Office: no Death's Domain object in the loaded scene"); + return false; + } + + if (!entrance.click(ENTER_DEATHS_DOMAIN_ACTION)) return false; + return Global.sleepUntil(Rs2Death::isInDeathsOffice, ENTER_TIMEOUT_MS); + } + + /** + * Opens the item retrieval interface. + *

+ * Entering Death's Domain auto-walks the player to Death and starts the conversation — verified in + * game — so this does not click the NPC. It advances the dialogue instead: click through Death's + * lines, then choose "Yes, have you got anything for me?". The option is matched on text, not + * on its list position, because the menu carries a "More options..." entry and can reorder. + */ + public static boolean openDeathsOffice() { + if (isDeathsOfficeOpen()) return true; + + if (!isInDeathsOffice()) { + log.warn("Cannot open Death's Office: not inside Death's Domain — call enterDeathsOffice first"); + return false; + } + + return Global.sleepUntil(Rs2Death::isDeathsOfficeOpen, Rs2Death::advanceReclaimDialogue, + INTERFACE_TIMEOUT_MS, 600); + } + + /** + * One step of Death's reclaim conversation: clear a "click to continue" line, or pick the reclaim + * option when the choices are up. Called on a poll until the retrieval interface opens. + */ + private static void advanceReclaimDialogue() { + if (Rs2Dialogue.hasContinue()) { + Rs2Dialogue.clickContinue(); + } else if (Rs2Dialogue.hasSelectAnOption()) { + Rs2Dialogue.clickOption(DEATH_RECLAIM_OPTION); + } + } + + /** + * Estimate of what a {@link #reclaimAll()} would cost, read from the open Death's Office interface. + * Requires the interface to already be open — the office cannot be inspected from afar — but the + * journey itself is free, so estimating on arrival and walking away still costs nothing. + *

+ * Charges 5% on every item whose unit price is 100,000 or more, and nothing on the rest. + * Confirmed in game: an office holding 862 coal (146 each), 875 iron ore, and 142 steel bars — about + * 307,000 in total but nothing worth 100k each — reclaimed for zero. That rules out a + * cumulative charge, and rules out testing the stack's total value; the threshold is strictly + * per unit. + *

+ * This is the same threshold a grave uses — also confirmed, 740 noted coal worth 111,000 in + * total reported "Fee: None". The two schedules differ only in the fee: a grave charges flat coin + * amounts per tier capped at 500,000, the office charges an uncapped 5%. + *

+ * Still an estimate, not the fee. It ignores the ironman half rate (so it reads high for them), + * and it works from RuneLite's wiki prices, which are periodically refreshed rather than tick-live + * and drift while you play — an 18× Earth rune stack was quoted at 90 gp and then 108 gp inside one + * session. Only the "charged" side of the rule is unverified: the free case is proven, but that + * items over 100k are billed at exactly 5% is inferred from the wiki, not observed. Leave headroom + * rather than comparing to a limit exactly. + * + * @return the estimated fee in coins, or {@code 0} when the interface is closed or nothing is + * chargeable. + */ + public static int estimateReclaimFee() { + Widget container = Rs2Widget.getWidget(InterfaceID.DeathOffice.ITEMS); + if (container == null) return 0; + + // Snapshot ids and quantities on the client thread, then price them off it. Widget item reads + // are client-thread only, while the price lookup is a plain cache hit that does not need to + // occupy the game loop. + // Kept per slot on purpose. Merging identical ids across slots first would let two separately + // free 60k stacks combine into one chargeable 120k entry. + List contents = Microbot.getClientThread().runOnClientThreadOptional(() -> { + List snapshot = new ArrayList<>(); + Widget[] slots = container.getDynamicChildren(); + if (slots == null) return snapshot; + + for (Widget slot : slots) { + int itemId = slot.getItemId(); + if (itemId <= 0) continue; + snapshot.add(new int[]{itemId, Math.max(1, slot.getItemQuantity())}); + } + return snapshot; + }).orElseGet(ArrayList::new); + + long chargeable = 0; + for (int[] slot : contents) { + // Force the wiki price rather than getItemPrice(), which follows the player's + // "useWikiItemPrices" RuneLite setting. Death values items at market rate, and the wiki + // feed is the one that tracks it; the alternative is the once-a-day Jagex guide price. + int unitPrice = Microbot.getItemManager().getItemPriceWithSource(slot[0], true); + if (unitPrice <= 0) continue; + + // Per-unit threshold, confirmed in game: an office holding 862 coal (146 each), 875 iron ore, + // and 142 steel bars — ~307k in total, but nothing worth 100k each — reclaimed for zero. + // Only items whose single-unit price clears 100k are charged. This is the grave's rule too; + // the office differs only in charging 5% rather than the grave's flat tiers. + if (unitPrice < DEATHS_OFFICE_FREE_THRESHOLD) continue; + + chargeable += (long) unitPrice * slot[1]; + } + + long estimate = chargeable * DEATHS_OFFICE_FEE_PERCENT / 100L; + log.debug("Death's Office holds {} gp of chargeable items — estimated fee {} gp", chargeable, estimate); + return (int) Math.min(estimate, Integer.MAX_VALUE); + } + + /** + * Reclaims everything Death is holding, into the inventory. Death's Office keeps items + * indefinitely, so a partial reclaim caused by a full inventory is safe to resume later. + *

+ * There is deliberately no spending limit, because one is not possible. The fee is never on + * screen before it is charged — verified live, {@code INFO} reads "Select an item to retrieve." + * whether the office is empty or holding items, the {@code 1}/{@code 5}/{@code X}/{@code All} + * buttons stay hidden until an item is selected, and {@code Take-All} never selects. Any cap here + * would be fiction. + *

+ * Calling this authorises an unbounded charge against Death's Coffer, and the bank after that. + * Death's Office holds items indefinitely, so declining to call it is always a safe alternative. + * + * @return {@code true} once the retrieval interface has closed with nothing left to collect. + */ + /** + * Reclaims everything, but only if {@link #estimateReclaimFee()} comes in at or under the ceiling. + *

+ * The guard is an estimate, not the fee — the office never publishes the real number before + * charging it, and wiki prices drift (an 18× Earth rune stack moved from 90 gp to 108 gp inside a + * single session). The estimate is biased high, so it should sit at or above the true charge, but + * treat the ceiling as a guard rail rather than a guarantee and leave headroom. + * + * @param maxEstimatedFee the highest estimated fee to accept, in coins. + * @return {@code false} when the estimate is over the ceiling and nothing was reclaimed. + */ + public static boolean reclaimAll(int maxEstimatedFee) { + if (!isDeathsOfficeOpen()) return false; + + int estimate = estimateReclaimFee(); + if (estimate > maxEstimatedFee) { + log.info("Estimated Death's Office fee {} is over the {} ceiling — leaving the items with " + + "Death, where they keep indefinitely", estimate, maxEstimatedFee); + return false; + } + return reclaimAll(); + } + + public static boolean reclaimAll() { + if (!isDeathsOfficeOpen()) return false; + + @Component int takeAll = Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ITEMS_CONTAINER) + ? InterfaceID.DeathOffice.TAKEALL + : InterfaceID.GravestoneRetrieval.BUTTON; + + clickAndSettle(takeAll); + Global.sleepUntil(() -> !isDeathsOfficeOpen() || Rs2Inventory.isFull(), LOOT_TIMEOUT_MS); + + if (isDeathsOfficeOpen()) { + log.warn("Death's Office still holds items, most likely because the inventory filled up"); + return false; + } + return true; + } + + // endregion + + // region orchestration + + /** + * @return {@code true} when there is a death worth acting on — either a grave still standing or + * items waiting at Death's Office. + */ + public static boolean hasDeathToHandle() { + return hasGrave() || hasGraveExpired(); + } + + /** + * Recovers from the grave, paying whatever the grave asks. Death's Office is left alone — see + * {@link #recoverItems(int, boolean)}. + */ + public static boolean recoverItems() { + return recoverItems(Integer.MAX_VALUE, false); + } + + /** + * Recovers from the grave only. Anything that already expired to Death's Office stays there. + * + * @param budget the highest grave fee to pay. {@code 0} takes only the free items; + * {@link Integer#MAX_VALUE} pays whatever is asked. + */ + public static boolean recoverItems(int budget) { + return recoverItems(budget, false); + } + + /** + * Walks to the grave and empties it, and optionally falls back to Death's Office once the grave has + * expired. Everything lands in the inventory; banking and re-gearing afterwards is left to the + * caller. + *

+ * Safe to call when nothing has happened — it returns {@code true} immediately. + * + * @param budget the highest grave fee to pay. A grave publishes its fee in {@code FEE}, so the + * limit is real there. It does not apply to Death's Office, which never shows a cost + * before charging it. + * @param includeDeathsOffice whether to make the trip to Death's Office when the grave has already + * expired. Defaults to off in the other overloads, and that default is deliberate: the + * office charges an uncapped 5% that cannot be checked beforehand, and it holds items + * indefinitely, so leaving them is always safe and always reversible by hand. + * @return {@code true} when the death was dealt with — including the deliberate choices to leave the + * paid half of a grave behind, or to leave the office untouched. + */ + public static boolean recoverItems(int budget, boolean includeDeathsOffice) { + if (!hasDeathToHandle()) { + log.debug("No death to handle"); + return true; + } + + if (!hasGrave() && !includeDeathsOffice) { + // Clear the record so the caller stops seeing a death it has chosen not to act on; the items + // keep at Death's Office indefinitely and can be collected by hand whenever. + log.info("Grave has expired and Death's Office recovery was not requested — leaving the " + + "items with Death"); + clearDeathState(); + return true; + } + + boolean collected = hasGrave() + ? collectFromGrave(budget) + : collectFromDeathsOffice(); + + if (!collected) { + log.warn("Could not collect after death"); + return false; + } + + clearDeathState(); + return true; + } + + /** + * Collects the grave. A refused paid half is not a failure: those items keep in Death's Office and + * the script is expected to carry on, which is the whole point of passing a budget. + */ + private static boolean collectFromGrave(int budget) { + if (!walkToGrave()) return false; + if (!openGrave()) return false; + if (!lootGraveFreeItems()) return false; + + lootGravePaidItems(budget); + + closeInterfaces(); + return true; + } + + /** + * Only reached when the caller explicitly opted in, because this spends an amount that cannot be + * known in advance. + */ + private static boolean collectFromDeathsOffice() { + if (!walkToDeathsOffice()) return false; + if (!enterDeathsOffice()) return false; + if (!openDeathsOffice()) return false; + + reclaimAll(); + + closeInterfaces(); + return true; + } + + /** + * Closes whichever retrieval interface is still up. A refused paid half or a fee over budget leaves + * it open, and the grave timer stays paused while it is, so it must not be left hanging. + *

+ * Public so a script that opened the office purely to call {@link #estimateReclaimFee()} can decline + * and walk away cleanly. + */ + public static void closeInterfaces() { + if (isGraveOpen()) { + Rs2Widget.clickWidget(InterfaceID.GravestoneGeneric.CLOSE); + Global.sleepUntil(() -> !isGraveOpen(), INTERFACE_TIMEOUT_MS); + } + + if (isDeathsOfficeOpen()) { + // DeathOffice exposes no CLOSE component in gameval, but the frame carries a dynamic child + // with a "Close" action — verified live at 669.1 index 11. Match on the action rather than + // that index, which is a layout detail that can shift between updates. + Rs2Widget.findWidgetsWithAction("Close", InterfaceID.DEATH_OFFICE, true); + if (Global.sleepUntil(() -> !isDeathsOfficeOpen(), INTERFACE_TIMEOUT_MS)) return; + + // Escape only works when the player has the setting enabled, so it is the fallback. + if (Rs2Settings.isEscCloseInterfaceSettingEnabled()) { + Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); + Global.sleepUntil(() -> !isDeathsOfficeOpen(), INTERFACE_TIMEOUT_MS); + } else { + log.warn("Could not close the Death's Office interface: no Close action hit and " + + "esc-close is disabled in game settings"); + } + } + } + + // endregion + + private static int parseFee(Widget widget) { + if (widget == null) return 0; + String text = Microbot.getClientThread().runOnClientThreadOptional(widget::getText).orElse(null); + return text == null ? 0 : parseFeeText(text); + } + + private static int parseFeeText(String text) { + Matcher matcher = DIGITS.matcher(text); + if (!matcher.find()) return 0; + try { + return Integer.parseInt(matcher.group().replace(",", "")); + } catch (NumberFormatException e) { + log.warn("Could not parse fee from '{}'", text); + return 0; + } + } + + private static void clickAndSettle(@Component int componentId) { + Rs2Widget.clickWidget(componentId); + Global.sleepUntilNextTick(); + } +} 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 b06d5fcac2a..a3768ffeb3f 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 @@ -146,6 +146,11 @@ net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint, boolean): List -> net.runelite.api.Tile#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint, boolean): List -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#toLocalInstance(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.Player#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.events.ActorDeath#getActor(): Actor +net.runelite.client.plugins.microbot.util.death.Rs2Death#onVarbitChanged(VarbitChanged): void -> net.runelite.api.events.VarbitChanged#getValue(): int +net.runelite.client.plugins.microbot.util.death.Rs2Death#onVarbitChanged(VarbitChanged): void -> net.runelite.api.events.VarbitChanged#getVarbitId(): int net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#getDepositBoxBounds(): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#getItems(): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#itemBounds(Rs2ItemModel): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle From 2f0488b05a0ce4a9a58241556cfb6b9f4d641e0b Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 00:22:45 +0100 Subject: [PATCH 02/53] docs(death): confirm fee tables and warn the office estimate can read low MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the recovery rules against the OSRS wiki's own tables. Both schedules are confirmed as implemented: a grave charges flat coin amounts per item by tier (1k / 10k / 100k for 100k-1m / 1m-10m / 10m+) capped at 500k, and Death's Office charges a flat 5% on items worth 100k or more, uncapped; ironmen get 50% off both. The wiki says "each reclaimed item", matching the per-unit behaviour observed in game (862 coal at 146 each reclaimed free from both grave and office). It also documents exceptions "to which the above rules do not neatly apply" — notably that stacks of amulet of glory (6) worth over 100,000 are charged 10% at Death's Office, double the rate and assessed on the stack's value rather than per unit. estimateReclaimFee applies the per-unit rule, so it predicts free for such a stack and reads LOW — the one direction a ceiling must not fail, since reclaimAll(maxEstimatedFee) spends real gold against it. Deliberately not special-cased: hardcoding glory would imply the exception list is complete, and the wiki states it is not. Instead estimateReclaimFee and reclaimAll(int) now state plainly that the estimate can read low, name glory as the known case, and tell callers to leave real headroom rather than treat the ceiling as a guarantee. Also removes a stale "biased high" claim and a duplicated javadoc block. Still unobserved in game: an actual non-zero charge. Only the free-below-100k case has been watched happen. Co-Authored-By: Claude Opus 4.8 --- docs/entity-guides/death.md | 12 +++++-- .../plugins/microbot/util/death/Rs2Death.java | 31 +++++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/entity-guides/death.md b/docs/entity-guides/death.md index d3896d279d4..116a9e1ca93 100644 --- a/docs/entity-guides/death.md +++ b/docs/entity-guides/death.md @@ -191,8 +191,16 @@ So the two schedules share the **same 100k per-unit threshold**; they differ onl stackable item under 100k each is free from both, regardless of stack size. An earlier note here claimed the office charges on cumulative value — that was wrong, and the test above disproves it. -One half is still unobserved: that an item **over** 100k is billed at exactly 5% (office) or the flat tier -(grave) is taken from the wiki, not yet seen in game. +One half is still unobserved in game: that an item **over** 100k is billed at exactly 5% (office) or the +flat tier (grave). The rates below are confirmed against the wiki's own tables, but a non-zero charge has +never been watched happen here. + +**Documented exceptions exist, and they break the per-unit rule.** The wiki lists items "to which the +above rules do not neatly apply" — notably *stacks of amulet of glory (6) worth over 100,000 are charged +**10%** at Death's Office*: double the normal rate, and assessed on the **stack's** value rather than per +unit. Such an item is charged where the per-unit rule predicts free, so `estimateReclaimFee()` reads +**low** for it. The wiki's list is explicitly non-exhaustive, so treat the estimate as a guide rather than +a bound whenever the office holds anything unusual. For reference, the tiers the interface already applies for you: diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index e812a1821ab..2873ff6a4ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -571,12 +571,23 @@ private static void advanceReclaimDialogue() { * total reported "Fee: None". The two schedules differ only in the fee: a grave charges flat coin * amounts per tier capped at 500,000, the office charges an uncapped 5%. *

- * Still an estimate, not the fee. It ignores the ironman half rate (so it reads high for them), - * and it works from RuneLite's wiki prices, which are periodically refreshed rather than tick-live - * and drift while you play — an 18× Earth rune stack was quoted at 90 gp and then 108 gp inside one - * session. Only the "charged" side of the rule is unverified: the free case is proven, but that - * items over 100k are billed at exactly 5% is inferred from the wiki, not observed. Leave headroom - * rather than comparing to a limit exactly. + * Still an estimate, not the fee — and it can read low. Sources of error, worst first: + *

+ * Leave real headroom rather than comparing to a limit exactly. * * @return the estimated fee in coins, or {@code 0} when the interface is closed or nothing is * chargeable. @@ -644,9 +655,11 @@ public static int estimateReclaimFee() { * Reclaims everything, but only if {@link #estimateReclaimFee()} comes in at or under the ceiling. *

* The guard is an estimate, not the fee — the office never publishes the real number before - * charging it, and wiki prices drift (an 18× Earth rune stack moved from 90 gp to 108 gp inside a - * single session). The estimate is biased high, so it should sit at or above the true charge, but - * treat the ceiling as a guard rail rather than a guarantee and leave headroom. + * charging it. It is usually conservative (it ignores ironman and boss discounts), but it can + * read low: wiki prices drift, and documented exceptions such as an amulet-of-glory stack are + * charged 10% on the stack's value rather than 5% per unit, so they are billed where the estimate + * predicts free. See {@link #estimateReclaimFee()} for the full list. Treat the ceiling as a guard + * rail, not a guarantee, and leave headroom — a reclaim can cost more than the number checked here. * * @param maxEstimatedFee the highest estimated fee to accept, in coins. * @return {@code false} when the estimate is over the ceiling and nothing was reclaimed. From 1b3e9a60a27a50c4d0379a0b11c69cecebdcdcb9 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 00:34:10 +0100 Subject: [PATCH 03/53] refactor(death): drop the Death's Office fee estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes estimateReclaimFee() and reclaimAll(int) — about 80 lines, over half of it caveats, with no callers outside the class. The office never publishes its fee before charging, so any estimate is a guess, and this one guessed LOW on documented exceptions: a stack of amulet of glory (6) over 100,000 is charged 10% on the stack rather than 5% per unit, so it is billed where the per-unit rule predicts free. A ceiling that can be quietly exceeded is worse than no ceiling, because reclaimAll(maxEstimatedFee) spent real gold against it. Special- casing glory was rejected: the wiki states the exception list is not exhaustive, so hardcoding one entry would imply a completeness that cannot be verified. What remains covers the same ground honestly: getPredictedGraveFee() reads the figure the game itself computed on the Items Kept on Death panel, reclaimAll() is unbounded and says so, and walk/enter/open plus closeInterfaces() let a script inspect the office and decline without paying — the trip is free, only the reclaim costs. A script that insists on its own cap can price the contents itself and owns that assumption. The fee schedules move from dead constants into the class javadoc as reference, since nothing computes them any more. Also corrects DeathsOfficeLocation's provenance note using the wiki's map data: every x matches exactly and every y sits a constant two tiles south of the wiki figure (four at Lumbridge, the one entry verified in-game against the real object). A uniform offset on the entry with known ground truth indicates the wiki centres its map north of the object, so these coordinates are the better estimate. Immaterial either way — enterDeathsOffice() resolves the entrance by id, never by coordinate. Co-Authored-By: Claude Opus 4.8 --- docs/entity-guides/death.md | 46 +++--- .../util/death/DeathsOfficeLocation.java | 16 +- .../plugins/microbot/util/death/Rs2Death.java | 145 ++---------------- 3 files changed, 44 insertions(+), 163 deletions(-) diff --git a/docs/entity-guides/death.md b/docs/entity-guides/death.md index 116a9e1ca93..165e68a9d67 100644 --- a/docs/entity-guides/death.md +++ b/docs/entity-guides/death.md @@ -19,8 +19,8 @@ Rs2Death.recoverItems(config.deathBudget(), config.useDeathsOffice()); // or price the office yourself before committing — the trip is free, only the reclaim costs if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) { - Rs2Death.reclaimAll(config.maxOfficeFee()); // ceiling checked against estimateReclaimFee() - Rs2Death.closeInterfaces(); // reclaimAll() with no argument = no ceiling + Rs2Death.reclaimAll(); // no cap is possible — see rule 11 + Rs2Death.closeInterfaces(); // or inspect first and close to decline without paying } ``` @@ -63,8 +63,8 @@ tab) publishes what the game has already calculated. Read it instead of computin `getPredictedGraveFee()` and `getRiskValue()` read these directly, so they are **authoritative** — the per-unit valuation, ironman rate, and any discounted-death allowance are already applied. That beats any -GE-price arithmetic, which is why `estimateReclaimFee()` exists only for Death's Office, where the game -publishes nothing. +GE-price arithmetic. Death's Office publishes nothing equivalent, and the API deliberately does not +estimate one — see rule 11. Two things to watch: @@ -198,9 +198,8 @@ never been watched happen here. **Documented exceptions exist, and they break the per-unit rule.** The wiki lists items "to which the above rules do not neatly apply" — notably *stacks of amulet of glory (6) worth over 100,000 are charged **10%** at Death's Office*: double the normal rate, and assessed on the **stack's** value rather than per -unit. Such an item is charged where the per-unit rule predicts free, so `estimateReclaimFee()` reads -**low** for it. The wiki's list is explicitly non-exhaustive, so treat the estimate as a guide rather than -a bound whenever the office holds anything unusual. +unit. Such an item is charged where the per-unit rule predicts free. The wiki's list is explicitly +non-exhaustive, which is the main reason this API does not try to predict an office fee at all. For reference, the tiers the interface already applies for you: @@ -340,30 +339,21 @@ carries over. **Contrast:** at a grave the worst case is the 500k cap. At the office it is an uncapped 5% of value, so a 10M-gear death costs 500k there with nothing to stop it. -`estimateReclaimFee()` exists for scripts that want a ceiling. It reads the open interface and charges 5% -on each item whose **unit** price is 100,000 or more, and nothing on the rest — the office's actual rule, -confirmed by the free ~307k reclaim above. So a resource-stack death now estimates near zero, matching the -game, instead of the wild overestimate the old cumulative version produced. +**There is deliberately no fee estimator.** An earlier version priced the office contents from GE data +and offered `reclaimAll(maxEstimatedFee)` as a ceiling. It was removed: the office never publishes the fee +before charging, so any such number is a guess, and it guessed **low** on documented exceptions (a glory +stack is charged 10% on the stack, not 5% per unit — see rule 7). A ceiling that can be exceeded is worse +than no ceiling, because callers trust it. -Prices come from `getItemPriceWithSource(id, true)`, **not** `getItemPrice(id)`. The latter follows the -player's "Use wiki item prices" RuneLite setting, so with that toggle off it silently returns the -once-a-day Jagex guide price instead of the wiki feed that tracks the market. No separate HTTP client is -needed — RuneLite already maintains this data. +What to use instead: -It is still an estimate, for two reasons: it ignores the ironman half rate (reads high for them), and the -5% rate on items **over** 100k is taken from the wiki, not yet observed in game — only the free case below -100k is proven. Three more limits to respect: +- `getPredictedGraveFee()` for a real number — the game computes it, the API just reads it (rule 0). +- `reclaimAll()` when the script accepts whatever it costs. +- Walk in, inspect what Death is holding, and `closeInterfaces()` to decline. The trip is free; only the + reclaim costs. A script that insists on its own cap can price the contents itself and owns that + assumption. -1. The interface must already be open, so you cannot price the office before travelling. The trip is - free, so estimate on arrival and walk away if it is too dear. -2. Wiki prices are periodically refreshed, not tick-live, and drift while you play — an 18x Earth rune - stack was quoted at 90 gp and then 108 gp within one session, a 20% move on a trivial item. -3. The feed need not match the game's own valuation. - -So `reclaimAll(maxEstimatedFee)` is a guard rail, not a guarantee. Leave headroom, and use the -no-argument `reclaimAll()` when the script genuinely does not care. - -**Where this applies:** `Rs2Death.reclaimAll`, `Rs2Death.recoverItems`, `Rs2Death.estimateReclaimFee`. +**Where this applies:** `Rs2Death.reclaimAll`, `Rs2Death.recoverItems`. ## 12. Death's Office needs the entrance object, then a dialogue — not an NPC click diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java index 28790e42b69..dd90c64dcb8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java @@ -16,14 +16,16 @@ * ({@link net.runelite.api.gameval.ObjectID1#DEATH_OFFICE_ACCESS_GRAVE}, id 38426) with the action * {@code Enter Death's Domain} — see {@link Rs2Death#enterDeathsOffice()}. *

- * {@link #LUMBRIDGE} is verified in-game. The other seven come from the wiki's map pins, which the - * Lumbridge entry calibrates as accurate to about two tiles — the pin there reads (3238, 3194) against - * an actual object at (3238, 3192). + * {@link #LUMBRIDGE} is verified in-game against the actual object. The other seven come from the wiki's + * map data, cross-checked against it: every x matches the wiki exactly, and every y sits a constant two + * tiles south of the wiki's figure (four at Lumbridge). A uniform offset across all eight, on the one + * entry with a known ground truth, says the wiki centres its map slightly north of the object rather than + * on it — so these values are the better estimate of the object tile, not a worse one. *

- * That margin does not matter in practice: {@link Rs2Death#walkToDeathsOffice()} only has to get close - * enough for the entrance object to load into the scene, and - * {@link Rs2Death#enterDeathsOffice()} then finds it by id rather than by coordinate. The - * {@code landmark} field records what each point is meant to sit beside. + * Either way the margin is irrelevant: {@link Rs2Death#walkToDeathsOffice()} only has to get close enough + * for the entrance object to load into the scene, and {@link Rs2Death#enterDeathsOffice()} then finds it + * by id, never by coordinate. A few tiles of drift costs nothing. The {@code landmark} field + * records what each point is meant to sit beside. */ @Getter @RequiredArgsConstructor diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index 2873ff6a4ef..33608652d91 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -50,23 +50,31 @@ *

  * Rs2Death.recoverItems(config.deathBudget(), true);
  * 
- * A script that wants a ceiling there can price the office on arrival — the trip costs nothing, only the - * reclaim does: + * The office charges an uncapped fee that it never shows before charging it, so there is deliberately no + * spending cap here — one would be fiction. A script that wants to decide for itself can walk there, + * inspect the contents, and back out without paying; only the reclaim costs anything: *
  * if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) {
- *     Rs2Death.reclaimAll(config.maxOfficeFee());   // or reclaimAll() for no ceiling
+ *     Rs2Death.reclaimAll();          // or inspect first and call closeInterfaces() to decline
  *     Rs2Death.closeInterfaces();
  * }
  * 
- * The ceiling is checked against {@link #estimateReclaimFee()}, since the office never shows the real - * fee before charging it. + * Use {@link #getPredictedGraveFee()} when you want a real number: it reads the figure the game itself + * computed on the Items Kept on Death panel, rather than estimating one. *

* Or drive the steps directly — {@link #walkToGrave()}, {@link #openGrave()}, * {@link #getGraveFee()}, {@link #lootGraveFreeItems()}, {@link #lootGravePaidItems(int)} — when the * script wants its own logic between them. *

- * Items left behind are not destroyed; they keep in Death's Office and can be reclaimed later at 5% of - * value (2.5% for ironmen). + * Items left behind are not destroyed; they keep in Death's Office indefinitely. + *

+ * Fee schedules, for reference — this class never computes them, it reads what the game reports: + * a grave charges flat coin amounts per item by tier (1,000 / 10,000 / 100,000 for 100k–1m / + * 1m–10m / 10m+), total capped at 500,000; Death's Office charges an uncapped 5%. Both test the + * item's unit price against 100,000, so a large stack of cheap items is free from either — + * confirmed in game with 862 coal at 146 each. Ironmen pay half. Documented exceptions exist and do not + * follow the unit-price rule (a stack of amulet of glory (6) over 100,000 is charged 10% at the office), + * which is why nothing here estimates a fee. *

* The first-death Death's Domain tutorial is not handled here — that stays with * {@link net.runelite.client.plugins.microbot.util.events.DeathEvent}, which normally only fires once @@ -100,20 +108,6 @@ public class Rs2Death { /** {@code GRAVESTONE_DURATION} is measured in game ticks, so convert before reporting a Duration. */ private static final long GAME_TICK_MS = 600L; - /** - * Death's Office charges 5% of each qualifying item's value, halved for ironmen. Estimates use the - * full rate. - *

- * Same 100k unit-price threshold as a grave (confirmed in game), but a different fee: a grave - * charges flat coin amounts by tier (1,000 / 10,000 / 100,000 for 100k–1m / 1m–10m / 10m+) capped at - * 500,000, whereas the office charges an uncapped 5%. Do not reuse one fee for the other. - */ - private static final long DEATHS_OFFICE_FEE_PERCENT = 5L; - - /** Items whose unit price is below this reclaim free — confirmed in game, grave and office. */ - private static final long DEATHS_OFFICE_FREE_THRESHOLD = 100_000L; - - /** Graves are lootable from up to 7 tiles with line of sight. */ private static final int GRAVE_INTERACT_DISTANCE = 7; @@ -556,86 +550,6 @@ private static void advanceReclaimDialogue() { } } - /** - * Estimate of what a {@link #reclaimAll()} would cost, read from the open Death's Office interface. - * Requires the interface to already be open — the office cannot be inspected from afar — but the - * journey itself is free, so estimating on arrival and walking away still costs nothing. - *

- * Charges 5% on every item whose unit price is 100,000 or more, and nothing on the rest. - * Confirmed in game: an office holding 862 coal (146 each), 875 iron ore, and 142 steel bars — about - * 307,000 in total but nothing worth 100k each — reclaimed for zero. That rules out a - * cumulative charge, and rules out testing the stack's total value; the threshold is strictly - * per unit. - *

- * This is the same threshold a grave uses — also confirmed, 740 noted coal worth 111,000 in - * total reported "Fee: None". The two schedules differ only in the fee: a grave charges flat coin - * amounts per tier capped at 500,000, the office charges an uncapped 5%. - *

- * Still an estimate, not the fee — and it can read low. Sources of error, worst first: - *

- * Leave real headroom rather than comparing to a limit exactly. - * - * @return the estimated fee in coins, or {@code 0} when the interface is closed or nothing is - * chargeable. - */ - public static int estimateReclaimFee() { - Widget container = Rs2Widget.getWidget(InterfaceID.DeathOffice.ITEMS); - if (container == null) return 0; - - // Snapshot ids and quantities on the client thread, then price them off it. Widget item reads - // are client-thread only, while the price lookup is a plain cache hit that does not need to - // occupy the game loop. - // Kept per slot on purpose. Merging identical ids across slots first would let two separately - // free 60k stacks combine into one chargeable 120k entry. - List contents = Microbot.getClientThread().runOnClientThreadOptional(() -> { - List snapshot = new ArrayList<>(); - Widget[] slots = container.getDynamicChildren(); - if (slots == null) return snapshot; - - for (Widget slot : slots) { - int itemId = slot.getItemId(); - if (itemId <= 0) continue; - snapshot.add(new int[]{itemId, Math.max(1, slot.getItemQuantity())}); - } - return snapshot; - }).orElseGet(ArrayList::new); - - long chargeable = 0; - for (int[] slot : contents) { - // Force the wiki price rather than getItemPrice(), which follows the player's - // "useWikiItemPrices" RuneLite setting. Death values items at market rate, and the wiki - // feed is the one that tracks it; the alternative is the once-a-day Jagex guide price. - int unitPrice = Microbot.getItemManager().getItemPriceWithSource(slot[0], true); - if (unitPrice <= 0) continue; - - // Per-unit threshold, confirmed in game: an office holding 862 coal (146 each), 875 iron ore, - // and 142 steel bars — ~307k in total, but nothing worth 100k each — reclaimed for zero. - // Only items whose single-unit price clears 100k are charged. This is the grave's rule too; - // the office differs only in charging 5% rather than the grave's flat tiers. - if (unitPrice < DEATHS_OFFICE_FREE_THRESHOLD) continue; - - chargeable += (long) unitPrice * slot[1]; - } - - long estimate = chargeable * DEATHS_OFFICE_FEE_PERCENT / 100L; - log.debug("Death's Office holds {} gp of chargeable items — estimated fee {} gp", chargeable, estimate); - return (int) Math.min(estimate, Integer.MAX_VALUE); - } - /** * Reclaims everything Death is holding, into the inventory. Death's Office keeps items * indefinitely, so a partial reclaim caused by a full inventory is safe to resume later. @@ -651,31 +565,6 @@ public static int estimateReclaimFee() { * * @return {@code true} once the retrieval interface has closed with nothing left to collect. */ - /** - * Reclaims everything, but only if {@link #estimateReclaimFee()} comes in at or under the ceiling. - *

- * The guard is an estimate, not the fee — the office never publishes the real number before - * charging it. It is usually conservative (it ignores ironman and boss discounts), but it can - * read low: wiki prices drift, and documented exceptions such as an amulet-of-glory stack are - * charged 10% on the stack's value rather than 5% per unit, so they are billed where the estimate - * predicts free. See {@link #estimateReclaimFee()} for the full list. Treat the ceiling as a guard - * rail, not a guarantee, and leave headroom — a reclaim can cost more than the number checked here. - * - * @param maxEstimatedFee the highest estimated fee to accept, in coins. - * @return {@code false} when the estimate is over the ceiling and nothing was reclaimed. - */ - public static boolean reclaimAll(int maxEstimatedFee) { - if (!isDeathsOfficeOpen()) return false; - - int estimate = estimateReclaimFee(); - if (estimate > maxEstimatedFee) { - log.info("Estimated Death's Office fee {} is over the {} ceiling — leaving the items with " - + "Death, where they keep indefinitely", estimate, maxEstimatedFee); - return false; - } - return reclaimAll(); - } - public static boolean reclaimAll() { if (!isDeathsOfficeOpen()) return false; @@ -802,8 +691,8 @@ private static boolean collectFromDeathsOffice() { * Closes whichever retrieval interface is still up. A refused paid half or a fee over budget leaves * it open, and the grave timer stays paused while it is, so it must not be left hanging. *

- * Public so a script that opened the office purely to call {@link #estimateReclaimFee()} can decline - * and walk away cleanly. + * Public so a script that opened the office only to inspect what Death is holding can decline and + * walk away cleanly without reclaiming. */ public static void closeInterfaces() { if (isGraveOpen()) { From 5971e548431306aa27bd4f02f4a63f2b28486cc8 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 00:57:01 +0100 Subject: [PATCH 04/53] feat(death): selective looting for graves and Death's Office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every looting path was Take-All: lootGraveFreeItems/lootGravePaidItems click the grave's section buttons and reclaimAll clicks the office's, so a script that only wanted its gear back had to take everything or nothing. The interfaces support picking individual slots; the API did not expose it. Adds: - getGraveFreeItems / getGravePaidItems / getDeathsOfficeItems — read what is waiting, with slot indices preserved (the slot is the param0 needed to click it). - lootGraveItems(Predicate) — takes matching slots from both halves via the per-slot Take action. - reclaimItems(Predicate) — takes matching slots from the office. The office selects first and only then reveals its quantity buttons, so each slot is a two-step click: Select, wait for ALL to become visible, then ALL. Slots are clicked highest-index first, because taking one re-packs the container and would otherwise invalidate the indices still to come. Both paths stop when the inventory fills rather than clicking into a full backpack. Also makes the Take-All paths report what they left behind: the office holds up to 120 stacks against 28 inventory slots, so a full reclaim can simply not fit. Nothing is lost there — Death keeps the remainder indefinitely — but a grave expires, so the grave warning includes the time left on the timer. Note the asymmetry, documented on the methods: the office charges per item reclaimed so taking less costs less, whereas a grave's fee covers its whole paid half at once. Co-Authored-By: Claude Opus 4.8 --- .../plugins/microbot/util/death/Rs2Death.java | 195 +++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index 33608652d91..8a05cfafebb 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -2,6 +2,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import net.runelite.api.MenuAction; import net.runelite.api.Player; import net.runelite.api.annotations.Component; import net.runelite.api.coords.WorldPoint; @@ -13,6 +14,7 @@ import net.runelite.api.gameval.VarbitID; import net.runelite.api.widgets.Widget; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; import net.runelite.client.plugins.microbot.util.Global; @@ -26,12 +28,14 @@ import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import java.awt.Rectangle; import java.awt.event.KeyEvent; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -66,6 +70,21 @@ * {@link #getGraveFee()}, {@link #lootGraveFreeItems()}, {@link #lootGravePaidItems(int)} — when the * script wants its own logic between them. *

+ * {@code recoverItems} and the {@code lootGrave*} / {@code reclaimAll} methods take everything. + * To take only some of it, inspect first and filter: + *

+ * Rs2Death.openGrave();
+ * Rs2Death.lootGraveItems(i -> i.getName().contains("rune"));   // leaves the rest
+ *
+ * Rs2Death.openDeathsOffice();
+ * Rs2Death.reclaimItems(i -> i.getId() == ItemID.DRAGON_SCIMITAR);
+ * 
+ * {@link #getGraveFreeItems()}, {@link #getGravePaidItems()} and {@link #getDeathsOfficeItems()} show + * what is waiting. Note the asymmetry: the office charges per item reclaimed, so taking less costs less, + * whereas a grave's fee covers its whole paid half at once. And anything left in a grave is only + * safe until the timer expires — it then moves to Death's Office at the higher fee — while anything left + * with Death keeps indefinitely. + *

* Items left behind are not destroyed; they keep in Death's Office indefinitely. *

* Fee schedules, for reference — this class never computes them, it reads what the game reports: @@ -93,6 +112,12 @@ public class Rs2Death { private static final String GRAVE_LOOT_ACTION = "Loot"; + /** Per-slot action on a grave item, for selective looting. */ + private static final String GRAVE_TAKE_ACTION = "Take"; + + /** Per-slot action in Death's Office — verified live; the office selects first, then takes. */ + private static final String DEATH_OFFICE_SELECT_ACTION = "Select"; + /** Death's reclaim dialogue choice, verified in game. Matched as a substring, so it tolerates * reordering and the trailing punctuation ("Yes, have you got anything for me?"). */ private static final String DEATH_RECLAIM_OPTION = "have you got anything for me"; @@ -314,6 +339,15 @@ public static int getRiskValue() { * caption as a plain child, so entries without an item id are skipped. */ private static List readDeathkeepItems(@Component int componentId) { + return readItemContainer(componentId); + } + + /** + * Reads the item slots out of any of the death interfaces' item containers, in slot order. The + * slot index is preserved on each {@link Rs2ItemModel}, because it is the {@code param0} needed to + * click that specific slot. + */ + private static List readItemContainer(@Component int componentId) { Widget container = Rs2Widget.getWidget(componentId); if (container == null) return Collections.emptyList(); @@ -425,12 +459,102 @@ public static int getGraveFee() { /** * Claims the half of the grave that costs nothing. Items behind a fee are untouched and stay put. */ + /** + * The items in the grave's free half — everything that costs nothing to reclaim. Requires the grave + * interface to be open ({@link #openGrave()}). + */ + public static List getGraveFreeItems() { + return readItemContainer(InterfaceID.GravestoneGeneric.FREEITEMS); + } + + /** + * The items in the grave's paid half — those behind the retrieval fee. Requires the grave interface + * to be open ({@link #openGrave()}). + */ + public static List getGravePaidItems() { + return readItemContainer(InterfaceID.GravestoneGeneric.PAYITEMS); + } + + /** + * Takes everything in the free half. Use {@link #lootGraveItems(Predicate)} to take only some + * of it. + */ public static boolean lootGraveFreeItems() { if (!isGraveOpen()) return false; clickAndSettle(InterfaceID.GravestoneGeneric.FREEBUTTON); return true; } + /** + * Takes only the grave items matching {@code filter}, one slot at a time, from both the free and the + * paid half. Anything not matched is left in the grave — and a grave is consumed once emptied, so + * whatever is left behind ends up at Death's Office rather than staying put. + *

+ * Slots are clicked highest-index first: taking an item re-packs the container, so descending order + * keeps the remaining slot indices valid. + *

+ * Paying is still all-or-nothing at the game's level — the fee covers the whole paid half — so a + * filter that matches anything in the paid half incurs the full fee. Check {@link #getGraveFee()} + * first if that matters. + * + * @param filter chooses which items to take. + * @return the number of slots successfully clicked. + */ + public static int lootGraveItems(Predicate filter) { + if (!isGraveOpen()) return 0; + + int taken = takeMatchingSlots(InterfaceID.GravestoneGeneric.FREEITEMS, filter, GRAVE_TAKE_ACTION); + taken += takeMatchingSlots(InterfaceID.GravestoneGeneric.PAYITEMS, filter, GRAVE_TAKE_ACTION); + return taken; + } + + /** + * Clicks each slot in {@code containerId} whose item matches {@code filter}, in descending slot + * order so earlier clicks cannot invalidate later indices. + */ + private static int takeMatchingSlots(@Component int containerId, Predicate filter, + String action) { + List items = readItemContainer(containerId); + int taken = 0; + for (int i = items.size() - 1; i >= 0; i--) { + Rs2ItemModel item = items.get(i); + if (filter != null && !filter.test(item)) continue; + if (Rs2Inventory.isFull()) { + log.warn("Inventory full after taking {} item(s) — {} left in the interface", + taken, i + 1); + break; + } + clickItemSlot(containerId, item, action); + taken++; + } + return taken; + } + + /** + * Clicks one item slot in a death interface. {@code param0} is the slot index and {@code param1} the + * container component, matching how {@code Rs2Bank} drives bank slots. + */ + private static void clickItemSlot(@Component int containerId, Rs2ItemModel item, String action) { + Rectangle bounds = Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget container = Rs2Widget.getWidget(containerId); + if (container == null) return null; + Widget[] children = container.getDynamicChildren(); + if (children == null || item.getSlot() >= children.length) return null; + return children[item.getSlot()].getBounds(); + }).orElse(null); + + Microbot.doInvoke(new NewMenuEntry() + .param0(item.getSlot()) + .param1(containerId) + .opcode(MenuAction.CC_OP.getId()) + .identifier(1) + .itemId(item.getId()) + .option(action) + .target(item.getName()), + bounds == null ? new Rectangle(1, 1) : bounds); + Global.sleepUntilNextTick(); + } + /** * Claims the items behind the retrieval fee, when the account can afford it and the fee fits the * budget. Everything lands in the inventory — this interface has no send-to-bank option. @@ -464,7 +588,15 @@ public static boolean lootGravePaidItems(int budget) { // The varbit is the authoritative signal: the interface can linger open after the last item is // claimed, so closing is not proof the grave was emptied. - return Global.sleepUntil(() -> !hasGrave(), LOOT_TIMEOUT_MS); + boolean emptied = Global.sleepUntil(() -> !hasGrave(), LOOT_TIMEOUT_MS); + if (!emptied && Rs2Inventory.isFull()) { + // Unlike Death's Office, a grave expires — anything still in it when the timer runs out + // moves on and costs the (usually higher) office fee to get back. + log.warn("Grave not emptied and the inventory is full — {} free item(s) and {} paid item(s) " + + "remain, with {} left on the grave timer", + getGraveFreeItems().size(), getGravePaidItems().size(), getGraveTimeRemaining()); + } + return emptied; } // endregion @@ -565,6 +697,60 @@ private static void advanceReclaimDialogue() { * * @return {@code true} once the retrieval interface has closed with nothing left to collect. */ + /** + * The items Death is currently holding. Requires the retrieval interface to be open + * ({@link #openDeathsOffice()}) — the office cannot be inspected from afar, though walking there and + * declining costs nothing. + */ + public static List getDeathsOfficeItems() { + return readItemContainer(InterfaceID.DeathOffice.ITEMS); + } + + /** + * Reclaims only the items matching {@code filter}, leaving the rest with Death — where they keep + * indefinitely, so anything skipped can be collected later. + *

+ * Each slot is taken in two steps, mirroring the interface: click the item ({@code Select}), then the + * {@code All} quantity button that appears. Slots are processed highest-index first so taking one + * cannot shift the indices of those still to come. + *

+ * The fee is charged per item reclaimed, so taking less costs less — unlike the grave, where paying + * covers the whole paid half at once. + * + * @param filter chooses which items to reclaim. + * @return the number of slots successfully taken. + */ + public static int reclaimItems(Predicate filter) { + if (!isDeathsOfficeOpen()) return 0; + + List items = getDeathsOfficeItems(); + int taken = 0; + for (int i = items.size() - 1; i >= 0; i--) { + Rs2ItemModel item = items.get(i); + if (filter != null && !filter.test(item)) continue; + if (Rs2Inventory.isFull()) { + log.warn("Inventory full after reclaiming {} item(s) — {} left with Death", taken, i + 1); + break; + } + + // Step 1: select the slot. Step 2: the quantity buttons only become visible once something + // is selected, so "All" is clicked after, not before. + clickItemSlot(InterfaceID.DeathOffice.ITEMS, item, DEATH_OFFICE_SELECT_ACTION); + if (!Global.sleepUntil(() -> Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ALL), + INTERFACE_TIMEOUT_MS)) { + log.warn("Quantity buttons did not appear after selecting {} — stopping", item.getName()); + break; + } + clickAndSettle(InterfaceID.DeathOffice.ALL); + taken++; + } + return taken; + } + + /** + * Reclaims everything Death is holding. Use {@link #reclaimItems(Predicate)} to take only + * some of it. + */ public static boolean reclaimAll() { if (!isDeathsOfficeOpen()) return false; @@ -576,7 +762,12 @@ public static boolean reclaimAll() { Global.sleepUntil(() -> !isDeathsOfficeOpen() || Rs2Inventory.isFull(), LOOT_TIMEOUT_MS); if (isDeathsOfficeOpen()) { - log.warn("Death's Office still holds items, most likely because the inventory filled up"); + // The office holds up to 120 stacks against 28 inventory slots, so a full reclaim can simply + // not fit. Nothing is lost — Death keeps the remainder indefinitely — but the caller needs to + // know to bank and come back. + log.warn("Death's Office still holds {} item(s) — inventory has {} free slot(s). Bank and " + + "call reclaimAll() again, or use reclaimItems(filter) to choose.", + getDeathsOfficeItems().size(), Rs2Inventory.emptySlotCount()); return false; } return true; From e317b5d24d3bb7500ee5019cf7ccda3009bc64a6 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 01:34:19 +0100 Subject: [PATCH 05/53] fix(death): address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four findings were valid: 1. getNearest used WorldPoint.distanceTo, which returns Integer.MAX_VALUE across planes. Every entrance is on plane 0, so a player on any upper floor scored MAX_VALUE for all eight and min() silently returned the first constant — Lumbridge — however far away it was. Switched to distanceTo2D. 2. Two Javadoc blocks were orphaned when the item-reader methods were inserted ahead of the methods they described: the grave "claims the half that costs nothing" block landed on getGraveFreeItems, and the detailed reclaimAll block (spending-limit rationale and @return) landed on getDeathsOfficeItems. Both moved to the methods they document; no implementation change. 3. The Death's Office example in the guide called reclaimAll() unconditionally under a comment about pricing the office first — stale since the fee estimator was removed. It now reads the contents, leaves the decision to the caller, and shows closeInterfaces() as the free way to decline. The fourth — make reclaimItems resolve container and quantity buttons per retrieval variant, mirroring reclaimAll — is not implementable as described. Confirmed against the game cache (iftypes): death_office (669) has 1/5/x/all/takeall, while gravestone_retrieval (602) has no quantity controls at all, only button / button_bank / discard. There is nothing to resolve to. The real defect underneath it was that reclaimItems read the DeathOffice container unconditionally even though isDeathsOfficeOpen accepts either variant, so on 602 it would read an empty container and report "took nothing". It now detects the variant and fails loudly, pointing the caller at reclaimAll(). Both interfaces' component lists are documented in the guide. Co-Authored-By: Claude Opus 4.8 --- docs/entity-guides/death.md | 23 +++++++-- .../util/death/DeathsOfficeLocation.java | 5 +- .../plugins/microbot/util/death/Rs2Death.java | 49 ++++++++++--------- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/docs/entity-guides/death.md b/docs/entity-guides/death.md index 165e68a9d67..5fe79d4dc49 100644 --- a/docs/entity-guides/death.md +++ b/docs/entity-guides/death.md @@ -17,10 +17,15 @@ if (Rs2Death.hasDeathToHandle()) { // opt in to the Death's Office trip as well, if the script wants expired items back Rs2Death.recoverItems(config.deathBudget(), config.useDeathsOffice()); -// or price the office yourself before committing — the trip is free, only the reclaim costs +// or inspect before committing — walking there is free, only the reclaim costs if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) { - Rs2Death.reclaimAll(); // no cap is possible — see rule 11 - Rs2Death.closeInterfaces(); // or inspect first and close to decline without paying + List waiting = Rs2Death.getDeathsOfficeItems(); + + if (worthReclaiming(waiting)) { // the script's own call — see rule 11, no cap is possible + Rs2Death.reclaimAll(); // takes everything, at whatever it costs + // or: Rs2Death.reclaimItems(i -> i.getName().contains("rune")); + } + Rs2Death.closeInterfaces(); // declining is free; Death keeps them indefinitely } ``` @@ -353,6 +358,18 @@ What to use instead: reclaim costs. A script that insists on its own cap can price the contents itself and owns that assumption. +**Two different retrieval interfaces exist, and only one supports selective taking.** Confirmed against +the game cache (`iftypes`): + +| Group | Components | Selective? | +|---|---|---| +| `death_office` (669) | `items`, **`1` `5` `x` `all`**, `takeall`, `info` | yes — select a slot, then a quantity | +| `gravestone_retrieval` (602) | `items`, `button`, `button_bank`, `discard`, `fee`, `info` | **no quantity controls at all** | + +`isDeathsOfficeOpen()` accepts either, so `reclaimItems(filter)` checks which one is actually up and +refuses on 602 rather than reading the wrong container and reporting "took nothing". `reclaimAll()` +handles both, clicking `takeall` or `button` as appropriate. + **Where this applies:** `Rs2Death.reclaimAll`, `Rs2Death.recoverItems`. ## 12. Death's Office needs the entrance object, then a dialogue — not an NPC click diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java index dd90c64dcb8..32478a32299 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java @@ -55,8 +55,11 @@ public static DeathsOfficeLocation getNearest() { public static DeathsOfficeLocation getNearest(WorldPoint from) { if (from == null) return null; + // distanceTo2D, not distanceTo: the latter returns Integer.MAX_VALUE across planes, and every + // entrance is on plane 0. A player upstairs would score MAX_VALUE for all of them, so min() + // would silently return the first constant (Lumbridge) however far away it is. return Arrays.stream(values()) - .min(Comparator.comparingInt(location -> location.entrance.distanceTo(from))) + .min(Comparator.comparingInt(location -> location.entrance.distanceTo2D(from))) .orElse(null); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index 8a05cfafebb..290f9efba23 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -456,9 +456,6 @@ public static int getGraveFee() { return parseFee(Rs2Widget.getWidget(InterfaceID.GravestoneGeneric.FEE)); } - /** - * Claims the half of the grave that costs nothing. Items behind a fee are untouched and stay put. - */ /** * The items in the grave's free half — everything that costs nothing to reclaim. Requires the grave * interface to be open ({@link #openGrave()}). @@ -476,8 +473,8 @@ public static List getGravePaidItems() { } /** - * Takes everything in the free half. Use {@link #lootGraveItems(Predicate)} to take only some - * of it. + * Takes everything in the free half; items behind the fee are untouched and stay put. Use + * {@link #lootGraveItems(Predicate)} to take only some of it. */ public static boolean lootGraveFreeItems() { if (!isGraveOpen()) return false; @@ -682,21 +679,6 @@ private static void advanceReclaimDialogue() { } } - /** - * Reclaims everything Death is holding, into the inventory. Death's Office keeps items - * indefinitely, so a partial reclaim caused by a full inventory is safe to resume later. - *

- * There is deliberately no spending limit, because one is not possible. The fee is never on - * screen before it is charged — verified live, {@code INFO} reads "Select an item to retrieve." - * whether the office is empty or holding items, the {@code 1}/{@code 5}/{@code X}/{@code All} - * buttons stay hidden until an item is selected, and {@code Take-All} never selects. Any cap here - * would be fiction. - *

- * Calling this authorises an unbounded charge against Death's Coffer, and the bank after that. - * Death's Office holds items indefinitely, so declining to call it is always a safe alternative. - * - * @return {@code true} once the retrieval interface has closed with nothing left to collect. - */ /** * The items Death is currently holding. Requires the retrieval interface to be open * ({@link #openDeathsOffice()}) — the office cannot be inspected from afar, though walking there and @@ -723,6 +705,17 @@ public static List getDeathsOfficeItems() { public static int reclaimItems(Predicate filter) { if (!isDeathsOfficeOpen()) return 0; + // Selective reclaim is DeathOffice-only. isDeathsOfficeOpen also accepts the + // GravestoneRetrieval variant, but that interface has no per-quantity controls at all — its + // components are BUTTON / BUTTON_BANK / DISCARD, with no 1/5/X/All — so the select-then-take + // flow below has nothing to click there. Fail loudly rather than reading the wrong container + // and silently reporting "took nothing". + if (!Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ITEMS_CONTAINER)) { + log.warn("Selective reclaim needs the Death's Office interface; the retrieval-service " + + "variant has no quantity controls. Use reclaimAll() instead."); + return 0; + } + List items = getDeathsOfficeItems(); int taken = 0; for (int i = items.size() - 1; i >= 0; i--) { @@ -748,9 +741,21 @@ public static int reclaimItems(Predicate filter) { } /** - * Reclaims everything Death is holding. Use {@link #reclaimItems(Predicate)} to take only - * some of it. + * Reclaims everything Death is holding, into the inventory. Death's Office keeps items + * indefinitely, so a partial reclaim caused by a full inventory is safe to resume later. + *

+ * There is deliberately no spending limit, because one is not possible. The fee is never on + * screen before it is charged — verified live, {@code INFO} reads "Select an item to retrieve." + * whether the office is empty or holding items, the {@code 1}/{@code 5}/{@code X}/{@code All} + * buttons stay hidden until an item is selected, and {@code Take-All} never selects. Any cap here + * would be fiction. + *

+ * Calling this authorises an unbounded charge against Death's Coffer, and the bank after that. + * Death's Office holds items indefinitely, so declining to call it is always a safe alternative. + * + * @return {@code true} once the retrieval interface has closed with nothing left to collect. */ + public static boolean reclaimAll() { if (!isDeathsOfficeOpen()) return false; From 9d26b1817c856a83316c74c299ce25238ef11041 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 01:41:48 +0100 Subject: [PATCH 06/53] fix(death): read the retrieval container that is actually open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDeathsOfficeItems always read InterfaceID.DeathOffice.ITEMS, but isDeathsOfficeOpen accepts either retrieval variant. With the GravestoneRetrieval variant up it returned an empty list, so an office still holding items looked empty — both to callers inspecting it and to reclaimAll's inventory-full warning, which would report "still holds 0 item(s)" while items remained. It now resolves the container from whichever interface is visible, matching how reclaimAll already picks between takeall and button. reclaimItems is unaffected: its guard has already established that the DeathOffice variant is the open one before it reads anything. Co-Authored-By: Claude Opus 4.8 --- .../plugins/microbot/util/death/Rs2Death.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index 290f9efba23..53cb8728122 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -685,7 +685,20 @@ private static void advanceReclaimDialogue() { * declining costs nothing. */ public static List getDeathsOfficeItems() { - return readItemContainer(InterfaceID.DeathOffice.ITEMS); + return readItemContainer(activeRetrievalItemsContainer()); + } + + /** + * The item container of whichever retrieval interface is actually open. {@link #isDeathsOfficeOpen()} + * accepts either variant, so reading {@code DeathOffice.ITEMS} unconditionally would return an empty + * list whenever the retrieval-service variant is the one up — making an office that still holds items + * look empty, both to callers and to {@link #reclaimAll()}'s inventory-full warning. + */ + @Component + private static int activeRetrievalItemsContainer() { + return Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ITEMS_CONTAINER) + ? InterfaceID.DeathOffice.ITEMS + : InterfaceID.GravestoneRetrieval.ITEMS; } /** From f881495452f777f82d44fe28149677688ef928e0 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 11:59:14 +0100 Subject: [PATCH 07/53] fix(shortestpath): a skill requirement the parser cannot read is not "no requirement" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Draynor underwall tunnel (42 Agility) was usable at any Agility level. Two rows in agility_shortcuts.tsv carried their Duration value separated by SPACES instead of a tab: 3070 3257 0 3066 3257 0 Climb-into;Underwall tunnel;19036 [42 Agility 7] 3066 3257 0 3070 3257 0 Climb-into;Underwall tunnel;19032 [42 Agility 7] split("\s+", 2) then read the skill name as "Agility 7", which matches no Skill, so skillLevels was never written. Zero is how "no requirement" is encoded, so the requirement did not merely fail — it disappeared. Worse than permissive. blocksWalkingEdgeWhenUnavailable blocks the walking edge a shortcut spans when the shortcut is unusable, precisely so the planner routes around it. With the gate erased the edge stays open and the shortcut looks free, so the planner PREFERS it as the shortest route and sends the walker back to a wall it cannot climb. The same tunnel's other approaches (lines 54-59) were well formed, which is why this only bit from the y=3257 side. Three parts: - the two rows repaired and normalised to the header's 9 columns - the parser now warns when a requirement names no known skill, instead of dropping it silently; an unreadable requirement and an absent one were indistinguishable in the logs - a test over the shipped TSVs asserting every Skills entry resolves. Verified it fails on the pre-fix data naming both rows, so it pins the class and not just this instance The row looks correct in an editor — the 7 sits where it belongs visually. That is why this needed a test rather than review. Co-Authored-By: Claude Fable 5 --- .../microbot/shortestpath/Transport.java | 18 +++ .../shortestpath/agility_shortcuts.tsv | 4 +- .../TransportSkillRequirementDataTest.java | 143 ++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java index 2b33e9069fa..8ffcc0c36ac 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java @@ -310,20 +310,38 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans int level = Integer.parseInt(levelAndSkill[0]); String skillName = levelAndSkill[1].trim(); + boolean resolved = false; Skill[] skills = Skill.values(); for (int i = 0; i < skills.length; i++) { if (skills[i].getName().equals(skillName)) { skillLevels[i] = level; + resolved = true; break; } } String normalizedSkillName = skillName.toLowerCase(Locale.ROOT); if (normalizedSkillName.startsWith("total")) { skillLevels[TOTAL_LEVEL_INDEX] = level; + resolved = true; } else if (normalizedSkillName.startsWith("combat")) { skillLevels[COMBAT_LEVEL_INDEX] = level; + resolved = true; } else if (normalizedSkillName.startsWith("quest")) { skillLevels[QUEST_POINTS_INDEX] = level; + resolved = true; + } + // A requirement we cannot resolve used to vanish without a word, and an unset level is + // indistinguishable from "no requirement" — so the transport became usable by everyone. + // That is how "42 Agility7" (a Duration separated by spaces instead of a tab) + // turned the Draynor underwall tunnel into a free shortcut: the name read as + // "Agility 7", matched nothing, and the 42 was silently dropped. Worse than a + // no-op, because blocksWalkingEdgeWhenUnavailable would otherwise have routed AROUND + // an unusable shortcut; with the gate erased the planner actively prefers it. + if (!resolved) { + log.warn("Transport skill requirement '{}' does not name a known skill (raw field '{}') " + + "— the requirement is being DROPPED, which makes this transport usable " + + "by any account. Check for spaces where the TSV needs a tab.", + requirement.trim(), value.trim()); } } } diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv index 7b1095334d7..83b7252114e 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv @@ -57,8 +57,8 @@ 3066 3261 0 3071 3260 0 Climb-into;Underwall tunnel;19032 42 Agility 3069 3259 0 3064 3260 0 Climb-into;Underwall tunnel;19036 42 Agility 3067 3260 0 3071 3260 0 Climb-into;Underwall tunnel;19032 42 Agility -3070 3257 0 3066 3257 0 Climb-into;Underwall tunnel;19036 42 Agility 7 -3066 3257 0 3070 3257 0 Climb-into;Underwall tunnel;19032 42 Agility 7 +3070 3257 0 3066 3257 0 Climb-into;Underwall tunnel;19036 42 Agility 7 +3066 3257 0 3070 3257 0 Climb-into;Underwall tunnel;19032 42 Agility 7 3035 9806 0 3028 9806 0 Squeeze-through;Crevice;16543 42 Agility 3028 9806 0 3035 9806 0 Squeeze-through;Crevice;16543 42 Agility 2878 3665 0 2878 3668 0 Climb;Rocks;16522 43 Agility diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java new file mode 100644 index 00000000000..8ec12ee66ac --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java @@ -0,0 +1,143 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.Skill; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Every Skills entry in the shipped transport data must name a skill the parser can resolve. + * + *

An unresolvable requirement does not fail loudly — {@code Transport} matches the skill name + * against {@link Skill#getName()} and simply never writes {@code skillLevels}, leaving it 0. Zero is + * how "no requirement" is encoded, so a malformed requirement silently becomes NO requirement and the + * transport turns usable by every account. + * + *

That is not merely permissive. {@code PathfinderConfig.blocksWalkingEdgeWhenUnavailable} blocks + * the walking edge a shortcut spans when the shortcut is unusable, so the planner routes around it — + * with the gate erased the edge stays open and the planner actively PREFERS the shortcut as the + * shortest route, sending the walker back repeatedly. + * + *

Live case: the Draynor underwall tunnel rows carried {@code "42 Agility7"}, a Duration + * value separated by spaces instead of a tab. The field parsed as skill name {@code "Agility 7"}, + * matched nothing, and a 42 Agility shortcut became free. It looks correct in an editor, which is + * exactly why it needs a test rather than review. + */ +public class TransportSkillRequirementDataTest { + + private static final String RESOURCE_DIR = + "/net/runelite/client/plugins/microbot/shortestpath/"; + + /** Every transport TSV that carries a Skills column. */ + private static final List FILES = Arrays.asList( + "transports.tsv", + "agility_shortcuts.tsv", + "boats.tsv", + "canoes.tsv", + "charter_ships.tsv", + "fairy_rings.tsv", + "gnome_gliders.tsv", + "hot_air_balloons.tsv", + "magic_carpets.tsv", + "magic_mushtrees.tsv", + "minecarts.tsv", + "quetzals.tsv", + "ships.tsv", + "spirit_trees.tsv", + "teleportation_items.tsv"); + + /** Names the parser accepts: any Skill, plus the total/combat/quest-points prefixes. */ + private static boolean resolvable(String skillName) { + for (Skill skill : Skill.values()) { + if (skill.getName().equals(skillName)) { + return true; + } + } + String lower = skillName.toLowerCase(); + return lower.startsWith("total") || lower.startsWith("combat") || lower.startsWith("quest"); + } + + @Test + public void everySkillRequirementInShippedDataResolves() { + List offenders = new ArrayList<>(); + Set filesChecked = new HashSet<>(); + + for (String file : FILES) { + try (InputStream in = getClass().getResourceAsStream(RESOURCE_DIR + file)) { + if (in == null) { + continue; // file genuinely absent from this branch; other rows still get checked + } + filesChecked.add(file); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + String headerLine = reader.readLine(); + if (headerLine == null) { + continue; + } + String[] header = headerLine.split("\t", -1); + int skillsCol = -1; + for (int i = 0; i < header.length; i++) { + if ("Skills".equals(header[i].trim())) { + skillsCol = i; + break; + } + } + if (skillsCol < 0) { + continue; + } + + String line; + int lineNo = 1; + while ((line = reader.readLine()) != null) { + lineNo++; + if (line.startsWith("#") || line.trim().isEmpty()) { + continue; + } + String[] fields = line.split("\t", -1); + if (skillsCol >= fields.length) { + continue; + } + String cell = fields[skillsCol]; + if (cell.trim().isEmpty()) { + continue; + } + for (String requirement : cell.split(";")) { + String trimmed = requirement.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] levelAndSkill = trimmed.split("\\s+", 2); + if (levelAndSkill.length < 2) { + offenders.add(file + ":" + lineNo + " [" + cell + "] — no skill name"); + continue; + } + if (!resolvable(levelAndSkill[1].trim())) { + offenders.add(file + ":" + lineNo + " [" + cell + "] — '" + + levelAndSkill[1].trim() + "' is not a known skill " + + "(spaces where a tab belongs?)"); + } + } + } + } catch (Exception e) { + throw new AssertionError("failed reading " + file, e); + } + } + + assertFalse("precondition: the transport resources should be readable", filesChecked.isEmpty()); + assertTrue("skill requirements that the parser will silently DROP, making these transports " + + "usable by any account:\n " + + offenders.stream().collect(Collectors.joining("\n ")), + offenders.isEmpty()); + } +} From 8d6722b4deab8ddecfc1107c29c94ff9ad168136 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 12:08:02 +0100 Subject: [PATCH 08/53] fix(walker): pick the destination when a ferryman asks instead of right-clicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Veos opened the conversation, reached the Port Piscarilius / Land's End menu, and the walker walked away leaving it on screen. Terminal travel decides how to pick a destination from a static name whitelist: if ("Mountain Guide".equalsIgnoreCase(transport.getName())) -> DIALOGUE_DESTINATION return DIRECT; DIRECT means "the right-click chose the destination, nothing more to do", and selectTerminalTravelDialogueDestination returns immediately on it. Veos's ships.tsv rows name a destination as the ACTION (Port Piscarilius, Land's End, Port Sarim) exactly like Cabin Boy Herbert or Captain Barnaby, so it resolved to DIRECT — but NPC 10724 (veos_visible_travel_amulet) does not offer those as right-click options any more; it asks in conversation. Right-clicking a destination is genuinely better than talking, so that stays the preferred path. The walker just has to notice when it did not get it: resolveTerminalNpcInteractionAction already reports which action the NPC actually offered, and already LOGS the fallback to a generic "Travel" — it simply did not act on it. When the configured destination-named action is unavailable, the destination was not chosen by the click, so it must be chosen in the dialogue regardless of the static mode. Also: the destination is not always in the first menu. If it is absent, try a menu-opening option ("Can you take me somewhere?") and look again, rather than reporting the option missing while it sits one click away. Runtime-detected rather than whitelisted, so the next ferryman Jagex moves into dialogue does not need a code change. The dead Veos and Captain Magoro branches gated on action=="Talk-to" — which their rows never carry — can now go, but that is deliberately left for a follow-up rather than bundled here. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 56 ++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) 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 5a8efd7896d..f9e255fba3c 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 @@ -8959,8 +8959,26 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i sleepTickJitter(2); Rs2Dialogue.clickContinue(); } + // Right-clicking the destination is always preferred and needs no + // dialogue — that is what DIRECT means. But the mode is decided + // statically from a name whitelist, so an NPC whose row names a + // destination it no longer offers (Veos: the row says + // "Port Piscarilius", the game now asks in conversation) resolved + // to DIRECT, skipped destination selection entirely, and left the + // walker staring at the destination menu. + // + // resolveTerminalNpcInteractionAction already told us which action + // the NPC actually offered. If it had to fall back to a generic one + // then the destination was NOT chosen by the click and has to be + // chosen in the dialogue, whatever the static mode says. + Rs2TerminalTravelMode effectiveTravelMode = terminalTravelMode; + if (!npcAction.equalsIgnoreCase(transport.getAction()) + && transport.getDisplayInfo() != null + && !transport.getDisplayInfo().isBlank()) { + effectiveTravelMode = Rs2TerminalTravelMode.DIALOGUE_DESTINATION; + } if (!selectTerminalTravelDialogueDestination( - transport, terminalTravelMode)) { + transport, effectiveTravelMode)) { break originLoop; } final int terminalDestinationIndex = precomputedIndexOfDest; @@ -10148,6 +10166,16 @@ static boolean isTerminalTravelTransport(TransportType transportType) { || transportType == TransportType.BOAT; } + /** + * Options that open the destination list on NPCs whose right-click menu has no per-destination + * entry. Veos answers "Can you take me somewhere?" with the Port Piscarilius / Land's End menu. + */ + private static final List TERMINAL_TRAVEL_MENU_OPENERS = List.of( + "Can you take me somewhere?", + "Can you take me somewhere", + "take me somewhere", + "Travel"); + private static boolean selectTerminalTravelDialogueDestination( Transport transport, Rs2TerminalTravelMode mode) { if (mode == Rs2TerminalTravelMode.DIRECT) { @@ -10165,13 +10193,27 @@ private static boolean selectTerminalTravelDialogueDestination( transport.getName(), transport.getDisplayInfo()); return false; } - if (!Rs2Dialogue.clickOption(transport.getDisplayInfo())) { - WebWalkLog.spWarn( - "terminal travel destination option missing name={} dest={}", - transport.getName(), transport.getDisplayInfo()); - return false; + if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { + return true; } - return true; + // The destination is not in THIS menu. Several ferrymen answer a "can you take me somewhere" + // option with the destination list, so open it and look again rather than giving up — the + // walker previously stopped here with the destination menu on screen and walked away. + for (String opener : TERMINAL_TRAVEL_MENU_OPENERS) { + if (!Rs2Dialogue.hasSelectAnOption() || !Rs2Dialogue.clickOption(opener)) { + continue; + } + WebWalkLog.spInfo("terminal travel menu opened via '{}' name={} dest={}", + opener, transport.getName(), transport.getDisplayInfo()); + sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000); + if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { + return true; + } + } + WebWalkLog.spWarn( + "terminal travel destination option missing name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return false; } private static TileObject findTerminalTravelObject(Transport transport) { From b6bfd23e33e8b680913b7a1027db27369de80cba Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 12:44:15 +0100 Subject: [PATCH 09/53] fix(walker): ARRIVED beside an object needs a tile we can stand on, not just distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "it says it succeeded before reaching the destination, if the destination was near interactable objects". Confirmed — one branch: if (reachableTileCheck || (!walkableCheck && distToTarget <= distance)) return WalkerState.ARRIVED; An unwalkable target is normal: you cannot stand ON a door, chest or bank booth, so the walk must finish beside it. But distanceTo is straight-line and knows nothing about walls, so being within distance of an object counted as arrival even with a wall in between. The caller then interacted from the wrong side and failed, while the walker reported success — wrong success, which is worse than a visible stall because the script blames itself. Arrival at an unwalkable target now also requires a reachable tile ADJACENT to it: somewhere we could actually stand to use it. That is the difference between "close to the object" and "able to use the object", and it is exactly what straight-line distance cannot express. Deliberately falls back to the old distance-only answer when the reachability BFS returns nothing, so a reachability hiccup cannot convert an arrival into a walk that never terminates. Declining an arrival that would previously have been granted logs arrival_declined_unreachable, so if this does cost a termination the line says so rather than the walk just hanging. The BFS was already being computed for the walkable case; this reuses it rather than adding a second one. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 49 +++++++++++++++- .../util/walker/Rs2WalkerUnitTest.java | 58 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) 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 f9e255fba3c..5be7a8339dc 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 @@ -1405,11 +1405,31 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance int distToTarget = playerLocWalk.distanceTo(target); LocalPoint localTarget = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target); boolean walkableCheck = Rs2Tile.isWalkable(localTarget); - boolean reachableTileCheck = distToTarget <= distance && Rs2Tile.getReachableTilesFromTile(playerLocWalk, distance).containsKey(target); + Map reachableWithinDistance = distToTarget <= distance + ? Rs2Tile.getReachableTilesFromTile(playerLocWalk, distance) + : Collections.emptyMap(); + boolean reachableTileCheck = distToTarget <= distance && reachableWithinDistance.containsKey(target); + + // An unwalkable target is normal — you cannot stand ON a door, chest or bank booth, so the + // walk has to finish beside it. But distanceTo is straight-line and knows nothing about walls, + // so "within distance of an object" was reported as ARRIVED even with a wall between: the + // caller then tried to interact from the wrong side of it and the script failed with the + // walker claiming success. Require somewhere we can actually STAND next to the target. + // + // Falls back to the old distance-only answer when the BFS is unavailable, so a reachability + // hiccup cannot turn arrival into a walk that never terminates. + boolean unwalkableTargetReached = !walkableCheck && distToTarget <= distance + && (reachableWithinDistance.isEmpty() + || hasReachableNeighbour(target, reachableWithinDistance)); - if (reachableTileCheck || (!walkableCheck && distToTarget <= distance)) { + if (reachableTileCheck || unwalkableTargetReached) { return WalkerState.ARRIVED; } + if (!walkableCheck && distToTarget <= distance && !reachableWithinDistance.isEmpty()) { + WebWalkLog.spInfo("arrival_declined_unreachable | target={} player={} dist={} — within distance " + + "but no reachable tile beside it; continuing", + compactWorldPoint(target), compactWorldPoint(playerLocWalk), distToTarget); + } final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); if (routeStatus.isCalculating()) { @@ -1594,6 +1614,31 @@ static boolean walkStepPathReachesTarget(List path, WorldPoint targe * @param target * @param distance */ + /** + * Whether any tile orthogonally or diagonally adjacent to {@code target} is in the player-origin + * reachable set — i.e. there is somewhere we can actually stand to interact with it. + *

+ * This is the difference between "close to the object" and "able to use the object". Straight-line + * distance says yes through a wall; this says no. + */ + static boolean hasReachableNeighbour(WorldPoint target, Map reachable) { + if (target == null || reachable == null || reachable.isEmpty()) { + return false; + } + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) { + continue; + } + if (reachable.containsKey( + new WorldPoint(target.getX() + dx, target.getY() + dy, target.getPlane()))) { + return true; + } + } + } + return false; + } + private static WalkerState processWalk(WorldPoint target, int distance) { // Solve the Draynor basement lever puzzle first if walking to a basement tile, so the // door-transports are unlocked before pathfinding. No-op outside the basement. The 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 64051e5ae1f..077fb6f5278 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 @@ -2313,4 +2313,62 @@ public void walkUntil_failedConditionFallsBackToNormalWalkerResult() { public void walkUntil_rejectsNullCondition() { Rs2Walker.walkUntil(new WorldPoint(3200, 3200, 0), 2, null); } + + // ---- arrival beside an unwalkable target (false-success near interactables) --------------------- + + /** + * "Within distance of an object" was reported as ARRIVED on straight-line distance alone. With a + * wall between, the caller then interacted from the wrong side and failed while the walker claimed + * success — the silent-wrong-success case. + */ + @Test + public void hasReachableNeighbour_trueWhenWeCanStandBesideTheTarget() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 0), 1); // directly south of it + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_acceptsDiagonalNeighbours() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3201, 3201, 0), 1); + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** Near in a straight line, but every adjacent tile is on the far side of a wall. */ + @Test + public void hasReachableNeighbour_falseWhenOnlyDistantTilesAreReachable() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3205, 3200, 0), 5); + reachable.put(new WorldPoint(3200, 3205, 0), 5); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** The target's own tile being reachable is not the question — we must stand BESIDE it. */ + @Test + public void hasReachableNeighbour_targetTileItselfDoesNotCount() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(chest, 0); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** A neighbour on another plane is not somewhere we can stand to use it. */ + @Test + public void hasReachableNeighbour_ignoresOtherPlanes() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 1), 1); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_toleratesMissingInputs() { + assertFalse(Rs2Walker.hasReachableNeighbour(null, new java.util.HashMap<>())); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), null)); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), new java.util.HashMap<>())); + } } From 7ae45e4523412eca6db301ae2fec753cf8a89b00 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 13:27:34 +0100 Subject: [PATCH 10/53] feat(shortestpath): measure whether the persistent live store is actually paying off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collision_conflict compares live against STATIC, so it answers "how wrong is the shipped map here" — the disease, not the treatment. It reads identically whether or not the persistent store works, which is why a whole evening of那 numbers said nothing about whether persistence was earning its keep. Every conclusion drawn from them was about the map, and none about the store. Adds a coverage counter taken against the overlay as it stood BEFORE the capture was merged in: overlayKnew=NN% (known=N new=N changed=N) known static was wrong and we already had the right answer — a previous visit spared us the blind one, which is the entire point of the store new static was wrong and we had nothing — the blind first visit changed the overlay disagreed with this capture: world changed, or stale learning worth knowing about separately The prior view is pinned before overlay.set(); mergeScene replaces regions rather than mutating them, so it stays a true "before" rather than seeing the capture it is meant to be compared against. Expected shape: high "new" on first exploration, climbing "known" on repeat routes. If "known" stays near zero on ground walked before, persistence is not working and no amount of static-map regeneration will help — which is exactly the distinction the existing metric could not make. Co-Authored-By: Claude Fable 5 --- .../shortestpath/ShortestPathPlugin.java | 18 ++++- .../live/LiveCollisionConflicts.java | 74 +++++++++++++++++++ .../live/LiveCollisionConflictsTest.java | 43 +++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) 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 a86cc2b66e8..49a60be7c75 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -56,6 +56,7 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionConflicts; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionPersistence; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionView; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveRouteValidator; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; @@ -662,7 +663,7 @@ private void markLiveCollisionDirty() { * decision with magnitudes instead of anecdotes. Runs off the fresh immutable snapshot, never on * the pathfinder hot path. */ - private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) { + private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot, LiveCollisionView priorOverlayView) { if (staticCollisionData == null) { return; } @@ -675,9 +676,14 @@ private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) { return; } lastCollisionConflictLogAtMs = now; - WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{} — live scene disagrees with the shipped map", + LiveCollisionConflicts.Coverage coverage = + LiveCollisionConflicts.coverage(snapshot, staticCollisionData, priorOverlayView); + WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{}" + + " | overlayKnew={}% (known={} new={} changed={}) — live scene disagrees with the shipped map", tally.liveOpensStatic, tally.liveBlocksStatic, tally.liveOpensSealed, - snapshot.getBaseX(), snapshot.getBaseY()); + snapshot.getBaseX(), snapshot.getBaseY(), + coverage.alreadyKnownPercent(), coverage.alreadyKnown, + coverage.newInformation, coverage.changed); } private void resetLearnedCollision() { @@ -787,8 +793,12 @@ void refreshLiveCollision() { return; } + // Pinned BEFORE the merge: this is what we knew on arrival, which is the only way to tell + // whether the persistent store spared us a blind first visit. mergeScene replaces regions + // rather than mutating them, so this view stays a true "before". + final LiveCollisionView priorOverlayView = overlay.current(); overlay.set(snapshot); - logLiveStaticConflicts(snapshot); + logLiveStaticConflicts(snapshot, priorOverlayView); // Persist the regions this capture just changed so the learned collision survives a restart. if (liveCollisionPersistence != null) { liveCollisionPersistence.persist(overlay.drainDirty()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java index 3bbb25fb208..cd2ca7b1830 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java @@ -44,6 +44,80 @@ public boolean isEmpty() { } } + /** + * How much of this scene's disagreement with the shipped map the accumulated overlay ALREADY knew. + *

+ * {@link Tally} answers "how wrong is the static map here", which is the disease, not the treatment — + * it compares live against STATIC and reads identically whether or not the persistent store is doing + * its job. This answers the question that actually matters once persistence exists: on arriving + * somewhere, had we already learned it on a previous visit? + */ + public static final class Coverage { + /** Static was wrong and the overlay already had the right answer — a previous visit paid off. */ + public final int alreadyKnown; + /** Static was wrong and the overlay had nothing — the blind first visit this store exists to end. */ + public final int newInformation; + /** The overlay had a DIFFERENT value than this capture: world changed, or stale learning. */ + public final int changed; + + Coverage(int alreadyKnown, int newInformation, int changed) { + this.alreadyKnown = alreadyKnown; + this.newInformation = newInformation; + this.changed = changed; + } + + public int total() { + return alreadyKnown + newInformation + changed; + } + + /** Percentage of this scene's static-map errors already covered before arriving. 0 when nothing conflicts. */ + public int alreadyKnownPercent() { + final int t = total(); + return t == 0 ? 0 : (int) Math.round(100.0 * alreadyKnown / t); + } + } + + /** + * Compares the capture against the overlay as it stood BEFORE this scene was merged in. + * + * @param priorView the overlay view pinned before the merge; {@code null} means nothing was learned + * yet, so every disagreement counts as new information + */ + public static Coverage coverage(LiveCollisionSnapshot snapshot, SplitFlagMap staticMap, + LiveCollisionView priorView) { + if (snapshot == null || staticMap == null) { + return new Coverage(0, 0, 0); + } + int alreadyKnown = 0; + int newInformation = 0; + int changed = 0; + final int baseX = snapshot.getBaseX(); + final int baseY = snapshot.getBaseY(); + for (int z = 0; z < snapshot.getPlaneCount(); z++) { + for (int ly = 0; ly < SCENE_SIZE; ly++) { + for (int lx = 0; lx < SCENE_SIZE; lx++) { + final int x = baseX + lx; + final int y = baseY + ly; + for (int flag = LiveCollisionSnapshot.FLAG_NORTH; flag <= LiveCollisionSnapshot.FLAG_EAST; flag++) { + final Boolean live = snapshot.edge(x, y, z, flag); + if (live == null || live == staticMap.get(x, y, z, flag)) { + continue; // unknown, or static was right — nothing for the store to carry + } + final Boolean known = priorView == null ? null : priorView.edge(x, y, z, flag); + if (known == null) { + newInformation++; + } else if (known.equals(live)) { + alreadyKnown++; + } else { + changed++; + } + } + } + } + } + return new Coverage(alreadyKnown, newInformation, changed); + } + private LiveCollisionConflicts() { } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java index 73e5498473b..2889b93e8e8 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java @@ -119,4 +119,47 @@ public void unknownEdgesNeverCount() { assertTrue(LiveCollisionConflicts.tally(null, staticMap).isEmpty()); assertTrue(LiveCollisionConflicts.tally(allUnknown, null).isEmpty()); } + + // ---- overlay coverage: is the persistent store actually paying off? ----------------------------- + + /** + * The Tally buckets compare live against STATIC, so they read the same whether or not the persistent + * store works — they measure how wrong the shipped map is, not whether we had already learned it. + * Coverage is the number that tells you the store is earning its keep. + */ + @Test + public void coverage_countsUnknownEdgesAsNewInformation() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage( + snapshotWithNorthEdge(!statik), staticMap, null); + assertEquals(1, c.newInformation); + assertEquals(0, c.alreadyKnown); + assertEquals(0, c.alreadyKnownPercent()); + } + + /** An edge the overlay already had, with the same value — a previous visit spared us the blind one. */ + @Test + public void coverage_countsMatchingOverlayEdgesAsAlreadyKnown() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionSnapshot scene = snapshotWithNorthEdge(!statik); + + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.mergeScene(scene); // "previous visit" + LiveCollisionView prior = overlay.current(); + + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage(scene, staticMap, prior); + assertEquals(1, c.alreadyKnown); + assertEquals(0, c.newInformation); + assertEquals(100, c.alreadyKnownPercent()); + } + + /** Agreement with static is not the store's business and must not be counted either way. */ + @Test + public void coverage_ignoresEdgesWhereStaticWasRight() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage( + snapshotWithNorthEdge(statik), staticMap, null); + assertEquals(0, c.total()); + } } From 9c63b24308b657191b93ce936d5d52bc3ef8917a Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 13:39:37 +0100 Subject: [PATCH 11/53] chore(walker): split doorOther into the click and the verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doorOther is the residual after the probe and both waits — ~790ms of a 3181ms scan — and it is the only part of door handling that is neither the player walking (2250ms, irreducible) nor scanning (60ms). It is therefore the only place a fix could plausibly come from, and it is currently one undifferentiated number. Two candidates, now timed separately rather than argued about: doorInteract Rs2GameObject.interact: composition resolve, menu entry construction, mouse click doorVerify doorStillHasAction: a radius-13 rescan that resolves a composition PER CANDIDATE outside the scan-scoped memo, plus nine transport-map lookups each. Only runs when traversal failed — but that is exactly the path a stuck door repeats The reason for measuring rather than fixing: the sampled 3181ms scan released by progress, so traversal SUCCEEDED and doorStillHasAction cannot have run in it. Whatever consumed 790ms there was the click path. Reading the code makes the rescan look like the expensive one, which is precisely the kind of plausible inference that has been wrong repeatedly today — doorProbe turned out to be waits rather than geometry, and the item fingerprint turned out to be loading compositions on the client thread. No behaviour change. Next door-heavy scan says which one to attack. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) 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 5be7a8339dc..68b5e4b4cf4 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 @@ -5326,6 +5326,8 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, rawScanDoorInteractionWaitMs = 0L; rawScanDoorEdgeWaitMs = 0L; rawScanDoorFindMs = 0L; + rawScanDoorInteractMs = 0L; + rawScanDoorVerifyMs = 0L; // Route order guard for ranged transport dispatch: set once a transport step is passed over, // so nothing further along the route can be actioned ahead of the obstacle in front of us. boolean sawUndispatchedTransportStep = false; @@ -5448,14 +5450,19 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, long doorFindMs = rawScanDoorFindMs; // What is left after the probe and both waits: the menu interaction and the // post-interaction verification. Previously all of this was reported as "doorProbe". - long doorOtherMs = Math.max(0L, doorMs - doorWaitMs - doorEdgeWaitMs - doorFindMs); - log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorFind={}ms doorEdgeWait={}ms doorOther={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", - totalMs, scannedIdx, snapshotMs, doorFindMs, doorEdgeWaitMs, doorOtherMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, + long doorInteractMs = rawScanDoorInteractMs; + long doorVerifyMs = rawScanDoorVerifyMs; + long doorOtherMs = Math.max(0L, doorMs - doorWaitMs - doorEdgeWaitMs - doorFindMs + - doorInteractMs - doorVerifyMs); + log.info("[Walker] slow raw scene scan: total={}ms idx={} snapshot={}ms doorFind={}ms doorInteract={}ms doorVerify={}ms doorEdgeWait={}ms doorOther={}ms doorWait={}ms doorCand={}ms rockfall={}ms transports={}ms resolved={} allowTransports={}", + totalMs, scannedIdx, snapshotMs, doorFindMs, doorInteractMs, doorVerifyMs, doorEdgeWaitMs, doorOtherMs, doorWaitMs, doorCandidateMs, rockfallMs, transportMs, resolved, allowTransportHandlers); } rawScanDoorInteractionWaitMs = 0L; rawScanDoorEdgeWaitMs = 0L; rawScanDoorFindMs = 0L; + rawScanDoorInteractMs = 0L; + rawScanDoorVerifyMs = 0L; } } @@ -5500,6 +5507,43 @@ private static DoorProbeContext doorProbeContext() { private static volatile long rawScanDoorEdgeWaitMs = 0L; /** Time inside the door segment probe during a raw scan (the actual geometry/snapshot work). */ private static volatile long rawScanDoorFindMs = 0L; + /** Time spent issuing the door menu click itself (composition resolve + menu entry + mouse). */ + private static volatile long rawScanDoorInteractMs = 0L; + /** Time spent verifying the outcome: traversal check, and the re-scan that asks if it is still shut. */ + private static volatile long rawScanDoorVerifyMs = 0L; + + /** + * The door menu click, timed. "doorOther" is the residual left after the probe and both waits, and + * at ~790ms of a 3181ms scan it is the only part of door handling that is neither the player + * walking nor a scan — so it needs its own number before anyone optimises against it. + */ + private static boolean interactDoorTimed(TileObject object, String action) { + long startedAt = System.currentTimeMillis(); + try { + return Rs2GameObject.interact(object, action); + } finally { + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorInteractMs += System.currentTimeMillis() - startedAt; + } + } + } + + /** + * "Is the door still shut?" — a radius-{@link #HANDLER_RANGE} rescan that resolves a composition per + * candidate OUTSIDE the scan-scoped memo, so nothing is cached. Only runs when traversal failed, but + * that is exactly the slow path a stuck door repeats, so it is timed separately. + */ + private static boolean doorStillHasActionTimed(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + long startedAt = System.currentTimeMillis(); + try { + return doorStillHasAction(probe, fromWp, toWp, doorActions, action); + } finally { + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorVerifyMs += System.currentTimeMillis() - startedAt; + } + } + } /** * The door segment probe, timed. "doorProbe" in the slow-scan line is a RESIDUAL — the whole @@ -6064,7 +6108,7 @@ private static boolean handleDoors(List path, int index, boolean all WorldPoint posBefore = Rs2Player.getWorldLocation(); boolean interacted; try { - interacted = Rs2GameObject.interact(object, action); + interacted = interactDoorTimed(object, action); } catch (Exception ex) { WebWalkLog.spInfo("door_interact_exception | mode=segment-door probe={} from={} to={} ex={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); @@ -6104,7 +6148,7 @@ private static boolean handleDoors(List path, int index, boolean all Rs2PathApi.learnBlockedEdge(fromWp, toWp, "wrong-traversal door @ " + compactWorldPoint(probe)); } - if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { + if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); } else { @@ -6197,7 +6241,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint posBefore = Rs2Player.getWorldLocation(); boolean interacted; try { - interacted = Rs2GameObject.interact(object, action); + interacted = interactDoorTimed(object, action); } catch (Exception ex) { WebWalkLog.spInfo("door_interact_exception | mode=segment-probe probe={} from={} to={} ex={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); @@ -6233,7 +6277,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return true; } - if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { + if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); } else { From c6728bdb181c79ce281cbdab9129f2ccf0ca4ca6 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Sun, 2 Aug 2026 18:27:54 +0100 Subject: [PATCH 12/53] fix(walker): learn the edge a refused route click proves is walled, and replan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusing a walled route click was always correct but was never a recovery. The planner keeps producing the same route, the net keeps refusing it, and the walker oscillates with no escape — reported as "the walker just became unrecoverable, it clicked back and forwards but was stuck in a loop". Root cause at the reported spot is map data, not logic. Probed every tile the walker kept clicking at Sinclair Mansion: 2736,3460 n=true s=true e=true w=true 2740,3463 n=true s=true e=true w=true 2737,3461 n=true s=true e=true w=true All four edges open on every one — the shipped map has no walls for that building at all, while the live capture reported liveBlocksStatic=330 in the same scene (every other reading this session has been single or low double digits). So the pathfinder plans through the walls, the player-origin BFS proves the tile is behind one, and route_click_walled fires forever. anchorIdx=-1 on every refusal is the signature of that deadlock. A refusal carries information nothing was using: the route crosses from reachable to unreachable at a specific edge, and that edge is impassable whatever the map says. Learning it makes the next plan route around. The first strike blocks the edge for THIS session, so the replan takes effect immediately; persistence across sessions still needs an independent second strike, which is what keeps a transient refusal from poisoning the store. learnBlockedEdge returns false for an edge already known, so the replan fires once per edge, not once per refusal. Only edges with BOTH ends inside the BFS budget are learned — beyond it "unreachable" means far away and the edge is innocent. That guard is the whole correctness argument, so it is a decision table rather than a comment. firstWalledRawEdge is deliberately pure: the obvious getClosestTileIndex start hint reads the scene on the client thread and made the tests hang. Does not fix the map data; makes the walker survive it. Fourth data gap tonight. Co-Authored-By: Claude Fable 5 (cherry picked from commit e93d3a5c2bad084fb5d927862f2331c8a1d75960) --- .../microbot/util/walker/Rs2Walker.java | 70 +++++++++++++++++++ .../util/walker/Rs2WalkerUnitTest.java | 55 +++++++++++++++ 2 files changed, 125 insertions(+) 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 68b5e4b4cf4..43ef6710ae2 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 @@ -3794,11 +3794,81 @@ private static WorldPoint findFurthestRawPathPointMatchingGated(List && playerLoc.distanceTo2D(selected) <= CLOSEST_INDEX_REACHABLE_STEP_BUDGET - 2) { WebWalkLog.spInfo("route_click_walled | to={} player={} anchorIdx={} — refused, falling back", compactWorldPoint(selected), compactWorldPoint(playerLoc), rawAnchorIndex); + learnWalledRouteEdge(rawPath, playerLoc, reachable); return null; } return selected; } + /** + * The route crosses from reachable to unreachable at some edge; that edge is impassable in reality, + * whatever the shipped map says. Learn it so the pathfinder routes around it instead of replanning + * the same way forever. + *

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

+ * The first strike blocks the edge for THIS session (see + * {@code PathfinderConfig#learnBlockedEdge}), so the replan below routes around it immediately; + * persistence across sessions still needs an independent second strike, which is what stops a + * transient refusal poisoning the store. learnBlockedEdge returns false for an edge already known, + * so the replan fires once per edge rather than on every refusal. + */ + private static void learnWalledRouteEdge(List rawPath, WorldPoint playerLoc, + Map reachable) { + WorldPoint[] edge = firstWalledRawEdge(rawPath, playerLoc, reachable, + CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + if (edge == null) { + return; + } + // Via the Rs2PathApi wrapper rather than the config directly: it takes the pathfinder mutex, + // which matters because the replan below runs straight after. Same return contract — true only + // when the edge was newly blocked for this session. + if (Rs2PathApi.learnBlockedEdge(edge[0], edge[1], "route-click-walled")) { + WebWalkLog.spInfo("walled_edge_learned | {} -> {} — replanning around it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + recalculatePath(); + } + } + + /** + * First raw-path step that leaves the player-origin BFS: {@code a} reachable, {@code b} not. + *

+ * Both endpoints must sit inside the BFS budget, or "not reachable" means merely far away and the + * edge is innocent — the same guard the refusal itself uses. + */ + static WorldPoint[] firstWalledRawEdge(List rawPath, WorldPoint playerLoc, + Map reachable, int stepBudget) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null + || reachable == null || reachable.isEmpty()) { + return null; + } + // Deliberately no getClosestTileIndex here: that reads the scene on the client thread, and this + // must stay pure so the decision table can cover it. The reachable set already confines the + // answer to the player's immediate surroundings, so a full scan is both cheap and sufficient. + final int maxDistance = stepBudget - 2; + for (int i = 0; i + 1 < rawPath.size(); i++) { + WorldPoint a = rawPath.get(i); + WorldPoint b = rawPath.get(i + 1); + if (a == null || b == null + || a.getPlane() != playerLoc.getPlane() || b.getPlane() != playerLoc.getPlane()) { + continue; + } + // Both ends inside the BFS budget, or "unreachable" only means "far" and the edge is + // innocent. Skip rather than stop: a route may leave and re-enter the budget. + if (playerLoc.distanceTo2D(a) > maxDistance || playerLoc.distanceTo2D(b) > maxDistance) { + continue; + } + if (reachable.containsKey(a) && !reachable.containsKey(b)) { + return new WorldPoint[]{a, b}; + } + } + return null; + } + /** * Selects the next minimap click target from the raw route, gated on collision reachability. *

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 077fb6f5278..320cfee8e6b 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 @@ -2371,4 +2371,59 @@ public void hasReachableNeighbour_toleratesMissingInputs() { assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), null)); assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), new java.util.HashMap<>())); } + + // ---- walled route edge learning (the Sinclair Mansion deadlock) --------------------------------- + + private static java.util.Map reachableSet(WorldPoint... tiles) { + java.util.Map m = new java.util.HashMap<>(); + for (int i = 0; i < tiles.length; i++) { + m.put(tiles[i], i); + } + return m; + } + + /** + * The route steps out of the BFS at b -> that edge is what is actually walled, whatever the shipped + * map claims. Learning it is what turns a permanent refuse/replan oscillation into one replan. + */ + @Test + public void firstWalledRawEdge_findsTheStepThatLeavesTheBfs() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint a = new WorldPoint(2740, 3468, 0); + WorldPoint b = new WorldPoint(2740, 3467, 0); + java.util.List raw = java.util.Arrays.asList(p, a, b, new WorldPoint(2740, 3466, 0)); + WorldPoint[] edge = Rs2Walker.firstWalledRawEdge(raw, p, reachableSet(p, a), 12); + assertNotNull(edge); + assertEquals(a, edge[0]); + assertEquals(b, edge[1]); + } + + /** Every step reachable — nothing is walled, so nothing may be learned. */ + @Test + public void firstWalledRawEdge_allReachableLearnsNothing() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint a = new WorldPoint(2740, 3468, 0); + java.util.List raw = java.util.Arrays.asList(p, a); + assertNull(Rs2Walker.firstWalledRawEdge(raw, p, reachableSet(p, a), 12)); + } + + /** + * Beyond the BFS budget "not reachable" means far away, not walled. Learning there would block a + * perfectly good edge permanently — the failure mode the two-strike store exists to avoid. + */ + @Test + public void firstWalledRawEdge_ignoresStepsBeyondTheBfsBudget() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint far = new WorldPoint(2740, 3449, 0); + java.util.List raw = java.util.Arrays.asList(p, far); + assertNull(Rs2Walker.firstWalledRawEdge(raw, p, reachableSet(p), 12)); + } + + @Test + public void firstWalledRawEdge_toleratesMissingInputs() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + assertNull(Rs2Walker.firstWalledRawEdge(null, p, reachableSet(p), 12)); + assertNull(Rs2Walker.firstWalledRawEdge(java.util.Collections.emptyList(), p, reachableSet(p), 12)); + assertNull(Rs2Walker.firstWalledRawEdge(java.util.Arrays.asList(p), p, null, 12)); + } } From 9f12bcedb2857ba36dcbeb262272b82aa485be8a Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 14:58:41 +0100 Subject: [PATCH 13/53] fix(walker): release the door wait when the DOOR opens, not when we are through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doors could not chain. Every release condition in the traversal wait observes the PLAYER, not the door: isDoorEdgeResolved is "within 1 tile of the far side", "moved closer to the far side", or "stationary, near, and reachable". Despite the name it never looks at the door. So "resolved" means we already walked through, and nothing could consider the next door until we were physically past this one. That is the wrong budget by an order of magnitude. An unlocked door opens within one game tick of the click landing — 0.6s at most. The traversal wait caps at 2200ms, ~3.7 ticks, and it was being spent walking, not opening. Measured cost was doorWait=2250ms per raw scan against doorFind=60ms of actual scanning: the waits, not the work. Observing the door instead releases us the moment it is open, while the server keeps walking us through it — which is the window in which the next door on the route can be clicked. One click per door, at range, chained, which is what a player does. doorStillHasAction already existed for after-the-fact verification and was never a release condition; it is now, via doorObservedOpen. TRANSPORT DOORS (the moves-you class) were the risk worth checking, since the door-scan exclusion is `isCatalogTransportObject && !isDoorLikeSceneObject` — a transport door that is also door-like stays on this path. They keep their action after relocating us, so the new condition stays false and the positional conditions release the wait, which they do at once because being moved is precisely what they detect. The guard in doorObservedOpen is the correctness argument: doorStillHasAction cannot distinguish "the action is gone" from "no object matched", and the second reading happens whenever the probe leaves the scan radius — which would report a shut door as open the moment we drifted. Requiring the door to be observable before trusting the reading removes that. Polling is rationed: nothing to see before the first tick, and the observation is a scene scan rather than a field read, so it runs on an interval instead of every poll. Guardrail baseline regenerated: 20 lines, verified as pure synthetic-lambda renumbering (index-agnostic sets identical, zero non-lambda lines, no violation naming the new code). Needs a live door-heavy run to confirm; door_await now reports releasedBy=door-opened. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 48 +++++++++++++++++-- .../util/walker/door/Rs2WalkerAwaits.java | 47 ++++++++++++++++++ .../util/walker/door/Rs2WalkerAwaitsTest.java | 27 +++++++++++ .../client-thread-guardrail-baseline.txt | 40 ++++++++-------- 4 files changed, 139 insertions(+), 23 deletions(-) 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 43ef6710ae2..c5430ca9702 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 @@ -6190,7 +6190,7 @@ private static boolean handleDoors(List path, int index, boolean all return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (!traversed && isQuestLockedDoorDialogue()) { @@ -6323,7 +6323,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (traversed) { @@ -6360,6 +6360,31 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } + /** + * The door itself is open: its opening action is gone while we are still close enough to see it. + *

+ * The proximity guard is what makes this safe to release a wait on. {@link #doorStillHasAction} + * cannot distinguish "the action is gone" from "no object matched", and the second reading happens + * whenever the probe falls outside the scan radius — which would otherwise report a shut door as + * open the moment we drifted away from it. + *

+ * TRANSPORT DOORS (the moves-you class) stay correct through this. They keep their action after + * relocating us, so this returns false and the positional conditions release the wait instead — + * and those fire at once, because being moved is exactly what they detect. If a relocation is far + * enough to put the door out of scan range, the guard below suppresses the reading rather than + * letting an out-of-range miss masquerade as an opened door. + */ + private static boolean doorObservedOpen(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || probe == null + || player.getPlane() != probe.getPlane() + || player.distanceTo2D(probe) > HANDLER_RANGE) { + return false; + } + return !doorStillHasAction(probe, fromWp, toWp, doorActions, action); + } + private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action) { if (probe == null || action == null) { @@ -7264,10 +7289,27 @@ && isAdjacentSamePlaneTransport(t) */ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { + waitForDoorInteractionProgress(fromWp, toWp, null, null, null); + } + + /** + * Door-identified variant: lets the await release the moment the door is OPEN rather than when we + * have finished walking through it. An unlocked door opens within a game tick, so the traversal + * that used to be waited out is time the server is already spending walking us — time in which the + * next door on the route could be clicked. Falls back to the positional conditions when the door + * cannot be identified or the config switch is off. + */ + private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp, + WorldPoint probe, List doorActions, + String action) { long startedAt = System.currentTimeMillis(); AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); + java.util.function.BooleanSupplier doorOpened = + (probe == null || action == null || !doorInteractionWhileApproachingEnabled()) + ? null + : () -> doorObservedOpen(probe, fromWp, toWp, doorActions, action); try { - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened); } finally { if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 17feaa108c8..a11d87e593c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -18,6 +18,13 @@ public final class Rs2WalkerAwaits { private static final long DOOR_IDLE_ACCEPT_MIN_MS = 1_200L; /** Above this combined wait, say which condition released the door await. */ private static final long DOOR_AWAIT_SLOW_LOG_MS = 900L; + /** + * An unlocked door opens within one game tick of the click landing, so there is nothing to observe + * before then and polling earlier only spends client-thread time. Checked on an interval rather + * than every poll because the observation is a scene scan (~60ms measured), not a field read. + */ + private static final long DOOR_OPEN_POLL_START_MS = 250L; + private static final long DOOR_OPEN_POLL_INTERVAL_MS = 250L; private Rs2WalkerAwaits() { } @@ -38,6 +45,15 @@ private static boolean conversationOpened() { } public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, null); + } + + /** + * @param doorOpened observes the DOOR (its "Open" action is gone), as opposed to every other + * release condition here, which observes the PLAYER. May be {@code null}. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened) { if (ticket == null) { return; } @@ -61,6 +77,7 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // "doors feel slow" into a specific target — the same play that took the transport problem // from four rounds of guessing to a one-shot fix. final String[] releasedBy = {"timeout"}; + final long[] lastOpenPollAt = {0L}; long traversalPhaseAt = System.currentTimeMillis(); sleepUntil(() -> { if (Thread.currentThread().isInterrupted() || conversationOpened()) { @@ -76,6 +93,22 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f releasedBy[0] = "edge-resolved"; return true; } + // Every condition around this one observes the PLAYER — "edge resolved" means we already + // walked through. That is why doors could not chain: nothing could look at the next door + // until we were physically past this one, so each door cost a full approach plus traversal + // instead of the single tick the door itself takes. Observing the DOOR releases us as soon + // as it is open, while the server keeps walking us through, so the next door on the route + // can be clicked immediately. Throttled because this one is a scene scan, not a field read. + if (doorOpened != null) { + long nowMs = System.currentTimeMillis(); + if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { + lastOpenPollAt[0] = nowMs; + if (doorOpened.getAsBoolean()) { + releasedBy[0] = "door-opened"; + return true; + } + } + } if (Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { releasedBy[0] = "arrived-far-side"; return true; @@ -119,6 +152,20 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f * {@code edgeResolved} is retained in the signature because callers pass their own observation * and it keeps the decision table explicit about the case that used to be the only one accepted. */ + /** + * Whether to spend a door-open observation on this poll. + *

+ * Two rules, both about cost rather than correctness. An unlocked door opens within one game tick + * of the click landing, so an observation before {@link #DOOR_OPEN_POLL_START_MS} can only ever + * report "still shut" and is pure waste. And the observation is a scene scan (~60ms measured), not + * a field read, so at the poll rate of the surrounding wait it would otherwise run several times a + * second for the whole budget — the cost that made door handling expensive in the first place. + */ + static boolean shouldPollDoorOpen(long sinceTraversalStartMs, long sinceLastPollMs) { + return sinceTraversalStartMs >= DOOR_OPEN_POLL_START_MS + && sinceLastPollMs >= DOOR_OPEN_POLL_INTERVAL_MS; + } + @SuppressWarnings("unused") static boolean shouldAcceptIdleDoorAwait(boolean moving, boolean animating, long elapsedMs, boolean edgeResolved) { if (moving || animating) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java index e77d06f98e7..98d7ec474c0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -33,4 +33,31 @@ public void shouldAcceptIdleDoorAwait_rejectsBeforeMinimumElapsed() { assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1200L, true)); assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 800L, true)); } + + // ---- door-open observation throttle ------------------------------------------------------------- + + /** + * An unlocked door opens within one game tick, so an observation before then can only report + * "still shut". The observation is a scene scan, not a field read, which is why it is rationed at + * all rather than run on every poll of the surrounding wait. + */ + @Test + public void shouldPollDoorOpen_notBeforeADoorCouldHaveOpened() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(0L, 10_000L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(100L, 10_000L)); + } + + @Test + public void shouldPollDoorOpen_onceTheFirstTickHasPassed() { + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(250L, 10_000L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(600L, 10_000L)); + } + + /** Rationed: a fresh observation is not worth a scene scan on every poll of the wait. */ + @Test + public void shouldPollDoorOpen_notMoreOftenThanTheInterval() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 0L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 100L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 250L)); + } } 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 a3768ffeb3f..a60b868d38c 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 @@ -803,34 +803,34 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$185(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$190(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$210(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$179(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$181(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$186(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$189(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$211(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$180(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$149(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$116(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$117(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$162(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$163(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$164(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$68(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint From a8633ad2262cdb56f7ce1100f361b18734d2578b Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 15:29:20 +0100 Subject: [PATCH 14/53] fix(walker): ask whether THIS door opened, not whether any door nearby is shut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live run produced zero releasedBy=door-opened, so the previous commit did nothing. The build was loaded — the classes carry the symbols and the client started after them — and the config defaults on, so the observation ran and always answered "still closed". It delegated to doorStillHasAction, whose predicate accepts any door-like object within TWO tiles of the probe. That is right for its own job (verify, then retry) and wrong as a release condition: in a door-heavy area a neighbouring shut door answers for the one we clicked, and the answer never changes. Measured on the second door of the run — released by progress at 1422ms after roughly five polls, on a door that was open by the first of them. Matching the probe tile itself, or the geometry of the edge being crossed, asks about the door the click was aimed at. Threaded as a flag through the existing predicate rather than a second near-identical one: the guardrail delta is then the same accepted violation with one more parameter, not a new entry to grandfather. The walker has 75 door methods already; another copy of this one helps nobody. door_await now carries openPolls, because a release that is not door-opened was ambiguous between "the observation never ran" and "it ran and the door was shut", and that ambiguity cost the run. Not yet fixed, from the same log: the FIRST door never opened at all (releasedBy=timeout, traversalWaitMs=2683, no progress) — the click did not land, and the recovery/interim clicks that followed are downstream of that stall, not an independent scheduler race. Guardrail baseline: one line, the signature change, verified as the same method and same target with no new violation. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 49 +++++++++++++------ .../util/walker/door/Rs2WalkerAwaits.java | 9 +++- .../client-thread-guardrail-baseline.txt | 2 +- 3 files changed, 43 insertions(+), 17 deletions(-) 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 c5430ca9702..f8e751a8261 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 @@ -6361,32 +6361,49 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, } /** - * The door itself is open: its opening action is gone while we are still close enough to see it. + * THE door we clicked is open — not "some door near here is open". *

- * The proximity guard is what makes this safe to release a wait on. {@link #doorStillHasAction} - * cannot distinguish "the action is gone" from "no object matched", and the second reading happens - * whenever the probe falls outside the scan radius — which would otherwise report a shut door as - * open the moment we drifted away from it. + * The first version of this delegated to {@link #doorStillHasAction}, whose predicate accepts any + * door-like object within TWO tiles of the probe. That is right for its own job (verify, then + * retry) but wrong as a release condition, and it is why the first live run produced no + * {@code releasedBy=door-opened} at all: in a door-heavy area a neighbouring shut door keeps the + * answer "still closed" forever, so the wait ran on to its positional conditions exactly as before. + * Matching on the probe tile itself, or on the geometry of the edge we are crossing, asks about the + * one door the click was aimed at. + *

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

* TRANSPORT DOORS (the moves-you class) stay correct through this. They keep their action after - * relocating us, so this returns false and the positional conditions release the wait instead — - * and those fire at once, because being moved is exactly what they detect. If a relocation is far - * enough to put the door out of scan range, the guard below suppresses the reading rather than - * letting an out-of-range miss masquerade as an opened door. + * relocating us, so this stays false and the positional conditions release the wait instead — and + * those fire at once, because being moved is precisely what they detect. */ private static boolean doorObservedOpen(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action) { WorldPoint player = Rs2Player.getWorldLocation(); - if (player == null || probe == null + if (player == null || probe == null || action == null || player.getPlane() != probe.getPlane() || player.distanceTo2D(probe) > HANDLER_RANGE) { return false; } - return !doorStillHasAction(probe, fromWp, toWp, doorActions, action); + return !doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); } private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action) { + return doorStillHasAction(probe, fromWp, toWp, doorActions, action, false); + } + + /** + * @param strictTile match only the door ON the probe tile or ON the {@code fromWp -> toWp} edge, + * instead of anything within two tiles. Required when the answer decides whether + * THIS door opened; the loose radius lets a neighbouring shut door answer for it. + */ + private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action, boolean strictTile) { if (probe == null || action == null) { return false; } @@ -6396,7 +6413,8 @@ private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, W anchor = probe; } - TileObject object = Rs2GameObject.getAll(o -> doorObjectStillHasAction(o, probe, fromWp, toWp, doorActions, action), + TileObject object = Rs2GameObject.getAll( + o -> doorObjectStillHasAction(o, probe, fromWp, toWp, doorActions, action, strictTile), anchor, Math.max(3, HANDLER_RANGE)) .stream() .findFirst() @@ -6405,7 +6423,7 @@ private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, W } private static boolean doorObjectStillHasAction(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, String action) { + List doorActions, String action, boolean strictTile) { if (object == null || object.getWorldLocation() == null || action == null) { return false; } @@ -6419,7 +6437,10 @@ private static boolean doorObjectStillHasAction(TileObject object, WorldPoint pr if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) { return false; } - boolean nearProbe = probe != null && loc.distanceTo2D(probe) <= 2; + // The two-tile radius is right for "is anything here still shut" (verify, then retry) but wrong + // for "did THIS door open" — a neighbouring shut door answers for it and the answer never changes. + boolean nearProbe = probe != null + && (strictTile ? loc.equals(probe) : loc.distanceTo2D(probe) <= 2); boolean onSegment = fromWp != null && toWp != null && Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp); if (!nearProbe && !onSegment) { return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index a11d87e593c..102bb7cdeb5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -78,6 +78,10 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // from four rounds of guessing to a one-shot fix. final String[] releasedBy = {"timeout"}; final long[] lastOpenPollAt = {0L}; + // Carried into the slow log: a release that is NOT door-opened is ambiguous between "the + // observation never ran" and "it ran and the door was shut", and the first live run could not + // tell those apart. The count settles it without another round trip. + final int[] openPolls = {0}; long traversalPhaseAt = System.currentTimeMillis(); sleepUntil(() -> { if (Thread.currentThread().isInterrupted() || conversationOpened()) { @@ -103,6 +107,7 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f long nowMs = System.currentTimeMillis(); if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { lastOpenPollAt[0] = nowMs; + openPolls[0]++; if (doorOpened.getAsBoolean()) { releasedBy[0] = "door-opened"; return true; @@ -131,8 +136,8 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f long traversalWaitMs = System.currentTimeMillis() - traversalPhaseAt; if (startWaitMs + traversalWaitMs >= DOOR_AWAIT_SLOW_LOG_MS) { - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, fromWp, toWp); + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], fromWp, toWp); } } 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 a60b868d38c..03b9adfeb5d 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 @@ -738,7 +738,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesD net.runelite.client.plugins.microbot.util.walker.Rs2Walker#closeWorldMap(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.WorldView#getPlane(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean From 517d7614a3aaac627f02b6a5e718acd94cedab14 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 16:06:24 +0100 Subject: [PATCH 15/53] chore(walker): make the door observation say what it read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live runs have ended without a single releasedBy=door-opened. openPolls proved the check RUNS (2-3 polls per door) but not what it read, so the tighten to the probe tile could not be told apart from the check being structurally unable to see an open door. Inferring the difference has now failed three times — classifier, impostor resolution and neighbour radius all read as correct on inspection. door_await gains saw=, evaluated once and only when the release was not door-opened: strict=false the check said OPEN, so a non-door-opened release is plumbing, not observation strict=true the door on this very tile still offers the opening action strict!=loose the tighten worked and a neighbouring door had been answering Deliberately built from the two existing readings rather than by enumerating scene objects: the enumeration version called TileObject#getWorldLocation() in a new method and would have added guardrail entries for a temporary diagnostic. This adds none — the baseline delta is 20 lines of pure synthetic-lambda renumbering, verified. Costs one extra scan per slow door await, and only on the awaits that already log. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 40 ++++++++++++++++++- .../util/walker/door/Rs2WalkerAwaits.java | 25 +++++++++++- .../client-thread-guardrail-baseline.txt | 40 +++++++++---------- 3 files changed, 82 insertions(+), 23 deletions(-) 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 f8e751a8261..9e875d69e0b 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 @@ -6392,6 +6392,41 @@ private static boolean doorObservedOpen(WorldPoint probe, WorldPoint fromWp, Wor return !doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); } + /** + * What the door observation actually sees, for the {@code door_await} log. + *

+ * Two live runs have now ended without a single {@code releasedBy=door-opened}, and neither could + * say why: the poll count proves the check ran, but not what it read. This names every object the + * strict match considers and the action currently on it, which separates the remaining candidates + * — nothing matched the tile at all, versus something matched and still offers the opening action. + */ + private static String describeDoorObservation(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, + List doorActions, String action) { + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || probe == null) { + return "no-player"; + } + if (player.getPlane() != probe.getPlane()) { + return "plane-mismatch"; + } + int distance = player.distanceTo2D(probe); + if (distance > HANDLER_RANGE) { + return "out-of-range dist=" + distance; + } + try { + // Only the two existing readings, so this adds no new off-client-thread call site of its + // own. They separate the remaining candidates on their own: + // strict=false -> the check said OPEN, so a release that is not door-opened is plumbing + // strict=true -> the door on this very tile still offers the opening action + // strict!=loose -> the tighten worked and a neighbour was answering before + boolean strict = doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); + boolean loose = doorStillHasAction(probe, fromWp, toWp, doorActions, action, false); + return "strict=" + strict + " loose=" + loose + " dist=" + distance + " want=" + action; + } catch (RuntimeException ex) { + return "scan-error:" + ex.getClass().getSimpleName(); + } + } + private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action) { return doorStillHasAction(probe, fromWp, toWp, doorActions, action, false); @@ -7329,8 +7364,11 @@ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint (probe == null || action == null || !doorInteractionWhileApproachingEnabled()) ? null : () -> doorObservedOpen(probe, fromWp, toWp, doorActions, action); + java.util.function.Supplier observation = + (probe == null || action == null) ? null + : () -> describeDoorObservation(probe, fromWp, toWp, doorActions, action); try { - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened); + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation); } finally { if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 102bb7cdeb5..8709ddd9972 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -54,6 +54,18 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f */ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, java.util.function.BooleanSupplier doorOpened) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, null); + } + + /** + * @param doorObservation describes what the door observation last SAW, carried onto the slow log. + * Two live runs failed to explain why {@code door-opened} never fires, and + * "the poll ran and said no" is not an explanation without the reading + * behind it. Evaluated once, only when the log is about to print. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation) { if (ticket == null) { return; } @@ -136,8 +148,17 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f long traversalWaitMs = System.currentTimeMillis() - traversalPhaseAt; if (startWaitMs + traversalWaitMs >= DOOR_AWAIT_SLOW_LOG_MS) { - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], fromWp, toWp); + String saw = "-"; + if (doorObservation != null && !"door-opened".equals(releasedBy[0])) { + try { + String detail = doorObservation.get(); + saw = detail == null ? "-" : detail; + } catch (RuntimeException ignored) { + saw = "error"; + } + } + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} saw={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], saw, fromWp, toWp); } } 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 03b9adfeb5d..5faedc70555 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 @@ -803,34 +803,34 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$186(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$189(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$211(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$180(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$149(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$187(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$190(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$192(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$212(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$181(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$183(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$150(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$117(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$163(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$164(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$165(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$70(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 From eb0ce2c568081cfdfad2f65774361d318f0ecc58 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 16:48:34 +0100 Subject: [PATCH 16/53] feat(walker): ask collision whether the door is open, not the door's menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walker had no direct reading of "this door is open". Every release condition in the traversal wait observes the PLAYER, and isDoorEdgeResolved — despite its name — is position only: within a tile of the far side, closer to the far side than the near one, or stationary-and-reachable. It learns a door opened by having already walked through it, which is exactly why doors cannot chain. The client's own collision data answers this directly. It is server-driven, so a door that opens clears its movement-block flag on that tick, and Rs2Tile already reads those flags for reachability. Rs2Tile.isEdgePassable asks about the one from->to step. Deliberately NOT isTileReachable, which was the existing (and only) collision-flavoured condition. That runs a BFS, so a still-shut door with a long way round reports the far tile as reachable and would release the wait having gone nowhere near the door; it also costs a scene search per call. One flag read has neither problem. This supersedes the object-action check as the primary signal. Two live runs produced no releasedBy=door-opened at all, and three rounds of inspecting the classifier, the impostor resolution and the match radius explained none of it. Collision does not care whether a door's menu text changes when it opens, which is the assumption that kept failing. The action check stays as a fallback rather than being removed, so nothing regresses where it did work. Unknowns answer false — off-scene, wrong plane, or an instance, where raw coordinates make the scene conversion unreliable. Callers release early on a true, so an unknown must never read as open; those cases fall through to the positional conditions as before. The collision rule is split into a pure isStepAllowed and covered by a decision table: per-direction flags (a door blocking north must not read as blocking east), stepping into a fully blocked tile, and diagonals not cutting corners. Guardrail baseline: 6 entries for isEdgePassableInternal plus one lambda renumber. Same scanner limitation as the already-baselined isTileReachableInternal beside it — the body is only reached through runClientReadBoolean, which runs on the client thread or hops via getClientThread().invoke(), verified before accepting the entries. Co-Authored-By: Claude Opus 5 --- .../plugins/microbot/util/tile/Rs2Tile.java | 74 ++++++++++++ .../util/walker/door/Rs2WalkerAwaits.java | 25 ++-- .../util/tile/Rs2TileEdgePassableTest.java | 112 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 8 +- 4 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/tile/Rs2TileEdgePassableTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 3a8a22ef202..c5a9f9003be 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -503,6 +503,80 @@ public static boolean isTileReachable(WorldPoint targetPoint) { return runClientReadBoolean(() -> isTileReachableInternal(targetPoint)); } + /** + * Whether a single step from {@code from} to {@code to} is currently permitted by the CLIENT's + * collision data — the live flags the server drives, so a door that has just opened clears its + * blocking flag here on the same tick. + *

+ * This is the direct answer to "can I walk through that door now", and it is deliberately not + * {@link #isTileReachable}: that runs a BFS, so a shut door with a long way round it still reports + * the far tile as reachable, and it costs a whole scene search. This reads one flag. + *

+ * Answers {@code false} for anything it cannot decide — off-scene, an instance (raw coordinates + * make the scene conversion unreliable), or a plane other than the one loaded. Callers use it to + * release early, so an unknown must never read as "open". + * + * @return true only when the step is known to be unobstructed + */ + public static boolean isEdgePassable(WorldPoint from, WorldPoint to) { + return runClientReadBoolean(() -> isEdgePassableInternal(from, to)); + } + + private static boolean isEdgePassableInternal(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) return false; + + final int dx = to.getX() - from.getX(); + final int dy = to.getY() - from.getY(); + if (dx == 0 && dy == 0) return true; + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) return false; + + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); + if (wv == null || wv.getPlane() != from.getPlane()) return false; + // Instance scenes repeat template chunks, so world -> scene by base offset is wrong there. + if (wv.getScene() != null && wv.getScene().isInstance()) return false; + + final int[][] flags = getFlagsInternal(); + if (flags == null) return false; + + final int fx = from.getX() - Microbot.getClient().getBaseX(); + final int fy = from.getY() - Microbot.getClient().getBaseY(); + final int tx = fx + dx; + final int ty = fy + dy; + if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) return false; + + return isStepAllowed(flags, fx, fy, dx, dy); + } + + /** + * The collision rule alone, with no client reads: is a single {@code (dx, dy)} step out of + * {@code (fx, fy)} unobstructed by these flags? Split out so the cardinal/diagonal rules are + * covered by a decision table rather than only by a live client. + */ + static boolean isStepAllowed(int[][] flags, int fx, int fy, int dx, int dy) { + if (dx == 0 && dy == 0) return true; + final int tx = fx + dx; + final int ty = fy + dy; + + if ((flags[tx][ty] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) != 0) return false; + + if (dx == 0 || dy == 0) { + return (flags[fx][fy] & cardinalBlockFlag(dx, dy)) == 0; + } + // Diagonal: both cardinal components must be clear, and so must the two tiles cut through — + // the same rule the reachability search uses for corners. + return (flags[fx][fy] & cardinalBlockFlag(dx, 0)) == 0 + && (flags[fx][fy] & cardinalBlockFlag(0, dy)) == 0 + && (flags[tx][fy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(0, dy))) == 0 + && (flags[fx][ty] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(dx, 0))) == 0; + } + + private static int cardinalBlockFlag(int dx, int dy) { + if (dx > 0) return CollisionDataFlag.BLOCK_MOVEMENT_EAST; + if (dx < 0) return CollisionDataFlag.BLOCK_MOVEMENT_WEST; + if (dy > 0) return CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + return CollisionDataFlag.BLOCK_MOVEMENT_SOUTH; + } + private static boolean isTileReachableInternal(WorldPoint targetPoint) { if (targetPoint == null) return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 8709ddd9972..ecacb997917 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -115,15 +115,22 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // instead of the single tick the door itself takes. Observing the DOOR releases us as soon // as it is open, while the server keeps walking us through, so the next door on the route // can be clicked immediately. Throttled because this one is a scene scan, not a field read. - if (doorOpened != null) { - long nowMs = System.currentTimeMillis(); - if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { - lastOpenPollAt[0] = nowMs; - openPolls[0]++; - if (doorOpened.getAsBoolean()) { - releasedBy[0] = "door-opened"; - return true; - } + long nowMs = System.currentTimeMillis(); + if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { + lastOpenPollAt[0] = nowMs; + openPolls[0]++; + // The collision edge is the authoritative answer to "can I walk through it now": the + // client's flags are server-driven, so an opened door clears its block on the same + // tick. Asked first because it is one flag read, and because it holds for doors whose + // menu actions do not change when they open — the case the action check below could + // never explain across two live runs. + if (Rs2Tile.isEdgePassable(fromWp, toWp)) { + releasedBy[0] = "door-edge-open"; + return true; + } + if (doorOpened != null && doorOpened.getAsBoolean()) { + releasedBy[0] = "door-opened"; + return true; } } if (Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/tile/Rs2TileEdgePassableTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/tile/Rs2TileEdgePassableTest.java new file mode 100644 index 00000000000..fae78ec9ee7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/tile/Rs2TileEdgePassableTest.java @@ -0,0 +1,112 @@ +package net.runelite.client.plugins.microbot.util.tile; + +import net.runelite.api.CollisionDataFlag; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The collision rule behind "can I walk through that door now". + *

+ * This is the signal the door waits release on, so a wrong answer either strands the walker in front + * of an open door or sends it on while the door is still shut. The walker previously had no direct + * reading at all — it inferred an opened door from the player having already moved through it, which + * is why doors could not be chained. + */ +public class Rs2TileEdgePassableTest { + + private static final int SIZE = 8; + private static final int FROM_X = 3; + private static final int FROM_Y = 3; + + private static int[][] openField() { + return new int[SIZE][SIZE]; + } + + // ---- cardinal steps: the door case --------------------------------------------------------- + + @Test + public void cardinalStepIsAllowedAcrossAnOpenEdge() { + int[][] flags = openField(); + assertTrue("north", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 1)); + assertTrue("south", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, -1)); + assertTrue("east", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 1, 0)); + assertTrue("west", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, -1, 0)); + } + + /** A shut door sets the blocking flag for its direction on the tile you are stepping OUT of. */ + @Test + public void cardinalStepIsRefusedWhenThatDirectionIsBlocked() { + int[][] flags = openField(); + flags[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + assertFalse("the blocked direction", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 1)); + assertTrue("every other direction stays open", Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 1, 0)); + assertTrue(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, -1)); + } + + /** + * Each direction has its own flag, so a door blocking north must not be read as blocking east. + * Getting this wrong would release the wait on the wrong door edge entirely. + */ + @Test + public void eachDirectionReadsItsOwnFlag() { + int[][] flags = openField(); + flags[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_EAST; + assertFalse(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 1, 0)); + assertTrue(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 1)); + + flags[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_WEST; + assertFalse(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, -1, 0)); + assertTrue(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 1, 0)); + + flags[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_SOUTH; + assertFalse(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, -1)); + assertTrue(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 1)); + } + + /** Somewhere you cannot stand is not somewhere you can step, however open the edge is. */ + @Test + public void stepIntoAFullyBlockedTileIsRefused() { + int[][] flags = openField(); + flags[FROM_X][FROM_Y + 1] = CollisionDataFlag.BLOCK_MOVEMENT_FULL; + assertFalse(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 1)); + } + + // ---- diagonals: corners may not be cut ------------------------------------------------------ + + @Test + public void diagonalIsAllowedWhenBothComponentsAreOpen() { + assertTrue(Rs2Tile.isStepAllowed(openField(), FROM_X, FROM_Y, 1, 1)); + } + + @Test + public void diagonalIsRefusedWhenEitherComponentIsBlocked() { + int[][] north = openField(); + north[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + assertFalse(Rs2Tile.isStepAllowed(north, FROM_X, FROM_Y, 1, 1)); + + int[][] east = openField(); + east[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_EAST; + assertFalse(Rs2Tile.isStepAllowed(east, FROM_X, FROM_Y, 1, 1)); + } + + /** The two tiles the diagonal cuts through must also permit it — no squeezing past a corner. */ + @Test + public void diagonalIsRefusedWhenTheCutTilesBlockIt() { + int[][] flags = openField(); + flags[FROM_X + 1][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_FULL; + assertFalse(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 1, 1)); + + int[][] other = openField(); + other[FROM_X][FROM_Y + 1] = CollisionDataFlag.BLOCK_MOVEMENT_FULL; + assertFalse(Rs2Tile.isStepAllowed(other, FROM_X, FROM_Y, 1, 1)); + } + + @Test + public void steppingNowhereIsAlwaysAllowed() { + int[][] flags = openField(); + flags[FROM_X][FROM_Y] = CollisionDataFlag.BLOCK_MOVEMENT_FULL; + assertTrue(Rs2Tile.isStepAllowed(flags, FROM_X, FROM_Y, 0, 0)); + } +} 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 5faedc70555..3ccb2341df4 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 @@ -690,6 +690,12 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int) net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isBankBoothInternal(WorldPoint): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView @@ -704,7 +710,7 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boo net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$32(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$33(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] From 4e0444caf11caeedb66c55ba1c99a2feca2e88eb Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 17:45:18 +0100 Subject: [PATCH 17/53] chore(walker): record WHY the door edge read decides, at the moment it decides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision edge check never released a door either — openPolls 1, 5 and 4 across three doors, no door-edge-open. A bare false from isEdgePassable is ambiguous between "the door is shut" and "this could not be decided" (instance, plane not loaded, off-scene), and those want opposite responses: the first means wait, the second means the signal is unavailable here and the whole approach needs rethinking. isEdgePassable now records its decision — open / blocked / instance / plane-not-loaded / off-scene / no-flags / not-adjacent — and door_await carries it as edge=. Also fixes a flaw in the previous diagnostic. saw= was evaluated when the log printed, which is AFTER the wait released, so it described the wrong instant: one run reported strict=true loose=false, a pair the predicate cannot produce, because the two scans happened either side of the door changing. The edge decision is captured at poll time. No new guardrail entries: the recording sits inside the existing isEdgePassableInternal rather than in a new method with its own client reads. Co-Authored-By: Claude Opus 5 --- .../plugins/microbot/util/tile/Rs2Tile.java | 55 ++++++++++++++++--- .../util/walker/door/Rs2WalkerAwaits.java | 12 +++- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index c5a9f9003be..5e9d34033ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -522,29 +522,68 @@ public static boolean isEdgePassable(WorldPoint from, WorldPoint to) { return runClientReadBoolean(() -> isEdgePassableInternal(from, to)); } + /** + * Why the last {@link #isEdgePassable} call answered as it did. A bare {@code false} is ambiguous + * between "the door is shut" and "this could not be decided", and callers that release a wait on + * {@code true} behave very differently depending on which it was. + */ + private static volatile String lastEdgeDecision = "-"; + + /** @see #lastEdgeDecision */ + public static String lastEdgeDecision() { + return lastEdgeDecision; + } + private static boolean isEdgePassableInternal(WorldPoint from, WorldPoint to) { - if (from == null || to == null || from.getPlane() != to.getPlane()) return false; + if (from == null || to == null || from.getPlane() != to.getPlane()) { + lastEdgeDecision = "bad-args"; + return false; + } final int dx = to.getX() - from.getX(); final int dy = to.getY() - from.getY(); - if (dx == 0 && dy == 0) return true; - if (Math.abs(dx) > 1 || Math.abs(dy) > 1) return false; + if (dx == 0 && dy == 0) { + lastEdgeDecision = "same-tile"; + return true; + } + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { + lastEdgeDecision = "not-adjacent"; + return false; + } final WorldView wv = Microbot.getClient().getTopLevelWorldView(); - if (wv == null || wv.getPlane() != from.getPlane()) return false; + if (wv == null) { + lastEdgeDecision = "no-worldview"; + return false; + } + if (wv.getPlane() != from.getPlane()) { + lastEdgeDecision = "plane-not-loaded"; + return false; + } // Instance scenes repeat template chunks, so world -> scene by base offset is wrong there. - if (wv.getScene() != null && wv.getScene().isInstance()) return false; + if (wv.getScene() != null && wv.getScene().isInstance()) { + lastEdgeDecision = "instance"; + return false; + } final int[][] flags = getFlagsInternal(); - if (flags == null) return false; + if (flags == null) { + lastEdgeDecision = "no-flags"; + return false; + } final int fx = from.getX() - Microbot.getClient().getBaseX(); final int fy = from.getY() - Microbot.getClient().getBaseY(); final int tx = fx + dx; final int ty = fy + dy; - if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) return false; + if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) { + lastEdgeDecision = "off-scene"; + return false; + } - return isStepAllowed(flags, fx, fy, dx, dy); + boolean allowed = isStepAllowed(flags, fx, fy, dx, dy); + lastEdgeDecision = allowed ? "open" : "blocked"; + return allowed; } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index ecacb997917..52d63eaf82c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -94,6 +94,7 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // observation never ran" and "it ran and the door was shut", and the first live run could not // tell those apart. The count settles it without another round trip. final int[] openPolls = {0}; + final String[] lastEdge = {"-"}; long traversalPhaseAt = System.currentTimeMillis(); sleepUntil(() -> { if (Thread.currentThread().isInterrupted() || conversationOpened()) { @@ -124,7 +125,12 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // tick. Asked first because it is one flag read, and because it holds for doors whose // menu actions do not change when they open — the case the action check below could // never explain across two live runs. - if (Rs2Tile.isEdgePassable(fromWp, toWp)) { + boolean edgeOpen = Rs2Tile.isEdgePassable(fromWp, toWp); + // Captured HERE, not when the log prints: the previous diagnostic read the door after + // the wait had already released and reported the state at the wrong instant (it + // produced a strict/loose pair that the predicate cannot actually produce). + lastEdge[0] = Rs2Tile.lastEdgeDecision(); + if (edgeOpen) { releasedBy[0] = "door-edge-open"; return true; } @@ -164,8 +170,8 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f saw = "error"; } } - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} saw={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], saw, fromWp, toWp); + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} edge={} saw={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], lastEdge[0], saw, fromWp, toWp); } } From 0d9a06538e42c1e1331d7d36dfaa459252f74f91 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 18:06:25 +0100 Subject: [PATCH 18/53] fix(walker): hold a ranged door click through its approach, release on a door outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edge= data closed the case: every "still shut" reading during a ranged click's wait was CORRECT. A door's 0-1 tick opening is measured from the interaction, not the click — when the click is issued from range the server first walks us over, and the door is genuinely shut for that entire approach. There was never a broken observation; there was a wait whose shape assumed the click happened from adjacent. Two concrete failures follow from that shape, both measured live: - The flat 2200ms cap expired mid-approach (releasedBy=timeout at 11 tiles with the player still walking), handing the recovery machinery its window — the competing replan/interim clicks the user called "a competing race for recovery". - The positional conditions release BEFORE the door opens: "progress" at Chebyshev 2 mid-approach, "edge-resolved" on reaching the near side. The release fails verification (door still shut), and the door is interacted a second time from adjacent — two full interactions per ranged door (1139ms + 2939ms on the same door in one run). A ranged click's wait is now an APPROACH: the budget is sized by the click distance at walking pace (capped 8s), and the positional conditions are disabled — it releases only on a DOOR outcome (collision edge open, opening action gone, conversation) or on a stall (idle-accept, unchanged) / walk cancellation (new, since budgets can now reach seconds where the flat cap bounded a stale hold at 2.2s). Adjacent clicks keep today's conditions and budget exactly. The click distance comes from the await ticket's before-position, so no call site changes. A "blocked" edge reading now also skips the scene-scan fallback — it is definitive, and the fallback only earns its scan when the edge cannot be decided. On release the door is open with the player beside it: verification passes on the first attempt, the cross-nudge issues the follow-through click, and the next door on the route is immediately clickable. That is the click-walk-click chain, with the double-interaction serialization deleted. Budget rule is a decision table (adjacent unchanged / approach-time scaling / hard cap). Guardrail baseline: pure lambda renumbering, verified. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 10 +- .../util/walker/door/Rs2WalkerAwaits.java | 91 +++++++++++++++---- .../util/walker/door/Rs2WalkerAwaitsTest.java | 30 ++++++ .../client-thread-guardrail-baseline.txt | 40 ++++---- 4 files changed, 130 insertions(+), 41 deletions(-) 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 9e875d69e0b..b5414293f0f 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 @@ -7367,8 +7367,16 @@ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint java.util.function.Supplier observation = (probe == null || action == null) ? null : () -> describeDoorObservation(probe, fromWp, toWp, doorActions, action); + // Ranged budgets can hold for seconds, so a walk that was cancelled or re-targeted mid-await + // must release it: currentTarget is captured NOW, and the supplier answers true the moment + // that walk stops being the active one. + WorldPoint walkTarget = currentTarget; + // isWalkCancelled(null) answers true, and a door can legitimately be handled outside a walk + // session (recovery paths); no target means there is nothing to be cancelled. + java.util.function.BooleanSupplier cancelled = + walkTarget == null ? null : () -> isWalkCancelled(walkTarget); try { - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation); + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation, cancelled); } finally { if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 52d63eaf82c..08589967d1e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -25,6 +25,32 @@ public final class Rs2WalkerAwaits { */ private static final long DOOR_OPEN_POLL_START_MS = 250L; private static final long DOOR_OPEN_POLL_INTERVAL_MS = 250L; + /** + * A click issued from further than the legacy dispatch band is a RANGED click: the server has to + * walk us to the door before anything can open, so the wait is an approach, not a traversal. + */ + static final int RANGED_CLICK_MIN_TILES = 3; + /** Walking pace is one tile per 0.6s; running arrives sooner and releases early via the edge read. */ + private static final long APPROACH_MS_PER_TILE = 600L; + /** Hard ceiling on any door await. The stall release keeps long budgets from ever stranding us. */ + private static final long DOOR_TRAVERSAL_MAX_BUDGET_MS = 8_000L; + + /** + * How long the traversal phase may hold, given how far from the door the click was issued. + *

+ * The flat 2200ms cap was sized for adjacent clicks — walk a step, door opens, step through. A + * ranged click spends its first seconds being WALKED to the door by the server, so the flat cap + * expired mid-approach: the wait released by timeout, the recovery machinery got its window (the + * competing-clicks race), and the door was then handled a second time from adjacent. Measured as + * two full interactions per ranged door. + */ + static long traversalBudgetMs(int clickDistanceTiles) { + if (clickDistanceTiles < RANGED_CLICK_MIN_TILES) { + return DOOR_TRAVERSAL_PROGRESS_WAIT_MS; + } + return Math.min(DOOR_TRAVERSAL_PROGRESS_WAIT_MS + (clickDistanceTiles - 2) * APPROACH_MS_PER_TILE, + DOOR_TRAVERSAL_MAX_BUDGET_MS); + } private Rs2WalkerAwaits() { } @@ -66,6 +92,18 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, java.util.function.BooleanSupplier doorOpened, java.util.function.Supplier doorObservation) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, doorObservation, null); + } + + /** + * @param cancelled the walk this door belongs to was cancelled or re-targeted; holding an await + * for a route that no longer exists serves nobody. Matters now that ranged + * budgets can reach seconds where the flat cap bounded the stale hold at 2.2s. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation, + java.util.function.BooleanSupplier cancelled) { if (ticket == null) { return; } @@ -95,55 +133,68 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // tell those apart. The count settles it without another round trip. final int[] openPolls = {0}; final String[] lastEdge = {"-"}; + + // A ranged click is an APPROACH, not a traversal: the server walks us to the door, the door + // opens on arrival (its 0-1 tick is measured from the interaction, not from the click), and + // only then is there anything to traverse. Live edge= data proved every "still shut" reading + // during the walk was CORRECT — the door genuinely is shut until we get there. The positional + // conditions are therefore wrong for this phase: "progress" fired at Chebyshev 2 mid-approach + // and "edge-resolved" fires on reaching the near side, both before the door opened, so every + // ranged door failed verification and was interacted twice. While approaching, a ranged wait + // holds until a DOOR outcome (edge open / opening action gone / conversation) or a stall. + final int clickDistance = ticket.beforePosition() == null || fromWp == null + || ticket.beforePosition().getPlane() != fromWp.getPlane() + ? 0 + : ticket.beforePosition().distanceTo2D(fromWp); + final boolean ranged = clickDistance >= RANGED_CLICK_MIN_TILES; + long traversalPhaseAt = System.currentTimeMillis(); sleepUntil(() -> { if (Thread.currentThread().isInterrupted() || conversationOpened()) { releasedBy[0] = "conversation-or-interrupt"; return true; } + if (cancelled != null && cancelled.getAsBoolean()) { + releasedBy[0] = "walk-cancelled"; + return true; + } WorldPoint now = Rs2Player.getWorldLocation(); if (now == null) { return false; } - boolean edgeResolved = isDoorEdgeResolved(fromWp, toWp); + boolean edgeResolved = !ranged && isDoorEdgeResolved(fromWp, toWp); if (edgeResolved) { releasedBy[0] = "edge-resolved"; return true; } - // Every condition around this one observes the PLAYER — "edge resolved" means we already - // walked through. That is why doors could not chain: nothing could look at the next door - // until we were physically past this one, so each door cost a full approach plus traversal - // instead of the single tick the door itself takes. Observing the DOOR releases us as soon - // as it is open, while the server keeps walking us through, so the next door on the route - // can be clicked immediately. Throttled because this one is a scene scan, not a field read. + // The door observations. The collision edge is authoritative — the client's flags are + // server-driven, so an opened door clears its block on that tick, whatever its menu says. + // Throttled because the fallback is a scene scan, not a field read. long nowMs = System.currentTimeMillis(); if (shouldPollDoorOpen(nowMs - traversalPhaseAt, nowMs - lastOpenPollAt[0])) { lastOpenPollAt[0] = nowMs; openPolls[0]++; - // The collision edge is the authoritative answer to "can I walk through it now": the - // client's flags are server-driven, so an opened door clears its block on the same - // tick. Asked first because it is one flag read, and because it holds for doors whose - // menu actions do not change when they open — the case the action check below could - // never explain across two live runs. boolean edgeOpen = Rs2Tile.isEdgePassable(fromWp, toWp); // Captured HERE, not when the log prints: the previous diagnostic read the door after - // the wait had already released and reported the state at the wrong instant (it - // produced a strict/loose pair that the predicate cannot actually produce). + // the wait had already released and reported the state at the wrong instant. lastEdge[0] = Rs2Tile.lastEdgeDecision(); if (edgeOpen) { releasedBy[0] = "door-edge-open"; return true; } - if (doorOpened != null && doorOpened.getAsBoolean()) { + // A "blocked" edge reading is definitive — the door is shut — so the scene-scan + // fallback only runs when the edge could not be decided (instance, off-scene, ...). + if (!"blocked".equals(lastEdge[0]) + && doorOpened != null && doorOpened.getAsBoolean()) { releasedBy[0] = "door-opened"; return true; } } - if (Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { + if (!ranged && Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { releasedBy[0] = "arrived-far-side"; return true; } - if (hasMeaningfulDoorProgress(ticket.beforePosition(), now, fromWp, toWp)) { + if (!ranged && hasMeaningfulDoorProgress(ticket.beforePosition(), now, fromWp, toWp)) { releasedBy[0] = "progress"; return true; } @@ -157,7 +208,7 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f releasedBy[0] = "idle-accepted"; } return idleAccepted; - }, DOOR_TRAVERSAL_PROGRESS_WAIT_MS); + }, (int) traversalBudgetMs(clickDistance)); long traversalWaitMs = System.currentTimeMillis() - traversalPhaseAt; if (startWaitMs + traversalWaitMs >= DOOR_AWAIT_SLOW_LOG_MS) { @@ -170,8 +221,8 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f saw = "error"; } } - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} openPolls={} edge={} saw={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, openPolls[0], lastEdge[0], saw, fromWp, toWp); + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} clickDist={} ranged={} openPolls={} edge={} saw={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, clickDistance, ranged, openPolls[0], lastEdge[0], saw, fromWp, toWp); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java index 98d7ec474c0..c797826da39 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -2,6 +2,7 @@ import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -60,4 +61,33 @@ public void shouldPollDoorOpen_notMoreOftenThanTheInterval() { assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 100L)); assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 250L)); } + + // ---- traversal budget by click distance --------------------------------------------------------- + + /** Adjacent clicks keep the flat cap they were sized for — no behaviour change for the legacy band. */ + @Test + public void traversalBudget_adjacentClicksKeepTheLegacyCap() { + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(0)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(1)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(2)); + } + + /** + * A ranged click spends its first seconds being WALKED to the door, at one tile per 0.6s. The + * flat cap expired mid-approach — measured releasedBy=timeout at 11 tiles with the player still + * walking — which handed the recovery machinery its window and cost a second interaction. + */ + @Test + public void traversalBudget_rangedClicksAreGivenTheApproachTime() { + assertEquals(2_200L + 600L, Rs2WalkerAwaits.traversalBudgetMs(3)); + assertEquals(2_200L + 5 * 600L, Rs2WalkerAwaits.traversalBudgetMs(7)); + assertEquals(2_200L + 9 * 600L, Rs2WalkerAwaits.traversalBudgetMs(11)); + } + + /** The stall release bounds a wedged approach, but a hard ceiling still caps the worst case. */ + @Test + public void traversalBudget_isCapped() { + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(12)); + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(50)); + } } diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 3ccb2341df4..ded0f0eb943 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 @@ -809,34 +809,34 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$187(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$190(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$192(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$212(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$181(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$183(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$150(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$156(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$193(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$213(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$184(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$151(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$164(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$165(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$166(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$70(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$71(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 From a5a6a27f61e33943709db1fdbd856e327af16589 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 18:54:50 +0100 Subject: [PATCH 19/53] feat(walker): chain the post-door click down the route instead of one tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a door opens the player stands on its near side with the click consumed — opening does not walk you through. The follow-through was a canvas click on the single far-side tile, which cost ~1.5s of nudge machinery per door and then ANOTHER click to resume the route. A player clicks once, somewhere ahead. tryDoorEdgeCrossNudge now selects the furthest REACHABLE route point past the opened door (selectPostDoorRouteTarget) and clicks that: crossing the edge is still crossing it when the destination is further along, so the nudge and the route-resume click fold into one. The next door on the route then comes into ranged-click reach while we are already moving toward it. The reachability gate is the safety argument, learned the hard way: the reverted 3d03ed1e87 clicked a tile the walled-route net had just REFUSED, because it selected without the gate. Every candidate here must be in the player-origin BFS — one BFS per nudge, map lookups per candidate, not the per-candidate reachability probe that froze MLM's loop (tryPostDoorFastMinimapClick still pays that; not touched here). The BFS runs after the door opened, so it sees through the doorway. The success test (isDoorEdgeNudgeResolved) is unchanged, and with no route or no qualifying candidate the single-tile nudge behaves exactly as before. The edge must be found ON the route — a route that merely folds past the door proves nothing about what lies beyond it. Decision table covers furthest-reachable selection, skipping tiles the BFS cannot vouch for, the null fallback, the off-route edge, the Euclidean cap and the plane break. Route threading: handleDoors passes its raw path through tryHandleDoorObject and into the nudge; the tail-loop recent-attempt re-nudge passes rawPath. Recovery-path callers without a route keep the old behaviour via the delegating overloads. Guardrail baseline: one line, the tryHandleDoorObject signature gaining the route parameter — same method, same accepted target, verified. Note for the record: one full-suite run flagged RouteClickTargetRegressionTest, which is pure path-generation and untouched by this change; it passed in isolation and in the final full suite. The failing run coincided with the machine killing the next Gradle daemon (exit 137) — the test's 10s pathfinder cutoff under memory pressure yields a partial path. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 103 ++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 96 ++++++++++++++++ .../client-thread-guardrail-baseline.txt | 2 +- 3 files changed, 190 insertions(+), 11 deletions(-) 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 b5414293f0f..7b99e16b9bf 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 @@ -2463,7 +2463,7 @@ && shouldSkipStartupPreclickSegmentHandlers( exitReason = "interim-in-flight"; break; } - if (tryRecentDoorAttemptEdgeNudge(playerLoc, target)) { + if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { exitReason = "recent-door-edge-nudge"; break; } @@ -6060,7 +6060,7 @@ private static boolean handleDoors(List path, int index, boolean all } if (snapshotDoor instanceof WallObject) { return tryHandleDoorObject(snapshotDoor, snapshotDoor.getWorldLocation(), - fromWp, toWp, doorActions, true); + fromWp, toWp, doorActions, true, path); } } @@ -6223,7 +6223,7 @@ private static boolean handleDoors(List path, int index, boolean all probe, fromWp, toWp); } else { markStationaryDoorOpened(probe); - if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, path)) { markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); return true; } @@ -6239,7 +6239,7 @@ private static boolean handleDoors(List path, int index, boolean all } TileObject nearbyDoor = allowSegmentProbe ? findDoorNearSegmentTimed(fromWp, toWp, doorActions) : null; - if (nearbyDoor != null && tryHandleDoorObject(nearbyDoor, nearbyDoor.getWorldLocation(), fromWp, toWp, doorActions, true)) { + if (nearbyDoor != null && tryHandleDoorObject(nearbyDoor, nearbyDoor.getWorldLocation(), fromWp, toWp, doorActions, true, path)) { return true; } @@ -6250,7 +6250,8 @@ private static boolean handleDoors(List path, int index, boolean all private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, boolean allowSegmentProbe) { + List doorActions, boolean allowSegmentProbe, + List routePath) { if (object == null || probe == null) return false; WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (!Rs2DoorGeometry.isDoorInteractionWithinRange(object, probe, fromWp, toWp, playerLoc, HANDLER_RANGE)) { @@ -6352,7 +6353,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, probe, fromWp, toWp); } else { markStationaryDoorOpened(probe); - if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget)) { + if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, routePath)) { markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); return true; } @@ -6620,6 +6621,24 @@ private static boolean tryPostDoorFastMinimapClick(List path, int ed } private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target) { + return tryDoorEdgeCrossNudge(fromWp, toWp, target, null); + } + + /** + * Route-aware variant: with the route in hand, the follow-through click goes to the furthest + * REACHABLE route point past the door instead of the single far-side tile. Crossing the edge is + * still crossing it if the destination is further along — the server paths us through the open + * door either way — so one click replaces the nudge-then-route-click pair, which is both faster + * and what a player actually does after opening a door. + *

+ * The reachability gate is the whole safety argument. The previous attempt at this (reverted) + * clicked a tile the walled-route net had just REFUSED, because it selected without the gate. + * Here every candidate must be in the player-origin BFS — the same collision evidence the refusal + * uses — and the BFS runs AFTER the door opened, so it sees through the doorway. No candidate, or + * no route: the single-tile nudge behaves exactly as before. The success test is unchanged. + */ + private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target, + List routePath) { if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return false; } @@ -6637,15 +6656,27 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, return false; } - boolean clicked = walkFastCanvas(toWp); + WorldPoint clickTo = toWp; + if (routePath != null && !routePath.isEmpty()) { + Map reachable = getClosestIndexReachableTiles(before); + WorldPoint routeTarget = selectPostDoorRouteTarget(routePath, fromWp, toWp, before, reachable, + POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN); + if (routeTarget != null) { + clickTo = routeTarget; + } + } + + boolean clicked = walkFastCanvas(clickTo); if (!clicked) { - clicked = walkMiniMapToward(toWp, before, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + clicked = walkMiniMapToward(clickTo, before, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); } if (!clicked) { return false; } - markFirstMovementClick("first_door_edge_nudge", target, before, "to=" + compactWorldPoint(toWp)); + markFirstMovementClick("first_door_edge_nudge", target, before, + "to=" + compactWorldPoint(clickTo) + + (clickTo.equals(toWp) ? "" : " pastDoorOf=" + compactWorldPoint(toWp))); sleepUntil(() -> { if (isWalkCancelled(target)) { return true; @@ -6669,6 +6700,11 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, } private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { + return tryRecentDoorAttemptEdgeNudge(playerLoc, target, null); + } + + private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target, + List routePath) { WorldPoint from = routeState.lastDoorAttemptFrom; WorldPoint to = routeState.lastDoorAttemptTo; long attemptedAt = routeState.lastDoorAttemptAtMs; @@ -6685,7 +6721,7 @@ private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, World if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { return false; } - boolean nudged = tryDoorEdgeCrossNudge(from, to, target); + boolean nudged = tryDoorEdgeCrossNudge(from, to, target, routePath); if (nudged) { WebWalkLog.tmark("recent_door_edge_nudge", System.currentTimeMillis() - routeState.walkSessionStartedAtMs, target, playerLoc, "from=" + compactWorldPoint(from) + " to=" + compactWorldPoint(to)); @@ -6693,6 +6729,53 @@ private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, World return nudged; } + /** + * The furthest route point past the just-opened door that the player can PROVABLY walk to. + *

+ * Pure selection over the supplied reachability map — one BFS in the caller, map lookups here — + * rather than a reachability probe per candidate, which is the client-thread cost that froze + * MLM's loop. The edge must be located ON the route (a fold that merely passes nearby proves + * nothing about what lies beyond the door), candidates keep to the player's plane and the + * Euclidean cap, and each must be in the map: a tile the BFS cannot reach is on the far side of + * some OTHER wall, and clicking it is the exact regression the walled-route net exists to refuse. + * Null when nothing qualifies — the caller then keeps the single-tile nudge. + */ + static WorldPoint selectPostDoorRouteTarget(List routePath, WorldPoint fromWp, WorldPoint toWp, + WorldPoint player, Map reachable, + int maxEuclidean) { + if (routePath == null || routePath.size() < 2 || fromWp == null || toWp == null + || player == null || reachable == null || reachable.isEmpty()) { + return null; + } + int edgeIdx = -1; + for (int i = 0; i + 1 < routePath.size(); i++) { + if (fromWp.equals(routePath.get(i)) && toWp.equals(routePath.get(i + 1))) { + edgeIdx = i; + break; + } + } + if (edgeIdx < 0) { + return null; + } + WorldPoint best = null; + for (int i = edgeIdx + 2; i < routePath.size(); i++) { + WorldPoint wp = routePath.get(i); + if (wp == null || wp.getPlane() != player.getPlane()) { + break; + } + if (player.distanceTo2D(wp) > maxEuclidean) { + break; + } + if (wp.equals(player)) { + continue; + } + if (reachable.containsKey(wp)) { + best = wp; + } + } + return best; + } + static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, WorldPoint fromWp, WorldPoint toWp) { if (before == null || after == null || fromWp == null || toWp == null) { return false; 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 320cfee8e6b..084bdcfe9b0 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 @@ -2426,4 +2426,100 @@ public void firstWalledRawEdge_toleratesMissingInputs() { assertNull(Rs2Walker.firstWalledRawEdge(java.util.Collections.emptyList(), p, reachableSet(p), 12)); assertNull(Rs2Walker.firstWalledRawEdge(java.util.Arrays.asList(p), p, null, 12)); } + + // ---- post-door route target (chain the click past the opened door) ------------------------------ + + /** + * After a door opens, the follow-through click should make route progress, not step one tile. + * Every candidate must sit in the player-origin reachability map — the tile the previous attempt + * at this feature clicked was one the walled-route net had just refused, precisely because the + * selection ran ungated. + */ + + private static java.util.List northRoute(int startY, int count) { + java.util.List route = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + route.add(new WorldPoint(3100, startY + i, 0)); + } + return route; + } + + @Test + public void postDoorTarget_picksTheFurthestReachableRoutePoint() { + java.util.List route = northRoute(3200, 8); // door edge 3201 -> 3202 + WorldPoint from = route.get(1); + WorldPoint to = route.get(2); + WorldPoint player = route.get(1); + java.util.Map reachable = + reachableSet(route.get(3), route.get(4), route.get(5)); + assertEquals(route.get(5), + Rs2Walker.selectPostDoorRouteTarget(route, from, to, player, reachable, 13)); + } + + /** An unreachable far candidate must not be clicked; the furthest REACHABLE one wins instead. */ + @Test + public void postDoorTarget_skipsTilesTheBfsCannotVouchFor() { + java.util.List route = northRoute(3200, 8); + WorldPoint from = route.get(1); + WorldPoint to = route.get(2); + WorldPoint player = route.get(1); + java.util.Map reachable = reachableSet(route.get(3), route.get(4)); + assertEquals(route.get(4), + Rs2Walker.selectPostDoorRouteTarget(route, from, to, player, reachable, 13)); + } + + /** Nothing reachable past the door: null, and the caller keeps the single-tile nudge. */ + @Test + public void postDoorTarget_nullWhenNothingPastTheDoorIsReachable() { + java.util.List route = northRoute(3200, 8); + java.util.Map reachable = reachableSet(route.get(0), route.get(1)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, route.get(1), route.get(2), + route.get(1), reachable, 13)); + } + + /** The edge must be ON the route: a route that merely passes nearby proves nothing beyond the door. */ + @Test + public void postDoorTarget_nullWhenTheEdgeIsNotOnTheRoute() { + java.util.List route = northRoute(3200, 8); + WorldPoint offRouteFrom = new WorldPoint(3105, 3201, 0); + WorldPoint offRouteTo = new WorldPoint(3105, 3202, 0); + java.util.Map reachable = reachableSet(route.get(4)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, offRouteFrom, offRouteTo, + route.get(1), reachable, 13)); + } + + /** Candidates stop at the Euclidean cap and at a plane change — the same rules as route clicks. */ + @Test + public void postDoorTarget_respectsTheCapAndThePlane() { + java.util.List route = northRoute(3200, 12); + WorldPoint player = route.get(1); + java.util.Map reachable = + reachableSet(route.get(3), route.get(9)); + // route.get(9) is 8 tiles from the player — inside a cap of 13, outside a cap of 6. + assertEquals(route.get(9), + Rs2Walker.selectPostDoorRouteTarget(route, route.get(1), route.get(2), player, reachable, 13)); + assertEquals(route.get(3), + Rs2Walker.selectPostDoorRouteTarget(route, route.get(1), route.get(2), player, reachable, 6)); + + java.util.List upstairs = new java.util.ArrayList<>(northRoute(3200, 4)); + upstairs.add(new WorldPoint(3100, 3204, 1)); + java.util.Map upstairsReachable = reachableSet(route.get(3)); + assertEquals(route.get(3), + Rs2Walker.selectPostDoorRouteTarget(upstairs, upstairs.get(1), upstairs.get(2), + upstairs.get(1), upstairsReachable, 13)); + } + + @Test + public void postDoorTarget_toleratesMissingInputs() { + java.util.List route = northRoute(3200, 4); + WorldPoint p = route.get(0); + java.util.Map reachable = reachableSet(route.get(3)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(null, p, route.get(1), p, reachable, 13)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, null, route.get(1), p, reachable, 13)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, p, null, p, reachable, 13)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, p, route.get(1), null, reachable, 13)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, p, route.get(1), p, null, 13)); + assertNull(Rs2Walker.selectPostDoorRouteTarget(route, p, route.get(1), p, + new java.util.HashMap<>(), 13)); + } } 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 ded0f0eb943..df754155e18 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 @@ -858,7 +858,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): i net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Player#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean, List): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint From 58269a67c0517a359cee796123e8455586eb2546 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 19:36:01 +0100 Subject: [PATCH 20/53] fix(walker): a door crossing that keeps going is resolved, not a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the post-door click now aimed past the door, success looks like "went through and kept moving" — and the resolution test could not see it. It accepted only arrival on or beside the far-side tile, which has a second, older blind spot the live log exposed: a nudge starts on fromWp, so beforeTo=1 and "afterTo < beforeTo" can only fire on exactly toWp — and a RUNNING player covers two tiles a tick and may never be observed there. Observed as 3369 -> 3367 -> 3365 with every poll reading unresolved, then door_edge_nudge_unresolved for a crossing that had plainly succeeded. The fact being tested is "did we cross the door's edge", so test it directly: the component of the player's displacement along the edge's own axis reaches the far side. Door edges are cardinal; anything else keeps the strict near-toWp rule alone (the wrong-neighbour diagonal case still answers false). A false "failed" is not cosmetic: the caller skips markNearbyDoorFamilyOpened and reports the door unhandled, inviting a re-probe of a door already behind us. Decision table covers the exact live case, the skipped-tile running case, the east-door variant, and walking parallel along the NEAR side (not a crossing, however far it gets). Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 30 +++++++++++- .../util/walker/Rs2WalkerUnitTest.java | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) 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 7b99e16b9bf..fb896adf07c 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 @@ -6793,7 +6793,35 @@ static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, Worl if (after.equals(toWp) || afterTo == 0) { return true; } - return afterTo <= 1 && afterTo < beforeTo; + if (afterTo <= 1 && afterTo < beforeTo) { + return true; + } + // The near-toWp rule alone cannot see a crossing that keeps going, and with the nudge now + // clicking a route point PAST the door, keeping going is the intended outcome. It also has a + // blind spot the live log caught even for short hops: a nudge starts on fromWp (beforeTo=1), + // so afterTo < beforeTo only fires on exactly toWp — and a RUNNING player covers two tiles a + // tick and skips that tile entirely (observed 3369 -> 3367 -> 3365, reported unresolved). + // Crossing the door's axis is the fact being tested, so test it directly. + return hasCrossedDoorAxis(fromWp, toWp, after); + } + + /** + * Whether {@code after} lies at or beyond the far side of the {@code fromWp -> toWp} door edge, + * measured along the edge's own axis. Door edges are cardinal; anything else answers false and + * the strict near-toWp rule stands alone. + */ + static boolean hasCrossedDoorAxis(WorldPoint fromWp, WorldPoint toWp, WorldPoint after) { + int dx = toWp.getX() - fromWp.getX(); + int dy = toWp.getY() - fromWp.getY(); + if (dx != 0 && dy == 0) { + int travelled = after.getX() - fromWp.getX(); + return dx > 0 ? travelled >= 1 : travelled <= -1; + } + if (dy != 0 && dx == 0) { + int travelled = after.getY() - fromWp.getY(); + return dy > 0 ? travelled >= 1 : travelled <= -1; + } + return false; } private static int interimPreclickTiles() { 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 084bdcfe9b0..530134af2eb 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 @@ -1645,6 +1645,53 @@ public void isDoorEdgeNudgeResolved_crossesToDoorTarget_returnsTrue() { new WorldPoint(3241, 3302, 0))); } + /** + * The nudge now clicks a route point PAST the door, so a successful crossing keeps going. The + * live log's exact case: south door 3369->3368, player observed at 3365 — through the door and + * three tiles beyond — reported unresolved by the near-toWp rule. + */ + @Test + public void isDoorEdgeNudgeResolved_ranOnPastTheDoor_returnsTrue() { + assertTrue(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3365, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + /** + * A running player covers two tiles a tick and may NEVER be observed on toWp itself: a nudge + * starting on fromWp has beforeTo=1, so "afterTo < beforeTo" could only fire on exactly toWp. + * Observed live as 3369 -> 3367 -> 3365 with every poll reading unresolved. + */ + @Test + public void isDoorEdgeNudgeResolved_runningSkipsTheFarSideTile_returnsTrue() { + assertTrue(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3367, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + /** Walking parallel along the NEAR side of the wall is not a crossing, however far it gets. */ + @Test + public void isDoorEdgeNudgeResolved_parallelOnTheNearSide_returnsFalse() { + assertFalse(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3106, 3369, 0), + new WorldPoint(3103, 3369, 0), + new WorldPoint(3106, 3369, 0), + new WorldPoint(3106, 3368, 0))); + } + + @Test + public void isDoorEdgeNudgeResolved_eastDoorCrossedAtSpeed_returnsTrue() { + assertTrue(Rs2Walker.isDoorEdgeNudgeResolved( + new WorldPoint(3240, 3301, 0), + new WorldPoint(3243, 3301, 0), + new WorldPoint(3240, 3301, 0), + new WorldPoint(3241, 3301, 0))); + } + @Test public void shouldClearInterimTarget_closeToCheckpoint_returnsTrue() { assertTrue(Rs2Walker.shouldClearInterimTarget( From f2678509cbf6a1f7f7b8b072e6e7cc4b5968c217 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 19:48:09 +0100 Subject: [PATCH 21/53] fix(walker): stop the walk-to-walk door hangover, and let different doors chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two inter-door costs measured on the 3-door Lumbridge gauntlet (33s total, ~13s of it between doors while standing still): STALE NUDGE (~2s at walk start). The recent-door-attempt edge survives across walk sessions and its 6s window comfortably spans a script's walk-to-walk gap, so a fresh walk re-nudged the PREVIOUS walk's door — observed as first_door_edge_nudge pointing BACKWARD at walk start (before=after: the click did nothing but burn the 1.2s wait). Cleared at markWalkSessionStart, exactly like the interim target it sits next to, which existed for the same reason. GLOBAL COOLDOWN SERIALISES CHAINS. The 1800ms window is anti-hammer for ONE door — re-clicking the same edge before the world catches up. A DIFFERENT door immediately after a successful open is chaining, not hammering; holding it for the full window cost up to 1.8s per adjacent-door pair. The throttle is now edge-scoped: the same edge keeps the full window, a different edge owes one game tick (600ms), and the dialogue defer stays unconditional. Pure decision in Rs2DoorHandler with a table; the unused no-arg wrapper is deleted rather than left as a trap. For the record, twice tonight the full suite flagged RouteClickTargetRegressionTest — pure path-generation, not loaded by this diff. It then failed once and passed twice in isolation with identical code: its 10s pathfinder cutoff under machine load returns a partial path. Flagged separately to be made hermetic (it also reads the developer's real learned-blocked-edges file via defaultFile()). Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 30 ++++++++++-- .../util/walker/door/Rs2DoorHandler.java | 19 ++++++++ .../util/walker/door/Rs2DoorHandlerTest.java | 47 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java 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 fb896adf07c..bede0f37510 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 @@ -363,6 +363,13 @@ private static void markWalkSessionStart(WorldPoint target) { // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. clearInterimTarget("walk-start"); + // Same staleness, door flavour: the recent-attempt edge belongs to the PREVIOUS walk, and its + // 6s window comfortably spans a script's walk-to-walk gap. A fresh walk re-nudged the old + // door — observed as a first_door_edge_nudge pointing BACKWARD at walk start, ~2s of standing + // still (or worse, a step the wrong way) before the new route's first click. + routeState.lastDoorAttemptFrom = null; + routeState.lastDoorAttemptTo = null; + routeState.lastDoorAttemptAtMs = 0L; resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); @@ -6163,7 +6170,7 @@ private static boolean handleDoors(List path, int index, boolean all compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; } - if (shouldThrottleGlobalDoorInteraction()) { + if (shouldThrottleGlobalDoorInteraction(fromWp, toWp)) { WebWalkLog.spInfo("door_global_await | mode=segment-door probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; @@ -6297,7 +6304,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; } - if (shouldThrottleGlobalDoorInteraction()) { + if (shouldThrottleGlobalDoorInteraction(fromWp, toWp)) { WebWalkLog.spInfo("door_global_await | mode=segment-probe probe={} from={} to={}", compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); return false; @@ -7084,8 +7091,21 @@ private static void clearInterimTarget(String reason) { routeState.interimLastRetargetAtMs = 0L; } - private static boolean shouldThrottleGlobalDoorInteraction() { - return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(routeState.nextDoorInteractionAllowedAtMs) + /** One game tick: the floor a DIFFERENT door still owes after any door click. */ + private static final long DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS = 600L; + + /** + * Edge-aware variant: the full window only binds a re-click of the SAME edge; a different door + * right after a successful open is chaining, not hammering, and owes one tick. The dialogue + * defer is unconditional either way — an open quest dialogue blocks every door equally. + */ + private static boolean shouldThrottleGlobalDoorInteraction(WorldPoint fromWp, WorldPoint toWp) { + boolean sameEdge = fromWp != null && toWp != null + && fromWp.equals(routeState.lastDoorAttemptFrom) + && toWp.equals(routeState.lastDoorAttemptTo); + return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(System.currentTimeMillis(), + routeState.nextDoorInteractionAllowedAtMs, sameEdge, + DOOR_INTERACTION_GLOBAL_COOLDOWN_MS, DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS) || shouldDeferDoorInteractionForDialogue(); } @@ -8193,7 +8213,7 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< } return false; } - if (shouldThrottleGlobalDoorInteraction()) { + if (shouldThrottleGlobalDoorInteraction(bestFrom, bestTo)) { WebWalkLog.spInfo("door_global_await | mode=path-adj probe={} from={} to={}", compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java index 4357448c64d..288b8d4143f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -61,6 +61,25 @@ public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteracti return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; } + /** + * Edge-scoped variant. The full window is anti-hammer for ONE door — re-clicking the same edge + * before the world has caught up. A DIFFERENT door immediately after a successful open is not + * hammering, it is chaining, and holding it for the full window serialised every pair of nearby + * doors. A different edge owes only the cross-edge floor (one game tick): enough that two clicks + * cannot land inside the same tick, no more. + * + * @param fullCooldownMs the window {@code nextAllowedAtMs} was stamped with + * @param crossEdgeCooldownMs the floor a different edge still owes + */ + public static boolean shouldThrottleGlobalDoorInteraction(long nowMs, long nextAllowedAtMs, + boolean sameEdgeAsLastAttempt, + long fullCooldownMs, long crossEdgeCooldownMs) { + if (sameEdgeAsLastAttempt) { + return nowMs < nextAllowedAtMs; + } + return nowMs < nextAllowedAtMs - (fullCooldownMs - crossEdgeCooldownMs); + } + public static long markGlobalDoorInteractionCooldown(long cooldownMs) { return System.currentTimeMillis() + cooldownMs; } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java new file mode 100644 index 00000000000..4b72dc07b9f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java @@ -0,0 +1,47 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The edge-scoped global door cooldown. The full window is anti-hammer for re-clicking ONE door; a + * different door immediately after a successful open is chaining, not hammering, and holding it for + * the full window serialised every pair of nearby doors at ~1.8s each. + */ +public class Rs2DoorHandlerTest { + + private static final long FULL = 1_800L; + private static final long CROSS = 600L; + private static final long CLICKED_AT = 100_000L; + private static final long NEXT_ALLOWED = CLICKED_AT + FULL; + + @Test + public void sameEdgeKeepsTheFullWindow() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + FULL, NEXT_ALLOWED, true, FULL, CROSS)); + } + + /** A different door owes one tick, no more — that is what a player chaining two doors looks like. */ + @Test + public void differentEdgeOwesOnlyTheCrossEdgeFloor() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 200L, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + CROSS, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, false, FULL, CROSS)); + } + + /** No window stamped (or long expired): nothing throttles either way. */ + @Test + public void expiredWindowThrottlesNothing() { + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 10_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT, 0L, false, FULL, CROSS)); + } +} From fc48e04b196b8e8603dd5b31817adf87c6b12568 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 20:43:52 +0100 Subject: [PATCH 22/53] fix(walker): verify the door we clicked, not the neighbourhood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-door verification decides whether the interaction opened the door — and it asked the loose question, any door-like object within two tiles still offering "Open". Beside a doubled door that reads the NEIGHBOUR: measured live as saw=strict=false loose=true — this door open, the one beside it shut — reported as "did not traverse, action still present", which suppressed markStationaryDoorOpened and the post-door route click entirely. The walker stood still ~2s per door until the generic click machinery caught up, in exactly the door-dense places chaining matters. The same neighbour-answers-for-it bug as the open observation, one call site further down. Both verify sites now use the strict per-tile/per-edge match. The loose reading survives only in the saw= diagnostic, which deliberately reports both side by side, and the loose delegate is deleted so nothing reaches for it again by accident. Confirmed working in the wild within the hour: a genuinely-shut gate quick-failed verification honestly (doorVerify=80ms) instead of burning a wait. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 bede0f37510..d041a6149bc 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 @@ -5606,15 +5606,22 @@ private static boolean interactDoorTimed(TileObject object, String action) { } /** - * "Is the door still shut?" — a radius-{@link #HANDLER_RANGE} rescan that resolves a composition per + * "Is THIS door still shut?" — a radius-{@link #HANDLER_RANGE} rescan that resolves a composition per * candidate OUTSIDE the scan-scoped memo, so nothing is cached. Only runs when traversal failed, but * that is exactly the slow path a stuck door repeats, so it is timed separately. + *

+ * STRICT on the probe tile, for the same reason {@code doorObservedOpen} is: this answer decides + * whether the door we just interacted with opened, and the loose two-tile radius let a NEIGHBOURING + * shut door answer for it. Measured live as {@code saw=strict=false loose=true} — this door open, + * a neighbour shut — reading as "did not traverse", which suppressed markStationaryDoorOpened and + * the post-door route click entirely; the walker stood still ~2s until the generic click machinery + * caught up. In a door-heavy area (the exact place chaining matters) that was every door. */ private static boolean doorStillHasActionTimed(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action) { long startedAt = System.currentTimeMillis(); try { - return doorStillHasAction(probe, fromWp, toWp, doorActions, action); + return doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); } finally { if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { rawScanDoorVerifyMs += System.currentTimeMillis() - startedAt; @@ -6435,15 +6442,12 @@ private static String describeDoorObservation(WorldPoint probe, WorldPoint fromW } } - private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, - List doorActions, String action) { - return doorStillHasAction(probe, fromWp, toWp, doorActions, action, false); - } - /** * @param strictTile match only the door ON the probe tile or ON the {@code fromWp -> toWp} edge, * instead of anything within two tiles. Required when the answer decides whether * THIS door opened; the loose radius lets a neighbouring shut door answer for it. + * Every decision-making caller is strict now — loose remains only for the + * {@code saw=} diagnostic, which reports both readings side by side. */ private static boolean doorStillHasAction(WorldPoint probe, WorldPoint fromWp, WorldPoint toWp, List doorActions, String action, boolean strictTile) { From e8dbde0e879695a989af88890ef4070c1ef26e7d Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 20:51:45 +0100 Subject: [PATCH 23/53] fix(walker): a ranged door hold ends when the door stops mattering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7s timeout at the (3115,3449) door decomposed into three gaps, each now closed: PASSED THE DOOR. Ranged holds had every positional release disabled, because near-side proximity fired mid-approach. But "past" is not "near": the player ended standing ON the far-side tile with the door still shut — the server had walked them there through another opening — and the hold ran its full budget anyway. Standing on toWp, or beyond the door along its own axis, is unambiguous and now releases (passed-door). The axis reading is the same one the nudge-resolution fix introduced, moved to Rs2DoorGeometry and shared instead of duplicated. REPLANNED UNDERNEATH. Live collision saw the awaited edge blocked, recalculated, and routed around via the trapdoor — one second before the timeout — while the hold kept waiting on the old plan's door. The cancel supplier now also releases when the Pathfinder instance changes (the replan signal), labelled cancelled-or-replanned. The walk-target check stays for the cancel/retarget case. WHY WAS IDLE-ACCEPT SILENT. Unanswerable from the log: silence is correct if the player walked the whole budget and a bug if the pose-based isMoving trap held it. door_await now tallies polls/movingPolls/animPolls so the next timeout answers it from one line. Correction for the record: the earlier analysis suggested the route never crossed that door's edge. Wrong — handleDoors derives the edge from consecutive raw route tiles by construction, so a route-crossing precondition is an invariant that already holds, and no such check was added. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 40 +++++++++---------- .../util/walker/door/Rs2DoorGeometry.java | 24 +++++++++++ .../util/walker/door/Rs2WalkerAwaits.java | 37 +++++++++++++---- 3 files changed, 72 insertions(+), 29 deletions(-) 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 d041a6149bc..65112f046c9 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 @@ -6817,22 +6817,11 @@ static boolean isDoorEdgeNudgeResolved(WorldPoint before, WorldPoint after, Worl } /** - * Whether {@code after} lies at or beyond the far side of the {@code fromWp -> toWp} door edge, - * measured along the edge's own axis. Door edges are cardinal; anything else answers false and - * the strict near-toWp rule stands alone. + * Whether {@code after} lies at or beyond the far side of the {@code fromWp -> toWp} door edge. + * Shared with the ranged door await, which uses the same reading as its "passed the door" release. */ static boolean hasCrossedDoorAxis(WorldPoint fromWp, WorldPoint toWp, WorldPoint after) { - int dx = toWp.getX() - fromWp.getX(); - int dy = toWp.getY() - fromWp.getY(); - if (dx != 0 && dy == 0) { - int travelled = after.getX() - fromWp.getX(); - return dx > 0 ? travelled >= 1 : travelled <= -1; - } - if (dy != 0 && dx == 0) { - int travelled = after.getY() - fromWp.getY(); - return dy > 0 ? travelled >= 1 : travelled <= -1; - } - return false; + return Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, after); } private static int interimPreclickTiles() { @@ -7502,14 +7491,23 @@ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint java.util.function.Supplier observation = (probe == null || action == null) ? null : () -> describeDoorObservation(probe, fromWp, toWp, doorActions, action); - // Ranged budgets can hold for seconds, so a walk that was cancelled or re-targeted mid-await - // must release it: currentTarget is captured NOW, and the supplier answers true the moment - // that walk stops being the active one. + // Ranged budgets can hold for seconds, so a hold must release when the plan it belongs to + // stops existing — the walk cancelled or re-targeted, OR the route replanned under the same + // target. The second case is what live collision does when it sees the awaited edge blocked: + // it recalculates and routes around, and holding the old plan's door after that is pure + // waste (measured: the replan fired a second before a 6.9s ranged timeout expired). + // A new Pathfinder instance IS the replan signal; the reference is captured at click time. WorldPoint walkTarget = currentTarget; - // isWalkCancelled(null) answers true, and a door can legitimately be handled outside a walk - // session (recovery paths); no target means there is nothing to be cancelled. - java.util.function.BooleanSupplier cancelled = - walkTarget == null ? null : () -> isWalkCancelled(walkTarget); + Object plannerAtClick = Rs2PathApi.getPathfinder(); + java.util.function.BooleanSupplier cancelled = () -> { + // isWalkCancelled(null) answers true, and a door can legitimately be handled outside a + // walk session (recovery paths); no target means there is nothing to be cancelled. + if (walkTarget != null && isWalkCancelled(walkTarget)) { + return true; + } + Object plannerNow = Rs2PathApi.getPathfinder(); + return plannerAtClick != null && plannerNow != null && plannerNow != plannerAtClick; + }; try { Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation, cancelled); } finally { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java index 89b727accd7..ca0f0dec77a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java @@ -19,6 +19,30 @@ public static boolean isDoorOnSegment(TileObject object, WorldPoint fromWp, Worl return isDoorOnSegment(object, object == null ? null : object.getWorldLocation(), fromWp, toWp); } + /** + * Whether {@code at} lies at or beyond the far side of the cardinal {@code from -> to} door edge, + * measured along the edge's own axis. This is the unambiguous "we are past the door" reading — + * unlike near-side proximity, which fires while still approaching. Door edges are cardinal; + * anything else answers false. + */ + public static boolean crossedDoorAxis(WorldPoint from, WorldPoint to, WorldPoint at) { + if (from == null || to == null || at == null + || from.getPlane() != to.getPlane() || at.getPlane() != to.getPlane()) { + return false; + } + int dx = to.getX() - from.getX(); + int dy = to.getY() - from.getY(); + if (dx != 0 && dy == 0) { + int travelled = at.getX() - from.getX(); + return dx > 0 ? travelled >= 1 : travelled <= -1; + } + if (dy != 0 && dx == 0) { + int travelled = at.getY() - from.getY(); + return dy > 0 ? travelled >= 1 : travelled <= -1; + } + return false; + } + /** As above, with the object's location supplied (see {@link #wallDoorTouchesSegment}). */ public static boolean isDoorOnSegment(TileObject object, WorldPoint objectLocation, WorldPoint fromWp, WorldPoint toWp) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 08589967d1e..27cb4e79f3d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -133,6 +133,9 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f // tell those apart. The count settles it without another round trip. final int[] openPolls = {0}; final String[] lastEdge = {"-"}; + final int[] totalPolls = {0}; + final int[] movingPolls = {0}; + final int[] animatingPolls = {0}; // A ranged click is an APPROACH, not a traversal: the server walks us to the door, the door // opens on arrival (its 0-1 tick is measured from the interaction, not from the click), and @@ -155,7 +158,7 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f return true; } if (cancelled != null && cancelled.getAsBoolean()) { - releasedBy[0] = "walk-cancelled"; + releasedBy[0] = "cancelled-or-replanned"; return true; } WorldPoint now = Rs2Player.getWorldLocation(); @@ -167,6 +170,15 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f releasedBy[0] = "edge-resolved"; return true; } + // The one positional reading a ranged hold may trust: we are ON the far side, or past the + // door along its own axis. Near-side proximity stays disabled for ranged clicks — that was + // the premature release — but "past" is unambiguous, and it is how a hold ends when the + // server walks us to the far side through another opening without the door ever needing to + // open. Measured as a 6.9s ranged timeout with the player standing on toWp, door shut. + if (ranged && (now.equals(toWp) || Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, now))) { + releasedBy[0] = "passed-door"; + return true; + } // The door observations. The collision edge is authoritative — the client's flags are // server-driven, so an opened door clears its block on that tick, whatever its menu says. // Throttled because the fallback is a scene scan, not a field read. @@ -199,11 +211,19 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f return true; } long elapsedMs = System.currentTimeMillis() - ticket.startedAtMs(); - boolean idleAccepted = shouldAcceptIdleDoorAwait( - Rs2Player.isMoving(), - Rs2Player.isAnimating(), - elapsedMs, - edgeResolved); + // Counted so a timeout can say why the stall release never fired — "idle-accept was + // silent" is ambiguous between the player walking the whole budget (correct silence) + // and the pose-based isMoving trap (a bug). The tally answers it from one log line. + boolean moving = Rs2Player.isMoving(); + boolean animating = Rs2Player.isAnimating(); + totalPolls[0]++; + if (moving) { + movingPolls[0]++; + } + if (animating) { + animatingPolls[0]++; + } + boolean idleAccepted = shouldAcceptIdleDoorAwait(moving, animating, elapsedMs, edgeResolved); if (idleAccepted) { releasedBy[0] = "idle-accepted"; } @@ -221,8 +241,9 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f saw = "error"; } } - WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} clickDist={} ranged={} openPolls={} edge={} saw={} from={} to={}", - releasedBy[0], startWaitMs, traversalWaitMs, clickDistance, ranged, openPolls[0], lastEdge[0], saw, fromWp, toWp); + WebWalkLog.spInfo("door_await | releasedBy={} startWaitMs={} traversalWaitMs={} clickDist={} ranged={} openPolls={} edge={} polls={} movingPolls={} animPolls={} saw={} from={} to={}", + releasedBy[0], startWaitMs, traversalWaitMs, clickDistance, ranged, openPolls[0], lastEdge[0], + totalPolls[0], movingPolls[0], animatingPolls[0], saw, fromWp, toWp); } } From b588e767fb6eb9671d28c0f770b39bfee2a781a1 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 22:29:17 +0100 Subject: [PATCH 24/53] =?UTF-8?q?fix(walker):=20stop=20pinning=20the=20min?= =?UTF-8?q?imap=20zoom=20=E2=80=94=20the=20click=20math=20never=20needed?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the walker locks the minimap at max zoom and re-zooms it the moment the user changes it. Both walkMiniMap AND isMiniMapClickable forced setMinimapZoom(5) on every call — including the probe, so merely ASKING whether a tile was clickable moved the user's zoom. There is no correctness reason for any of it. Perspective.localToMinimap reads the LIVE zoom (s = 4d / client.getMinimapZoom()) and scales its range with it, so the conversion is exact at every setting. Zoom only moves a trade-off: zoomed IN shrinks clickable range (~16 tiles at zoom 5, ~40 zoomed out — the forcing was costing reach, not buying it), zoomed out shrinks pixels-per-tile. A far point that does not convert already degrades through the existing fallbacks to a nearer route point — which is what a human at that zoom does — and the tile-exact clicks near walls use the canvas path, which is pixel-precise at any zoom. walkMiniMap now clicks at whatever zoom the user has; the probe is side-effect free. The explicit-zoom overload keeps its contract for external callers that genuinely want a particular zoom, but nothing in the walker calls it. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) 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 65112f046c9..dc9140021e8 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 @@ -3604,10 +3604,27 @@ private static void manageRunEnergy(int pathRemaining) { } } + /** + * Explicit-zoom variant, kept for external callers that genuinely want a particular zoom. The + * walker itself never uses it: {@code Perspective.localToMinimap} reads the LIVE zoom, so the + * click math is correct at any setting, and pinning the minimap at max zoom on every click both + * looked bot-like and fought the user's own zoom the moment they changed it. + */ public static boolean walkMiniMap(WorldPoint worldPoint, double zoomDistance) { if (Microbot.getClient().getMinimapZoom() != zoomDistance) Microbot.getClient().setMinimapZoom(zoomDistance); + return walkMiniMap(worldPoint); + } + /** + * Clicks {@code worldPoint} on the minimap at whatever zoom the user has. Zoom only moves the + * trade-off between reach and pixel precision — zoomed IN shrinks clickable range (~16 tiles at + * zoom 5, ~40 zoomed out), zoomed out shrinks pixels-per-tile — and every caller already has a + * fallback for an unclickable point (nearer route point, canvas click), which is exactly what a + * human at that zoom would do. Tile-exact clicks near walls use the canvas path, which is + * pixel-precise at any zoom. + */ + public static boolean walkMiniMap(WorldPoint worldPoint) { Point point = Rs2MiniMap.worldToMinimap(worldPoint); if (point == null) return false; @@ -3617,18 +3634,11 @@ public static boolean walkMiniMap(WorldPoint worldPoint, double zoomDistance) { return true; } - - public static boolean walkMiniMap(WorldPoint worldPoint) { - return walkMiniMap(worldPoint, 5); - } - - private static boolean isMiniMapClickable(WorldPoint worldPoint, double zoomDistance) { + /** Side-effect free: a "could I click this?" probe must never move the user's zoom. */ + private static boolean isMiniMapClickable(WorldPoint worldPoint) { if (worldPoint == null) { return false; } - if (Microbot.getClient().getMinimapZoom() != zoomDistance) { - Microbot.getClient().setMinimapZoom(zoomDistance); - } Point point = Rs2MiniMap.worldToMinimap(worldPoint); return point != null && (disableWalkerUpdate || Rs2MiniMap.isPointInsideMinimap(point)); } @@ -4033,7 +4043,7 @@ static WorldPoint findFurthestVisibleKnownRawPathPoint(List rawPath, return findFurthestRawPathPointMatchingGated(rawPath, playerLoc, maxEuclidean, rawAnchorIndex, candidate -> !candidate.equals(playerLoc) && isKnownWalkableOrUnloaded(candidate) - && isMiniMapClickable(candidate, 5)); + && isMiniMapClickable(candidate)); } // rawPathStepDistance (pure) moved to geometry/WalkerPathGeometry (P1) alongside its only caller, @@ -5990,7 +6000,7 @@ private static int findForwardReachableRecoveryIndex(List path, // findForwardRecoveryIndex extracted to recovery/RouteRecovery (P1 walker decomposition) private static boolean isMiniMapRecoveryClickable(WorldPoint worldPoint) { - return isMiniMapClickable(worldPoint, 5); + return isMiniMapClickable(worldPoint); } // interpolateClickableTarget extracted to recovery/RouteRecovery (P1) From 4d802adbf573be5fa5662a9f61188d623587d671 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 22:46:42 +0100 Subject: [PATCH 25/53] feat(walker): when one click is provably the whole walk, make it one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts call walkTo for five-tile hops — spot changes, bank-to-spot shuffles — and every call paid the pipeline's fixed head: transport refresh, pathfinder, session setup, the startup handler pass. Measured at 1.3-1.5s before the FIRST click for moves a human does in one click. The existing short-circuit (tryDirectShortWalk) sits INSIDE the pipeline, takes the planned path as an argument, and can only ever save the tail. With hundreds of Hub scripts funnelling through this one entry point, the entry point is the fix. The gate is a reachability proof, not a distance guess: a target within 12 tiles that is BFS-reachable on the client's live collision flags needs no door, no transport and no plan — a shut door on the way reads as blocked and fails the gate, so anything that needs the pipeline still gets it. Deliberately strict: the target TILE itself must be reachable. Walk-beside-an-object calls (booths, trees) decline and take the full pipeline, because "within distance" with a wall between is exactly the false arrival the pipeline's richer checks exist to refuse — coverage traded for correctness on the hottest path in the client. Canvas click first, minimap at the user's own zoom second. The wait is bounded by walking pace plus slack (decision table), stall detection is position-diffed rather than isMoving() (the pose-based read stays true while turning on the spot), and walkUntil completion conditions are polled in the wait. Every non-arrival outcome — no click landed, stall, budget, interrupt — falls through to the full pipeline exactly as if the fast path had never existed: the degraded case is yesterday's behaviour, never a new failure mode. Placed inside the walker lock ahead of the banked-transports branch, so both walk modes benefit and concurrency semantics are unchanged. One short_walk log line per fast walk (result=arrived/completion/handoff) so live runs show it engaging and winning. Guardrail baseline: 23 lines, verified pure synthetic-lambda renumbering. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 102 ++++++++++++++++++ .../util/walker/Rs2WalkerUnitTest.java | 20 ++++ .../client-thread-guardrail-baseline.txt | 46 ++++---- 3 files changed, 145 insertions(+), 23 deletions(-) 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 dc9140021e8..341ff1ce425 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 @@ -1322,6 +1322,10 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } try { + WalkerState shortWalk = tryShortWalkFastPath(target, distance); + if (shortWalk != null) { + return shortWalk; + } return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() ? walkWithBankedTransportsAndStateLocked(target, distance, false) : walkWithStateInternal(target, distance)); @@ -1330,6 +1334,104 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } + /** The fast path only ever helps within minimap reach; beyond it the full pipeline is correct. */ + private static final int SHORT_WALK_FAST_PATH_MAX_TILES = 12; + /** Position unchanged this long means the click did not take; the full pipeline takes over. */ + private static final long SHORT_WALK_STALL_MS = 1_800L; + + /** Walking pace plus slack; anything longer means something interfered and the pipeline should own it. */ + static long shortWalkBudgetMs(int euclideanTiles) { + return 600L * Math.max(1, euclideanTiles) + 2_400L; + } + + /** + * One click IS the whole walk, when that can be proven up front. + *

+ * Scripts call {@link #walkTo} for five-tile hops, and every such call paid the pipeline's fixed + * head — transport refresh, pathfinder, session setup, the startup handler pass — measured at + * 1.3-1.5s before the first click, for moves a human does with one click in ~0.3s. The existing + * short-circuit ({@code tryDirectShortWalk}) sits INSIDE the pipeline and only saves its tail. + *

+ * The gate is a reachability proof, not a distance guess: a CLOSE target that is BFS-reachable on + * the client's live collision flags needs no door, no transport and no plan — a shut door on the + * way reads as blocked and fails the gate, so anything that needs the pipeline still gets it. + * Deliberately strict: the target TILE itself must be reachable. Walk-beside-an-object calls + * (bank booths, trees) decline and take the full pipeline, because "within distance" with a wall + * between is exactly the false-arrival the pipeline's richer checks exist to refuse. + *

+ * Declining ({@code null}) always falls through to today's behaviour, and so does a click that + * stalls — the budget and stall checks make the degraded case "what always happened", never a + * new failure mode. + */ + private static WalkerState tryShortWalkFastPath(WorldPoint target, int distance) { + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null || target == null || start.getPlane() != target.getPlane()) { + return null; + } + int euclidean = start.distanceTo2D(target); + if (euclidean > SHORT_WALK_FAST_PATH_MAX_TILES) { + return null; + } + // Already within range: the internal arrival checks answer richer questions (unwalkable + // targets, reachable neighbours) than this path should re-implement. + if (start.distanceTo(target) <= distance) { + return null; + } + if (!Rs2Tile.isTileReachable(target)) { + return null; + } + + manageRunEnergy(euclidean); + long startedAt = System.currentTimeMillis(); + boolean clicked = walkFastCanvas(target); + if (!clicked) { + clicked = walkMiniMap(target); + } + if (!clicked) { + return null; + } + + WalkCompletionContext completion = walkCompletionContext.get(); + final WorldPoint[] lastPos = {start}; + final long[] lastMoveAt = {System.currentTimeMillis()}; + sleepUntil(() -> { + if (Thread.currentThread().isInterrupted()) { + return true; + } + if (completion != null && evaluateWalkCompletion(completion)) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + if (now == null) { + return false; + } + if (!now.equals(lastPos[0])) { + lastPos[0] = now; + lastMoveAt[0] = System.currentTimeMillis(); + } + if (now.distanceTo(target) <= distance) { + return true; + } + // Position-diffed, not isMoving(): the pose-based read stays true while turning on the + // spot, and a stalled click must hand over to the pipeline promptly. + return System.currentTimeMillis() - lastMoveAt[0] > SHORT_WALK_STALL_MS; + }, (int) shortWalkBudgetMs(euclidean)); + + WorldPoint end = Rs2Player.getWorldLocation(); + boolean arrived = end != null && end.distanceTo(target) <= distance; + boolean completionMet = completion != null && completion.met; + WebWalkLog.spInfo("short_walk | result={} to={} euclid={} elapsedMs={} from={}", + arrived ? "arrived" : completionMet ? "completion" : "handoff", + compactWorldPoint(target), euclidean, System.currentTimeMillis() - startedAt, + compactWorldPoint(start)); + if (arrived || completionMet) { + return WalkerState.ARRIVED; + } + // Not there: the click stalled, or something interfered. The pipeline owns it from here, + // exactly as if this path had never existed. + return null; + } + /** * Like {@link #walkWithState} but bounds how long this thread waits for {@link #walkerLock}. * Use when another walk may hold the lock during Leagues UI (see {@link Rs2LeaguesTransport#leaguesTeleport}) 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 530134af2eb..368bbd814d7 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 @@ -2556,6 +2556,26 @@ public void postDoorTarget_respectsTheCapAndThePlane() { upstairs.get(1), upstairsReachable, 13)); } + // ---- short-walk fast path budget ----------------------------------------------------------------- + + /** + * The budget bounds how long a single proven-reachable click may own the walk before the full + * pipeline takes over: walking pace per tile plus slack. Too tight hands healthy walks to the + * pipeline mid-stride; too loose delays recovery when the click did not take. + */ + @Test + public void shortWalkBudget_scalesWithDistanceAtWalkingPace() { + assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(1)); + assertEquals(5 * 600L + 2_400L, Rs2Walker.shortWalkBudgetMs(5)); + assertEquals(12 * 600L + 2_400L, Rs2Walker.shortWalkBudgetMs(12)); + } + + @Test + public void shortWalkBudget_neverBelowTheOneTileFloor() { + assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(0)); + assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(-3)); + } + @Test public void postDoorTarget_toleratesMissingInputs() { java.util.List route = northRoute(3200, 4); 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 df754155e18..0418cc1bfe5 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 @@ -807,36 +807,36 @@ 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$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$193(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$213(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$184(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$151(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$17(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$189(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$192(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$194(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$214(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$183(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$185(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$152(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$159(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$159(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$165(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$130(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$166(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$167(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$8(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$71(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$40(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$72(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 From 5455512eb058817af1f1e0d5679cc454b0323d1c Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 23:08:24 +0100 Subject: [PATCH 26/53] fix(walker): a shut transport door is not a blocked route step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed twice, identical fingerprint: the walker stands at a door working it, and "[LiveCollision] route step A -> B now blocked; recalculating" yanks the route out from under it — the Edgeville 3115,3449 hold-to-timeout, and tonight's 2585,3141 mid-handling reroute the user called out ("it went out the door and then it rerouted for no reason"). Both doors are CATALOG TRANSPORT doors, which is the fact that explains it. LiveRouteValidator recognises transports by shape — cross-plane or non-adjacent steps — but a door transport joins two ADJACENT SAME-PLANE tiles, so its step is indistinguishable from walking. While the door is shut the live collision edge honestly reads blocked. That is the door's NORMAL state, not an obstruction: the pathfinder planned through it as a transport edge and the runtime executor opens it on contact. Recalculating on it is always wrong. The validator now takes a transport-step predicate and skips planned catalog-transport edges; the plugin supplies it from the transports-by-origin map (one map lookup per validated step). Scanning continues past the skipped edge, so a genuine obstruction further along still triggers the recalc this validator exists for. The validator's class doc claimed openable doors read as passable in the overlay via the door mask — true for scene doors the mask catches, demonstrably not for this class. Co-Authored-By: Claude Fable 5 --- .../shortestpath/ShortestPathPlugin.java | 15 ++++++++++++++- .../pathfinder/live/LiveRouteValidator.java | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) 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 49a60be7c75..e8668899442 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 @@ -846,7 +846,20 @@ private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) final CollisionMap map = pathfinderConfig.getMap(); map.beginSearch(); // pin the freshly captured snapshot for this validation final int from = LiveRouteValidator.nearestIndex(path, me); - final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map); + // A door transport joins two adjacent same-plane tiles, so to the validator its step looks + // like walking — and while the door is SHUT the edge honestly reads blocked. That is its + // normal state, not an obstruction: the walker's executor opens it on contact. Recalculating + // here yanked the route out from under the walker while it stood at the door handling it. + final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map, + (a, b) -> { + for (Transport t : pathfinderConfig.getTransportsPacked() + .getOrDefault(WorldPointUtil.packWorldPoint(a), java.util.Collections.emptySet())) { + if (b.equals(t.getDestination())) { + return true; + } + } + return false; + }); if (blocked >= 0) { lastLiveRecalcMs = now; log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating", 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 index 88341e6174c..ba6da489968 100644 --- 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 @@ -4,6 +4,7 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; import java.util.List; +import java.util.function.BiPredicate; /** * Validates the walking steps of an in-progress route against a {@link CollisionMap}, so the walker can @@ -50,6 +51,21 @@ public static int nearestIndex(List path, WorldPoint player) { * 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) { + return firstBlockedStep(path, fromIndex, lookahead, map, null); + } + + /** + * @param transportStep answers whether the {@code a -> b} step was planned as a CATALOG TRANSPORT. + * The plane/adjacency heuristics above cannot see one class of transport: a + * door transport joins two ADJACENT SAME-PLANE tiles, so its step is + * indistinguishable from walking — and while shut it reads as blocked, which + * made this validator recalculate the route out from under the walker as it + * stood at the door handling it (observed twice, both catalog transport + * doors). A transport edge's "blocked" is its normal shut state; the runtime + * executor owns it, and it is never this validator's business. + */ + public static int firstBlockedStep(List path, int fromIndex, int lookahead, CollisionMap map, + BiPredicate transportStep) { if (path == null || map == null) { return -1; } @@ -68,6 +84,9 @@ public static int firstBlockedStep(List path, int fromIndex, int loo if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { continue; // non-adjacent: a transport jump, not a walking step } + if (transportStep != null && transportStep.test(a, b)) { + continue; // planned door-transport edge: shut is its normal state, the executor owns it + } if (!map.canStep(a.getX(), a.getY(), a.getPlane(), dx, dy)) { return i; } From 45393439428ae4ad0dbbdbd1b033b73a1c9a813d Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 23:20:18 +0100 Subject: [PATCH 27/53] fix(walker): the validator must respect ANY door the executor has claimed, not just catalog ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport-catalog predicate from 5455512eb0 was the right fix for the wrong subclass. The door that kept triggering "route step now blocked; recalculating" in both directions is object 81 — fightarena_door1, the Fight Arena quest door — identified live off the agent server. It is in NO transport catalog (grep of every shipped TSV: nothing at 2584/2585,3141), so a catalog lookup can never exempt it, and the user's report held: cross it westbound, recalc; walk back eastbound, recalc again, straight into a script-level retarget. Quest doors are also exactly the class the live capture's door mask is least reliable for (impostor-varbit compositions), so "the overlay reads door edges as passable" cannot be assumed for them either. The walker already stamps every door attempt with its edge before clicking. That stamp is the authoritative "the executor owns this edge" signal, catalog or not: Rs2Walker.isActiveDoorEdge(a, b) answers it for either direction within a 10s claim window, and the validator's skip predicate now consults it ahead of the catalog lookup. A door the walker is actively working can no longer have the route recalculated out from under it, whatever kind of door it is. Co-Authored-By: Claude Fable 5 --- .../shortestpath/ShortestPathPlugin.java | 6 +++++ .../microbot/util/walker/Rs2Walker.java | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) 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 e8668899442..4274e4d4de5 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 @@ -852,6 +852,12 @@ private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) // here yanked the route out from under the walker while it stood at the door handling it. final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map, (a, b) -> { + // The walker's door subsystem has claimed this edge — catalog or not. Quest doors + // (fightarena_door1) are in no catalog, yet the recalc mid-interaction is just as + // wrong there. + if (Rs2Walker.isActiveDoorEdge(a, b)) { + return true; + } for (Transport t : pathfinderConfig.getTransportsPacked() .getOrDefault(WorldPointUtil.packWorldPoint(a), java.util.Collections.emptySet())) { if (b.equals(t.getDestination())) { 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 341ff1ce425..bacb5cad345 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 @@ -6822,6 +6822,31 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, return progressed; } + /** How long a door attempt claims its edge against outside interference (route revalidation). */ + private static final long ACTIVE_DOOR_EDGE_CLAIM_MS = 10_000L; + + /** + * Whether {@code a -> b} (either direction) is the door edge this walker most recently attempted, + * within the claim window. The live-collision route validator uses this as "the executor owns + * that edge, leave it alone": a shut door on the route honestly reads blocked, and recalculating + * the route out from under an in-progress door interaction was observed on a quest door + * (fightarena_door1, 2585,3141) that is in no transport catalog — the catalog check alone cannot + * cover doors the walker handles purely as scene objects. + */ + public static boolean isActiveDoorEdge(WorldPoint a, WorldPoint b) { + WorldPoint from = routeState.lastDoorAttemptFrom; + WorldPoint to = routeState.lastDoorAttemptTo; + long attemptedAt = routeState.lastDoorAttemptAtMs; + if (a == null || b == null || from == null || to == null || attemptedAt <= 0L) { + return false; + } + long ageMs = System.currentTimeMillis() - attemptedAt; + if (ageMs < 0L || ageMs > ACTIVE_DOOR_EDGE_CLAIM_MS) { + return false; + } + return (a.equals(from) && b.equals(to)) || (a.equals(to) && b.equals(from)); + } + private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { return tryRecentDoorAttemptEdgeNudge(playerLoc, target, null); } From c41208c8fccba8e3bb308ee8fd1c8064f5831e22 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 7 Aug 2026 00:22:56 +0100 Subject: [PATCH 28/53] fix(walker): a door the player has crossed is resolved, even when it shut itself behind them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watched live at the Fight Arena doors (agent server, position sampled every 1.2s): the character crosses the door east, and one tick later these quest doors close themselves. "Shut door on my route" is then TRUE again for an edge the player is already past, and the door machinery re-engaged it — the character stepped BACK through the door it had just crossed, then oscillated: through, back, sidestep, through. The walker also issued a route click one tile BACKWARD (to=2583 with the goal 9 tiles east) because with the door shut again, the only reachable route tile was behind the player. The invariant that ends it: a route door edge whose axis the player has already crossed, in route direction, is RESOLVED for this walk — whatever the door reads right now. handleDoors gates on it at entry (covering the segment loop, the raw scans and the recovery probes, which all funnel through it), and tryDoorEdgeCrossNudge treats at-or-past the far side as success instead of aiming its fallback click at toWp — one tile backward — for a player standing beyond it. Directionality keeps the guard honest: from/to derive from consecutive route tiles, so a walk genuinely routed back the other way carries the reversed edge and is unaffected. crossedDoorAxis is the shared, decision-table-tested primitive from the nudge-resolution fix. Also for the record from the same live watch: quest automation was OFF — the larger reversals in the trace were the user's own manual play between walker targets, and the zero "now blocked; recalculating" lines confirm the two validator fixes hold on a build that includes them. Co-Authored-By: Claude Fable 5 --- .../microbot/util/walker/Rs2Walker.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 bacb5cad345..73d3ada2c5f 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 @@ -6167,6 +6167,19 @@ private static boolean handleDoors(List path, int index, boolean all return false; } + // A door edge the player has already CROSSED (in route direction) is resolved for this walk, + // whatever the door reads now. The Fight Arena quest doors shut themselves the moment you are + // through, so "shut door on my route" stayed true after crossing and the machinery kept + // re-engaging a door behind the player — watched live as the character stepping BACK through + // the door it had just passed, then oscillating. The axis reading is directional, so a walk + // genuinely routed back the other way derives the reversed edge from its own route tiles and + // is unaffected. + WorldPoint playerForCrossing = Rs2Player.getWorldLocation(); + if (playerForCrossing != null + && Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, playerForCrossing)) { + return false; + } + if (shouldDeferDoorHandlingToTransport(path, index)) { return false; } @@ -6769,7 +6782,10 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, if (before == null || before.getPlane() != toWp.getPlane()) { return false; } - if (before.equals(toWp)) { + // At or past the far side: the crossing this nudge exists to produce has happened. Without + // this, a player one step BEYOND toWp still passed the distance gate and the fallback click + // aimed at toWp — one tile backward, straight back into a self-closing door. + if (before.equals(toWp) || Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, before)) { return true; } if (before.distanceTo2D(toWp) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { From af908cf4d162cf70d73a841c7a129f7a07abf5fd Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 7 Aug 2026 12:52:48 +0100 Subject: [PATCH 29/53] feat(walker): minimap strides scale with the user's zoom, capped by what the BFS can vouch for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-pinning the minimap zoom made the question live: zoomed out, the minimap shows ~38 tiles of radius while every stride stayed capped at the flat 11 tuned for the pinned zoom-5 window. A zoomed-out player clicks big strides — that is half the point of zooming out — so reach now follows the visible radius (20*4/zoom, the exact scale Perspective.localToMinimap uses), minus two tiles so a click never lands on the rim. Floored at 11: a fully zoomed-IN minimap behaves exactly as before. Capped at 18, and the cap is NOT the minimap's limit but the walled-click net's: every stride target must sit inside the player-origin reachability BFS (20-step budget) or a wall between could not be detected, which is the Clock Tower click-through-the-wall class. 18 leaves two steps of path-vs-Euclidean slack inside that budget. Raising it further means growing a client-thread BFS and gets measured first. At the default zoom (4) strides go 11 -> 18; fully zoomed out likewise 18. Wired at all five stride sites — the tail-loop stride, walkStep, the route-backed final click, the interim continuation click, and the reachability sample radius that must cover whatever the stride can reach. Pure decision (zoomAwareMinimapReach) with a decision table; degenerate zoom readings fall back to the old flat reach. Amended: the first cut of this commit accidentally swept an unrelated in-progress QuestingScript.java change from the working tree (git add -A); this one carries only the walker change, and the WIP is back in the tree untouched. Co-Authored-By: Claude Fable 5 (cherry picked from commit b949273a83dd24f1e6648d075ff4d3fd3a57d3ed) --- .../microbot/util/walker/Rs2Walker.java | 49 ++++++++++++++++--- .../util/walker/Rs2WalkerUnitTest.java | 26 ++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) 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 73d3ada2c5f..b78b7372883 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 @@ -207,6 +207,42 @@ public static WorldPoint getCurrentTarget() { */ private static final int LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS = 48; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; + /** + * Ceiling for zoom-extended minimap strides. NOT the minimap's limit — zoomed out it shows ~38 + * tiles — but the walled-click net's: every stride target must sit inside the player-origin + * reachability BFS ({@link #CLOSEST_INDEX_REACHABLE_STEP_BUDGET} = 20 steps), or a wall between + * could not be detected and the Clock Tower click-through-the-wall class comes back. 18 leaves + * two steps of path-vs-Euclidean slack inside that budget. + */ + private static final int ZOOMED_OUT_MINIMAP_REACH_CAP = 18; + + /** + * How far a minimap stride may reach at {@code minimapZoom}, in tiles. + *

+ * The minimap shows {@code 20 * 4 / zoom} tiles of radius (the scale Perspective.localToMinimap + * uses), so the old flat reach of {@value #NORMAL_MINIMAP_REACH_EUCLIDEAN} — tuned for the days + * the walker pinned zoom at 5, a 16-tile window — wastes most of a zoomed-out minimap: a player + * zoomed out clicks big strides, and since the zoom un-pinning that choice belongs to the user. + * Two tiles are kept off the rim so the click never lands on the very edge, and the floor keeps + * a fully zoomed-IN minimap exactly as reachable as today. + */ + static int zoomAwareMinimapReach(double minimapZoom, int floorTiles, int capTiles) { + if (minimapZoom <= 0) { + return floorTiles; + } + int visibleRadius = (int) Math.floor(20.0 * 4.0 / minimapZoom) - 2; + return Math.max(floorTiles, Math.min(visibleRadius, capTiles)); + } + + /** Shell wrapper: the live zoom read, floored at today's reach, capped at the BFS horizon. */ + private static int normalMinimapReach() { + try { + return zoomAwareMinimapReach(Microbot.getClient().getMinimapZoom(), + NORMAL_MINIMAP_REACH_EUCLIDEAN, ZOOMED_OUT_MINIMAP_REACH_CAP); + } catch (Exception e) { + return NORMAL_MINIMAP_REACH_EUCLIDEAN; + } + } // UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES moved into recovery/RouteRecovery (P1) /** * Stationary window before an active route issues a recovery nudge. @@ -1658,8 +1694,9 @@ public static WalkerState walkStep(WorldPoint target, int distance) { // target nor a planned-path point is clickable (e.g. the route needs a transport walkStep can't // cross), no click is issued and we hold on the line rather than wander off it — walkStep is not // built for transport routes; use the blocking walkTo/walkUntil for those. - boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= NORMAL_MINIMAP_REACH_EUCLIDEAN; - clickMiniMapOrFallback(rawPath, target, playerLoc, NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, allowDirectionalFallback, -1); + int walkStepReach = normalMinimapReach(); + boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= walkStepReach; + clickMiniMapOrFallback(rawPath, target, playerLoc, walkStepReach - 1, allowDirectionalFallback, -1); return WalkerState.MOVING; } @@ -2871,7 +2908,7 @@ && walkFastCanvas(recoverTarget)) { // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). WorldPoint playerLoc = Rs2Player.getWorldLocation(); - final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; + final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); // Checkpoint-style walking: once we set a minimap flag, let the player actually // travel toward it. Do not keep recalculating/clicking new targets mid-run. @@ -3240,7 +3277,7 @@ && walkFastCanvas(recoverTarget)) { if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, - NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); + normalMinimapReach() - 1, rawAnchorIndex); } else { finalClick = Rs2Walker.walkFastCanvas(canvasClickWp); } @@ -3478,7 +3515,7 @@ public static WorldPoint getPointWithWallDistance(WorldPoint target, WorldPoint Set reachableFromPlayer = playerLoc == null ? Collections.emptySet() : Rs2Tile.getReachableTilesFromTile(playerLoc, - Math.max(2, NORMAL_MINIMAP_REACH_EUCLIDEAN)).keySet(); + Math.max(2, normalMinimapReach())).keySet(); if (hasMinimapRelevantMovementFlag(localPoint, flags)) { WorldPoint best = bestWallDistanceNeighbor(tiles.keySet(), playerLoc, reachableFromPlayer, @@ -4228,7 +4265,7 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, return false; } return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", - NORMAL_MINIMAP_REACH_EUCLIDEAN, false); + normalMinimapReach(), false); } private static boolean tryIssueRouteMovementClick(List rawPath, 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 368bbd814d7..3445dc9959f 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 @@ -2556,6 +2556,32 @@ public void postDoorTarget_respectsTheCapAndThePlane() { upstairs.get(1), upstairsReachable, 13)); } + // ---- zoom-aware minimap reach -------------------------------------------------------------------- + + /** + * The minimap shows 20*4/zoom tiles of radius. Reach scales with what the USER's zoom makes + * visible, floored at the flat reach the walker always had, and capped at the reachability BFS + * horizon — beyond it a wall between could not be detected and the click-through-wall class + * returns. + */ + @Test + public void zoomAwareReach_zoomedOutStridesFurther() { + assertEquals(18, Rs2Walker.zoomAwareMinimapReach(4.0, 11, 18)); // default zoom: 20-2 -> cap + assertEquals(18, Rs2Walker.zoomAwareMinimapReach(2.0, 11, 18)); // fully out: 38 -> cap + } + + @Test + public void zoomAwareReach_zoomedInKeepsTheOldFloor() { + assertEquals(14, Rs2Walker.zoomAwareMinimapReach(5.0, 11, 18)); // pinned-era zoom: 16-2 + assertEquals(11, Rs2Walker.zoomAwareMinimapReach(8.0, 11, 18)); // fully in: 10-2 -> floor + } + + @Test + public void zoomAwareReach_degenerateZoomFallsBackToTheFloor() { + assertEquals(11, Rs2Walker.zoomAwareMinimapReach(0.0, 11, 18)); + assertEquals(11, Rs2Walker.zoomAwareMinimapReach(-1.0, 11, 18)); + } + // ---- short-walk fast path budget ----------------------------------------------------------------- /** From 97945be41d78124015e8eabd9ed965d9d74bf5a6 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 7 Aug 2026 17:53:09 +0100 Subject: [PATCH 30/53] fix(pathfinder): prove an unreachable target sealed in ~1ms instead of flooding a million nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37 SEARCH_EXHAUSTED terminations in one evening's log, each expanding ~1.1M nodes over 1.2-3.8s of CPU — mostly for destinations TWO TILES away. An unreachable target made the forward search flood the entire connected world component before admitting it, and retries hammered the same flood. A bounded reverse flood from the target answers first. It reuses the bidirectional machinery's getReverseNeighbors with the incoming-transports index, so a room entered by a staircase or door transport grows past its walls and reads reachable — an upstairs destination is never falsely sealed — and anywhere-teleports (null origin, absent from that index) are checked per component tile. Only a frontier that drains under the 1024-node budget without touching start proves anything; big components are INCONCLUSIVE and the full search runs exactly as before. A sealed verdict does not abandon the caller: the search retargets the component's walkable rim, sorted nearest to START — the reachable rim is on the approach side, and goal-side ordering burned the whole substitute budget on best-effort (measured 50k nodes at Shantay Pass vs a direct walk to the near-side rim). The walk still ends beside the sealed area, which is all the old flood's path ever bought, and the termination stays SEARCH_EXHAUSTED because reaching a substitute is not reaching the caller's target. The substitute pass carries both a 2s leash and a 50k node budget: its targets can themselves prove unreachable (a moat tile whose rim is an unreachable pocket), and the time leash alone WAS the flood. The probe is failure-proof — any exception degrades to the full search, never a failed run. Measured on the pinned corpus: Shantay no-ticket 1.1M nodes/multi-second -> path to the gate's north side in 185ms goal-sorted, hundreds of nodes start-sorted; sealed courtyard tile -> approach path beside it; void tile -> instant empty result. The Shantay no-ticket corpus assertion needed its proxy tightened: "visits within 2 of the gate" also condemned a route that walks UP TO the gate and stops — which is what the fast path now correctly produces, and what a coinless player does. It now measures the crossing itself (a tile strictly south of the gate line at the pass) plus a must-not-arrive check, ending the proxy games its own comment history documents. For the record: one full-suite run flagged RouteClickTargetRegressionTest, which then passed 3/3 in isolation on identical code — the known tiebreaker-lottery flake, being made hermetic separately. Co-Authored-By: Claude Fable 5 (cherry picked from commit f9cfb3e1f078ad7a4d4d4735b947c3f60e58480c) --- .../shortestpath/pathfinder/Pathfinder.java | 193 +++++++++++++++++- .../SealedTargetFastPathTest.java | 190 +++++++++++++++++ .../shortestpath/WalkerRouteCorpusTest.java | 18 +- 3 files changed, 385 insertions(+), 16 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java 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 7cd40039930..8aacab398fc 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 @@ -311,6 +311,135 @@ private int minChebyshevStartToAnyTarget() { return best; } + // ---- sealed-target fast path --------------------------------------------------------------- + + /** Reverse-flood budget: clears any fenced yard or walled room in well under this, ~1-3ms. */ + private static final int SEALED_PROBE_NODE_BUDGET = 1024; + private static final int SEALED_SUBSTITUTE_TARGET_CAP = 8; + /** The rim substitutes can themselves prove unreachable; they get a short leash, not 18s. */ + private static final long SEALED_SUBSTITUTE_CUTOFF_MS = 2_000L; + /** + * Node budget for the substitute pass. The time leash alone still allowed a 2-million-node flood + * (a sealed moat tile whose rim is itself an unreachable pocket): two seconds at search speed IS + * the flood. Truncating a genuinely long approach to a sealed destination is fine — the walker + * walks the partial path, replans closer, and the next probe answers from nearer. + */ + private static final long SEALED_SUBSTITUTE_NODE_BUDGET = 50_000L; + + /** The targets the search LOOPS actually chase; equals {@link #targetsPacked} except in sealed mode. */ + private int[] searchTargetsPacked; + private boolean sealedTargetMode; + private long cutoffOverrideMillis = -1L; + + private long effectiveCutoffMillis() { + long configured = config.getCalculationCutoffMillis(); + return cutoffOverrideMillis > 0 ? Math.min(cutoffOverrideMillis, configured) : configured; + } + + /** + * Bounded reverse flood from the single target, deciding whether its graph component is provably + * SEALED — unreachable by walking, by any transport whose origin exists, and not landed in by any + * anywhere-teleport. + *

+ * Exists because an unreachable destination made the forward search flood the ENTIRE world + * component before giving up: measured 37 times in one evening at ~1.1M nodes and 1.2-3.8s of CPU + * each, mostly for destinations TWO TILES away (a sealed map-data tile, or an interaction target + * the caller asked for by coordinate). The reverse flood explores only the target's own component, + * which for every observed case is tiny, and answers in ~1ms. + *

+ * Correctness leans on three things. The flood uses {@code getReverseNeighbors} with the + * incoming-transports index, so a room entered by a staircase or door transport GROWS past its + * walls and reads reachable — an upstairs destination is never falsely sealed. Anywhere-teleports + * (null origin, excluded from that index) are checked per component tile instead. And the budget + * makes big components INCONCLUSIVE rather than sealed: only a frontier that genuinely drains + * under budget without touching {@code start} proves anything. + * + * @return {@code null} when reachable or inconclusive (run the normal search); otherwise the + * component's walkable rim — same-plane cardinal neighbours just outside it with at least one + * open edge — nearest-first to the goal, possibly empty (a void tile with a void rim). + */ + private int[] sealedTargetSubstitutes(int goalPacked) { + final Map> incoming = new HashMap<>(512); + final Set anywhereTeleportDests = new HashSet<>(); + for (Map.Entry> e : config.getTransports().entrySet()) { + for (Transport t : e.getValue()) { + if (t.getDestination() == null) { + continue; + } + int dp = WorldPointUtil.packWorldPoint(t.getDestination()); + if (t.getOrigin() == null) { + anywhereTeleportDests.add(dp); + } else { + incoming.computeIfAbsent(dp, k -> new HashSet<>()).add(t); + } + } + } + final Set puzzleAllow = new HashSet<>(4); + puzzleAllow.add(goalPacked); + puzzleAllow.add(start); + final VisitedTiles probeVisited = new VisitedTiles(map); + final ArrayDeque frontier = new ArrayDeque<>(); + final Set component = new LinkedHashSet<>(); + frontier.add(new Node(goalPacked, null)); + probeVisited.set(goalPacked); + int expanded = 0; + while (!frontier.isEmpty()) { + if (expanded >= SEALED_PROBE_NODE_BUDGET) { + return null; // big component: inconclusive, let the real search decide + } + Node n = frontier.poll(); + expanded++; + if (anywhereTeleportDests.contains(n.packedPosition)) { + return null; // an anywhere-teleport lands inside: reachable + } + component.add(n.packedPosition); + for (Node pred : map.getReverseNeighbors(n, probeVisited, config, puzzleAllow, incoming)) { + if (pred.packedPosition == start) { + return null; // reachable + } + probeVisited.set(pred.packedPosition); + frontier.add(pred); + } + } + + final int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + final Set rim = new LinkedHashSet<>(); + for (int packed : component) { + final int x = WorldPointUtil.unpackWorldX(packed); + final int y = WorldPointUtil.unpackWorldY(packed); + final int z = WorldPointUtil.unpackWorldPlane(packed); + for (int[] d : dirs) { + final int nx = x + d[0]; + final int ny = y + d[1]; + final int np = WorldPointUtil.packWorldPoint(nx, ny, z); + if (component.contains(np) || rim.contains(np)) { + continue; + } + for (int[] out : dirs) { + if (map.canStep(nx, ny, z, out[0], out[1])) { + rim.add(np); + break; + } + } + } + } + // Nearest to START, not to the goal: the reachable rim is on the approach side, and ranking + // it first lets the substitute search REACH a target in hundreds of nodes. Goal-side rim + // tiles are usually inside the sealed pocket's far side — unreachable by construction — and + // ranking them first burned the whole substitute node budget on best-effort (measured 50k + // nodes at Shantay Pass vs a direct walk to the near-side rim). + final List nearest = new ArrayList<>(rim); + nearest.sort(Comparator.comparingInt(p -> WorldPointUtil.distanceBetween(p, start))); + final int take = Math.min(SEALED_SUBSTITUTE_TARGET_CAP, nearest.size()); + final int[] substitutes = new int[take]; + for (int i = 0; i < take; i++) { + substitutes[i] = nearest.get(i); + } + WebWalkLog.pf("target_sealed dst={} component={} rim={} probeNodes={}", + WorldPointUtil.toString(goalPacked), component.size(), rim.size(), expanded); + return substitutes; + } + private void buildIncomingByDestination(Map> out) { out.clear(); for (Map.Entry> e : config.getTransports().entrySet()) { @@ -402,7 +531,7 @@ private void runUnidirectional() { int bestDistance = Integer.MAX_VALUE; long bestHeuristic = Integer.MAX_VALUE; - long cutoffDurationMillis = config.getCalculationCutoffMillis(); + long cutoffDurationMillis = effectiveCutoffMillis(); long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; config.refreshTeleports(start, 31); boolean reachedGoal = false; @@ -439,7 +568,7 @@ private void runUnidirectional() { final int nodePos = node.packedPosition; boolean reached = false; - for (int target : targetsPacked) { + for (int target : searchTargetsPacked) { if (nodePos == target) { bestLastNode = node; reached = true; @@ -459,9 +588,10 @@ private void runUnidirectional() { break; } - if (System.currentTimeMillis() > cutoffTimeMillis) { + if (System.currentTimeMillis() > cutoffTimeMillis + || (sealedTargetMode && stats.getNodesChecked() > SEALED_SUBSTITUTE_NODE_BUDGET)) { timedOut = true; - WebWalkLog.pf("cutoff bestDist={} nodes={}", bestDistance, stats.getNodesChecked()); + WebWalkLog.pf("cutoff bestDist={} nodes={} sealedMode={}", bestDistance, stats.getNodesChecked(), sealedTargetMode); break; } @@ -491,12 +621,12 @@ private void runUnidirectional() { } private void runBidirectional() { - int goalPacked = targetsPacked[0]; + int goalPacked = searchTargetsPacked[0]; Map> incoming = new HashMap<>(512); buildIncomingByDestination(incoming); Set puzzleAllow = new HashSet<>(targets.size() + 1); - for (int t : targetsPacked) { + for (int t : searchTargetsPacked) { puzzleAllow.add(t); } puzzleAllow.add(start); @@ -518,7 +648,7 @@ private void runBidirectional() { int bestDistance = Integer.MAX_VALUE; long bestHeuristic = Integer.MAX_VALUE; - long cutoffDurationMillis = config.getCalculationCutoffMillis(); + long cutoffDurationMillis = effectiveCutoffMillis(); long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; config.refreshTeleports(start, 31); boolean timedOut = false; @@ -563,7 +693,7 @@ private void runBidirectional() { break; } - for (int target : targetsPacked) { + for (int target : searchTargetsPacked) { int distance = WorldPointUtil.distanceBetween(nodePos, target); long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { @@ -602,9 +732,10 @@ private void runBidirectional() { addNeighborsBackwardWithMeet(node, visitedB, incoming, puzzleAllow, forwardAt, backwardAt, bestMeetingCost, meetF, meetB); } - if (System.currentTimeMillis() > cutoffTimeMillis) { + if (System.currentTimeMillis() > cutoffTimeMillis + || (sealedTargetMode && stats.getNodesChecked() > SEALED_SUBSTITUTE_NODE_BUDGET)) { timedOut = true; - WebWalkLog.pf("bidir_cutoff nodes={}", stats.getNodesChecked()); + WebWalkLog.pf("bidir_cutoff nodes={} sealedMode={}", stats.getNodesChecked(), sealedTargetMode); break; } } @@ -658,8 +789,40 @@ public void run() { // thread cannot mix two scenes into one path. No-op when live collision is disabled. map.beginSearch(); stats.start(); + + searchTargetsPacked = targetsPacked; + sealedTargetMode = false; + cutoffOverrideMillis = -1L; + if (targetsPacked.length == 1 && targetsPacked[0] != start) { + int[] rim = null; + try { + rim = sealedTargetSubstitutes(targetsPacked[0]); + } catch (RuntimeException probeFailure) { + // The probe is an optimisation; any anomaly degrades to the full search, never + // to a failed run. (First seen with a mocked CollisionMap whose VisitedTiles had + // no region planes.) + log.debug("[Pathfinder] sealed-target probe failed, running full search: {}", + probeFailure.toString()); + } + if (rim != null) { + sealedTargetMode = true; + if (rim.length == 0) { + // A sealed component with a void rim (off-map or instance-template garbage): + // nothing to walk toward, nothing to search for. + WebWalkLog.pf("target_sealed no_walkable_rim dst={}", + WorldPointUtil.toString(targetsPacked[0])); + terminationReason = PathTerminationReason.SEARCH_EXHAUSTED; + return; + } + // Search for the rim instead: the walk still ends beside the sealed area — the + // same best-effort the old full flood produced — at a thousandth of the cost. + searchTargetsPacked = rim; + cutoffOverrideMillis = SEALED_SUBSTITUTE_CUTOFF_MS; + } + } + int minCheb = minChebyshevStartToAnyTarget(); - boolean useBidir = targetsPacked.length == 1 + boolean useBidir = searchTargetsPacked.length == 1 && minCheb >= BIDIRECTIONAL_MIN_CHEBYSHEV; pathfinderDiag("run mode decision useBidir=%s minCheb=%d bidirThreshold=%d targetsPacked=%d cutoffMs=%d cancelAlready=%s", useBidir, @@ -674,6 +837,14 @@ public void run() { } else { runUnidirectional(); } + // Reaching a rim substitute is not reaching the caller's target, and the substitute pass + // hitting its short leash (the rim itself can be unreachable — a sealed tile inside a + // locked interior) changes nothing either: the original destination's unreachability is + // already PROVEN, and callers keying decisions off the termination must hear exactly that. + if (sealedTargetMode && (terminationReason == PathTerminationReason.TARGET_REACHED + || terminationReason == PathTerminationReason.CUTOFF_REACHED)) { + terminationReason = PathTerminationReason.SEARCH_EXHAUSTED; + } } catch (Exception e) { terminationReason = PathTerminationReason.FAILED; log.error("[Pathfinder] Exception in run(): ", e); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java new file mode 100644 index 00000000000..ede1ad47a09 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java @@ -0,0 +1,190 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathTerminationReason; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * The sealed-target fast path: an unreachable destination must fail in ~a thousand nodes, not by + * flooding the entire world component. + *

+ * Pinned against the live failure of 2026-08-06/07: 37 {@code SEARCH_EXHAUSTED} terminations at + * ~1.1M nodes and 1.2-3.8s each, mostly for destinations TWO TILES from the player — a sealed tile + * targeted by coordinate. The reverse probe explores only the target's own component and answers in + * about a millisecond; the search then runs against the component's walkable rim so the walk still + * ends beside the sealed area, which is all the old flood's best-effort path ever bought. + */ +public class SealedTargetFastPathTest { + + private static SplitFlagMap collisionMap; + private static HashMap> transports; + + /** Lumbridge courtyard: mapped, ordinary, walkable ground. */ + private static final WorldPoint SRC = new WorldPoint(3222, 3218, 0); + + /** Generous ceiling: the old failure mode expanded ~1.1M nodes; the fast path needs ~1k. */ + private static final long NODE_CEILING = 60_000; + + @BeforeClass + public static void load() { + collisionMap = SplitFlagMap.fromResources(); + transports = Transport.loadAllFromResources(); + } + + private static PathfinderConfig newConfig() { + PathfinderConfig config = new PathfinderConfig(collisionMap, transports, + Collections.emptyList(), null, null); + try { + java.lang.reflect.Field f = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + f.setAccessible(true); + f.setLong(config, 10_000); + for (Map.Entry> e : transports.entrySet()) { + if (e.getKey() == null) continue; + config.getTransports().put(e.getKey(), e.getValue()); + config.getTransportsPacked().put(WorldPointUtil.packWorldPoint(e.getKey()), e.getValue()); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return config; + } + + private static boolean hasAnyStepOut(CollisionMap map, int x, int y, int z) { + return map.canStep(x, y, z, 1, 0) || map.canStep(x, y, z, -1, 0) + || map.canStep(x, y, z, 0, 1) || map.canStep(x, y, z, 0, -1); + } + + /** + * Mirrors the probe's sealed reading: no neighbour can step INTO the tile from any of the 8 + * directions. An object footprint blocks entry from every side while its own edge flags can + * still read as notional exits, so an exit-based test misses exactly the live case's tiles. + */ + private static boolean noEntry(CollisionMap map, int x, int y, int z) { + int[][] all = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + for (int[] d : all) { + if (map.canStep(x - d[0], y - d[1], z, d[0], d[1])) { + return false; + } + } + return true; + } + + /** A floorless upper plane has no map data: every edge reads blocked, and its rim is equally void. */ + @Test + public void voidTargetFailsFastWithNoPath() { + PathfinderConfig config = newConfig(); + WorldPoint dst = new WorldPoint(3222, 3218, 3); + assumeTrue("precondition: the shipped map must seal the void tile", + !hasAnyStepOut(config.getMap(), dst.getX(), dst.getY(), dst.getPlane())); + + long startedAt = System.currentTimeMillis(); + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pf.getTerminationReason()); + assertTrue("void target must fail fast, took " + elapsed + "ms", elapsed < 2_000); + assertTrue("void target must not flood: nodes=" + pf.getStats().getNodesChecked(), + pf.getStats().getNodesChecked() < NODE_CEILING); + assertTrue("no walkable rim means no path", pf.getPath().isEmpty()); + } + + /** + * A fully-blocked tile beside walkable ground (an interactable's footprint, the live case's + * shape): the search must end SEARCH_EXHAUSTED quickly WITH a best-effort path that stops on the + * rim beside the sealed tile — the same utility the 1.1M-node flood used to buy for 3.8s. + */ + @Test + public void sealedTileWithWalkableRimYieldsTheApproachPath() { + PathfinderConfig config = newConfig(); + CollisionMap map = config.getMap(); + map.beginSearch(); + + // Self-locating with a PROVABLY REACHABLE rim: BFS the walkable area around SRC first, then + // pick a sealed tile one of whose neighbours is in that area. Earlier attempts picked sealed + // tiles by geometry alone and landed on moat/interior tiles whose rim is an unreachable + // pocket — the unreachable-rim case, which is bounded elsewhere; this test is the live case: + // an interactable's sealed footprint beside ground the player can stand on. + Set reachable = new java.util.HashSet<>(); + java.util.ArrayDeque frontier = new java.util.ArrayDeque<>(); + reachable.add(SRC); + frontier.add(SRC); + int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + while (!frontier.isEmpty() && reachable.size() < 1_500) { + WorldPoint c = frontier.poll(); + for (int[] d : dirs) { + if (!map.canStep(c.getX(), c.getY(), 0, d[0], d[1])) { + continue; + } + WorldPoint n = new WorldPoint(c.getX() + d[0], c.getY() + d[1], 0); + if (reachable.add(n)) { + frontier.add(n); + } + } + } + WorldPoint dst = null; + int bestDist = Integer.MAX_VALUE; + for (WorldPoint open : reachable) { + for (int[] d : dirs) { + int x = open.getX() + d[0]; + int y = open.getY() + d[1]; + WorldPoint cand = new WorldPoint(x, y, 0); + if (reachable.contains(cand) || !noEntry(map, x, y, 0)) { + continue; + } + int dist = Math.max(Math.abs(x - SRC.getX()), Math.abs(y - SRC.getY())); + if (dist >= 3 && dist < bestDist) { + bestDist = dist; + dst = cand; + } + } + } + assumeTrue("precondition: found a sealed tile whose rim the player can stand on", dst != null); + + long startedAt = System.currentTimeMillis(); + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals("the ORIGINAL target is unreachable and the caller must hear it", + PathTerminationReason.SEARCH_EXHAUSTED, pf.getTerminationReason()); + assertTrue("sealed target must fail fast, took " + elapsed + "ms for dst=" + dst, + elapsed < 3_000); + assertTrue("sealed target must not flood: nodes=" + pf.getStats().getNodesChecked() + " dst=" + dst, + pf.getStats().getNodesChecked() < NODE_CEILING); + assertTrue("the walk still gets an approach path to the rim", !pf.getPath().isEmpty()); + WorldPoint last = pf.getPath().get(pf.getPath().size() - 1); + assertNotNull(last); + assertTrue("approach path must end beside the sealed tile, ended at " + last + " for dst=" + dst, + last.distanceTo2D(dst) <= 2); + } + + /** The probe must not disturb ordinary reachable routes: same courtyard, short hop, reached. */ + @Test + public void reachableTargetStillReached() { + PathfinderConfig config = newConfig(); + WorldPoint dst = new WorldPoint(3232, 3218, 0); + + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + + assertEquals(PathTerminationReason.TARGET_REACHED, pf.getTerminationReason()); + assertTrue(!pf.getPath().isEmpty()); + assertEquals(dst, pf.getPath().get(pf.getPath().size() - 1)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index 3f74635127a..74f681286b7 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -608,11 +608,19 @@ public void shantaySouthbound_withCoinsOnly_crossesTheGate() { public void shantaySouthbound_withNothing_neverCrossesTheGate() { List path = route(configWith(WalkerRouteCorpusTest::unrestricted), NORTH_OF_GATE, SOUTH_OF_GATE); - // Same predicate as the positive tests. The old form required BOTH tiles either side of the - // gate at radius 0, so a diagonal step across the gate satisfied neither and the assertion - // passed while the route did cross. - assertFalse("without a ticket or coins the route must not cross the gate", - visits(path, GATE, 2)); + // Crossing means a path tile strictly SOUTH of the gate line at the pass. The previous + // proximity proxy (visits within 2 of the gate) also failed a route that walks UP TO the + // gate's north side and stops — which is exactly what the sealed-target fast path now + // produces, and exactly what a player without coins does. (The proxy before THAT required + // both flanking tiles at radius 0 and missed a diagonal crossing; measuring the crossing + // itself ends the proxy games.) + boolean crossed = path.stream().anyMatch(p -> p != null + && p.getPlane() == GATE.getPlane() + && p.getY() < GATE.getY() + && Math.abs(p.getX() - GATE.getX()) <= 4); + assertFalse("without a ticket or coins the route must not cross the gate", crossed); + assertFalse("without a ticket or coins the route must not arrive south", + arrives(path, SOUTH_OF_GATE, 3)); } // ---- Port Sarim, Wydin's shop (the door-poisoning incident) ------------------------------------ From 8f36719a759aaf9ffcb03ce798b4135856d14365 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 7 Aug 2026 19:10:45 +0100 Subject: [PATCH 31/53] feat(pathfinder): learned blocked edges are session-only; blocked_edges.tsv is the authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy decision: the two-strike persistent learned-edge store is gone. What stays is the part that was always right — the observing session blocks a failed edge immediately, because it just watched the failure and anything less loops the walker into the same obstacle (the Sinclair deadlock escape). What goes is everything that existed to manage persistence: the file under ~/.runelite, the constructor load, the strike counting, the independence window, the probation semantics, and the test seam for simulating restarts. The store's history argued for this. Its probation machinery existed to self-heal its own poisonings (the Wydin door needed a hand-edit before two-strike, and two-strike existed to prevent the next one). Its default file leaked developer state into every test that constructed a PathfinderConfig — the hermeticity exposure flagged on RouteClickTargetRegressionTest. And in months of use it never accumulated a single confirmed row: the user's live file holds four probation entries, nothing enforced. An edge worth remembering across sessions is worth a reviewed row in blocked_edges.tsv, which ships with the client, survives the live-collision override, and is already the home of the Hemenster/Al Kharid/Varrock permanent blocks. learnBlockedEdge keeps its signature and return semantics (true = newly blocked this session), so Rs2Walker's walled-route learning and wrong-traversal callers are untouched. LearnedBlockedEdges and its parser/strike tests are deleted; the session semantics get their own decision table (block-once, direction-scoped, nothing survives a fresh config, null-safe). The suite run flagged only RouteClickTargetRegressionTest, the known tiebreaker-lottery flake, green in isolation immediately after. Co-Authored-By: Claude Fable 5 (cherry picked from commit 68fccb17de95c2fa527233c2ce211385950cae2a) --- .../shortestpath/PurchasableItemCatalog.java | 2 +- .../pathfinder/LearnedBlockedEdges.java | 236 ------------------ .../pathfinder/PathfinderConfig.java | 127 ++-------- .../LearnedBlockedEdgeSessionTest.java | 72 ++++++ .../LearnedBlockedEdgeStrikesTest.java | 109 -------- .../pathfinder/LearnedBlockedEdgesTest.java | 138 ---------- 6 files changed, 92 insertions(+), 592 deletions(-) delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java delete mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java delete mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java index d8130de3438..a6330e27483 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java @@ -22,7 +22,7 @@ * consulted here — transports.tsv carries a duplicate-row OR (item row + currency-twin row) so the * pathfinder already plans through the transport for either holding. * - *

Parsing is lenient like {@code LearnedBlockedEdges}: a malformed row is logged and skipped, + *

Parsing is lenient: a malformed row is logged and skipped, * never fatal. */ @Slf4j diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java deleted file mode 100644 index e7b74d4a9af..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java +++ /dev/null @@ -1,236 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.RuneLite; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.List; -import java.util.Scanner; - -/** - * Human-editable, on-disk store of blocked walking edges the walker learned at runtime — a - * door it physically failed to traverse the same way twice (e.g. a one-way door, or door geometry the - * static map doesn't encode). Distinct from the shipped {@code blocked_edges.tsv} resource (curated map- - * data gaps) and from {@code restrictions.tsv} (quest/skill/item-gated tiles that auto-lift): entries - * here are stable map properties safe to avoid permanently. - * - *

The file lives under {@code /microbot/learned-blocked-edges.tsv} and shares the first - * four columns of {@code blocked_edges.tsv} so a line can be copied between them by hand. Because it is - * user-owned, parsing is deliberately lenient: a malformed row is logged and skipped, never fatal — - * unlike the resource loader, which throws. Delete the file to reset everything the walker has learned. - * - *

Columns 5–6 ({@code Strikes}, {@code Last strike ms}) implement two-strike hardening: one bad - * observation must not poison the store permanently (a mid-walk sample once blacklisted the Wydin shop - * door and needed a hand-edit). A row is only enforced on load once two independent - * observations agree; a first-strike row is probation — blocked for the session that observed it, - * ignored by later sessions until re-confirmed. Rows without the columns (legacy, or hand-copied from - * {@code blocked_edges.tsv}) parse as already-confirmed so existing behavior is preserved. - * - *

This class only does file I/O and parsing. The packed-edge encoding, the strike accounting and the - * pathfinder wiring live in {@link PathfinderConfig}, which owns the authoritative in-memory state. - */ -@Slf4j -public final class LearnedBlockedEdges { - private static final String DELIM_COLUMN = "\t"; - private static final String PREFIX_COMMENT = "#"; - private static final String HEADER = "# Origin\tDestination\tBidirectional\tDisplay info\tStrikes\tLast strike ms"; - /** Rows predating the strike columns were trusted unconditionally; keep them that way. */ - static final int LEGACY_STRIKES = 2; - - /** - * One parsed row. {@code bidirectional} blocks the reverse edge too; {@code info} is free-text; - * {@code strikes}/{@code lastStrikeAtMs} carry the two-strike confirmation state. - */ - public static final class Edge { - public final WorldPoint origin; - public final WorldPoint destination; - public final boolean bidirectional; - public final String info; - public final int strikes; - public final long lastStrikeAtMs; - - public Edge(WorldPoint origin, WorldPoint destination, boolean bidirectional, String info) { - this(origin, destination, bidirectional, info, 1, 0L); - } - - public Edge(WorldPoint origin, WorldPoint destination, boolean bidirectional, String info, - int strikes, long lastStrikeAtMs) { - this.origin = origin; - this.destination = destination; - this.bidirectional = bidirectional; - this.info = info == null ? "" : info; - this.strikes = strikes; - this.lastStrikeAtMs = lastStrikeAtMs; - } - - /** A copy with one more strike stamped at {@code atMs}. */ - public Edge withStrikeAt(long atMs) { - return new Edge(origin, destination, bidirectional, info, strikes + 1, atMs); - } - } - - private LearnedBlockedEdges() { - } - - /** Default store location, mirroring {@code LiveCollisionPersistence}'s {@code microbot} subdir. */ - public static File defaultFile() { - return new File(new File(RuneLite.RUNELITE_DIR, "microbot"), "learned-blocked-edges.tsv"); - } - - /** - * Reads every well-formed row. A missing file yields an empty list; a malformed row is skipped with - * a warning so one bad hand-edit can't stop the walker from loading the rest. - */ - public static List load(File file) { - List edges = new ArrayList<>(); - if (file == null || !file.isFile()) { - return edges; - } - - try { - String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - try (Scanner scanner = new Scanner(content)) { - while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) { - continue; - } - Edge edge = parseRow(line); - if (edge != null) { - edges.add(edge); - } - } - } - } catch (IOException e) { - log.warn("[Walker] Unable to read learned blocked edges from {}: {}", file, e.getMessage()); - } - - return edges; - } - - private static Edge parseRow(String line) { - String[] fields = line.split(DELIM_COLUMN); - if (fields.length < 2) { - log.warn("[Walker] Skipping malformed learned-blocked-edge row (need Origin and Destination): {}", line); - return null; - } - - WorldPoint origin = parsePoint(fields[0]); - WorldPoint destination = parsePoint(fields[1]); - if (origin == null || destination == null) { - log.warn("[Walker] Skipping learned-blocked-edge row with unparseable point(s): {}", line); - return null; - } - - boolean bidirectional = fields.length > 2 && Boolean.parseBoolean(fields[2].trim()); - String info = fields.length > 3 ? fields[3].trim() : ""; - int strikes = LEGACY_STRIKES; - if (fields.length > 4 && !fields[4].trim().isEmpty()) { - try { - strikes = Integer.parseInt(fields[4].trim()); - } catch (NumberFormatException e) { - log.warn("[Walker] Unparseable strike count, treating as confirmed: {}", line); - } - } - long lastStrikeAtMs = 0L; - if (fields.length > 5 && !fields[5].trim().isEmpty()) { - try { - lastStrikeAtMs = Long.parseLong(fields[5].trim()); - } catch (NumberFormatException e) { - // timestamp is advisory; a missing one just widens the independence window - } - } - return new Edge(origin, destination, bidirectional, info, strikes, lastStrikeAtMs); - } - - private static WorldPoint parsePoint(String field) { - if (field == null || field.isBlank()) { - return null; - } - String[] parts = field.trim().split(" "); - if (parts.length != 3) { - return null; - } - try { - return new WorldPoint( - Integer.parseInt(parts[0]), - Integer.parseInt(parts[1]), - Integer.parseInt(parts[2])); - } catch (NumberFormatException e) { - return null; - } - } - - /** - * Appends one row, creating the parent directory and header on first write. Callers are responsible - * for de-duplication (the {@link PathfinderConfig} in-memory set is the source of truth). - */ - public static void append(File file, Edge edge) { - if (file == null || edge == null || edge.origin == null || edge.destination == null) { - return; - } - try { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) { - Files.createDirectories(parent.toPath()); - } - boolean newFile = !file.isFile() || file.length() == 0; - StringBuilder sb = new StringBuilder(); - if (newFile) { - sb.append(HEADER).append(System.lineSeparator()); - } - sb.append(formatRow(edge)).append(System.lineSeparator()); - Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), - StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (IOException e) { - log.warn("[Walker] Unable to append learned blocked edge to {}: {}", file, e.getMessage()); - } - } - - /** - * Rewrites the whole store (header + rows). Used when a strike count changes; {@link #append} - * stays the cheap path for brand-new rows. The file is tiny — a walker learns a handful of edges - * over its lifetime — so a full rewrite is simpler than in-place editing. - */ - public static void save(File file, List edges) { - if (file == null || edges == null) { - return; - } - try { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) { - Files.createDirectories(parent.toPath()); - } - StringBuilder sb = new StringBuilder(HEADER).append(System.lineSeparator()); - for (Edge edge : edges) { - if (edge == null || edge.origin == null || edge.destination == null) { - continue; - } - sb.append(formatRow(edge)).append(System.lineSeparator()); - } - Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (IOException e) { - log.warn("[Walker] Unable to save learned blocked edges to {}: {}", file, e.getMessage()); - } - } - - private static String formatRow(Edge edge) { - return formatPoint(edge.origin) + DELIM_COLUMN - + formatPoint(edge.destination) + DELIM_COLUMN - + edge.bidirectional + DELIM_COLUMN - + (edge.info == null ? "" : edge.info) + DELIM_COLUMN - + edge.strikes + DELIM_COLUMN - + edge.lastStrikeAtMs; - } - - private static String formatPoint(WorldPoint p) { - return p.getX() + " " + p.getY() + " " + p.getPlane(); - } -} 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 d02209eaa6d..b523c62de9f 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 @@ -117,21 +117,6 @@ public Set getUsableTeleportsSnapshot() { * they survive. Loaded once in the constructor; grown by {@link #learnBlockedEdge}. */ private final Set learnedBlockedEdgeKeys = ConcurrentHashMap.newKeySet(); - /** Backing file for {@link #learnedBlockedEdgeKeys}; redirectable for tests. */ - private volatile File learnedBlockedEdgesFile; - /** - * Two-strike hardening state: every row of the learned store (probation included), in file order, - * plus a by-key index for strike accounting. {@link #learnedBlockedEdgeKeys} holds only what is - * ENFORCED this session (confirmed rows + this session's own observations). Guarded by - * {@link #learnedEdgeLock}. - */ - private final List learnedEdgeRows = new ArrayList<>(); - private final Map learnedEdgeRowsByKey = new HashMap<>(); - private final Object learnedEdgeLock = new Object(); - /** Observations needed before a learned block survives into LATER sessions. */ - static final int LEARNED_EDGE_ENFORCE_STRIKES = 2; - /** A repeat observation only counts as independent evidence after this long. */ - static final long LEARNED_EDGE_STRIKE_INDEPENDENCE_MS = 10 * 60_000L; private final Client client; private final ShortestPathConfig config; @@ -288,8 +273,6 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr this.transportsPacked = new PrimitiveIntHashMap<>(allTransports.size() / 2); this.blockedTransportEdgesPacked = ConcurrentHashMap.newKeySet(); addStaticBlockedEdges(); - this.learnedBlockedEdgesFile = LearnedBlockedEdges.defaultFile(); - loadLearnedBlockedEdges(); this.client = client; this.config = config; this.transportPlanningPolicy = Objects.requireNonNull( @@ -856,63 +839,25 @@ private void addStaticBlockedEdges() { } /** - * (Re)loads the human-editable learned-blocked-edges TSV. Only rows with - * {@link #LEARNED_EDGE_ENFORCE_STRIKES}+ strikes are applied to the live block set — a - * single-strike row is probation: the session that observed it blocked it at the time, but a - * fresh session ignores it until a second independent observation confirms (one bad sample must - * not poison the store permanently). A reload drops previously-applied learned keys first so the - * test seam can simulate a restart; static blocked edges are re-added and unaffected. - */ - private void loadLearnedBlockedEdges() { - synchronized (learnedEdgeLock) { - blockedTransportEdgesPacked.removeAll(learnedBlockedEdgeKeys); - addStaticBlockedEdges(); - learnedBlockedEdgeKeys.clear(); - learnedEdgeRows.clear(); - learnedEdgeRowsByKey.clear(); - for (LearnedBlockedEdges.Edge edge : LearnedBlockedEdges.load(learnedBlockedEdgesFile)) { - long key = transportEdgeKey( - WorldPointUtil.packWorldPoint(edge.origin), - WorldPointUtil.packWorldPoint(edge.destination)); - learnedEdgeRows.add(edge); - learnedEdgeRowsByKey.put(key, edge); - boolean enforced = edge.strikes >= LEARNED_EDGE_ENFORCE_STRIKES; - if (enforced) { - learnedBlockedEdgeKeys.add(key); - blockedTransportEdgesPacked.add(key); - } else { - log.debug("[Walker] Learned edge on probation (strike {}/{}), not enforced: {} -> {}", - edge.strikes, LEARNED_EDGE_ENFORCE_STRIKES, edge.origin, edge.destination); - } - if (edge.bidirectional) { - long reverse = transportEdgeKey( - WorldPointUtil.packWorldPoint(edge.destination), - WorldPointUtil.packWorldPoint(edge.origin)); - learnedEdgeRowsByKey.putIfAbsent(reverse, edge); - if (enforced) { - learnedBlockedEdgeKeys.add(reverse); - blockedTransportEdgesPacked.add(reverse); - } - } - } - } - } - - /** - * Records a walking edge the walker just failed to traverse (e.g. a door that moved the player the - * wrong way). The observing session blocks the edge immediately — it just watched the failure, and - * anything less loops the walker into the same door. PERSISTENCE is two-strike gated: the row is - * written on probation (strike 1) and later sessions ignore it until a second observation at least - * {@link #LEARNED_EDGE_STRIKE_INDEPENDENCE_MS} later confirms it. One bad sample (the Wydin door - * poisoning) therefore self-heals on restart instead of requiring a hand-edit. - * - *

Only the attempted direction is blocked — not bidirectionally — so a genuinely one-way door - * stays usable the other way. Callers must only pass stable map properties here; temporary, - * quest/skill-gated doors are handled by {@code restrictions.tsv} and must not be learned, or the - * bot would avoid them forever after the requirement is met. + * Records a walking edge the walker just failed to traverse (e.g. a door that moved the player + * the wrong way, or a route click the reachability net proved walled). The observing session + * blocks the edge immediately — it just watched the failure, and anything less loops the walker + * into the same obstacle. + *

+ * SESSION-ONLY by policy (2026-08-07): nothing is persisted, and nothing learned in an earlier + * session is loaded. The hand-curated {@code blocked_edges.tsv} is the sole cross-session + * authority. The two-strike persistent store this replaces spent its history managing its own + * failure modes — the Wydin door poisoning needed probation semantics to self-heal, and the + * store's default file leaked developer state into every test that built a config. An edge worth + * remembering across sessions is worth a reviewed TSV row. + *

+ * Only the attempted direction is blocked — not bidirectionally — so a genuinely one-way door + * stays usable the other way. Callers must only pass stable map properties here; + * temporary, quest/skill-gated doors are handled by {@code restrictions.tsv} and must not be + * learned, or the bot would avoid them for the rest of the session after the requirement is met. * * @return {@code true} if this edge was newly blocked for this session; {@code false} if it was - * already enforced. + * already blocked. */ public boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) { if (origin == null || destination == null) { @@ -925,45 +870,11 @@ public boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, Strin return false; } blockedTransportEdgesPacked.add(key); - long now = System.currentTimeMillis(); - synchronized (learnedEdgeLock) { - LearnedBlockedEdges.Edge existing = learnedEdgeRowsByKey.get(key); - if (existing == null) { - LearnedBlockedEdges.Edge row = new LearnedBlockedEdges.Edge( - origin, destination, false, reason == null ? "" : reason, 1, now); - learnedEdgeRows.add(row); - learnedEdgeRowsByKey.put(key, row); - LearnedBlockedEdges.append(learnedBlockedEdgesFile, row); - log.info("[Walker] Learned blocked edge {} -> {} ({}) — strike 1/{}: blocked this session, " - + "enforced across sessions only after independent confirmation; {}", - origin, destination, reason, LEARNED_EDGE_ENFORCE_STRIKES, learnedBlockedEdgesFile); - } else if (existing.strikes < LEARNED_EDGE_ENFORCE_STRIKES - && now - existing.lastStrikeAtMs > LEARNED_EDGE_STRIKE_INDEPENDENCE_MS) { - LearnedBlockedEdges.Edge confirmed = existing.withStrikeAt(now); - int idx = learnedEdgeRows.indexOf(existing); - if (idx >= 0) { - learnedEdgeRows.set(idx, confirmed); - } - learnedEdgeRowsByKey.put(key, confirmed); - LearnedBlockedEdges.save(learnedBlockedEdgesFile, learnedEdgeRows); - log.info("[Walker] Learned blocked edge {} -> {} ({}) — strike {}/{}: persistently enforced", - origin, destination, reason, confirmed.strikes, LEARNED_EDGE_ENFORCE_STRIKES); - } else { - // Probation row re-observed within the independence window (e.g. a rapid client - // restart into the same stuck spot): session block stands, persistence unchanged. - log.debug("[Walker] Learned blocked edge {} -> {} re-observed within the independence " - + "window; probation unchanged", origin, destination); - } - } + log.info("[Walker] Learned blocked edge {} -> {} ({}) — blocked for THIS SESSION only; " + + "permanent blocks belong in blocked_edges.tsv", origin, destination, reason); return true; } - /** Test seam: redirect the learned-edge store to a temp file and (re)load it. */ - void setLearnedBlockedEdgesFileForTest(File file) { - this.learnedBlockedEdgesFile = file; - loadLearnedBlockedEdges(); - } - private void addBlockedEdge(WorldPoint origin, WorldPoint destination) { blockedTransportEdgesPacked.add(transportEdgeKey( WorldPointUtil.packWorldPoint(origin), diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java new file mode 100644 index 00000000000..5753476e33c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java @@ -0,0 +1,72 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Learned blocked edges are SESSION-ONLY by policy (2026-08-07): the observing session blocks the + * edge immediately — it watched the failure happen, and anything less loops the walker into the same + * obstacle — but nothing is persisted and nothing is loaded. The hand-curated blocked_edges.tsv is + * the sole cross-session authority. This replaces the two-strike persistent store, whose probation + * machinery existed to manage its own poisonings and whose default file leaked developer state into + * every test that built a config. + */ +public class LearnedBlockedEdgeSessionTest { + + private static final WorldPoint FROM = new WorldPoint(3012, 3204, 0); + private static final WorldPoint TO = new WorldPoint(3011, 3204, 0); + + private static SplitFlagMap collisionMap; + + @BeforeClass + public static void loadMap() { + collisionMap = SplitFlagMap.fromResources(); + } + + private static PathfinderConfig newConfig() { + return new PathfinderConfig(collisionMap, new HashMap<>(), Collections.emptyList(), null, null); + } + + @Test + public void firstObservationBlocksTheSession() { + PathfinderConfig config = newConfig(); + assertTrue("first observation must block this session", + config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + assertFalse("repeat in the same session is already blocked", + config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + } + + /** Directionality: a one-way failure must not condemn the reverse crossing. */ + @Test + public void onlyTheAttemptedDirectionIsBlocked() { + PathfinderConfig config = newConfig(); + assertTrue(config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + assertTrue("the reverse direction is a separate observation", + config.learnBlockedEdge(TO, FROM, "wrong-traversal")); + } + + /** The whole policy: nothing learned in one session exists in the next. */ + @Test + public void nothingSurvivesIntoAFreshConfig() { + PathfinderConfig first = newConfig(); + assertTrue(first.learnBlockedEdge(FROM, TO, "wrong-traversal")); + + PathfinderConfig restarted = newConfig(); + assertTrue("a fresh session must not inherit the block", + restarted.learnBlockedEdge(FROM, TO, "wrong-traversal")); + } + + @Test + public void nullEndpointsAreRejected() { + PathfinderConfig config = newConfig(); + assertFalse(config.learnBlockedEdge(null, TO, "x")); + assertFalse(config.learnBlockedEdge(FROM, null, "x")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java deleted file mode 100644 index e8706353264..00000000000 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import net.runelite.api.coords.WorldPoint; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Two-strike hardening, tested purely through {@code learnBlockedEdge}'s return value (true = newly - * blocked this session, false = already enforced) and the on-disk rows — no private state: - * - *

    - *
  • The observing session blocks immediately (it watched the failure happen).
  • - *
  • A single strike does NOT survive a restart — the row is probation, so one bad sample (the - * Wydin door poisoning, which needed a hand-edit) self-heals.
  • - *
  • A second observation, independent by the 10-minute window, confirms and enforces forever.
  • - *
  • Legacy rows without strike columns keep their unconditional trust.
  • - *
- * - * A "restart" is simulated with the same package-private seam the store already exposes: - * {@code setLearnedBlockedEdgesFileForTest} clears and reloads from the file. - */ -public class LearnedBlockedEdgeStrikesTest { - - private static final WorldPoint FROM = new WorldPoint(3012, 3204, 0); - private static final WorldPoint TO = new WorldPoint(3011, 3204, 0); - - private static SplitFlagMap collisionMap; - - private PathfinderConfig config; - private File store; - - @BeforeClass - public static void loadMap() { - collisionMap = SplitFlagMap.fromResources(); - } - - @Before - public void setUp() throws Exception { - store = Files.createTempFile("learned-strikes", ".tsv").toFile(); - store.deleteOnExit(); - Files.delete(store.toPath()); - config = new PathfinderConfig(collisionMap, new HashMap<>(), Collections.emptyList(), null, null); - config.setLearnedBlockedEdgesFileForTest(store); - } - - private void simulateRestart() { - config.setLearnedBlockedEdgesFileForTest(store); - } - - @Test - public void firstStrikeBlocksTheSessionButDoesNotSurviveRestart() { - assertTrue("first observation must block this session", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - assertFalse("repeat in the same session is already enforced", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - - List rows = LearnedBlockedEdges.load(store); - assertEquals(1, rows.size()); - assertEquals("persisted on probation", 1, rows.get(0).strikes); - - simulateRestart(); - assertTrue("a probation row must NOT be enforced on load — learning it again must succeed", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - assertEquals("a re-observation within the independence window must not confirm", - 1, LearnedBlockedEdges.load(store).get(0).strikes); - } - - @Test - public void independentSecondStrikeConfirmsAndEnforces() { - long elevenMinutesAgo = System.currentTimeMillis() - 11 * 60_000L; - LearnedBlockedEdges.append(store, new LearnedBlockedEdges.Edge( - FROM, TO, false, "wrong-traversal", 1, elevenMinutesAgo)); - simulateRestart(); - - assertTrue("probation row is not enforced, so the session may observe it again", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - - List rows = LearnedBlockedEdges.load(store); - assertEquals(1, rows.size()); - assertEquals("independent second strike must confirm", 2, rows.get(0).strikes); - - simulateRestart(); - assertFalse("a confirmed row must be enforced on load", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - } - - @Test - public void legacyRowsWithoutStrikeColumnsStayEnforced() throws Exception { - String content = "# Origin\tDestination\tBidirectional\tDisplay info" + System.lineSeparator() - + "3012 3204 0\t3011 3204 0\tfalse\tlegacy hand-copied row" + System.lineSeparator(); - Files.write(store.toPath(), content.getBytes(StandardCharsets.UTF_8)); - simulateRestart(); - - assertFalse("legacy rows predate strike tracking and keep their unconditional trust", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - } -} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java deleted file mode 100644 index 474b9fe7f92..00000000000 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Covers the learned-blocked-edge substrate: the human-editable TSV round-trip and its lenient parsing, - * plus the packed-edge block check the pathfinder actually consults ({@link PathfinderConfig#isBlockedTransportStep}). - * Deliberately avoids constructing a full {@link PathfinderConfig} (heavy game deps) — the graph wiring is - * exercised through the same static predicate {@code getNeighbors}/{@code getReverseNeighbors} use. - */ -public class LearnedBlockedEdgesTest { - - private static final WorldPoint FROM = new WorldPoint(3200, 3200, 0); - private static final WorldPoint TO = new WorldPoint(3201, 3200, 0); // one tile east - - @Test - public void appendThenLoadRoundTrips() throws Exception { - File file = Files.createTempFile("learned-edges", ".tsv").toFile(); - file.deleteOnExit(); - Files.delete(file.toPath()); // start from "no file" so append writes the header - - LearnedBlockedEdges.append(file, new LearnedBlockedEdges.Edge(FROM, TO, false, "wrong-traversal door @ 3200,3200,0")); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals(FROM, loaded.get(0).origin); - assertEquals(TO, loaded.get(0).destination); - assertFalse(loaded.get(0).bidirectional); - assertTrue(loaded.get(0).info.contains("wrong-traversal")); - } - - @Test - public void strikeColumnsRoundTripAndSaveRewrites() throws Exception { - File file = Files.createTempFile("learned-edges-strikes", ".tsv").toFile(); - file.deleteOnExit(); - Files.delete(file.toPath()); - - LearnedBlockedEdges.append(file, new LearnedBlockedEdges.Edge(FROM, TO, false, "probation", 1, 123456789L)); - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals(1, loaded.get(0).strikes); - assertEquals(123456789L, loaded.get(0).lastStrikeAtMs); - - LearnedBlockedEdges.save(file, List.of(loaded.get(0).withStrikeAt(987654321L))); - loaded = LearnedBlockedEdges.load(file); - assertEquals("save must rewrite, not append", 1, loaded.size()); - assertEquals(2, loaded.get(0).strikes); - assertEquals(987654321L, loaded.get(0).lastStrikeAtMs); - assertEquals("row identity survives the rewrite", FROM, loaded.get(0).origin); - } - - @Test - public void legacyRowsWithoutStrikeColumnsParseAsConfirmed() throws Exception { - File file = Files.createTempFile("learned-edges-legacy", ".tsv").toFile(); - file.deleteOnExit(); - String content = String.join(System.lineSeparator(), - "# Origin\tDestination\tBidirectional\tDisplay info", - "3200 3200 0\t3201 3200 0\tfalse\tlegacy row"); - Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals("rows predating strike tracking stay unconditionally trusted", - LearnedBlockedEdges.LEGACY_STRIKES, loaded.get(0).strikes); - assertEquals(0L, loaded.get(0).lastStrikeAtMs); - } - - @Test - public void loadMissingFileYieldsEmpty() { - File missing = new File(System.getProperty("java.io.tmpdir"), "learned-edges-does-not-exist-" + System.nanoTime() + ".tsv"); - assertTrue(LearnedBlockedEdges.load(missing).isEmpty()); - } - - @Test - public void malformedRowsAreSkippedNotFatal() throws Exception { - File file = Files.createTempFile("learned-edges-malformed", ".tsv").toFile(); - file.deleteOnExit(); - String content = String.join(System.lineSeparator(), - "# Origin\tDestination\tBidirectional\tDisplay info", - "3200 3200 0\t3201 3200 0\tfalse\tgood row", - "this is not a valid row", // too few columns - "3200 3200\t3201 3200 0\tfalse\tbad origin (2 coords)", // unparseable point - "3300 3300 0\t3301 3300 0\ttrue\tsecond good row (bidirectional)"); - Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(2, loaded.size()); - assertTrue(loaded.get(1).bidirectional); - } - - @Test - public void learnedEdgeKeyBlocksTheCardinalStep() { - Set blocked = new HashSet<>(); - blocked.add(PathfinderConfig.transportEdgeKey( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO))); - - // The exact learned direction is blocked... - assertTrue(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO), - blocked)); - - // ...but the reverse edge is not (we learn only the attempted direction). - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(TO), - WorldPointUtil.packWorldPoint(FROM), - blocked)); - - // An unrelated edge stays open. - WorldPoint elsewhere = new WorldPoint(3500, 3500, 0); - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(elsewhere), - WorldPointUtil.packWorldPoint(new WorldPoint(3501, 3500, 0)), - blocked)); - } - - @Test - public void emptyBlockSetNeverBlocks() { - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO), - new HashSet<>())); - } -} From 6ce862ecd8b9af345a703c2c317c8b191a8f28c8 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 7 Aug 2026 20:06:43 +0100 Subject: [PATCH 32/53] chore(pathfinder): light the three dark regions of the transport refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1158ms post-login client-thread freeze hides in code the stage timers never covered. The outer wrapper read transports=1154ms while the inner stages (merge/cache/filter/ similar) summed to ~30ms on every instrumented run — and the slow-stage breakdown line never fired for the slow case, because its threshold summed only the measured stages. Three regions were dark: entry the quest-state and item gates (four getQuestState calls — quest state can run a clientscript — plus fairy-ring inventory/equipment/bank checks and the gnome glider/spirit tree/quetzal gates) key leagues context + the cache-key fingerprint verify/ condition encoding, verification hashing, and the snapshot capture's deep capture copy of ~5k transport sets Each now has a timer, carried on the stage log AND the slow log, and the slow threshold sums all eight stages so the breakdown actually prints when the freeze happens. The next slow login names its stage instead of hiding it; the fix targets whatever it names. The only suite failure was RouteClickTargetRegressionTest, the known tiebreaker-lottery flake, green in isolation immediately after. Co-Authored-By: Claude Fable 5 (cherry picked from commit a356c1b3795e8311b6f8d023323e14e1948b5386) --- .../pathfinder/PathfinderConfig.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 b523c62de9f..19cfa395ab1 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 @@ -456,6 +456,12 @@ public void filterLocations(Set locations, boolean canReviveFiltered * @param target Optional target destination for optimized filtering (null for standard filtering) */ private void refreshTransports(WorldPoint target) { + // The 1.1s post-login client-thread freeze hid in the UNMEASURED parts of this method: the + // stage timers summed to ~30ms while the outer wrapper read 1154ms, and the slow-stage log + // never fired. Three regions were dark: this entry block (quest-state + bank/item gates), + // the cache-key phase, and the verify/capture block after filtering. Each now has a timer, + // carried on both the stage log and the slow log, so the next slow login names its stage. + long entryStart = System.currentTimeMillis(); useFairyRings = ShortestPathPlugin.override("useFairyRings", config.useFairyRings()) && !QuestState.NOT_STARTED.equals(Rs2Player.getQuestState(Quest.FAIRYTALE_II__CURE_A_QUEEN)) && (Rs2Inventory.contains(ItemID.DRAMEN_STAFF, ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) @@ -469,8 +475,12 @@ private void refreshTransports(WorldPoint target) { useQuetzals = ShortestPathPlugin.override("useQuetzals", config.useQuetzals()) && QuestState.FINISHED.equals(Rs2Player.getQuestState(Quest.TWILIGHTS_PROMISE)); + long entryTime = System.currentTimeMillis() - entryStart; + + long keyStart = System.currentTimeMillis(); final Rs2LeaguesTransport.LeaguesContext leaguesCtx = Rs2LeaguesTransport.leaguesContext(); final int refreshCacheKeyHash = computeTransportRefreshCacheKeyHash(target, leaguesCtx); + long keyTime = System.currentTimeMillis() - keyStart; TransportRefreshSnapshot snap = transportRefreshSnapshots.get(refreshCacheKeyHash); if (snap != null && client != null) { @@ -721,6 +731,7 @@ private void refreshTransports(WorldPoint target) { typeStats); long filterTime = System.currentTimeMillis() - filterStart; + long verifyStart = System.currentTimeMillis(); int[] sortedVarbitConditions = encodeSortedConditionTriples(varbitConditions); int[] sortedVarplayerConditions = encodeSortedConditionTriples(varplayerConditions); int[] sortedQuestIds = mergedList.values().stream() @@ -739,10 +750,13 @@ private void refreshTransports(WorldPoint target) { sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); int[] verificationComponents = computeTransportRefreshVerificationComponents(refreshBoostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); + long verifyTime = System.currentTimeMillis() - verifyStart; + long captureStart = System.currentTimeMillis(); transportRefreshSnapshots.put(refreshCacheKeyHash, TransportRefreshSnapshot.capture( refreshCacheKeyHash, verificationHash, verificationComponents, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds, transports, usableTeleports)); + long captureTime = System.currentTimeMillis() - captureStart; long similarStart = System.currentTimeMillis(); if (useBankItems && config.maxSimilarTransportDistance() > 0) { @@ -757,16 +771,19 @@ private void refreshTransports(WorldPoint target) { refreshVarplayerValues = null; // varbit/varplayer counts = distinct ids referenced by merged transport definitions this refresh, not total client var space. - WebWalkLog.cfg("refresh_transports merge={}ms cache={}ms filter={}ms useTrans={}ms similar={}ms total/chk={}/{} usablePost={} vb={} vp={}", - mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, similarTime, + WebWalkLog.cfg("refresh_transports entry={}ms key={}ms merge={}ms cache={}ms filter={}ms useTrans={}ms verify={}ms capture={}ms similar={}ms total/chk={}/{} usablePost={} vb={} vp={}", + entryTime, keyTime, mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, + verifyTime, captureTime, similarTime, totalTransports, checkedTransports, usableTeleports.size(), varbitIds.size(), varplayerIds.size()); // Surface the same breakdown at INFO when the miss is slow enough to be the visible cold // start, so the dominant stage is identifiable without enabling debug logging. - long refreshTransportsTotalMs = mergeTime + cacheTime + filterTime + similarTime; + long refreshTransportsTotalMs = entryTime + keyTime + mergeTime + cacheTime + filterTime + + verifyTime + captureTime + similarTime; if (refreshTransportsTotalMs >= SLOW_REFRESH_LOG_THRESHOLD_MS) { - WebWalkLog.cfgSlow("slow refresh_transports merge={}ms cache={}ms filter={}ms useTrans={}ms similar={}ms total/chk={}/{} vb={} vp={}", - mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, similarTime, + WebWalkLog.cfgSlow("slow refresh_transports entry={}ms key={}ms merge={}ms cache={}ms filter={}ms useTrans={}ms verify={}ms capture={}ms similar={}ms total/chk={}/{} vb={} vp={}", + entryTime, keyTime, mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, + verifyTime, captureTime, similarTime, totalTransports, checkedTransports, varbitIds.size(), varplayerIds.size()); typeStats.entrySet().stream() .sorted((a, b) -> Integer.compare(b.getValue()[2], a.getValue()[2])) From 77cb930eaea357c2ef3742dfc7d8fcc5d8a6d097 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Sat, 8 Aug 2026 17:13:57 +0100 Subject: [PATCH 33/53] fix(interact): unreachable NPCs and objects now walk through the door instead of stalling forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported as widespread, and it is: scripts click NPCs and objects with a shut door between, the server walks the player to the door and prints "I can't reach that!", and nothing recovers — observed live as the Kudos museum script blocked on three separate interactions in a building made of gates. The interaction layer's ONLY reachability recovery was dead code. The chat listener has set cantReachTarget on every can't-reach message all along, but the flag that gates the recovery (isCantReachTargetDetectionEnabled) defaulted to false and NOTHING in the repo ever set it true. Even enabled, the recovery was wrong twice over: the NPC path walked to a nearest-line-of-sight tile — which for an NPC behind a door selects tiles on the unreachable side — and its LOS "all clear" branch cleared the flag without walking, so a through-window NPC re-clicked forever without ever escalating. The object layer had no reactive handling at all, and its opt-in checkCanReach variant gated on line-of-sight — which solid objects fail from everywhere (docs/entity-guides) — before falling back to a raw canvas click that opens no door either. Detection now defaults ON, and both layers recover the same way: on the game's own can't-reach verdict, walk to the target with Rs2Walker.walkTo(loc, 2) — the walker opens doors en route, and its arrival semantics require a standable tile BESIDE an unwalkable target, which is precisely the reachability proof the follow-up click needs — then clear the flag and click from beside it. The retry escalation (pause + operator message after 3-5 failed rounds) is preserved on both paths. The trigger stays the game's own message, which is what keeps this safe: a ranged attack through a fence or a shouted conversation across a chasm never prints can't-reach, so legitimate at-range interactions are untouched. LOS is consulted nowhere. Amended: the first cut of this commit accidentally swept the user's staged Kudos and Varrock-cleaner plugin work in via the shared index; this one carries only the interaction-layer change and their staging is preserved untouched. Co-Authored-By: Claude Fable 5 (cherry picked from commit 4ec3ab817114cdd5838c0bec6cff708922feb7e3) --- .../client/plugins/microbot/Microbot.java | 10 ++++- .../util/gameobject/Rs2GameObject.java | 39 +++++++++++++++-- .../plugins/microbot/util/npc/Rs2Npc.java | 43 +++++++++++-------- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 1270bfa3c37..840cd92abdc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -103,7 +103,15 @@ public class Microbot { public static boolean enableAutoRunOn = true; public static boolean useStaminaPotsIfNeeded = true; public static int runEnergyThreshold = 1000; - public static boolean isCantReachTargetDetectionEnabled = false; + /** + * Reactive unreachable-interaction recovery. When the game prints "I can't reach that!" + * (a shut door or wall between the player and a clicked NPC/object), the next interact call + * routes through the walker — which opens doors — before re-clicking. ON by default since + * 2026-08-08: it was off, nothing in the repo enabled it, and the only recovery path in the + * interaction layer was dead code — every script clicking through a wall stalled silently. + * Left as a flag so a plugin with its own recovery can opt out. + */ + public static boolean isCantReachTargetDetectionEnabled = true; @Getter @Inject diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java index 795c8d631cc..9f4f2133023 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java @@ -20,6 +20,7 @@ import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.apache.commons.lang3.tuple.Triple; @@ -141,11 +142,14 @@ public static boolean interact(TileObject tileObject, String action, boolean che if (tileObject == null) return false; if (!checkCanReach) return clickObject(tileObject, action); - if (checkCanReach && Rs2GameObject.hasLineOfSight(tileObject)) + // Proactive variant: prove we can stand beside the object BEFORE clicking. The old gate was + // line-of-sight — which solid objects fail from everywhere (docs/entity-guides), so callers + // deadlocked — and its fallback was a raw canvas click at the object's tile, which opens no + // door either. The walker's arrival semantics require a standable adjacent tile and open + // any doors on the way, and it returns immediately when already beside the target. + if (Rs2Walker.walkTo(tileObject.getWorldLocation(), 2)) { return clickObject(tileObject, action); - - Rs2Walker.walkFastCanvas(tileObject.getWorldLocation()); - + } return false; } @@ -1773,6 +1777,33 @@ private static boolean clickObject(TileObject object) { public static boolean clickObject(TileObject object, String action) { if (object == null) return false; + if (Microbot.isCantReachTargetDetectionEnabled && Microbot.cantReachTarget) { + // The game said "I can't reach that!" on the previous interaction — something solid sits + // between us and the target, most often a shut door. The walker is the only recovery + // that opens doors; its arrival check requires a standable tile BESIDE an unwalkable + // target, which is exactly the reachability proof a follow-up click needs. LOS is + // deliberately not consulted: solid objects fail line-of-sight from everywhere + // (docs/entity-guides), which is how the old opt-in checkCanReach path deadlocked. + if (Microbot.cantReachTargetRetries >= Rs2Random.between(3, 5)) { + Microbot.pauseAllScripts.compareAndSet(false, true); + Microbot.showMessage("Your bot tried to interact with an object for " + + Microbot.cantReachTargetRetries + " times but failed. Please take a look at what is happening."); + return false; + } + WorldPoint objectLocation = object.getWorldLocation(); + if (objectLocation == null) return false; + Microbot.cantReachTargetRetries++; + Microbot.log("[Interact] can't-reach recovery: walking to object " + object.getId() + + " at " + objectLocation + " (attempt " + Microbot.cantReachTargetRetries + ")"); + if (Rs2Walker.walkTo(objectLocation, 2)) { + Microbot.pauseAllScripts.compareAndSet(true, false); + Microbot.cantReachTarget = false; + Microbot.cantReachTargetRetries = 0; + // fall through and click from beside it + } else { + return false; + } + } // Use LocalPoint-based distance when the object is in the current scene (e.g. inside a // POH instance, where Rs2Player.getWorldLocation() returns the overworld-template tile // and distanceTo() against an instance world point yields Integer.MAX_VALUE, falsely diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2Npc.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2Npc.java index 2a278f99b67..b4ec9885c8b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2Npc.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2Npc.java @@ -642,26 +642,35 @@ public static boolean interact(Rs2NpcModel npc, String action) { Microbot.status = action + " " + npc.getName(); try { if (Microbot.isCantReachTargetDetectionEnabled && Microbot.cantReachTarget) { - if (!hasLineOfSight(npc)) { - if (Microbot.cantReachTargetRetries >= Rs2Random.between(3, 5)) { - Microbot.pauseAllScripts.compareAndSet(false, true); - Microbot.showMessage("Your bot tried to interact with an NPC for " - + Microbot.cantReachTargetRetries + " times but failed. Please take a look at what is happening."); - return false; - } - final WorldPoint npcWorldPoint = npc.getWorldLocation(); - if (npcWorldPoint == null) { - log.error("Error interacting with NPC '{}' for action '{}': WorldPoint is null", npc.getName(), action); - return false; - } - Rs2Walker.walkTo(Rs2Tile.getNearestWalkableTileWithLineOfSight(npcWorldPoint), 0); - Microbot.pauseAllScripts.compareAndSet(true, false); - Microbot.cantReachTargetRetries++; + // The game itself said "I can't reach that!" on the previous interaction — a shut + // door or wall sits between us and the target. The walker is the only recovery that + // OPENS doors, so walk to the NPC (doors handled en route) and re-click on arrival. + // The old branch selected a line-of-sight tile instead, which for an NPC behind a + // door picks tiles on the unreachable side — and its LOS "all clear" path cleared + // the flag without ever walking, so a through-window NPC clicked forever without + // escalating. LOS is deliberately not consulted: a ranged attack through a fence + // never prints can't-reach, so every trigger here genuinely needs adjacency. + if (Microbot.cantReachTargetRetries >= Rs2Random.between(3, 5)) { + Microbot.pauseAllScripts.compareAndSet(false, true); + Microbot.showMessage("Your bot tried to interact with an NPC for " + + Microbot.cantReachTargetRetries + " times but failed. Please take a look at what is happening."); return false; - } else { - Microbot.pauseAllScripts.compareAndSet(true, false); + } + final WorldPoint npcWorldPoint = npc.getWorldLocation(); + if (npcWorldPoint == null) { + log.error("Error interacting with NPC '{}' for action '{}': WorldPoint is null", npc.getName(), action); + return false; + } + Microbot.cantReachTargetRetries++; + log.info("[Interact] can't-reach recovery: walking to NPC '{}' at {} (attempt {})", + npc.getName(), npcWorldPoint, Microbot.cantReachTargetRetries); + if (Rs2Walker.walkTo(npcWorldPoint, 2)) { + Microbot.pauseAllScripts.compareAndSet(true, false); Microbot.cantReachTarget = false; Microbot.cantReachTargetRetries = 0; + // fall through and click from beside it + } else { + return false; } } From b84c3c097916b139acefb731b4dad136c84eb3c6 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Sat, 8 Aug 2026 20:03:28 +0100 Subject: [PATCH 34/53] =?UTF-8?q?fix(walker):=20unwall=20the=20Varrock=20m?= =?UTF-8?q?useum=20=E2=80=94=20the=20planner=20was=20banned=20from=20its?= =?UTF-8?q?=20own=20doorways?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kudos museum dead-end had a second cause under the interaction-layer one: every route to a museum-interior target was unplannable, so even the new can't-reach recovery had nothing to work with — walkTo cannot open a door the PLANNER refuses to route through. The proposed fix was a transports.tsv row for interior gate 24536 at (3261,3446)->(3261,3447). Verification found the actual gap one level deeper: restrictions.tsv has carried an unconditional "# Varrock museum" block since a Feb 2025 bulk upstream commit (no stated rationale) restricting the south double-door tiles (3264/3265,3442) and the gate tile (3261,3446). Restricted points are skipped as graph nodes entirely, so a transport row could never have helped — and none is needed: with the restrictions lifted, the corpus proves the interior routable immediately, meaning the static map already holds these as ordinary passable door edges. The runtime door machinery opens them on contact — today's live log shows exactly that for the south door at (3265,3442), which the walker opened in 1.2s on a walk that STARTED inside the restricted tile. The likely original motive — the pre-rewrite executor could not survive these doors, so the area was banned — is obsolete: doors chain now, and a museum through-route is at worst a valid shortcut. Pinned by a corpus route from outside the south door across the gate line to a display-pen tile. Co-Authored-By: Claude Fable 5 (cherry picked from commit 040c6e9963287240e584340158d6ae36f343f7e3) --- .../microbot/shortestpath/restrictions.tsv | 8 ++++---- .../shortestpath/WalkerRouteCorpusTest.java | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv index c66bd5a99f1..4668dbadb55 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/restrictions.tsv @@ -1,8 +1,8 @@ # Origin Item IDs Quests Skills isMembers Varbits Varplayers -# Varrock museum -3265 3442 0 -3264 3442 0 -3261 3446 0 +# Varrock museum south doors + interior gate were unconditionally restricted here (Feb 2025, +# bulk commit, no stated reason) which made every interior target unroutable — the Kudos museum +# script's interactions all dead-ended at the gate. The doors are ordinary openable scene doors +# the runtime handles; restrictions removed 2026-08-08. # Gate between digsite and varrock 3296 3429 0 3637>152 3296 3428 0 3637>152 diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index 74f681286b7..8cfd17b82e2 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -623,6 +623,26 @@ public void shantaySouthbound_withNothing_neverCrossesTheGate() { arrives(path, SOUTH_OF_GATE, 3)); } + // ---- Varrock museum interior (the Kudos dead-end) ---------------------------------------------- + + /** + * The museum's south doors and interior gate sat in restrictions.tsv unconditionally (Feb 2025 + * bulk commit, no stated reason), so every interior target was unroutable: the Kudos script's + * museum interactions all dead-ended at the gate, and the can't-reach recovery cannot open a + * door the PLANNER refuses to route through. The doors are ordinary openable scene doors the + * runtime handles. Route in from outside the south door, across the (3261,3446)->(3261,3447) + * gate line, to a display-pen tile. + */ + @Test + public void varrockMuseumInteriorIsRoutable() { + List path = route(configWith(WalkerRouteCorpusTest::unrestricted), + new WorldPoint(3264, 3439, 0), new WorldPoint(3261, 3449, 0)); + assertTrue("route must reach the museum interior past the gate", + arrives(path, new WorldPoint(3261, 3449, 0), 1)); + assertTrue("route must cross AT the interior gate", + visits(path, new WorldPoint(3261, 3446, 0), 1)); + } + // ---- Port Sarim, Wydin's shop (the door-poisoning incident) ------------------------------------ @Test From febd691dcf1e33a95f549133c7a0e7b1bd8291a2 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Sat, 8 Aug 2026 20:34:51 +0100 Subject: [PATCH 35/53] fix(walker): the Varrock museum guard barrier is a moves-you transport, not a door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User was right and my previous commit was half a fix. Lifting the restrictions let the PLANNER route through the museum doorways, but gate 24536 (vm_barrier_guard_gate) is the moves-you class, so the EXECUTOR still had nothing it could do with it. Measured through the agent server rather than inferred, standing beside it: player (3261,3447) -> click Open -> (3261,3446) player (3261,3446) -> click Open -> (3261,3447) player (3261,3447) -> click Open -> (3261,3446) One click relocates the player across the barrier every time, and the gate's composition never changes (transformedId stays 24536, action stays "Open" — it never enters an open state). That is the Al Kharid toll gate / Wydin back room signature: the door pipeline waits forever for an "open" that cannot happen, so the edge needs catalog rows. Added as a bidirectional pair beside the Wydin rows that document the same class. Note the trap this hid behind: the static map already reports every edge at both tiles passable (probed n/s/e/w all true), so the planner was happy to route through and only the executor failed — exactly the "collision says yes, executor can't" shape recorded in the transport-doors notes. Collision evidence alone would have said there was nothing wrong here. The corpus test from the previous commit was too weak to have caught any of this: it asserted the path passed within one tile of the gate, which any route merely reaching the doorway satisfies. It now asserts the route SELECTS transport 24536. Not claimed: an exhaustive audit of every museum interior door. The two south doors (24565/24567) are ordinary — today's live log shows the walker opening one and stepping through in 1.2s — but the agent server's /objects endpoint refused radii past ~6 tiles, so the interior beyond the barrier is unsurveyed. Co-Authored-By: Claude Opus 5 (cherry picked from commit 24a013b9c0ef50760243eb374e9066dc07d1e433) --- .../microbot/shortestpath/transports.tsv | 5 ++++ .../shortestpath/WalkerRouteCorpusTest.java | 29 ++++++++++--------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index d5fdc6401f8..e0ea059366f 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -1127,6 +1127,11 @@ 3012 3203 1 3012 3203 0 Climb-down;Ladder;16679 3012 3204 0 3011 3204 0 Open;Door;2069 3011 3204 0 3012 3204 0 Open;Door;2069 +# Varrock museum guard barrier (vm_barrier_guard_gate) - MEASURED 2026-08-08 via agent server: +# clicking Open MOVES the player through (3447<->3446, reproduced 3x) and the gate never enters +# an open state, so the runtime door pipeline can never resolve it - it needs catalog rows. +3261 3446 0 3261 3447 0 Open;Gate;24536 +3261 3447 0 3261 3446 0 Open;Gate;24536 3011 3184 0 3011 3184 1 Climb-up;Ladder;9558 3011 3184 1 3011 3184 0 Climb-down;Ladder;9559 3012 3235 0 3012 3235 1 Climb-up;Ladder;16683 diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index 8cfd17b82e2..f71005c7bd2 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -626,21 +626,24 @@ public void shantaySouthbound_withNothing_neverCrossesTheGate() { // ---- Varrock museum interior (the Kudos dead-end) ---------------------------------------------- /** - * The museum's south doors and interior gate sat in restrictions.tsv unconditionally (Feb 2025 - * bulk commit, no stated reason), so every interior target was unroutable: the Kudos script's - * museum interactions all dead-ended at the gate, and the can't-reach recovery cannot open a - * door the PLANNER refuses to route through. The doors are ordinary openable scene doors the - * runtime handles. Route in from outside the south door, across the (3261,3446)->(3261,3447) - * gate line, to a display-pen tile. + * The museum guard barrier (24536) is a MOVES-YOU gate, measured 2026-08-08 through the agent + * server: one click on "Open" relocates the player across it (3447 -> 3446 -> 3447, reproduced + * three times) and the gate never enters an open state, so the runtime door pipeline can never + * resolve it. Two independent defects kept the museum interior unroutable: restrictions.tsv + * banned the doorway tiles outright (planner could not stand there), and the barrier had no + * catalog rows (executor had nothing to click). Assert the route SELECTS the transport rather + * than merely passing near the gate tile — an earlier version of this test checked proximity and + * would have passed on a route that never crossed. */ @Test - public void varrockMuseumInteriorIsRoutable() { - List path = route(configWith(WalkerRouteCorpusTest::unrestricted), - new WorldPoint(3264, 3439, 0), new WorldPoint(3261, 3449, 0)); - assertTrue("route must reach the museum interior past the gate", - arrives(path, new WorldPoint(3261, 3449, 0), 1)); - assertTrue("route must cross AT the interior gate", - visits(path, new WorldPoint(3261, 3446, 0), 1)); + public void varrockMuseumGuardBarrierIsATransport() { + PathfinderConfig config = configWith(WalkerRouteCorpusTest::unrestricted); + Pathfinder pf = runPathfinder(config, + new WorldPoint(3261, 3449, 0), new WorldPoint(3261, 3443, 0)); + assertTrue("route across the museum barrier must select gate 24536", + selectsTransportObject(pf, 24536)); + assertTrue("route must arrive south of the barrier", + arrives(pf.getPath(), new WorldPoint(3261, 3443, 0), 1)); } // ---- Port Sarim, Wydin's shop (the door-poisoning incident) ------------------------------------ From 85211d9622946c61b307e36961c078e58d08afd3 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 14:48:08 +0100 Subject: [PATCH 36/53] refactor(walker): make the walk loop's exit reasons a type, not 43 strings processWalk carried its control flow in a String. Forty-seven assignment sites produced forty-three distinct values, and eight places downstream matched them by equality or startsWith to decide three things: whether the iteration counted as route progress (partial-retry budget), whether it was exempt from the tail-iteration cap, and whether a post-door canvas nudge was owed. A value no branch had classified simply fell through to the default in each, silently. Two of the reasons are produced inside a ternary and never appear in a search for `exitReason = "..."`, so the set could not even be recovered by reading the code. Replaces it with WalkExit: the reasons are now enumerable, the three behaviours are flags on the constant, and a new value cannot be added without deciding what it means. Wire names are preserved exactly, because live walker debugging here is log-driven and renaming a reason would blind the one diagnostic that works. This commit is deliberately inert. WalkExitTest asserts, for every constant, that each flag agrees with the legacy string predicate evaluated on that constant's wire name, so the classification is provably unchanged. The legacy predicates stay (deprecated, unused by production) purely to hold that characterization up; the follow-up that corrects the classification moves the expectations in that test, making the test diff the record of exactly what behaviour changed. The architecture guard caught the three lines this added and is ratcheted DOWN 1647 -> 1646 rather than raised. It was raised once already to absorb a merge; doing that again would make it a rubber stamp. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 159 ++++++++++-------- .../microbot/util/walker/state/WalkExit.java | 158 +++++++++++++++++ .../microbot/util/walker/WalkExitTest.java | 150 +++++++++++++++++ 3 files changed, 399 insertions(+), 68 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java 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 b78b7372883..6e7a99eab67 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 @@ -66,6 +66,7 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; import net.runelite.client.plugins.microbot.util.walker.door.Rs2WalkerAwaits; @@ -768,7 +769,13 @@ public static boolean isWalkableInCollisionMap(WorldPoint tile) { } /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */ - private static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { + /** + * @deprecated superseded by {@link WalkExit#isDoorLike()}. Retained only so + * {@code WalkExitTest} can prove the enum classifies every reason exactly as this did. + * Delete once that characterization is no longer needed. + */ + @Deprecated + static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { if (exitReason == null) { return false; } @@ -791,6 +798,7 @@ private static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { * walk, so an ordinary door could exhaust it ~100 tiles into a working route and report * UNREACHABLE while the player was still advancing. See {@code movement.md} #25. */ + @Deprecated static boolean isRouteProgressExit(String exitReason) { if (exitReason == null) { return false; @@ -815,6 +823,22 @@ static boolean isRouteProgressExit(String exitReason) { } } + /** + * The tail-exemption condition exactly as it was written inline in {@code processWalk}'s + * epilogue before {@link WalkExit} existed. + * + * @deprecated superseded by {@link WalkExit#isTailExempt()}. Retained only so + * {@code WalkExitTest} can prove the enum classifies every reason exactly as this did. + */ + @Deprecated + static boolean isTailExemptExit(String exitReason) { + return "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); + } + /** @return true only when a canvas click was actually issued, so the caller can size its minimap hold-off. */ private static boolean maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List path) { if (goal == null || path == null || path.isEmpty()) { @@ -2153,7 +2177,8 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { boolean doorOrTransportResult = false; boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); - String exitReason = "end-of-path"; + WalkExit exit = WalkExit.END_OF_PATH; + String offPathDeferDetail = ""; Map doorEdgesAttemptedThisTail = new HashMap<>(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); @@ -2167,15 +2192,15 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { || activeInterimPlayer == null || activeInterimPlayer.distanceTo(target) > immediateFinishTh) && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { - exitReason = "interim-in-flight"; - WebWalkLog.earlyExit(exitReason, + exit = WalkExit.INTERIM_IN_FLIGHT; + WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), activeInterimPlayer, target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("tail exempt exitReason=%s tailBefore=%d early=true interim=%s", - exitReason, + exit.wireName(offPathDeferDetail), processWalkTail, routeState.interimTargetWp); processWalkTail--; @@ -2226,7 +2251,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); if (rawSceneHandled) { doorOrTransportResult = true; - exitReason = "raw-path-scene-object-handled"; + exit = WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED; } long currentTileTransportStartAt = System.currentTimeMillis(); @@ -2237,7 +2262,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM "handled=" + currentTileTransportHandled + " ms=" + (System.currentTimeMillis() - currentTileTransportStartAt)); if (currentTileTransportHandled) { doorOrTransportResult = true; - exitReason = "current-tile-transport-handled"; + exit = WalkExit.CURRENT_TILE_TRANSPORT_HANDLED; } if (!doorOrTransportResult) { @@ -2284,7 +2309,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM if (isTransportInteractionSettling()) { tmarkPostTransport("post_transport_settling_yield", target, "at=" + compactWorldPoint(playerForPathCheck)); - exitReason = "transport-settling-yield"; + exit = WalkExit.TRANSPORT_SETTLING_YIELD; break; } boolean nearPath = isNearPath(); @@ -2311,7 +2336,8 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM playerForPathCheck, "defer=" + deferReason); } - exitReason = "off-path-deferred:" + deferReason; + exit = WalkExit.OFF_PATH_DEFERRED; + offPathDeferDetail = deferReason; break; } Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); @@ -2324,7 +2350,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } else { recalculatePathForRecovery(); } - exitReason = "not-near-path"; + exit = WalkExit.NOT_NEAR_PATH; break; } if (!nearPath && recentTransportWindow) { @@ -2339,7 +2365,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // and these calls do scene-object scans that add up across 100+ segment paths. WorldPoint playerNearSeg = Rs2Player.getWorldLocation(); if (playerNearSeg == null) { - exitReason = "player-location-null"; + exit = WalkExit.PLAYER_LOCATION_NULL; break; } int segDistance = currentWorldPoint.distanceTo2D(playerNearSeg); @@ -2413,7 +2439,7 @@ && shouldSkipStartupPreclickSegmentHandlers( if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=door handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "door-handled"; + exit = WalkExit.DOOR_HANDLED; break; } @@ -2428,7 +2454,7 @@ && shouldSkipStartupPreclickSegmentHandlers( obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { tmarkPostTransport("post_transport_segment_handler", target, "stage=path_adj handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "path-blocker-handled"; + exit = WalkExit.PATH_BLOCKER_HANDLED; break; } } @@ -2446,7 +2472,7 @@ && shouldSkipStartupPreclickSegmentHandlers( if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=rockfall handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "rockfall-handled"; + exit = WalkExit.ROCKFALL_HANDLED; break; } @@ -2463,7 +2489,7 @@ && shouldSkipStartupPreclickSegmentHandlers( if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=transport handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "transport-handled"; + exit = WalkExit.TRANSPORT_HANDLED; break; } tmarkPostTransport("post_transport_segment_handler", target, @@ -2502,9 +2528,9 @@ && shouldSkipStartupPreclickSegmentHandlers( + "tile={} idx={}/{} routeStart={} player={}", currentWorldPoint, i, path.size(), indexOfStartPoint, playerLoc); if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { - exitReason = "route-fold-continuation-click"; + exit = WalkExit.ROUTE_FOLD_CONTINUATION_CLICK; } else { - exitReason = "route-fold-continuation-pending"; + exit = WalkExit.ROUTE_FOLD_CONTINUATION_PENDING; } break; } @@ -2550,7 +2576,7 @@ && shouldSkipStartupPreclickSegmentHandlers( inInstance); if (frontierObstacle.kind() == ObstacleResolution.Kind.INTERACTED) { // A rockfall was mined or an on-origin transport/shortcut was taken. - exitReason = "frontier-obstacle-handled"; + exit = WalkExit.FRONTIER_OBSTACLE_HANDLED; break; } if (frontierObstacle.kind() == ObstacleResolution.Kind.ABORT) { @@ -2563,9 +2589,9 @@ && shouldSkipStartupPreclickSegmentHandlers( boolean resolvedAfterWait = waitForDoorEdgeResolution(edgeFrom, edgeTo, obstaclePolicy.edgeResolutionWaitTimeoutMs()); if (resolvedAfterWait && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target)) { - exitReason = "door-edge-resolved-fast-click"; + exit = WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK; } else { - exitReason = resolvedAfterWait ? "door-edge-resolved-after-wait" : "door-edge-waiting-retry"; + exit = resolvedAfterWait ? WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT : WalkExit.DOOR_EDGE_WAITING_RETRY; } break; } @@ -2577,14 +2603,14 @@ && shouldSkipStartupPreclickSegmentHandlers( && !afterNearbyWait.equals(playerLoc); if (resolvedAfterNearbyWait && progressedAfterNearbyWait) { if (tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target)) { - exitReason = "door-edge-resolved-fast-click"; + exit = WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK; } else { - exitReason = "door-edge-resolved-after-nearby-wait"; + exit = WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT; } break; } if (!resolvedAfterNearbyWait) { - exitReason = "door-edge-nearby-waiting-retry"; + exit = WalkExit.DOOR_EDGE_NEARBY_WAITING_RETRY; break; } } @@ -2596,36 +2622,36 @@ && shouldSkipStartupPreclickSegmentHandlers( if (gateDoorInteraction) { // Avoid any follow-up door probing right after an interaction; // resolver is still settling and re-probes can loop. - exitReason = "door-settling-yield"; + exit = WalkExit.DOOR_SETTLING_YIELD; break; } if (pendingDoorTraversal) { // Keep one-shot behavior after door open: let traversal finish // before issuing fallback path-adj/recovery actions. - exitReason = "door-traversal-pending-yield"; + exit = WalkExit.DOOR_TRAVERSAL_PENDING_YIELD; break; } if (shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())) { - exitReason = "interim-in-flight"; + exit = WalkExit.INTERIM_IN_FLIGHT; break; } if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { - exitReason = "recent-door-edge-nudge"; + exit = WalkExit.RECENT_DOOR_EDGE_NUDGE; break; } if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { - exitReason = "door-handled-local-reachability-raw-scan"; + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN; break; } if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, null)) { - exitReason = "door-handled-local-reachability"; + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY; break; } if (isRecoveryMovementInFlight()) { - exitReason = "recovery-move-in-flight"; + exit = WalkExit.RECOVERY_MOVE_IN_FLIGHT; break; } boolean unresolvedDoorNearRawPath = hasUnresolvedDoorLikeObjectNearRawPath(rawPath, @@ -2641,7 +2667,7 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE)) { - exitReason = "door-handled-nearby-route-door"; + exit = WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR; break; } // Fallback: only interact with objects on/adjacent to blocked path edges @@ -2653,7 +2679,7 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, && nowMs - routeState.lastDoorPathAdjAttemptAtMs > 1200) { routeState.lastDoorPathAdjAttemptAtMs = nowMs; if (tryResolvePathAdjacentBlocker(playerLoc, rawPath, rawEdgeStart, 3, 10)) { - exitReason = "door-handled-path-adj-scan"; + exit = WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN; break; } } @@ -2685,12 +2711,12 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, routeState.lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_suppressed_approach | to={} idx={} tile={}", compactWorldPoint(doorApproach), rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-suppressed-approach-click"; + exit = WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK; break; } WebWalkLog.spInfo("door_recovery_suppressed | reason=nearby-route-door idx={} tile={}", rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-recovery-suppressed"; + exit = WalkExit.DOOR_RECOVERY_SUPPRESSED; break; } @@ -2705,7 +2731,7 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, // obstacle by construction and may dispatch from range. if ((PohTeleports.isInHouse() || !inInstance) && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { - exitReason = "transport-handled-local-reachability"; + exit = WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY; break; } @@ -2734,7 +2760,7 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { if (playerLocNow != null && !playerLocNow.equals(playerLoc)) { WebWalkLog.spInfo("recovery_position_stale | was={} now={} idx={} re-evaluating", compactWorldPoint(playerLoc), compactWorldPoint(playerLocNow), i); - exitReason = "recovery-position-stale"; + exit = WalkExit.RECOVERY_POSITION_STALE; break; } final int recoveryMinimapReach = STALL_RECOVERY_MINIMAP_REACH_EUCLIDEAN; @@ -2811,7 +2837,7 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt System.currentTimeMillis(), routeState.lastWalledRecoveryReplanAtMs, WALLED_RECOVERY_REPLAN_COOLDOWN_MS); if (clickAction == RouteRecovery.RecoveryClickAction.YIELD_ACTION_IN_FLIGHT) { - exitReason = "recovery-click-preempted-by-action"; + exit = WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION; break; } if (clickAction == RouteRecovery.RecoveryClickAction.REPLAN_WALLED) { @@ -2819,11 +2845,11 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt WebWalkLog.spInfo("recovery_target_walled | to={} player={} replanning", compactWorldPoint(recoverTarget), compactWorldPoint(playerLoc)); recalculatePathForRecovery(); - exitReason = "recovery-target-walled-replan"; + exit = WalkExit.RECOVERY_TARGET_WALLED_REPLAN; break; } if (clickAction == RouteRecovery.RecoveryClickAction.WAIT_WALLED) { - exitReason = "recovery-target-walled-waiting"; + exit = WalkExit.RECOVERY_TARGET_WALLED_WAITING; break; } WorldPoint clickedRecoveryTarget = null; @@ -2874,10 +2900,10 @@ && walkFastCanvas(recoverTarget)) { // spurious stall-recalc right after issuing recovery movement. routeState.lastMovedTimeMs = System.currentTimeMillis(); routeState.stuckCount = 0; - exitReason = "local-recovery-click"; + exit = WalkExit.LOCAL_RECOVERY_CLICK; break; } - exitReason = "local-reachability-miss-no-click"; + exit = WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK; break; } } @@ -2890,7 +2916,7 @@ && walkFastCanvas(recoverTarget)) { // unreachable / door-edge-resolution branch above is intentionally left alone — it // waits on the door edge itself and issues its own resolution-aware fast click. if (isDoorInteractionSettling()) { - exitReason = "door-settling-yield"; + exit = WalkExit.DOOR_SETTLING_YIELD; break; } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); @@ -2932,7 +2958,7 @@ && walkFastCanvas(recoverTarget)) { routeState.interimLastDistanceToTarget = Integer.MAX_VALUE; routeState.interimLastRetargetAtMs = 0L; doorOrTransportResult = true; - exitReason = "door-handled-during-interim"; + exit = WalkExit.DOOR_HANDLED_DURING_INTERIM; break; } final WorldPoint posBeforeWait = playerLoc; @@ -2950,7 +2976,7 @@ && walkFastCanvas(recoverTarget)) { boolean closeEnoughForNextClick = posAfterWait != null && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_CLOSE_TILES; if (!closeEnoughForNextClick && Rs2Player.isMoving()) { - exitReason = "interim-in-flight"; + exit = WalkExit.INTERIM_IN_FLIGHT; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", interimFinal, posAfterWait == null ? interimDist : interimFinal.distanceTo2D(posAfterWait), @@ -3076,7 +3102,7 @@ && walkFastCanvas(recoverTarget)) { smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, playerLoc)) { doorOrTransportResult = true; - exitReason = "door-handled-before-minimap-click"; + exit = WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK; break; } clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, MINIMAP_REACH_EUCLIDEAN - 1); @@ -3168,12 +3194,12 @@ && walkFastCanvas(recoverTarget)) { if (!Rs2Player.isMoving()) { if (handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target)) { doorOrTransportResult = true; - exitReason = "post-click-raw-path-scene-object-handled"; + exit = WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED; break; } if (handleCurrentTileTransportTowardPath(rawPath, path, target)) { doorOrTransportResult = true; - exitReason = "post-click-current-tile-transport-handled"; + exit = WalkExit.POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED; break; } } @@ -3189,7 +3215,7 @@ && walkFastCanvas(recoverTarget)) { // path tiles are further away and will also fail — break and let the outer // loop wait for the player to walk closer before re-evaluating. if (!clicked) { - exitReason = "click-failed-off-minimap"; + exit = WalkExit.CLICK_FAILED_OFF_MINIMAP; routeState.interimTargetWp = null; routeState.interimTargetIdx = -1; routeState.interimSetAtMs = 0L; @@ -3210,7 +3236,7 @@ && walkFastCanvas(recoverTarget)) { } } - if (doorOrTransportResult && shouldCanvasNudgeAfterDoorLikeExit(exitReason)) { + if (doorOrTransportResult && exit.isDoorLike()) { boolean canvasNudged = maybeCanvasNudgeAfterDoor(target, distance, path); // Arm after nudge returns so the window does not expire during in-nudge waits. The long // window exists to stop a minimap click landing on the heels of a CANVAS click, so it is @@ -3233,15 +3259,15 @@ && walkFastCanvas(recoverTarget)) { } } - if (!"end-of-path".equals(exitReason)) { - WebWalkLog.earlyExit(exitReason, + if (exit != WalkExit.END_OF_PATH) { + WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), Rs2Player.getWorldLocation(), target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("early-exit detail reason=%s interim=%s doorOrTransport=%s partialPath=%s", - exitReason, + exit.wireName(offPathDeferDetail), routeState.interimTargetWp, doorOrTransportResult, partialPath); @@ -3250,7 +3276,7 @@ && walkFastCanvas(recoverTarget)) { // Only do the final-tile canvas click if we iterated the whole path cleanly. // Exiting because the player left the path may still mean movement is active. // so don't clobber that destination. - if (!doorOrTransportResult && "end-of-path".equals(exitReason)) { + if (!doorOrTransportResult && exit == WalkExit.END_OF_PATH) { if (walkCancelledDiag(target, "processWalk:before-final-canvas", processWalkTail)) { return WalkerState.EXIT; } @@ -3294,9 +3320,9 @@ && walkFastCanvas(recoverTarget)) { // the previous movement command. Charging those passes as failures can exhaust the // bounded tail loop before the player reaches a nearby transport origin. if (!doorOrTransportResult - && "end-of-path".equals(exitReason) + && exit == WalkExit.END_OF_PATH && Rs2Player.isMoving()) { - exitReason = "route-move-in-flight"; + exit = WalkExit.ROUTE_MOVE_IN_FLIGHT; } WorldPoint pathLastForFinish = path.get(path.size() - 1); int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); @@ -3329,9 +3355,9 @@ && walkFastCanvas(recoverTarget)) { } // A handled door/transport/blocker ended the iteration because work was done, not // because the walker is stuck. Still re-route, but do not charge the budget for it. - if (isRouteProgressExit(exitReason)) { + if (exit.isProgress()) { walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", - exitReason, processWalkTail, partialRetriesWorking); + exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); recalculatePath(); continue; } @@ -3356,10 +3382,10 @@ && walkFastCanvas(recoverTarget)) { setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { - if (isOffPathRecalcDeferredExit(exitReason)) { + if (exit == WalkExit.OFF_PATH_DEFERRED) { // Wait briefly for the player to re-enter the path or for the progress signal // that deferred the recalc to expire. Prevents a tight loop around isNearPath(). - String deferReason = offPathDeferredReasonFromExit(exitReason); + String deferReason = offPathDeferDetail; long offPathWaitMs = offPathRecalcDeferredWaitMs(deferReason, System.currentTimeMillis(), routeState.lastMovedTimeMs, @@ -3398,17 +3424,13 @@ && walkFastCanvas(recoverTarget)) { } // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. - if ("interim-in-flight".equals(exitReason) - || "recovery-move-in-flight".equals(exitReason) - || "route-move-in-flight".equals(exitReason) - || "route-fold-continuation-click".equals(exitReason) - || isOffPathRecalcDeferredExit(exitReason)) { - walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); + if (exit.isTailExempt()) { + walkerDiag("tail exempt exitReason=%s tailBefore=%d", exit.wireName(offPathDeferDetail), processWalkTail); processWalkTail--; } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", processWalkTail + 1, - exitReason, + exit.wireName(offPathDeferDetail), Rs2Player.getWorldLocation().distanceTo(target), partialPath); continue; @@ -11729,11 +11751,12 @@ static int offPathRecalcDeferredWaitMs(String reason, Math.min(OFF_PATH_RECALC_DEFER_WAIT_MAX_MS, remainingMs)); } - private static boolean isOffPathRecalcDeferredExit(String exitReason) { + static boolean isOffPathRecalcDeferredExit(String exitReason) { return exitReason != null && exitReason.startsWith("off-path-deferred:"); } - private static String offPathDeferredReasonFromExit(String exitReason) { + @Deprecated + static String offPathDeferredReasonFromExit(String exitReason) { if (!isOffPathRecalcDeferredExit(exitReason)) { return ""; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java new file mode 100644 index 00000000000..f5aca9651e6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java @@ -0,0 +1,158 @@ +package net.runelite.client.plugins.microbot.util.walker.state; + +/** + * Why one iteration of the {@code processWalk} tail loop ended. + * + *

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

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

Wire names are load-bearing

+ * {@link #wireName()} returns the exact string the old code logged. Live walker debugging in this + * repo is log-driven, and renaming an exit reason would blind the one diagnostic that works. Do not + * "tidy" these strings. + * + * @see net.runelite.client.plugins.microbot.util.walker.Rs2Walker + */ +public enum WalkExit +{ + // ---- loop completed normally ---- + + /** The segment loop ran to the end of the path without any handler acting. */ + END_OF_PATH("end-of-path", false, false, false), + + // ---- an obstacle handler acted (route progress) ---- + + DOOR_HANDLED("door-handled", true, false, true), + DOOR_HANDLED_BEFORE_MINIMAP_CLICK("door-handled-before-minimap-click", true, false, true), + DOOR_HANDLED_DURING_INTERIM("door-handled-during-interim", true, false, true), + DOOR_HANDLED_LOCAL_REACHABILITY("door-handled-local-reachability", true, false, true), + DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN("door-handled-local-reachability-raw-scan", true, false, true), + DOOR_HANDLED_NEARBY_ROUTE_DOOR("door-handled-nearby-route-door", true, false, true), + DOOR_HANDLED_PATH_ADJ_SCAN("door-handled-path-adj-scan", true, false, true), + PATH_BLOCKER_HANDLED("path-blocker-handled", true, false, false), + ROCKFALL_HANDLED("rockfall-handled", true, false, false), + TRANSPORT_HANDLED("transport-handled", true, false, false), + CURRENT_TILE_TRANSPORT_HANDLED("current-tile-transport-handled", true, false, false), + POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED("post-click-current-tile-transport-handled", true, false, false), + RAW_PATH_SCENE_OBJECT_HANDLED("raw-path-scene-object-handled", true, false, true), + POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED("post-click-raw-path-scene-object-handled", true, false, true), + + // ---- recovery acted, or resolved the blocked frontier ---- + + FRONTIER_OBSTACLE_HANDLED("frontier-obstacle-handled", false, false, false), + TRANSPORT_HANDLED_LOCAL_REACHABILITY("transport-handled-local-reachability", false, false, false), + LOCAL_RECOVERY_CLICK("local-recovery-click", false, false, false), + LOCAL_REACHABILITY_MISS_NO_CLICK("local-reachability-miss-no-click", false, false, false), + RECENT_DOOR_EDGE_NUDGE("recent-door-edge-nudge", false, false, false), + DOOR_SUPPRESSED_APPROACH_CLICK("door-suppressed-approach-click", false, false, false), + DOOR_RECOVERY_SUPPRESSED("door-recovery-suppressed", false, false, false), + RECOVERY_POSITION_STALE("recovery-position-stale", false, false, false), + RECOVERY_CLICK_PREEMPTED_BY_ACTION("recovery-click-preempted-by-action", false, false, false), + RECOVERY_TARGET_WALLED_REPLAN("recovery-target-walled-replan", false, false, false), + RECOVERY_TARGET_WALLED_WAITING("recovery-target-walled-waiting", false, false, false), + + // ---- door edge resolution around a recent attempt ---- + + DOOR_EDGE_RESOLVED_FAST_CLICK("door-edge-resolved-fast-click", false, false, false), + DOOR_EDGE_RESOLVED_AFTER_WAIT("door-edge-resolved-after-wait", false, false, false), + DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT("door-edge-resolved-after-nearby-wait", false, false, false), + DOOR_EDGE_WAITING_RETRY("door-edge-waiting-retry", false, false, false), + DOOR_EDGE_NEARBY_WAITING_RETRY("door-edge-nearby-waiting-retry", false, false, false), + + // ---- yields while one of our own actions is still in flight ---- + + INTERIM_IN_FLIGHT("interim-in-flight", true, true, false), + RECOVERY_MOVE_IN_FLIGHT("recovery-move-in-flight", true, true, false), + ROUTE_MOVE_IN_FLIGHT("route-move-in-flight", false, true, false), + DOOR_SETTLING_YIELD("door-settling-yield", false, false, false), + DOOR_TRAVERSAL_PENDING_YIELD("door-traversal-pending-yield", false, false, false), + TRANSPORT_SETTLING_YIELD("transport-settling-yield", false, false, false), + + // ---- route geometry / fold handling ---- + + ROUTE_FOLD_CONTINUATION_CLICK("route-fold-continuation-click", true, true, false), + ROUTE_FOLD_CONTINUATION_PENDING("route-fold-continuation-pending", false, false, false), + + // ---- the walk is not tracking the route ---- + + /** + * Off-path, but a recent click / route progress / busy state says the player may still be + * advancing, so the replan was deferred. Carries a detail string naming the deferral reason; + * see {@link #wireName(String)}. + */ + OFF_PATH_DEFERRED("off-path-deferred", false, true, false), + NOT_NEAR_PATH("not-near-path", false, false, false), + CLICK_FAILED_OFF_MINIMAP("click-failed-off-minimap", false, false, false), + PLAYER_LOCATION_NULL("player-location-null", false, false, false); + + private final String wireName; + private final boolean progress; + private final boolean tailExempt; + private final boolean doorLike; + + WalkExit(String wireName, boolean progress, boolean tailExempt, boolean doorLike) + { + this.wireName = wireName; + this.progress = progress; + this.tailExempt = tailExempt; + this.doorLike = doorLike; + } + + /** The exact string this reason has always been logged as. Never change these. */ + public String wireName() + { + return wireName; + } + + /** + * Log name including the deferral detail for {@link #OFF_PATH_DEFERRED}, which was previously + * built by string concatenation at the assignment site and parsed back apart downstream. + */ + public String wireName(String detail) + { + if (this != OFF_PATH_DEFERRED) + { + return wireName; + } + return wireName + ":" + (detail == null ? "" : detail); + } + + /** + * The iteration ended because the walker did something that advances the route, or + * because movement it owns is already in flight — progress, not a failed attempt. + * + *

The partial-retry budget exists for "the goal is unreachable and we are stuck". Spending it + * on these conflates the two: on a partial path the budget is armed for the entire walk, so an + * ordinary door can exhaust it far into a working route and report UNREACHABLE while the player + * is still advancing. See {@code movement.md} #25. + */ + public boolean isProgress() + { + return progress; + } + + /** + * Benign yields that must not consume the bounded tail-iteration budget, so long waits cannot + * exhaust it and EXIT a healthy walk. + */ + public boolean isTailExempt() + { + return tailExempt; + } + + /** A door-like exit owes the post-door canvas nudge and its minimap hold-off window. */ + public boolean isDoorLike() + { + return doorLike; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java new file mode 100644 index 00000000000..0064d1f12d7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java @@ -0,0 +1,150 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Characterization of {@link WalkExit} against the string predicates it replaced. + * + *

This test exists to make the {@code String exitReason} → enum refactor provably inert. + * For every constant it asserts that the enum's three flags agree with the legacy predicates + * evaluated on that constant's wire name. Green means the refactor changed no behaviour. + * + *

When a classification is deliberately corrected, the expectation moves here, and the + * diff to this file is the record of exactly what changed — which is precisely what the string + * version could never provide. Do not "fix" a failure by editing the enum until you have written + * down why the new answer is the right one. + */ +public class WalkExitTest +{ + /** Detail used when exercising the parameterized reason; the legacy form always had a suffix. */ + private static final String OFF_PATH_DETAIL = "recent-click"; + + private static String legacyWireName(WalkExit exit) + { + return exit == WalkExit.OFF_PATH_DEFERRED ? exit.wireName(OFF_PATH_DETAIL) : exit.wireName(); + } + + @Test + @SuppressWarnings("deprecation") + public void progressClassificationMatchesTheLegacyPredicate() + { + for (WalkExit exit : WalkExit.values()) + { + String wire = legacyWireName(exit); + assertEquals(exit.name() + " (\"" + wire + "\") changed its route-progress meaning", + Rs2Walker.isRouteProgressExit(wire), exit.isProgress()); + } + } + + @Test + @SuppressWarnings("deprecation") + public void tailExemptionMatchesTheLegacyPredicate() + { + for (WalkExit exit : WalkExit.values()) + { + String wire = legacyWireName(exit); + assertEquals(exit.name() + " (\"" + wire + "\") changed its tail-exemption meaning", + Rs2Walker.isTailExemptExit(wire), exit.isTailExempt()); + } + } + + @Test + @SuppressWarnings("deprecation") + public void doorLikeClassificationMatchesTheLegacyPredicate() + { + for (WalkExit exit : WalkExit.values()) + { + String wire = legacyWireName(exit); + assertEquals(exit.name() + " (\"" + wire + "\") changed its door-like meaning", + Rs2Walker.shouldCanvasNudgeAfterDoorLikeExit(wire), exit.isDoorLike()); + } + } + + /** + * The whole point of the enum is that the set of reasons is enumerable. Two of them + * ({@code door-edge-resolved-after-wait}, {@code door-edge-waiting-retry}) were produced inside a + * ternary and never appeared in a search for {@code exitReason = "…"}, so the reason set could not + * be recovered by reading the code. Pin the full set so a new value has to be added here too. + */ + @Test + public void theReasonSetIsComplete() + { + Set expected = new HashSet<>(Arrays.asList( + "end-of-path", + "door-handled", + "door-handled-before-minimap-click", + "door-handled-during-interim", + "door-handled-local-reachability", + "door-handled-local-reachability-raw-scan", + "door-handled-nearby-route-door", + "door-handled-path-adj-scan", + "path-blocker-handled", + "rockfall-handled", + "transport-handled", + "current-tile-transport-handled", + "post-click-current-tile-transport-handled", + "raw-path-scene-object-handled", + "post-click-raw-path-scene-object-handled", + "frontier-obstacle-handled", + "transport-handled-local-reachability", + "local-recovery-click", + "local-reachability-miss-no-click", + "recent-door-edge-nudge", + "door-suppressed-approach-click", + "door-recovery-suppressed", + "recovery-position-stale", + "recovery-click-preempted-by-action", + "recovery-target-walled-replan", + "recovery-target-walled-waiting", + "door-edge-resolved-fast-click", + "door-edge-resolved-after-wait", + "door-edge-resolved-after-nearby-wait", + "door-edge-waiting-retry", + "door-edge-nearby-waiting-retry", + "interim-in-flight", + "recovery-move-in-flight", + "route-move-in-flight", + "door-settling-yield", + "door-traversal-pending-yield", + "transport-settling-yield", + "route-fold-continuation-click", + "route-fold-continuation-pending", + "off-path-deferred", + "not-near-path", + "click-failed-off-minimap", + "player-location-null")); + + Set actual = new HashSet<>(); + for (WalkExit exit : WalkExit.values()) + { + actual.add(exit.wireName()); + } + assertEquals("the set of walker exit reasons changed", expected, actual); + assertEquals("wire names must be unique", WalkExit.values().length, actual.size()); + } + + /** The parameterized reason has to rebuild the exact string the log consumers expect. */ + @Test + public void offPathDeferredKeepsItsDetailSuffix() + { + assertEquals("off-path-deferred:recent-click", + WalkExit.OFF_PATH_DEFERRED.wireName("recent-click")); + assertEquals("off-path-deferred:", WalkExit.OFF_PATH_DEFERRED.wireName(null)); + assertTrue(WalkExit.OFF_PATH_DEFERRED.wireName("x").startsWith("off-path-deferred:")); + } + + /** A detail on any other reason is meaningless and must not corrupt its wire name. */ + @Test + public void detailIsIgnoredForNonParameterizedReasons() + { + assertEquals("door-handled", WalkExit.DOOR_HANDLED.wireName("ignored")); + } +} From 4a5b49f56284fdf1a296dc17ea97a2d156744be7 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 14:52:49 +0100 Subject: [PATCH 37/53] fix(walker): stop reporting UNREACHABLE while the walk is still advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a partial path — a route the pathfinder could not run all the way to the goal, which is every long or awkward walk — each iteration that is not classified as route progress spends one of three retries and forces a full replan. Three of them and processWalk returns UNREACHABLE and the walk is abandoned. Fourteen reasons were misclassified. Every one of them means the walker either just advanced the route or is waiting on movement it issued itself: - recovery took a transport or mined the obstacle on the blocked edge (transport-handled-local-reachability, frontier-obstacle-handled) - a click was issued and the player is walking (local-recovery-click, door-suppressed-approach-click, recent-door-edge-nudge, route-move-in-flight) - the door actually opened (the three door-edge-resolved-* reasons) - we are waiting on an action we issued (door/transport settle yields, door-traversal-pending, recovery-click-preempted-by-action) - the pass was abandoned because the player MOVED (recovery-position-stale) So three settle windows at one ordinary door, on a partial route, could exhaust the budget and abort a walk that was working exactly as designed. The predicate this replaces documented this very failure mode in its own javadoc and then covered about half the cases. It could not cover the rest, because its list was hand-maintained against a set of forty-three strings that no one could enumerate: seven were matched by a startsWith("door-handled") prefix, which silently excluded door-edge-resolved-* and door-suppressed-approach-click precisely because they read like door-handled reasons without matching the prefix. The divergence from the old classification is pinned as an explicit list in WalkExitTest, asserted in both directions: an unlisted reason that changes meaning fails, and a listed one that reverts fails too. The reasons that genuinely mean "not advancing" are pinned separately, so the budget still drains and a truly unreachable goal still terminates. Follow-up: the deprecated string predicates in Rs2Walker are now referenced only by that divergence test. They can be deleted once the historical baseline moves into the test as data. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/state/WalkExit.java | 44 ++++++---- .../microbot/util/walker/WalkExitTest.java | 81 ++++++++++++++++++- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java index f5aca9651e6..3ac39299509 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java @@ -48,35 +48,49 @@ public enum WalkExit POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED("post-click-raw-path-scene-object-handled", true, false, true), // ---- recovery acted, or resolved the blocked frontier ---- - - FRONTIER_OBSTACLE_HANDLED("frontier-obstacle-handled", false, false, false), - TRANSPORT_HANDLED_LOCAL_REACHABILITY("transport-handled-local-reachability", false, false, false), - LOCAL_RECOVERY_CLICK("local-recovery-click", false, false, false), + // Recovery doing its job is progress. These were all non-progress, which is how a walk that was + // mining a rockfall, taking a shortcut or clicking its way back onto the route could spend its + // whole retry budget and report UNREACHABLE while advancing. + + /** A rockfall was mined or an on-origin transport/shortcut was taken at the blocked frontier. */ + FRONTIER_OBSTACLE_HANDLED("frontier-obstacle-handled", true, false, false), + /** Recovery took a transport (e.g. an agility shortcut) on the blocked edge. */ + TRANSPORT_HANDLED_LOCAL_REACHABILITY("transport-handled-local-reachability", true, false, false), + /** A recovery click was issued and movement was confirmed to start. */ + LOCAL_RECOVERY_CLICK("local-recovery-click", true, false, false), LOCAL_REACHABILITY_MISS_NO_CLICK("local-reachability-miss-no-click", false, false, false), - RECENT_DOOR_EDGE_NUDGE("recent-door-edge-nudge", false, false, false), - DOOR_SUPPRESSED_APPROACH_CLICK("door-suppressed-approach-click", false, false, false), + /** The door-edge nudge acted. */ + RECENT_DOOR_EDGE_NUDGE("recent-door-edge-nudge", true, false, false), + /** A minimap click toward the door approach was issued; the player is walking to it. */ + DOOR_SUPPRESSED_APPROACH_CLICK("door-suppressed-approach-click", true, false, false), DOOR_RECOVERY_SUPPRESSED("door-recovery-suppressed", false, false, false), - RECOVERY_POSITION_STALE("recovery-position-stale", false, false, false), - RECOVERY_CLICK_PREEMPTED_BY_ACTION("recovery-click-preempted-by-action", false, false, false), + /** The pass was abandoned because the player MOVED mid-pass — movement is the definition of progress. */ + RECOVERY_POSITION_STALE("recovery-position-stale", true, false, false), + /** Yielded because a door open / walker-owned movement is still in flight. */ + RECOVERY_CLICK_PREEMPTED_BY_ACTION("recovery-click-preempted-by-action", true, false, false), + /** Genuinely walled: this is the "we are stuck" signal the retry budget exists for. */ RECOVERY_TARGET_WALLED_REPLAN("recovery-target-walled-replan", false, false, false), RECOVERY_TARGET_WALLED_WAITING("recovery-target-walled-waiting", false, false, false), // ---- door edge resolution around a recent attempt ---- + // "Resolved" means the door opened. Only the waiting-retry pair is a failure to advance. - DOOR_EDGE_RESOLVED_FAST_CLICK("door-edge-resolved-fast-click", false, false, false), - DOOR_EDGE_RESOLVED_AFTER_WAIT("door-edge-resolved-after-wait", false, false, false), - DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT("door-edge-resolved-after-nearby-wait", false, false, false), + DOOR_EDGE_RESOLVED_FAST_CLICK("door-edge-resolved-fast-click", true, false, false), + DOOR_EDGE_RESOLVED_AFTER_WAIT("door-edge-resolved-after-wait", true, false, false), + DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT("door-edge-resolved-after-nearby-wait", true, false, false), DOOR_EDGE_WAITING_RETRY("door-edge-waiting-retry", false, false, false), DOOR_EDGE_NEARBY_WAITING_RETRY("door-edge-nearby-waiting-retry", false, false, false), // ---- yields while one of our own actions is still in flight ---- + // Waiting for an action we issued is not a failed attempt. Charging these meant three settle + // windows at one ordinary door could exhaust the budget and abort the walk. INTERIM_IN_FLIGHT("interim-in-flight", true, true, false), RECOVERY_MOVE_IN_FLIGHT("recovery-move-in-flight", true, true, false), - ROUTE_MOVE_IN_FLIGHT("route-move-in-flight", false, true, false), - DOOR_SETTLING_YIELD("door-settling-yield", false, false, false), - DOOR_TRAVERSAL_PENDING_YIELD("door-traversal-pending-yield", false, false, false), - TRANSPORT_SETTLING_YIELD("transport-settling-yield", false, false, false), + ROUTE_MOVE_IN_FLIGHT("route-move-in-flight", true, true, false), + DOOR_SETTLING_YIELD("door-settling-yield", true, false, false), + DOOR_TRAVERSAL_PENDING_YIELD("door-traversal-pending-yield", true, false, false), + TRANSPORT_SETTLING_YIELD("transport-settling-yield", true, false, false), // ---- route geometry / fold handling ---- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java index 0064d1f12d7..09fa9b9e78e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java @@ -8,6 +8,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; /** @@ -32,15 +33,89 @@ private static String legacyWireName(WalkExit exit) return exit == WalkExit.OFF_PATH_DEFERRED ? exit.wireName(OFF_PATH_DETAIL) : exit.wireName(); } + /** + * The fourteen reasons whose route-progress classification was deliberately corrected once the + * enum made the set enumerable. Every one of them means the walker either just advanced the + * route or is waiting on movement it issued itself, yet all fourteen were charged against the + * partial-retry budget — three of them in a row on a partial route reported UNREACHABLE and + * aborted a walk that was working. + * + *

Kept as an explicit list rather than folded away, because this set is the + * behaviour change. Adding to it later means saying which reason and why. + */ + private static final Set RECLASSIFIED_AS_PROGRESS = new HashSet<>(Arrays.asList( + // recovery resolved the blocked frontier + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + // a click was issued and the player is walking + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK, + WalkExit.RECENT_DOOR_EDGE_NUDGE, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + // the door actually opened + WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT, + // waiting on an action we issued + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_TRAVERSAL_PENDING_YIELD, + WalkExit.TRANSPORT_SETTLING_YIELD, + WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION, + // the pass was abandoned because the player moved + WalkExit.RECOVERY_POSITION_STALE)); + + /** + * Pins the correction: the enum must differ from the legacy predicate on exactly the reasons in + * {@link #RECLASSIFIED_AS_PROGRESS}, and agree with it everywhere else. A drift in either + * direction — an unlisted reason quietly changing meaning, or a listed one silently reverting — + * fails here. + */ @Test @SuppressWarnings("deprecation") - public void progressClassificationMatchesTheLegacyPredicate() + public void progressClassificationDivergesFromLegacyExactlyWhereIntended() { for (WalkExit exit : WalkExit.values()) { String wire = legacyWireName(exit); - assertEquals(exit.name() + " (\"" + wire + "\") changed its route-progress meaning", - Rs2Walker.isRouteProgressExit(wire), exit.isProgress()); + boolean legacy = Rs2Walker.isRouteProgressExit(wire); + if (RECLASSIFIED_AS_PROGRESS.contains(exit)) + { + assertFalse(exit.name() + " is listed as reclassified but the legacy predicate already " + + "called it progress — remove it from the list", legacy); + assertTrue(exit.name() + " was reclassified as route progress and must report it", + exit.isProgress()); + } + else + { + assertEquals(exit.name() + " (\"" + wire + "\") changed its route-progress meaning " + + "without being listed as a deliberate reclassification", + legacy, exit.isProgress()); + } + } + } + + /** + * The budget must still drain on the reasons that genuinely mean "not advancing", or a truly + * unreachable goal never terminates and the walk spins until the tail cap trips. + */ + @Test + public void reasonsThatMeanStuckStillConsumeTheBudget() + { + for (WalkExit stuck : new WalkExit[]{ + WalkExit.END_OF_PATH, + WalkExit.NOT_NEAR_PATH, + WalkExit.PLAYER_LOCATION_NULL, + WalkExit.CLICK_FAILED_OFF_MINIMAP, + WalkExit.DOOR_EDGE_WAITING_RETRY, + WalkExit.DOOR_EDGE_NEARBY_WAITING_RETRY, + WalkExit.DOOR_RECOVERY_SUPPRESSED, + WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK, + WalkExit.RECOVERY_TARGET_WALLED_REPLAN, + WalkExit.RECOVERY_TARGET_WALLED_WAITING, + WalkExit.ROUTE_FOLD_CONTINUATION_PENDING}) + { + assertFalse(stuck.name() + " does not advance the route and must still spend a retry", + stuck.isProgress()); } } From 8c4f63fa63360119a7a0425bda66eb8334f125bf Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 15:01:47 +0100 Subject: [PATCH 38/53] fix(walker): stop the post-transport window leaking into the next walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For fifteen seconds after taking a transport the walker deliberately runs degraded: it skips the raw scene scan, skips the per-segment door, rockfall and transport handlers, disables ranged door dispatch for the whole pass (one skipped segment sets segmentSkippedThisPass, which withdraws the nearest-obstacle guarantee that ranged dispatch depends on), and bypasses the off-path recalc entirely. That is right for the seconds after a landing and badly wrong for a walk that has only just started: a fresh walk would ignore the door in front of it and never replan when it drifted off route. setTarget(null) already cleared the handoff on the normal completion path, with a comment saying exactly why. Walk-session start did not agree: it nulled the three location fields and left lastTransportHandledAtMs, which is the field every window check actually reads. So the window stayed armed for its full duration while the destination it describes was already null. The gap showed up on any walk that ended WITHOUT clearing its target — an exception, the tail cap tripping, or an external cancellation, the last of which is routine when a quest script interrupts a walk mid-route. Clearing at session start is sufficient on its own: walkWithStateInternal is the only caller of markWalkSessionStart and the only route into processWalk, banked walks included. So the walk-ending paths do not each need their own clear, and nothing is added to processWalk. Also drops lastTransportHandledAtLocation, which was written on every transport handoff and never read by anything. Removing it takes a Rs2Player.getWorldLocation() client-thread hop off the transport path. The state reset is split out of markWalkSessionStart as resetWalkSessionState so it performs no game reads and these staleness invariants can be unit-tested rather than re-discovered live. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 36 +++++-- .../util/walker/state/WalkerRouteState.java | 24 ++++- .../walker/WalkSessionStateResetTest.java | 102 ++++++++++++++++++ 3 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java 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 6e7a99eab67..4d8a1a53d59 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 @@ -389,13 +389,31 @@ private static void markWalkSessionStart(WorldPoint target) { { evidence.started = true; } + resetWalkSessionState(); + WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); + } + + /** + * The per-walk state reset. Split out from {@link #markWalkSessionStart} because it performs no + * game reads, so the staleness invariants below can be unit-tested instead of re-discovered live. + * + *

Every {@code processWalk} entry runs through here ({@code walkWithStateInternal} is its only + * caller, banked walks included), which is why clearing here is sufficient and the walk-ending + * paths do not each need their own clear. + */ + static void resetWalkSessionState() { routeState.walkSessionStartedAtMs = System.currentTimeMillis(); routeState.firstMovementClickMarked = false; startupPhasesLogged.clear(); TERMINAL_TRAVEL_ATTEMPTED_EDGES.clear(); - routeState.lastTransportHandledAtLocation = null; - routeState.lastTransportOriginLocation = null; - routeState.lastTransportDestinationLocation = null; + // The transport handoff belongs to the PREVIOUS walk. Only the three location fields used to + // be nulled here, leaving lastTransportHandledAtMs — the field every window check actually + // reads — armed for its full 15s. A walk starting inside that window (after an interrupted, + // errored or tail-exceeded walk, which do not clear the target) then ran degraded: raw scene + // scan skipped, per-segment door/rockfall/transport handlers skipped, ranged door dispatch + // disabled for the whole pass, and off-path recalc bypassed entirely. setTarget(null) already + // cleared all four on the normal completion path; this makes the two agree. + clearRecentTransportContext(); // The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. @@ -411,14 +429,15 @@ private static void markWalkSessionStart(WorldPoint target) { synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); } - WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); + } + + /** Same package (e.g. unit tests) only — not part of the script API. */ + static WalkerRouteState routeStateForTesting() { + return routeState; } private static void clearRecentTransportContext() { - routeState.lastTransportHandledAtMs = 0L; - routeState.lastTransportHandledAtLocation = null; - routeState.lastTransportOriginLocation = null; - routeState.lastTransportDestinationLocation = null; + routeState.clearRecentTransportContext(); } private static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { @@ -10603,7 +10622,6 @@ private static boolean isTransportOriginNearPlayer(WorldPoint routeOrigin, private static boolean finishHandledTransport(Transport transport) { long handoffStartedAt = System.currentTimeMillis(); routeState.lastTransportHandledAtMs = handoffStartedAt; - routeState.lastTransportHandledAtLocation = Rs2Player.getWorldLocation(); routeState.lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; routeState.lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; WorldPoint goal = currentTarget; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index dc734130d80..1f574917851 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -20,15 +20,33 @@ public final class WalkerRouteState { // ---- transport handoff: set when a transport (stairs, ladder, shortcut, teleport) is taken, read by // the post-transport settling/window logic in processWalk. ---- - /** Wall-clock ms when the last transport was handled; 0 when none this session. */ + /** + * Wall-clock ms when the last transport was handled; 0 when none this session. + * + *

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

Clear all of it together. Nulling only the locations leaves + * {@link #lastTransportHandledAtMs} set, and every window check keys off that timestamp — the + * window stays armed for its full duration while the destination it is supposed to be about is + * already gone. + */ + public void clearRecentTransportContext() { + lastTransportHandledAtMs = 0L; + lastTransportOriginLocation = null; + lastTransportDestinationLocation = null; + } + // ---- route progress: tracks how far along the current route the player has advanced, used to detect // real forward progress (vs thrashing) and to decide when to reset on a new/changed route. ---- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java new file mode 100644 index 00000000000..5bc06513de2 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java @@ -0,0 +1,102 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * The post-transport window must not survive into the next walk. + * + *

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

The leak was subtle because walk-session start did clear the transport context — but + * only the three location fields, not {@code lastTransportHandledAtMs}, which is the field every + * window check actually reads. So the window stayed armed for its full 15 seconds while the + * destination it describes was already null. + */ +public class WalkSessionStateResetTest +{ + private WalkerRouteState routeState; + + @Before + public void setUp() + { + routeState = Rs2Walker.routeStateForTesting(); + routeState.clearRecentTransportContext(); + } + + /** + * The regression itself. A walk that ends without clearing its target — an exception, the tail + * cap tripping, or an external cancellation — leaves the window armed; starting the next walk has + * to disarm it. + */ + @Test + public void startingAWalkEndsAnyPostTransportWindowLeftByThePreviousOne() + { + routeState.lastTransportHandledAtMs = System.currentTimeMillis(); + routeState.lastTransportOriginLocation = new WorldPoint(3200, 3200, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3200, 3210, 1); + + Rs2Walker.resetWalkSessionState(); + + assertEquals("the post-transport window must be disarmed at walk start; every window check " + + "reads this timestamp, so leaving it set suppresses the new walk's handlers", + 0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** + * Clearing the locations alone is what the bug was. Pin the timestamp explicitly so a future + * edit cannot reintroduce a partial clear that looks right and does nothing. + */ + @Test + public void clearingTheTransportContextClearsTheTimestampNotJustTheLocations() + { + routeState.lastTransportHandledAtMs = 1_234_567L; + routeState.lastTransportOriginLocation = new WorldPoint(1, 2, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3, 4, 0); + + routeState.clearRecentTransportContext(); + + assertEquals(0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** Walk start also drops the previous walk's door attempt, for the same staleness reason. */ + @Test + public void startingAWalkDropsThePreviousWalksDoorAttempt() + { + routeState.lastDoorAttemptFrom = new WorldPoint(3010, 3204, 0); + routeState.lastDoorAttemptTo = new WorldPoint(3011, 3204, 0); + routeState.lastDoorAttemptAtMs = System.currentTimeMillis(); + + Rs2Walker.resetWalkSessionState(); + + assertNull(routeState.lastDoorAttemptFrom); + assertNull(routeState.lastDoorAttemptTo); + assertEquals(0L, routeState.lastDoorAttemptAtMs); + } + + /** Route progress belongs to the route that made it. */ + @Test + public void startingAWalkResetsRouteProgress() + { + routeState.routeProgressIdx = 42; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + + Rs2Walker.resetWalkSessionState(); + + assertEquals(-1, routeState.routeProgressIdx); + assertEquals(0L, routeState.routeProgressAdvancedAtMs); + } +} From 0787a9369439fc482eacacd9b6d78aa60098eaf3 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 15:15:01 +0100 Subject: [PATCH 39/53] test(shortestpath): stop a starved pathfinder masquerading as a route regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculationCutoffMillis is a NO-PROGRESS guard, not a runtime budget. Under CPU contention — a full-suite run, or the client running alongside the build, which is the normal state of a dev machine here — the search gets starved and returns a best-effort PARTIAL path. A partial path wanders through tiles this test requires to be absent, so it fails in exactly the shape of a real routing change. That is not theoretical. This test going red was read as a route-data regression specific to one branch, and sent an investigation off bisecting for a change that did not exist. The apparent "green on one branch, red on the other" split was an artifact of isolated versus contended runs: a clean worktree at the same commit passes. The route is now verified to actually reach the goal before any of its content is asserted, with one retry and then an explicit "pathfinder starved — INCONCLUSIVE, not a route regression" failure naming where the search stopped. The no-progress cutoff goes 10s to 30s, which costs nothing on a healthy run because the guard resets on every heuristic improvement. Deliberately not addressed: the pathfinder's per-node random tiebreaker can vary equal-cost routes, which is a separate source of flakiness here. A hermetic rework of this test exists on another branch and should not be duplicated. Co-Authored-By: Claude Opus 5 --- .../RouteClickTargetRegressionTest.java | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java index d21f4faeae6..b472f12f9ed 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java @@ -48,7 +48,41 @@ public static void load() { // Computed once: each Pathfinder.run() reloads all transports and, via // CollisionMap.getCachedRegionId, calls Rs2Player.getWorldLocation(), which has no client // thread under test and blocks for its full timeout. - sharedRawPath = computeRawPath(START, GOAL); + sharedRawPath = computeRawPathReachingGoal(START, GOAL); + } + + /** + * Computes the route, and refuses to report a starved run as a route regression. + * + *

{@code calculationCutoffMillis} is a NO-PROGRESS wall-clock guard. Under CPU contention — + * a full-suite run, or a client running alongside the build — the search can be starved into + * returning a best-effort PARTIAL path, and a partial path wanders through tiles the assertions + * below require to be absent. That failure looks exactly like the regression this class exists + * to catch, and it has already been misread as one: a red run here sent an investigation off + * hunting a route-data change that did not exist. + * + *

So: a generous cutoff, one retry, and if the path still does not reach the goal, fail as + * explicitly inconclusive rather than as a route change. + */ + private static List computeRawPathReachingGoal(WorldPoint start, WorldPoint goal) { + List path = computeRawPath(start, goal); + if (reachesGoal(path, goal)) { + return path; + } + path = computeRawPath(start, goal); + if (reachesGoal(path, goal)) { + return path; + } + throw new AssertionError("pathfinder starved — INCONCLUSIVE, not a route regression: the " + + "search did not reach " + goal + " within its no-progress cutoff on two attempts " + + "(got " + path.size() + " tiles, ending at " + + (path.isEmpty() ? "nothing" : path.get(path.size() - 1)) + "). Re-run this test on an " + + "idle machine before treating it as a routing change."); + } + + /** The pathfinder returns a best-effort partial path when starved, so check the endpoint. */ + private static boolean reachesGoal(List path, WorldPoint goal) { + return !path.isEmpty() && path.get(path.size() - 1).equals(goal); } private static List computeRawPath(WorldPoint start, WorldPoint goal) { @@ -58,7 +92,10 @@ private static List computeRawPath(WorldPoint start, WorldPoint goal try { java.lang.reflect.Field f = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); f.setAccessible(true); - f.setLong(config, 10000); + // 30s of NO PROGRESS, not 30s of runtime: the guard resets on every heuristic + // improvement, so this costs nothing on a healthy run and only buys headroom on a + // contended one. + f.setLong(config, 30_000); for (Map.Entry> e : transports.entrySet()) { if (e.getKey() == null) continue; config.getTransports().put(e.getKey(), e.getValue()); From 7595dcac67e0637e50cb2ffc58f4a406f6a21e74 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 15:21:28 +0100 Subject: [PATCH 40/53] refactor(walker): extract the walk loop's end-of-iteration decision, and see the livelock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things end a processWalk iteration: the partial-retry accounting and the tail-iteration exemption. Both were inline, both are load-bearing, and neither could be tested — which is how a partial route came to report UNREACHABLE while the player was still advancing. TailDecision now owns the decision and is pure: given "did we arrive", "is this a partial route", the exit reason and the budget, it returns the action. The caller still does the work — replanning, telemetry, clearing the target. Thirteen decision-table cases pin the interactions, including the budget-refill rule, which is subtle enough to have needed a paragraph of comment to survive: the route-progress timestamp is also bumped by a mere replan, and every retry replans, so refilling on the timestamp alone would let a retry refill the budget it just spent. Also makes the loop's real termination behaviour visible. The iteration cap is not a bound: several exit reasons decrement the counter, so a walk that keeps producing one of them goes round forever, and nothing else in the call chain imposes a time limit. Two observations now say so out loud — a wall-clock budget, and a cap on uninterrupted tail-exempt iterations, which is the state the iteration cap structurally cannot see because those iterations refund their own charge. Both are OBSERVE-ONLY: they log and do not abort. A budget that kills a working long walk would be a worse bug than the livelock it guards against, and a banked walk across several transports is legitimately minutes. Decide enforcement from live logs, once we know they fire on real livelocks and never on healthy walks. processWalk 1646 -> 1641 lines; the guard ratchets down again. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 81 ++++++++--- .../util/walker/recovery/TailDecision.java | 113 ++++++++++++++++ .../walker/recovery/TailDecisionTest.java | 126 ++++++++++++++++++ 3 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java 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 4d8a1a53d59..30b98049b4a 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 @@ -66,6 +66,7 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; @@ -1760,8 +1761,58 @@ public static WalkerState walkStep(WorldPoint target, int distance) { * lines appear across the gap the loop is spinning without acting and the state here says why, and * if they stop the thread is blocked inside a wait and the last line says which pass entered it. */ + /** + * How long a single walk may run before it is reported as a probable livelock. + * + *

Sized to catch a loop that will never finish, NOT a slow journey: a long banked walk across + * several transports is legitimately minutes. Currently OBSERVE-ONLY — it logs and does not + * abort — because a budget that kills a working walk would be a worse bug than the livelock it + * guards against. Promote to enforcement only after live logs show it firing on real livelocks + * and never on healthy walks. + */ + private static final long WALK_WALL_CLOCK_BUDGET_MS = 300_000L; + /** Uninterrupted tail-exempt iterations before the loop is reported as yielding without advancing. */ + private static final int MAX_CONSECUTIVE_EXEMPT_ITERATIONS = 24; + /** One budget report per walk session; 0 when this session has not reported yet. */ + private static volatile long walkBudgetReportedForSessionAtMs = 0L; + + /** + * Reports a walk that has outlived its wall-clock budget. + * + *

{@code MAX_PROCESS_WALK_TAIL_ITERATIONS} is not a bound on its own: several exit reasons + * decrement the tail counter, so a walk that keeps producing one of them loops forever, and + * nothing else in the call chain imposes a time limit. This makes that state visible in the log + * instead of silent. + */ + private static void reportWalkBudgetIfExhausted(WorldPoint target, long nowMs, int processWalkTail) { + long startedAt = routeState.walkSessionStartedAtMs; + if (!TailDecision.isWallClockExhausted(startedAt, nowMs, WALK_WALL_CLOCK_BUDGET_MS) + || walkBudgetReportedForSessionAtMs == startedAt) { + return; + } + walkBudgetReportedForSessionAtMs = startedAt; + log.warn("[Walker] walk exceeded its {}ms budget (running {}ms) target={} at={} tail={} —" + + " probable livelock; the tail cap cannot catch this because exempt exits refund it", + WALK_WALL_CLOCK_BUDGET_MS, nowMs - startedAt, target, + Rs2Player.getWorldLocation(), processWalkTail); + } + + /** + * Reports a loop that keeps yielding without advancing. Every one of these iterations refunds + * its own tail charge, so no number of them can trip the iteration cap. + */ + private static void reportExemptRunTooLong(WorldPoint target, String exitWireName, int run) { + if (run % MAX_CONSECUTIVE_EXEMPT_ITERATIONS != 1) { + return; + } + log.warn("[Walker] {} consecutive tail-exempt iterations (exit={}) target={} at={} —" + + " the loop is yielding without advancing and cannot exhaust the tail cap", + run, exitWireName, target, Rs2Player.getWorldLocation()); + } + private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { long now = System.currentTimeMillis(); + reportWalkBudgetIfExhausted(target, now, processWalkTail); if (now - lastHeartbeatAtMs < WALKER_HEARTBEAT_INTERVAL_MS) { return; } @@ -1867,6 +1918,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // budget. Without this the counter is monotonic for the entire walk. long lastPartialRetryAtMs = 0L; WorldPoint lastPartialRetryAtLoc = null; + int consecutiveExemptIterations = 0; WorldPoint lastAttemptedMinimapClick = null; boolean lastAttemptedMinimapClickOk = false; long lastAttemptedMinimapClickAtMs = 0L; @@ -3353,38 +3405,28 @@ && walkFastCanvas(recoverTarget)) { if (walkCancelledDiag(target, "processWalk:partial-path-branch", processWalkTail)) { return WalkerState.EXIT; } - // Route progress since the last retry means the walk is working — refill the budget. - // It otherwise only ever increments, so "3 retries" meant three outer-loop iterations - // for the whole journey rather than three consecutive failures to advance. - // - // Standing somewhere new is required as well as the progress timestamp: - // routeState.routeProgressAdvancedAtMs is also bumped whenever the route is merely REPLACED, and - // each retry calls recalculatePath(), so the timestamp alone would let a retry refill - // the budget it just spent. When the target is genuinely unreachable the player stops - // moving, so requiring movement is what still lets the budget drain and terminate. WorldPoint retryLoc = Rs2Player.getWorldLocation(); boolean movedSinceLastRetry = lastPartialRetryAtLoc == null || (retryLoc != null && !retryLoc.equals(lastPartialRetryAtLoc)); - if (partialRetriesWorking > 0 - && movedSinceLastRetry - && routeState.routeProgressAdvancedAtMs > lastPartialRetryAtMs) { + if (TailDecision.shouldRefillPartialRetryBudget(partialRetriesWorking, movedSinceLastRetry, + routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs)) { walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); partialRetriesWorking = 0; } - // A handled door/transport/blocker ended the iteration because work was done, not - // because the walker is stuck. Still re-route, but do not charge the budget for it. - if (exit.isProgress()) { + TailDecision.TailAction partialAction = TailDecision.decide(false, true, exit, + partialRetriesWorking, TailDecision.MAX_PARTIAL_RETRIES); + if (partialAction == TailDecision.TailAction.PARTIAL_PROGRESS_REPLAN) { walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); recalculatePath(); continue; } - if (partialRetriesWorking < 3) { + if (partialAction == TailDecision.TailAction.PARTIAL_RETRY_REPLAN) { lastPartialRetryAtMs = System.currentTimeMillis(); lastPartialRetryAtLoc = retryLoc; Telemetry.recordPartialRetry(partialRetriesWorking + 1, finalDist); - WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, 3); + WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, TailDecision.MAX_PARTIAL_RETRIES); recalculatePath(); partialRetriesWorking++; continue; @@ -3444,8 +3486,13 @@ && walkFastCanvas(recoverTarget)) { // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. if (exit.isTailExempt()) { + if (TailDecision.isExemptRunTooLong(++consecutiveExemptIterations, MAX_CONSECUTIVE_EXEMPT_ITERATIONS)) { + reportExemptRunTooLong(target, exit.wireName(offPathDeferDetail), consecutiveExemptIterations); + } walkerDiag("tail exempt exitReason=%s tailBefore=%d", exit.wireName(offPathDeferDetail), processWalkTail); processWalkTail--; + } else { + consecutiveExemptIterations = 0; } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", processWalkTail + 1, diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java new file mode 100644 index 00000000000..078db6c91a2 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java @@ -0,0 +1,113 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +/** + * What the walk loop does at the end of one iteration: finish, replan, give up, or go round again. + * + *

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

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

Standing somewhere new is required as well as the progress timestamp: the timestamp is also + * bumped when the route is merely REPLACED, and every retry replans, so the timestamp alone + * would let a retry refill the budget it just spent. Requiring movement is what still lets the + * budget drain when the target is genuinely unreachable and the player has stopped. + */ + public static boolean shouldRefillPartialRetryBudget(int retriesSpent, + boolean movedSinceLastRetry, + long routeProgressAdvancedAtMs, + long lastPartialRetryAtMs) + { + return retriesSpent > 0 + && movedSinceLastRetry + && routeProgressAdvancedAtMs > lastPartialRetryAtMs; + } + + /** + * @param retriesSpent budget already spent, AFTER any refill from + * {@link #shouldRefillPartialRetryBudget} + */ + public static TailAction decide(boolean withinFinishThreshold, + boolean partialPath, + WalkExit exit, + int retriesSpent, + int maxRetries) + { + if (withinFinishThreshold) + { + return TailAction.ARRIVED; + } + if (partialPath) + { + if (exit != null && exit.isProgress()) + { + return TailAction.PARTIAL_PROGRESS_REPLAN; + } + return retriesSpent < maxRetries + ? TailAction.PARTIAL_RETRY_REPLAN + : TailAction.PARTIAL_EXHAUSTED; + } + return exit != null && exit.isTailExempt() + ? TailAction.CONTINUE_TAIL_EXEMPT + : TailAction.CONTINUE; + } + + /** + * Whether the walk has run past its wall-clock budget. + * + *

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

Sized to catch a livelock, not a slow walk — a budget that aborts a working long journey + * would be a worse bug than the one it is guarding against. + */ + public static boolean isWallClockExhausted(long walkStartedAtMs, long nowMs, long budgetMs) + { + return walkStartedAtMs > 0L && budgetMs > 0L && nowMs - walkStartedAtMs > budgetMs; + } + + /** + * Companion bound to {@link #isWallClockExhausted}: an uninterrupted run of tail-exempt + * iterations means the loop is yielding without ever advancing, which the iteration cap cannot + * see because those iterations refund themselves. + */ + public static boolean isExemptRunTooLong(int consecutiveExemptIterations, int cap) + { + return cap > 0 && consecutiveExemptIterations > cap; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java new file mode 100644 index 00000000000..dbfbb75aa0f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java @@ -0,0 +1,126 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision.TailAction; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Decision table for the end of a walk-loop iteration. + * + *

These interactions used to be inline in {@code processWalk} and could only be verified by + * walking around in-game, which is how a partial route came to report UNREACHABLE while the player + * was still advancing. + */ +public class TailDecisionTest +{ + private static final int MAX = TailDecision.MAX_PARTIAL_RETRIES; + + @Test + public void arrivalWinsOverEverythingElse() + { + assertEquals(TailAction.ARRIVED, + TailDecision.decide(true, true, WalkExit.NOT_NEAR_PATH, MAX, MAX)); + assertEquals(TailAction.ARRIVED, + TailDecision.decide(true, false, WalkExit.END_OF_PATH, 0, MAX)); + } + + @Test + public void completeRouteContinues() + { + assertEquals(TailAction.CONTINUE, + TailDecision.decide(false, false, WalkExit.END_OF_PATH, 0, MAX)); + } + + @Test + public void completeRouteExemptsBenignYieldsFromTheIterationCap() + { + assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, + TailDecision.decide(false, false, WalkExit.INTERIM_IN_FLIGHT, 0, MAX)); + assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, + TailDecision.decide(false, false, WalkExit.OFF_PATH_DEFERRED, 0, MAX)); + } + + /** + * The regression the whole exercise started from: on a partial route an iteration that advanced + * the walk must not spend budget, no matter how much is already spent. + */ + @Test + public void partialRouteDoesNotSpendBudgetOnAnIterationThatAdvanced() + { + for (WalkExit progress : new WalkExit[]{ + WalkExit.DOOR_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT}) + { + assertEquals(progress.name() + " advanced the route and must not spend a retry", + TailAction.PARTIAL_PROGRESS_REPLAN, + TailDecision.decide(false, true, progress, MAX, MAX)); + } + } + + @Test + public void partialRouteSpendsBudgetWhenItDidNotAdvance() + { + assertEquals(TailAction.PARTIAL_RETRY_REPLAN, + TailDecision.decide(false, true, WalkExit.LOCAL_REACHABILITY_MISS_NO_CLICK, 0, MAX)); + assertEquals(TailAction.PARTIAL_RETRY_REPLAN, + TailDecision.decide(false, true, WalkExit.NOT_NEAR_PATH, MAX - 1, MAX)); + } + + /** The budget must still terminate, or a genuinely unreachable goal never gives up. */ + @Test + public void partialRouteGivesUpOnceTheBudgetIsSpent() + { + assertEquals(TailAction.PARTIAL_EXHAUSTED, + TailDecision.decide(false, true, WalkExit.NOT_NEAR_PATH, MAX, MAX)); + assertEquals(TailAction.PARTIAL_EXHAUSTED, + TailDecision.decide(false, true, WalkExit.DOOR_RECOVERY_SUPPRESSED, MAX + 1, MAX)); + } + + @Test + public void budgetRefillsOnlyWhenTheWalkBothMovedAndAdvancedSinceTheLastRetry() + { + assertTrue("moved and route progressed after the last retry — the walk is working", + TailDecision.shouldRefillPartialRetryBudget(2, true, 500L, 400L)); + assertFalse("standing still: the route timestamp alone is also bumped by a mere replan, " + + "so a retry could refill the budget it just spent", + TailDecision.shouldRefillPartialRetryBudget(2, false, 500L, 400L)); + assertFalse("no route progress since the last retry", + TailDecision.shouldRefillPartialRetryBudget(2, true, 300L, 400L)); + assertFalse("nothing spent, nothing to refill", + TailDecision.shouldRefillPartialRetryBudget(0, true, 500L, 400L)); + } + + @Test + public void wallClockBudgetIgnoresWalksThatHaveNotStartedOrHaveNoBudget() + { + assertFalse(TailDecision.isWallClockExhausted(0L, 10_000_000L, 1_000L)); + assertFalse(TailDecision.isWallClockExhausted(1_000L, 10_000_000L, 0L)); + } + + @Test + public void wallClockBudgetTripsOnlyAfterTheBudgetElapses() + { + assertFalse(TailDecision.isWallClockExhausted(1_000L, 1_000L + 300_000L, 300_000L)); + assertTrue(TailDecision.isWallClockExhausted(1_000L, 1_001L + 300_000L, 300_000L)); + } + + /** + * The tail cap cannot see this state: every exempt iteration refunds its own charge, so the + * counter never rises and the loop can yield forever. + */ + @Test + public void exemptRunIsBoundedSeparatelyFromTheIterationCap() + { + assertFalse(TailDecision.isExemptRunTooLong(24, 24)); + assertTrue(TailDecision.isExemptRunTooLong(25, 24)); + assertFalse("a disabled cap must not fire", TailDecision.isExemptRunTooLong(1_000, 0)); + } +} From f8b90cf3ebbb52726ceb95b026885cffcaf6c461 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 17:23:03 +0100 Subject: [PATCH 41/53] fix(walker): two corrections a real farm-run log proved necessary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of these come from one Ardougne-to-Catherby-to-Ardougne run that succeeded end to end. A walk arriving is not evidence that it was right. 1. Stop learning a blocked edge from the BFS frontier. The walled-route net refuses a click when the selected tile is Chebyshev-near yet absent from the player-origin BFS, and then LEARNS the first route edge that leaves that BFS as blocked. But the proximity guard is Chebyshev while the BFS budget counts steps, and those are not the same thing: a tile thirteen tiles away as the crow flies can be thirty steps away around a building, and it is then missing from the BFS for want of budget rather than because anything blocks it. The log caught it outright at the Port Sarim / Land's End docks. A click to (2760,3238) was refused as walled and the edge (2759,3230)->(2759,3231) learned as blocked. Nine seconds later the walker was standing on (2760,3238), having simply walked there. Refusing the click on that evidence is merely conservative and has fallbacks. Writing it into the learned-blocked-edge store is not: routing believes it for the rest of the session. So the edge is now only convicted when its near end is strictly INSIDE the frontier — the BFS expands every tile below its budget, so an interior tile whose neighbour is still missing proves the neighbour unreachable, whereas a tile sitting AT the budget never had its neighbours enumerated and proves nothing. This also removes the replan that followed each false conviction, which was on the critical path of the first click: two of the three walks in the log took ~5.4s to their first click against ~1.1s for the one that did not trigger it. 2. The exempt-run bound counted yields, and walking is mostly yields. Shipped this morning at 24 consecutive tail-exempt iterations. The log shows a completely healthy Catherby-to-Ardougne leg yielding interim-in-flight 28 times in a row while steadily covering ground, because that is simply what travelling between minimap clicks looks like. It would have fired a false livelock warning on a working walk. A bound on yields is a bound on walking. The state worth reporting is yielding while STATIONARY, so the run now resets whenever the player tile changes. Being observe-only is what made this cost a log line instead of an aborted walk. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 48 +++++++++++++++++-- .../util/walker/Rs2WalkerUnitTest.java | 44 +++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) 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 30b98049b4a..60b7aac8e5f 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 @@ -415,6 +415,7 @@ static void resetWalkSessionState() { // disabled for the whole pass, and off-path recalc bypassed entirely. setTarget(null) already // cleared all four on the normal completion path; this makes the two agree. clearRecentTransportContext(); + lastExemptRunLocation = null; // The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. @@ -1797,6 +1798,28 @@ private static void reportWalkBudgetIfExhausted(WorldPoint target, long nowMs, i Rs2Player.getWorldLocation(), processWalkTail); } + /** Player tile at the last tail-exempt iteration; a change means the run was making progress. */ + private static volatile WorldPoint lastExemptRunLocation = null; + + /** + * Counts consecutive tail-exempt iterations THAT DID NOT MOVE THE PLAYER. + * + *

Counting every exempt iteration was wrong, and a real farm-run log proved it: a completely + * healthy Catherby-to-Ardougne walk yielded {@code interim-in-flight} 28 times in a row while + * steadily covering ground, because that is simply what travelling between minimap clicks looks + * like. A bound on yields is a bound on walking; the state actually worth reporting is yielding + * while STATIONARY, which no number of tail refunds can ever surface through the iteration cap. + */ + private static int trackExemptRun(int run, WorldPoint target, WalkExit exit, String detail) { + WorldPoint at = Rs2Player.getWorldLocation(); + int next = (at != null && !at.equals(lastExemptRunLocation)) ? 1 : run + 1; + lastExemptRunLocation = at; + if (TailDecision.isExemptRunTooLong(next, MAX_CONSECUTIVE_EXEMPT_ITERATIONS)) { + reportExemptRunTooLong(target, exit.wireName(detail), next); + } + return next; + } + /** * Reports a loop that keeps yielding without advancing. Every one of these iterations refunds * its own tail charge, so no number of them can trip the iteration cap. @@ -3486,9 +3509,7 @@ && walkFastCanvas(recoverTarget)) { // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. if (exit.isTailExempt()) { - if (TailDecision.isExemptRunTooLong(++consecutiveExemptIterations, MAX_CONSECUTIVE_EXEMPT_ITERATIONS)) { - reportExemptRunTooLong(target, exit.wireName(offPathDeferDetail), consecutiveExemptIterations); - } + consecutiveExemptIterations = trackExemptRun(consecutiveExemptIterations, target, exit, offPathDeferDetail); walkerDiag("tail exempt exitReason=%s tailBefore=%d", exit.wireName(offPathDeferDetail), processWalkTail); processWalkTail--; } else { @@ -4083,6 +4104,19 @@ private static void learnWalledRouteEdge(List rawPath, WorldPoint pl *

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

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

+ * Measured at the Port Sarim / Land's End docks: a click to (2760,3238) was refused as walled and + * the edge (2759,3230)->(2759,3231) was learned — and nine seconds later the walker was standing on + * (2760,3238), having simply walked there. So {@code a} must also be strictly INSIDE the frontier: + * the BFS expands every tile below its budget, so an interior {@code a} whose neighbour {@code b} is + * still missing proves {@code b} unreachable, whereas an {@code a} sitting AT the budget never had + * its neighbours enumerated at all and proves nothing. */ static WorldPoint[] firstWalledRawEdge(List rawPath, WorldPoint playerLoc, Map reachable, int stepBudget) { @@ -4106,7 +4140,13 @@ static WorldPoint[] firstWalledRawEdge(List rawPath, WorldPoint play if (playerLoc.distanceTo2D(a) > maxDistance || playerLoc.distanceTo2D(b) > maxDistance) { continue; } - if (reachable.containsKey(a) && !reachable.containsKey(b)) { + Integer stepsToA = reachable.get(a); + // At the budget, a's neighbours were never enumerated, so b's absence is ignorance, not a + // wall. Only an interior a can convict the edge. + if (stepsToA == null || stepsToA >= stepBudget) { + continue; + } + if (!reachable.containsKey(b)) { return new WorldPoint[]{a, b}; } } 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 3445dc9959f..4e5eebcf3a4 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 @@ -2466,6 +2466,50 @@ public void firstWalledRawEdge_ignoresStepsBeyondTheBfsBudget() { assertNull(Rs2Walker.firstWalledRawEdge(raw, p, reachableSet(p), 12)); } + /** + * A tile sitting AT the BFS budget never had its neighbours enumerated, so the next route tile is + * missing for want of budget, not because anything blocks it. Convicting that edge writes a lie + * into the learned-blocked-edge store and routing believes it for the rest of the session. + * + *

Pinned from a real farm run at the Port Sarim / Land's End docks: a click to (2760,3238) was + * refused as walled and the edge (2759,3230)->(2759,3231) learned — nine seconds later the walker + * was standing on (2760,3238), having simply walked there. Chebyshev-near, step-far. + */ + @Test + public void firstWalledRawEdge_doesNotConvictTheBfsFrontierItself() { + WorldPoint player = new WorldPoint(2772, 3234, 0); + WorldPoint onFrontier = new WorldPoint(2759, 3230, 0); + WorldPoint beyond = new WorldPoint(2759, 3231, 0); + java.util.List raw = java.util.Arrays.asList(player, onFrontier, beyond); + + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(player, 0); + // Thirteen tiles away as the crow flies, but twenty STEPS around the dock buildings — exactly + // the budget, so the BFS stopped here and knows nothing about what lies past it. + reachable.put(onFrontier, 20); + + assertNull("a tile at the budget proves nothing about its neighbour", + Rs2Walker.firstWalledRawEdge(raw, player, reachable, 20)); + } + + /** An interior tile DID have its neighbours enumerated, so a missing neighbour is genuinely walled. */ + @Test + public void firstWalledRawEdge_stillConvictsAnEdgeLeavingTheBfsInterior() { + WorldPoint player = new WorldPoint(2772, 3234, 0); + WorldPoint interior = new WorldPoint(2770, 3234, 0); + WorldPoint walled = new WorldPoint(2769, 3234, 0); + java.util.List raw = java.util.Arrays.asList(player, interior, walled); + + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(player, 0); + reachable.put(interior, 2); + + WorldPoint[] edge = Rs2Walker.firstWalledRawEdge(raw, player, reachable, 20); + assertNotNull("the BFS had budget left at this tile and still could not reach the next one", edge); + assertEquals(interior, edge[0]); + assertEquals(walled, edge[1]); + } + @Test public void firstWalledRawEdge_toleratesMissingInputs() { WorldPoint p = new WorldPoint(2740, 3469, 0); From f26c1496c51b4cae6d11a2d832d475b0d9b4e260 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 18:40:59 +0100 Subject: [PATCH 42/53] =?UTF-8?q?fix(walker):=20a=20stile=20is=20climbed,?= =?UTF-8?q?=20not=20opened=20=E2=80=94=20stop=20the=20door=20cascade=20own?= =?UTF-8?q?ing=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The door cascade's completion contract is "the blocked edge became passable": it clicks, then waits for the edge to open. A stile never opens. You climb over it and arrive on the far side, so that wait can only ever time out. A catalog transport was classified door-like on its NAME, and "stile" is in the door-name fragments, so a Stile at (2637,3350) with action Climb-over was handed to the door cascade. Measured near Ardougne, from first contact to actually crossing: twenty seconds. The handler clicked it, logged door_edge_post_unresolved because the edge never opened, and the walk then spent six refused route clicks, a recovery click onto the far side of the fence, a stall, a replan and an idle nudge before the transport handler got the same object and crossed it in one action. So the action now wins over the name: a catalog row whose action moves the player ACROSS the obstacle — Climb-over, Climb-through, Squeeze-through, Cross — is not door-like, whatever it is called, and shouldDeferDoorHandlingToTransport hands it to the transport handler that can actually complete it. Opening actions are untouched: a named gate you Open is still the door cascade's job, because the door cascade is what knows how to open things. This is the third obstacle in this class to need the same correction (the Varrock museum guard barrier and the Port Sarim back-room door were both fixed as individual data rows). Deciding on the action generalises it: moves-you obstacles are their own class, and the class now has a rule instead of a growing list of coordinates. Co-Authored-By: Claude Opus 5 --- .../util/walker/door/Rs2DoorClassifier.java | 35 +++++++++++++++++++ .../util/walker/door/Rs2DoorProbe.java | 7 ++++ .../util/walker/door/Rs2DoorProbeTest.java | 27 ++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java index 8545312f3b1..c9ddb5a6c0e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java @@ -29,9 +29,44 @@ public final class Rs2DoorClassifier { "push", "climb-over", "climb-through", "squeeze-through", "cross", "force", "exit" ); + /** + * Actions that carry the player ACROSS the obstacle rather than opening an edge in it. + * + *

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

Measured near Ardougne: a Stile at (2637,3350) with action Climb-over classified as a door + * on its name, was taken by the door cascade, logged {@code door_edge_post_unresolved}, and cost + * twenty seconds of refused clicks, a recovery wander and a replan before the transport handler + * finally crossed it in one action. See {@code walker-transport-doors}: moves-you obstacles are + * their own class and belong to the transport handler. + */ + private static final List MOVES_YOU_ACTIONS = List.of( + "climb-over", "climb-through", "squeeze-through", "cross" + ); + private Rs2DoorClassifier() { } + /** + * Whether {@code action} moves the player across the obstacle instead of opening it. + * + * @see #MOVES_YOU_ACTIONS + */ + public static boolean isMovesYouAction(String action) { + if (action == null) { + return false; + } + String al = action.toLowerCase(Locale.ROOT).trim(); + for (String movesYou : MOVES_YOU_ACTIONS) { + if (al.startsWith(movesYou)) { + return true; + } + } + return false; + } + public static boolean isNullOrPlaceholderObjectName(String name) { if (name == null) { return true; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index e66aab862dc..29fe886d35a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java @@ -78,6 +78,13 @@ public static boolean isCatalogTransportObject(TileObject object) { public static boolean isDoorLikeCatalogTransport(Rs2TransportEdge transport) { if (transport == null || transport.getType() != Rs2TransportType.TRANSPORT) { return false; + } + // The ACTION wins over the name. A stile is named door-like and a fence gap is not named at + // all, but both are crossed by moving through them, and the door cascade can only wait for an + // edge to open — a wait a moves-you obstacle can never satisfy. Deciding on the name alone is + // what handed a Climb-over stile to the door handler and cost twenty seconds per crossing. + if (Rs2DoorClassifier.isMovesYouAction(transport.getAction())) { + return false; } return Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getTarget()) || Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getDisplayInfo()) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java index 2feb2d9ac86..fb2e1e3c83a 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java @@ -75,6 +75,33 @@ public void nonTransportTypeIsNeverDoorLike() { transport(Rs2TransportType.AGILITY_SHORTCUT, "Gate", "Gate", "Open"))); } + /** + * The regression this class exists for after the Ardougne stile. A Stile is named door-like and + * would classify as a door on its name alone — but it is crossed by climbing over it, and the + * door cascade can only wait for an edge to open. That wait timed out + * ({@code door_edge_post_unresolved}) and cost twenty seconds of refused clicks, a recovery + * wander and a replan before the transport handler crossed it in a single action. + */ + @Test + public void aMovesYouObstacleIsNotDoorLikeEvenWhenItsNameIs() { + assertFalse("a Climb-over stile belongs to the transport handler, not the door cascade", + Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Stile", "Stile", "Climb-over"))); + assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gate", "Gate", "Squeeze-through"))); + assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gangplank", "Gangplank", "Cross"))); + } + + /** Opening actions are untouched: a named gate you Open is still the door cascade's job. */ + @Test + public void anOpeningActionIsStillDoorLike() { + assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Gate", "Gate", "Open"))); + assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( + transport(Rs2TransportType.TRANSPORT, "Door", "Door", "Walk-through"))); + } + @Test public void nullIsNotDoorLike() { assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport(null)); From 7bac7b5817396c5edbe4294d1d658357c7d733d1 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 18:56:58 +0100 Subject: [PATCH 43/53] chore(walker): make the silent seconds measurable instead of inferable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live logs have now shown a stretch of walk startup with NO output at all, heartbeat included. By the heartbeat's own contract that means the thread is blocked inside a wait rather than spinning, but nothing says inside what, and the last two attempts to answer that from the log alone were both guesses. Diagnostics only; no behaviour changes. Startup marks are deduped per phase per walk, so a startup that REPLANS goes silent for the whole of its second pass: pf_wait_retry, pf_ready and path_snapshot have all been logged already and never fire again. That is precisely the window a walled-click replan lands in, which is why the slowest starts are the least visible ones — a four-second gap containing nothing but the replan that caused it. They now re-arm on a replan taken before the first movement click, so each startup attempt narrates its own. The heartbeat also carries the player-origin BFS cost now. Every getClosestTileIndex runs one, and the loop asks for a route index many times per iteration — route progress, interim tracking, near-path checks, click selection, each recovery probe. There are thirty call sites. Each is a fresh breadth-first search executed on the CLIENT thread, so the cost is a round trip rather than arithmetic, and it appears in no existing timing line. That makes it the leading candidate for the missing seconds, and it is a candidate precisely because nothing has ever measured it. Deliberately measuring before optimising. The obvious fix — memoise the BFS per tick — trades correctness for speed in the one place the walker can least afford it: the BFS reflects live collision, so a memo held across a door opening answers with the world as it was. That trade is only worth making against a number, and there is no number yet. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 60b7aac8e5f..f5ed06c66dd 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 @@ -416,6 +416,8 @@ static void resetWalkSessionState() { // cleared all four on the normal completion path; this makes the two agree. clearRecentTransportContext(); lastExemptRunLocation = null; + reachableBfsCalls.set(0); + reachableBfsMillis.set(0L); // The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. @@ -1844,7 +1846,7 @@ private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { // DEBUG, not INFO: this fires every second for the whole of every walk, and it exists to // diagnose stalls, not to narrate healthy ones. Behind the verbose toggle it costs nothing // until someone is actually chasing a silent stretch in the log. - WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={}", + WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={} bfs={}/{}ms", processWalkTail, compactWorldPoint(playerLoc), compactWorldPoint(target), Rs2Player.isMoving(), Rs2Player.isAnimating(), @@ -1852,7 +1854,8 @@ private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { routeState.interimSetAtMs > 0L ? now - routeState.interimSetAtMs : -1L, routeState.lastMovedTimeMs > 0L ? now - routeState.lastMovedTimeMs : -1L, routeState.doorInteractionSettleStartedAtMs > 0L - ? now - routeState.doorInteractionSettleStartedAtMs : -1L); + ? now - routeState.doorInteractionSettleStartedAtMs : -1L, + reachableBfsCalls.get(), reachableBfsMillis.get()); } /** @@ -9056,11 +9059,27 @@ static int getClosestTileIndex(List path, WorldPoint playerLoc) { /** Step budget of {@link #getClosestIndexReachableTiles}'s BFS; also the route-blocked scan gate's bound. */ private static final int CLOSEST_INDEX_REACHABLE_STEP_BUDGET = 20; + /** + * Calls and milliseconds spent in the player-origin BFS since the current walk started. + * + *

Every {@code getClosestTileIndex} runs one of these, and the walk loop asks for a route + * index many times per iteration — route progress, interim tracking, near-path checks, click + * selection, each recovery probe. Each one is a fresh breadth-first search executed on the CLIENT + * thread, so the cost is a round trip, not arithmetic, and it does not show up in any existing + * timing line. A walk that goes silent for seconds with no heartbeat is blocked inside something, + * and this is the leading candidate; these two numbers ride on the heartbeat so the next log + * settles it instead of another round of inference. + */ + private static final AtomicInteger reachableBfsCalls = new AtomicInteger(); + private static final AtomicLong reachableBfsMillis = new AtomicLong(); + private static HashMap getClosestIndexReachableTiles(WorldPoint playerLoc) { if (playerLoc == null) { return new HashMap<>(); } HashMap tiles; + long bfsStartedAt = System.currentTimeMillis(); + reachableBfsCalls.incrementAndGet(); try { tiles = Rs2Tile.getReachableTilesFromTile( playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); @@ -9068,10 +9087,12 @@ private static HashMap getClosestIndexReachableTiles(WorldP if (!isClientThreadReadTimeout(failure)) { throw failure; } + reachableBfsMillis.addAndGet(System.currentTimeMillis() - bfsStartedAt); WebWalkLog.spInfo("client_thread_timeout_fallback | op=closest_route_index"); return nearbyTilesIgnoringCollision( playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); } + reachableBfsMillis.addAndGet(System.currentTimeMillis() - bfsStartedAt); // If an animation/shortcut puts the player on a collision-odd tile, keep route progress // anchored by distance instead of repeatedly recalculating an empty reachable set. @@ -9334,6 +9355,14 @@ private static void recalculatePath(Rs2PlannerShadowContext.Invocation invocatio if (goal == null) { return; } + // Startup marks are deduped per phase per walk, so a startup that REPLANS goes silent for its + // whole second pass — pf_wait_retry, pf_ready and path_snapshot have all been logged already. + // That is exactly the window a walled-click replan lands in, which is why the slowest starts + // are the least visible ones: a four-second gap with nothing in it but the replan itself. + // Re-arm them so each startup attempt narrates its own. + if (!routeState.firstMovementClickMarked) { + startupPhasesLogged.clear(); + } // Must not call setTarget(null)+setTarget(goal): that briefly clears {@link #currentTarget}, // and processWalk on another thread treats null as cancel (isWalkCancelled). Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal, invocation); From ac8bbe6a36ebe49dbd97f2d880e61a7798b5ec5d Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 19:30:36 +0100 Subject: [PATCH 44/53] fix(walker): let recovery take the transport before it suppresses itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocked-frontier recovery tries, in order: door handlers, then a suppression branch for "an unresolved door is near the route", then the transport on the blocked edge. The suppression branch breaks out of recovery, so whenever it fired the transport never got its turn — even when the transport WAS the thing blocking us and the only handler able to cross it. Measured near Draynor on a catalog transport at (3064,3282): the click to it was refused as walled, the door handlers declined it (non-standard-door-action), the path-adjacent scan found no candidates, and recovery then suppressed itself for a "nearby route door" which was this very transport. Four seconds later the raw scene scan dispatched it from range and crossed in a single action. Ten seconds at a gate, and every step of it was the walker asking the wrong handler. The transport attempt now runs before suppression. Suppression is unchanged and still guards the generic recovery click below it — the Clock Tower failure it exists for is untouched. It simply no longer outranks the handler that can resolve the edge. Mechanically this is a move, not new logic, and the block it moves above already ran on every path where suppression declined. processWalk stays at 1641 lines; the comment was trimmed to fit rather than raising the guard. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) 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 f5ed06c66dd..7a36464c032 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 @@ -2780,6 +2780,23 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, break; } } + // A shortcut / transport on the blocked frontier is TAKEN here rather than + // routed around: the minimap fallback below would pick the tile on the FAR + // side and send the server the long way around the gap. Recovery acts on the + // edge blocking us right now, so it is the nearest obstacle by construction + // and may dispatch from range. + // Ordered BEFORE door suppression, which breaks out of recovery and so never + // let the transport have its turn. Measured near Draynor: a catalog transport + // at (3064,3282) was refused as walled, declined by the door handlers, then + // suppressed as a "nearby route door" that was this very transport — four + // seconds before the raw scan dispatched it. Suppression still guards the + // generic recovery click below; it just no longer outranks this. + if ((PohTeleports.isInHouse() || !inInstance) + && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { + exit = WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY; + break; + } + if (unresolvedDoorNearRawPath) { // An unresolved door sits on/near the blocked edge but every door handler above // declined (settling / recent-attempt cooldowns). Do NOT fall through to the @@ -2817,21 +2834,6 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, break; } - // An agility shortcut / transport sitting on the blocked frontier is TAKEN - // here rather than routed around. The minimap-click fallback below picks the - // furthest path tile within Euclidean minimap reach, which for a stepping-stone - // (or any gap/wall shortcut) is the tile on the FAR side -- clicking it makes the - // server walk the long way around the gap it should have crossed. Taking the - // transport first mirrors the segment-handler transport scan (which can be - // skipped in the post-transport window) and the door/rockfall handling above. - // Recovery acts on the edge blocking us RIGHT NOW, so it is the nearest - // obstacle by construction and may dispatch from range. - if ((PohTeleports.isInHouse() || !inInstance) - && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { - exit = WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY; - break; - } - // Door/obstacle detection above found nothing to open. The local // reachability BFS is bounded (~39 tiles) and is frequently a FALSE // negative — a viable route exists, just longer than the BFS radius or From 819866a317c0dfacb1766724f32035d7b8440409 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 19:53:35 +0100 Subject: [PATCH 45/53] refactor(walker): give the per-segment gate a decision table (D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a route segment's obstacle handlers run was inline boolean soup: two independent skip reasons — the window after a transport, and startup before the first movement click — with the logged reason derived from a ternary over which one fired. The pair matters more than it looks. A skipped segment was never EXAMINED, so an obstacle on it is neither resolved nor ruled out, which is why a skip silently withdraws the right to click a door at range. That coupling is what produced the Falador U-turn: segments 11 and 12 skipped with no_nearby_planned_transport, the door at (2985,3341) then clicked from range while the door at (2981,3340) was still shut between us and it, the server routed around the building, and a traversal wait that could never be satisfied timed out. Ten seconds and a U-turn, out of two booleans that never appeared in the same expression. SegmentGate now owns the decision as one enum-returning function, with the log reason carried by the constant rather than reconstructed at the call site, and mayDispatchDoorAtRange named for the invariant it protects. Twelve decision-table cases pin it, including the ones that must NOT skip: a planned transport nearby, an unreachable segment tile, a door attempt or settle or recovery in flight, an immediate transport step at startup, and the precedence when both skips apply. Behaviour-preserving: same conditions, same precedence, same wire strings. The two startup-preclick cases move from Rs2WalkerUnitTest into the new table, where the rest of their family now lives. processWalk 1641 -> 1630; the guard ratchets down a third time. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 49 ++---- .../util/walker/segment/SegmentGate.java | 124 +++++++++++++++ .../util/walker/Rs2WalkerUnitTest.java | 29 +--- .../util/walker/segment/SegmentGateTest.java | 146 ++++++++++++++++++ 4 files changed, 283 insertions(+), 65 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java 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 7a36464c032..85626eebf3a 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 @@ -66,6 +66,7 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate; import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; @@ -625,21 +626,6 @@ private static ObstaclePolicy obstaclePolicyForCurrentPhase() { : STEADY_OBSTACLE_POLICY; } - static boolean shouldSkipStartupPreclickSegmentHandlers(boolean startupBeforeFirstClick, - int segmentIdx, - int routeStartIdx, - boolean recentDoorAttemptNearSegment, - boolean doorSettling, - boolean recoveryInFlight) { - if (!startupBeforeFirstClick || routeStartIdx < 0 || segmentIdx < routeStartIdx) { - return false; - } - if (recentDoorAttemptNearSegment || doorSettling || recoveryInFlight) { - return false; - } - return true; - } - static boolean shouldRunActiveRouteIdleNudge(boolean idleNudgeDue, boolean immediateRouteTransportPending) { return idleNudgeDue && !immediateRouteTransportPending; @@ -2473,31 +2459,19 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean startupBeforeFirstClick = currentWalkerPhase() == WalkerPhase.STARTUP; boolean immediateSegmentTransportStep = hasImmediatePlannedTransportStep(path, i, playerNearSeg); boolean recentDoorAttemptNearSegment = hasRecentDoorAttemptNearIndex(path, i); - boolean skipPostTransportSegmentHandlers = recentTransportWindow - && !upcomingNearbyTransport - && !recentDoorAttemptNearSegment - && !isDoorInteractionSettling() - && !isRecoveryMovementInFlight() - && reachableTilesCache.containsKey(currentWorldPoint); - boolean skipStartupPreclickSegmentHandlers = !immediateSegmentTransportStep - && shouldSkipStartupPreclickSegmentHandlers( - startupBeforeFirstClick, - i, - indexOfStartPoint, - recentDoorAttemptNearSegment, - isDoorInteractionSettling(), - isRecoveryMovementInFlight()); - if (skipPostTransportSegmentHandlers || skipStartupPreclickSegmentHandlers) { + SegmentGate.SegmentAction segmentAction = SegmentGate.decide( + recentTransportWindow, upcomingNearbyTransport, recentDoorAttemptNearSegment, + isDoorInteractionSettling(), isRecoveryMovementInFlight(), + reachableTilesCache.containsKey(currentWorldPoint), + startupBeforeFirstClick, immediateSegmentTransportStep, i, indexOfStartPoint); + if (segmentAction.isSkip()) { segmentSkippedThisPass = true; - if (skipStartupPreclickSegmentHandlers) { + if (segmentAction == SegmentGate.SegmentAction.SKIP_STARTUP_PRECLICK) { markStartupPhase("preclick_segment_handler_skip", target, - "i=" + i + " reason=startup_before_first_click"); + "i=" + i + " reason=" + segmentAction.wireReason()); } tmarkPostTransport("post_transport_segment_handler_skip", - target, - "i=" + i + " reason=" + (skipPostTransportSegmentHandlers - ? "no_nearby_planned_transport" - : "startup_before_first_click")); + target, "i=" + i + " reason=" + segmentAction.wireReason()); } else { long segmentHandlerStartAt = System.currentTimeMillis(); int rawI = (i < smoothedToRaw.length) ? smoothedToRaw[i] : 0; @@ -2523,7 +2497,8 @@ && shouldSkipStartupPreclickSegmentHandlers( // // With an earlier segment skipped, doors fall back to the stationary requirement, // which is the behaviour from before ranged door dispatch existed. - boolean nearestSegmentDoor = !segmentHandlersRanThisPass && !segmentSkippedThisPass; + boolean nearestSegmentDoor = SegmentGate.mayDispatchDoorAtRange( + segmentHandlersRanThisPass, segmentSkippedThisPass); segmentHandlersRanThisPass = true; boolean doorMovementGateOk = !Rs2Player.isMoving() || (nearestSegmentDoor && doorInteractionWhileApproachingEnabled()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java new file mode 100644 index 00000000000..64be28deab0 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGate.java @@ -0,0 +1,124 @@ +package net.runelite.client.plugins.microbot.util.walker.segment; + +/** + * Whether the obstacle handlers run for one route segment, and whether a door on it may be clicked + * from range. + * + *

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

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

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

Measured at Falador: segments 11 and 12 skipped with {@code no_nearby_planned_transport}, + * then the door at (2985,3341) clicked from range while the door at (2981,3340) was still shut + * between us and it. The server began routing AROUND the building, dragging the player south to + * (2960,3330), and the traversal wait it could never satisfy timed out. Ten seconds and a U-turn. + */ + public static boolean mayDispatchDoorAtRange(boolean handlersAlreadyRanThisPass, + boolean anySegmentSkippedThisPass) + { + return !handlersAlreadyRanThisPass && !anySegmentSkippedThisPass; + } +} diff --git a/runelite-client/src/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 4e5eebcf3a4..ae90586b491 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 @@ -1957,34 +1957,7 @@ public void shouldRunActiveRouteIdleNudge_waitsForImmediateTransport() { assertFalse(Rs2Walker.shouldRunActiveRouteIdleNudge(false, false)); } - @Test - public void shouldSkipStartupPreclickSegmentHandlers_skipsBeforeFirstMovementClick() { - assertTrue(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( - true, - 5, - 5, - false, - false, - false)); - } - - @Test - public void shouldSkipStartupPreclickSegmentHandlers_keepsDoorRecoveryAndSteadyEdges() { - assertFalse(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( - true, - 8, - 5, - true, - false, - false)); - assertFalse(Rs2Walker.shouldSkipStartupPreclickSegmentHandlers( - false, - 8, - 5, - false, - false, - false)); - } + // Startup-preclick skipping moved to SegmentGate; its cases live in SegmentGateTest. @Test public void rawPathForwardAnchorIndex_keepsFallbackAheadOfAnchor() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java new file mode 100644 index 00000000000..c1288faa444 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/segment/SegmentGateTest.java @@ -0,0 +1,146 @@ +package net.runelite.client.plugins.microbot.util.walker.segment; + +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate.SegmentAction; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Decision table for whether one route segment's obstacle handlers run. + * + *

These conditions were inline boolean soup in {@code processWalk}, and the interaction between + * them — a skipped segment silently withdrawing the right to click a door at range — is what + * produced the Falador U-turn. + */ +public class SegmentGateTest +{ + /** Steady state, nothing special: examine the segment. */ + private static SegmentAction decide(boolean recentTransportWindow, + boolean upcomingNearbyTransport, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight, + boolean tileReachable, + boolean startupBeforeFirstClick, + boolean immediateSegmentTransportStep, + int segmentIdx, + int routeStartIdx) + { + return SegmentGate.decide(recentTransportWindow, upcomingNearbyTransport, + recentDoorAttemptNearSegment, doorSettling, recoveryInFlight, tileReachable, + startupBeforeFirstClick, immediateSegmentTransportStep, segmentIdx, routeStartIdx); + } + + @Test + public void steadyStateRunsTheHandlers() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, false, false, 5, 5)); + } + + @Test + public void postTransportWindowSkipsWhenNoTransportIsComingUp() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, false, false, 5, 5)); + } + + /** The window must not hide the transport it is a window for. */ + @Test + public void aPlannedTransportNearbyOverridesThePostTransportSkip() + { + assertEquals(SegmentAction.RUN, + decide(true, true, false, false, false, true, false, false, 5, 5)); + } + + /** An unreachable segment tile is the case the handlers exist for, so it is never skipped. */ + @Test + public void anUnreachableSegmentIsNeverSkippedByTheTransportWindow() + { + assertEquals(SegmentAction.RUN, + decide(true, false, false, false, false, false, false, false, 5, 5)); + } + + @Test + public void doorWorkInFlightOverridesThePostTransportSkip() + { + assertEquals("a recent door attempt near this segment must still be examined", + SegmentAction.RUN, decide(true, false, true, false, false, true, false, false, 5, 5)); + assertEquals("a settling door must still be examined", + SegmentAction.RUN, decide(true, false, false, true, false, true, false, false, 5, 5)); + assertEquals("recovery movement in flight must still be examined", + SegmentAction.RUN, decide(true, false, false, false, true, true, false, false, 5, 5)); + } + + @Test + public void startupSkipsSegmentsUntilTheFirstMovementClick() + { + assertEquals(SegmentAction.SKIP_STARTUP_PRECLICK, + decide(false, false, false, false, false, true, true, false, 5, 5)); + } + + /** A transport we are standing next to is taken at startup rather than deferred. */ + @Test + public void anImmediateTransportStepIsNotSkippedAtStartup() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, true, true, 5, 5)); + } + + @Test + public void startupSkipDoesNotApplyBehindTheRouteStartOrOutsideStartup() + { + assertEquals("segments behind the route start are not startup-skipped", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 3, 5)); + assertEquals("a negative route start means we do not know where the route begins", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 5, -1)); + assertEquals("not in startup", + SegmentAction.RUN, decide(false, false, false, false, false, true, false, false, 8, 5)); + } + + @Test + public void startupSkipYieldsToDoorWorkInFlight() + { + assertEquals(SegmentAction.RUN, + decide(false, false, true, false, false, true, true, false, 8, 5)); + } + + /** Both apply: the post-transport reason wins, matching the original reason ternary. */ + @Test + public void postTransportReasonWinsWhenBothSkipsApply() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, true, false, 5, 5)); + } + + /** Log consumers key off these strings; they must not drift. */ + @Test + public void wireReasonsAreStable() + { + assertEquals("no_nearby_planned_transport", + SegmentAction.SKIP_POST_TRANSPORT_WINDOW.wireReason()); + assertEquals("startup_before_first_click", SegmentAction.SKIP_STARTUP_PRECLICK.wireReason()); + assertFalse(SegmentAction.RUN.isSkip()); + assertTrue(SegmentAction.SKIP_POST_TRANSPORT_WINDOW.isSkip()); + assertTrue(SegmentAction.SKIP_STARTUP_PRECLICK.isSkip()); + } + + /** + * The Falador invariant. A skipped segment was never examined, so the first segment that DOES run + * is not the nearest unresolved obstacle just because it is the first one handled — and only the + * nearest may be clicked at range. + */ + @Test + public void aSkippedSegmentWithdrawsTheRightToClickADoorAtRange() + { + assertTrue("first handler this pass, nothing skipped before it", + SegmentGate.mayDispatchDoorAtRange(false, false)); + assertFalse("an earlier segment was skipped and never examined", + SegmentGate.mayDispatchDoorAtRange(false, true)); + assertFalse("something already handled this pass, so this is not the nearest", + SegmentGate.mayDispatchDoorAtRange(true, false)); + assertFalse(SegmentGate.mayDispatchDoorAtRange(true, true)); + } +} From a1659793d4ea24085d5ee21523c91cdce495778e Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 20:02:31 +0100 Subject: [PATCH 46/53] fix(walker): stop crediting a spinning player as route progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rs2Player.isMoving() compares the pose animation against the idle pose, so it reads TRUE while the player merely TURNS ON THE SPOT. Stall accounting credited that as progress and refreshed the clock, so a player wedged against a wall or a door who kept re-facing it could never be declared stuck — the one state the stall detector exists to catch. The walker would sit there indefinitely, because its own sensor kept insisting it was walking. Requiring an actual tile change instead would be worse. A walking step takes ~600ms and this check samples faster than that, so "same tile as the last sample" is the normal state of a healthy walk; demanding a delta every sample would declare every walk stalled. That is presumably why the pose flag was used in the first place. The right question is not whether the tile changed since the last sample but whether it has changed at all RECENTLY. Walking refreshes that continuously; spinning never does. So the pose flag is now credited only when a real tile change happened within 2.5s — several walking steps of slack, and no help at all to a player who is only rotating. Tracked on its own field rather than reusing lastMovedTimeMs, which several places deliberately refresh to buy grace and therefore cannot answer "is the player really covering ground". Seeded at walk start, because an unknown tile-change time credits the pose and would otherwise hand a spinning player the benefit of the doubt for the whole first stall window. Scoped to stall accounting on purpose. Rs2Player.isMoving() has 65 call sites in the walker alone and more across every other plugin; changing its meaning globally is an unrelated blast radius. This changes who is allowed to believe it, not what it says. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 17 ++++- .../walker/stall/Rs2WalkerStallPolicy.java | 29 ++++++++ .../util/walker/state/WalkerRouteState.java | 9 +++ .../stall/Rs2WalkerStallPolicyTest.java | 73 +++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java 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 85626eebf3a..769b0c5a9d2 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 @@ -419,6 +419,10 @@ static void resetWalkSessionState() { lastExemptRunLocation = null; reachableBfsCalls.set(0); reachableBfsMillis.set(0L); + // Seed rather than zero: a fresh walk has not moved yet, and an unknown tile-change time + // credits the pose flag, which would hand a spinning player the benefit of the doubt for the + // whole first stall window. + routeState.lastTileChangeAtMs = System.currentTimeMillis(); // The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. @@ -11925,7 +11929,11 @@ private static void checkIfStuck() { boolean anim = Rs2Player.isAnimating(); if (now != null && now.equals(routeState.lastPosition)) { boolean nearPath = isNearPath(); - boolean poseWalkingNearPath = Rs2Player.isMoving() && nearPath; + long sinceTileChangeMs = routeState.lastTileChangeAtMs > 0L + ? System.currentTimeMillis() - routeState.lastTileChangeAtMs + : -1L; + boolean poseWalkingNearPath = Rs2WalkerStallPolicy.poseCountsAsProgress( + Rs2Player.isMoving(), nearPath, sinceTileChangeMs, POSE_PROGRESS_TILE_CHANGE_WINDOW_MS); boolean animProgressNearPath = anim && !routeState.prevAnimatingForStuckCheck && nearPath; if (animProgressNearPath || poseWalkingNearPath) { routeState.lastMovedTimeMs = System.currentTimeMillis(); @@ -11934,6 +11942,7 @@ private static void checkIfStuck() { routeState.stuckCount++; } } else { + routeState.lastTileChangeAtMs = System.currentTimeMillis(); routeState.stuckCount = 0; routeState.lastMovedTimeMs = System.currentTimeMillis(); } @@ -11955,6 +11964,12 @@ private static void checkIfStuck() { * segments sometimes delay tile deltas without {@link Rs2Player#isMoving()} flipping immediately. */ private static final long MINIMAP_CLICK_STALL_GRACE_MS = 12_000L; + /** + * How recently the player must have actually changed tile for the pose-based movement flag to + * count as route progress. A walking step is ~600ms and a running one ~300ms, so a healthy walk + * refreshes this many times over; a player turning on the spot never does. + */ + private static final long POSE_PROGRESS_TILE_CHANGE_WINDOW_MS = 2_500L; private static boolean interactingActorNearWalkablePath() { Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java index b0bb74bbe78..44a81fe425e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java @@ -30,6 +30,35 @@ public static boolean shouldSkipStallAccounting(long leaguesPendingMaxAgeMs) { return !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON); } + /** + * Whether the pose-based movement flag may be credited as route progress. + * + *

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

Requiring a tile change outright would be worse: a walking step takes ~600ms and the check + * samples faster than that, so "same tile as last sample" is the normal state of a healthy walk. + * The question is not whether the tile changed since the last sample but whether it has changed + * at all RECENTLY — walking changes tile continuously, spinning never does. + * + * @param sinceTileChangeMs ms since the player last actually changed tile; negative when unknown, + * which is treated as "cannot disprove movement" and credits the pose + */ + public static boolean poseCountsAsProgress(boolean poseMoving, + boolean nearPath, + long sinceTileChangeMs, + long tileChangeWindowMs) { + if (!poseMoving || !nearPath) { + return false; + } + if (sinceTileChangeMs < 0L) { + return true; + } + return sinceTileChangeMs < tileChangeWindowMs; + } + /** * Computes the stall threshold by multiplying {@code baseMs} by the maximum applicable multiplier. * Result uses {@link Math#round(double)}. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index 1f574917851..8d640f6daf3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -99,6 +99,15 @@ public void clearRecentTransportContext() { public volatile WorldPoint lastPosition = null; /** Wall-clock ms the player last changed tiles (or a click granted grace). */ public volatile long lastMovedTimeMs = 0L; + /** + * Wall-clock ms the player last actually CHANGED TILE — no click grace, no pose, no animation. + * + *

Distinct from {@link #lastMovedTimeMs}, which several places refresh to buy grace and which + * therefore cannot answer "is the player really covering ground". This one only ever moves when + * the observed tile differs from the previous sample, which is what makes it a usable check on + * the pose-based movement flag. + */ + public volatile long lastTileChangeAtMs = 0L; /** Rising-edge detection for animation progress without tile delta in the stuck check. */ public volatile boolean prevAnimatingForStuckCheck = false; /** Wall-clock ms of the last walled-recovery replan (cooldown selects replan vs wait). */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java new file mode 100644 index 00000000000..fd38ecda653 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java @@ -0,0 +1,73 @@ +package net.runelite.client.plugins.microbot.util.walker.stall; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The walker's stall detector, and specifically what it is allowed to call "movement". + */ +public class Rs2WalkerStallPolicyTest +{ + private static final long WINDOW = 2_500L; + + /** + * The bug this exists for. {@code Rs2Player.isMoving()} compares the pose animation against the + * idle pose, so it reads TRUE while the player merely turns on the spot. Crediting that as + * progress refreshed the stall clock, so a player wedged against a wall or a door who kept + * re-facing it could never be declared stuck — the exact state the detector exists to catch. + */ + @Test + public void turningOnTheSpotIsNotProgress() + { + assertFalse("pose says moving, but no tile has changed in ten seconds", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 10_000L, WINDOW)); + } + + /** + * And the reason it cannot simply require a tile change: a walking step is ~600ms while the check + * samples faster, so "same tile as the last sample" is the normal state of a healthy walk. + */ + @Test + public void walkingBetweenTilesIsStillProgress() + { + assertTrue("mid-step, tile changed 400ms ago", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 400L, WINDOW)); + assertTrue("just inside the window", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW - 1, WINDOW)); + assertFalse("just outside it", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW, WINDOW)); + } + + /** An unknown tile-change time must not manufacture a stall. */ + @Test + public void unknownTileChangeTimeCreditsThePose() + { + assertTrue(Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, -1L, WINDOW)); + } + + /** Both original conditions still gate it: off-path movement was never route progress. */ + @Test + public void poseAndNearPathAreStillBothRequired() + { + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(false, true, 100L, WINDOW)); + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(true, false, 100L, WINDOW)); + } + + /** The threshold takes the largest applicable multiplier, not their product. */ + @Test + public void thresholdUsesTheLargestMultiplierNotTheProduct() + { + assertEquals(24_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + true, true, true, true, true)); + assertEquals(12_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, false, false)); + assertEquals("an interim waypoint alone", 21_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, true, false)); + } +} From 915866eb0dfb8e8a4e9ab71c6dac15dd4fb13ba9 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 22:42:46 +0100 Subject: [PATCH 47/53] refactor(walker): name which interim the loop is waiting on, and retire the legacy predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate places in the walk loop yield to a live interim waypoint, and all three logged the same word. A line reading interim-in-flight could mean the route-level yield, the blocked-frontier recovery deferring to a click it already made, or click selection finding the player still travelling. That ambiguity has now blocked two stall diagnoses: the log says the walker is waiting, and nothing says which wait. Each site gets its own constant, wire names suffixed :route, :recovery and :click. The shared interim-in-flight prefix keeps one grep matching all three, and the flags are identical, so nothing about the behaviour changes — only what the log can tell you afterwards. This also discharges the follow-up left by the classification fix. The string predicates the enum replaced survived only to hold up the characterization test that proved that refactor inert; they have done that job, and keeping dead production code alive to serve a test is the kind of accretion this whole effort exists to reverse. They are deleted, and their classification moves into WalkExitTest as three explicit sets, checked exhaustively against every constant. The sets are strictly better than the predicates were. A reason cannot change meaning without changing the list, a new constant cannot be added without being classified, and the lists read as documentation of what the walker considers progress rather than as a startsWith("door-handled") prefix rule that silently excluded half the door reasons. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 76 +--------- .../microbot/util/walker/state/WalkExit.java | 12 +- .../util/walker/Rs2WalkerUnitTest.java | 55 +------ .../microbot/util/walker/WalkExitTest.java | 141 ++++++++++++------ .../walker/recovery/TailDecisionTest.java | 2 +- 5 files changed, 114 insertions(+), 172 deletions(-) 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 769b0c5a9d2..7dcc80542a5 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 @@ -782,23 +782,6 @@ public static boolean isWalkableInCollisionMap(WorldPoint tile) { } /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */ - /** - * @deprecated superseded by {@link WalkExit#isDoorLike()}. Retained only so - * {@code WalkExitTest} can prove the enum classifies every reason exactly as this did. - * Delete once that characterization is no longer needed. - */ - @Deprecated - static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { - if (exitReason == null) { - return false; - } - if (exitReason.startsWith("door-handled")) { - return true; - } - return "raw-path-scene-object-handled".equals(exitReason) - || "post-click-raw-path-scene-object-handled".equals(exitReason); - } - /** * Exit reasons meaning the path loop ended because the walker did something that * advances the route — opened a door, took a transport, cleared a blocker — or because @@ -811,47 +794,6 @@ static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { * walk, so an ordinary door could exhaust it ~100 tiles into a working route and report * UNREACHABLE while the player was still advancing. See {@code movement.md} #25. */ - @Deprecated - static boolean isRouteProgressExit(String exitReason) { - if (exitReason == null) { - return false; - } - if (exitReason.startsWith("door-handled")) { - return true; - } - switch (exitReason) { - case "raw-path-scene-object-handled": - case "post-click-raw-path-scene-object-handled": - case "current-tile-transport-handled": - case "post-click-current-tile-transport-handled": - case "transport-handled": - case "rockfall-handled": - case "path-blocker-handled": - case "interim-in-flight": - case "recovery-move-in-flight": - case "route-fold-continuation-click": - return true; - default: - return false; - } - } - - /** - * The tail-exemption condition exactly as it was written inline in {@code processWalk}'s - * epilogue before {@link WalkExit} existed. - * - * @deprecated superseded by {@link WalkExit#isTailExempt()}. Retained only so - * {@code WalkExitTest} can prove the enum classifies every reason exactly as this did. - */ - @Deprecated - static boolean isTailExemptExit(String exitReason) { - return "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); - } - /** @return true only when a canvas click was actually issued, so the caller can size its minimap hold-off. */ private static boolean maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List path) { if (goal == null || path == null || path.isEmpty()) { @@ -2279,7 +2221,7 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { || activeInterimPlayer == null || activeInterimPlayer.distanceTo(target) > immediateFinishTh) && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { - exit = WalkExit.INTERIM_IN_FLIGHT; + exit = WalkExit.INTERIM_IN_FLIGHT_ROUTE; WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), activeInterimPlayer, target, @@ -2708,7 +2650,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM break; } if (shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())) { - exit = WalkExit.INTERIM_IN_FLIGHT; + exit = WalkExit.INTERIM_IN_FLIGHT_RECOVERY; break; } if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { @@ -3054,7 +2996,7 @@ && walkFastCanvas(recoverTarget)) { boolean closeEnoughForNextClick = posAfterWait != null && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_CLOSE_TILES; if (!closeEnoughForNextClick && Rs2Player.isMoving()) { - exit = WalkExit.INTERIM_IN_FLIGHT; + exit = WalkExit.INTERIM_IN_FLIGHT_CLICK; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", interimFinal, posAfterWait == null ? interimDist : interimFinal.distanceTo2D(posAfterWait), @@ -11866,18 +11808,6 @@ static int offPathRecalcDeferredWaitMs(String reason, Math.min(OFF_PATH_RECALC_DEFER_WAIT_MAX_MS, remainingMs)); } - static boolean isOffPathRecalcDeferredExit(String exitReason) { - return exitReason != null && exitReason.startsWith("off-path-deferred:"); - } - - @Deprecated - static String offPathDeferredReasonFromExit(String exitReason) { - if (!isOffPathRecalcDeferredExit(exitReason)) { - return ""; - } - return exitReason.substring("off-path-deferred:".length()); - } - private static boolean isRecentEvent(long nowMs, long eventAtMs, long graceMs) { return eventAtMs > 0L && nowMs >= eventAtMs && nowMs - eventAtMs < graceMs; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java index 3ac39299509..361bd99e812 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkExit.java @@ -85,7 +85,17 @@ public enum WalkExit // Waiting for an action we issued is not a failed attempt. Charging these meant three settle // windows at one ordinary door could exhaust the budget and abort the walk. - INTERIM_IN_FLIGHT("interim-in-flight", true, true, false), + /** + * Yielded to a live interim waypoint. Three separate places in the loop do this, and until they + * were told apart a log line reading {@code interim-in-flight} could mean any of them — which + * twice made a real stall undiagnosable from the log. The suffix names the site; the shared + * {@code interim-in-flight} prefix keeps one grep matching all three. + */ + INTERIM_IN_FLIGHT_ROUTE("interim-in-flight:route", true, true, false), + /** The blocked-frontier recovery deferred to an interim it had already clicked. */ + INTERIM_IN_FLIGHT_RECOVERY("interim-in-flight:recovery", true, true, false), + /** Click selection found the player still travelling to the previous interim. */ + INTERIM_IN_FLIGHT_CLICK("interim-in-flight:click", true, true, false), RECOVERY_MOVE_IN_FLIGHT("recovery-move-in-flight", true, true, false), ROUTE_MOVE_IN_FLIGHT("route-move-in-flight", true, true, false), DOOR_SETTLING_YIELD("door-settling-yield", true, false, false), 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 ae90586b491..25223655d33 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 @@ -983,59 +983,8 @@ public void rockfallGateStaysClosedAwayFromTheMine() { Rs2ObstacleHandler.isMotherlodeRockfallCandidate(varrock, null, 0)); } - /** - * A handled door/transport/blocker must NOT be charged against the partial-retry budget. - * - *

Regression for a walk to an underground goal that reported UNREACHABLE while still - * advancing. The path end sat 31 tiles short of the goal, so {@code partialPath} was true on - * every iteration and the budget was armed for the whole walk. Opening one door ended the - * iteration, landed in the partial branch and spent a retry; the next iteration spent the last - * one a second later without the player ever walking. Three retries were gone ~100 tiles into a - * route that was working, and the walker gave up on the surface having never reached the ladder. - */ - @Test - public void routeProgressExits_areNotChargedAgainstThePartialRetryBudget() { - for (String progress : new String[]{ - "door-handled", - "door-handled-local-reachability", - "door-handled-during-interim", - "door-handled-before-minimap-click", - "transport-handled", - "current-tile-transport-handled", - "post-click-current-tile-transport-handled", - "raw-path-scene-object-handled", - "post-click-raw-path-scene-object-handled", - "rockfall-handled", - "path-blocker-handled", - "interim-in-flight", - "recovery-move-in-flight", - "route-fold-continuation-click"}) { - assertTrue("'" + progress + "' means the walker advanced the route, so it must not spend " - + "a partial retry", Rs2Walker.isRouteProgressExit(progress)); - } - } - - /** - * The exemption must stay narrow: reasons that mean the walker failed to advance still have to - * consume the budget, otherwise a genuinely unreachable goal never terminates and the walk spins - * until the outer tail cap trips. - */ - @Test - public void nonProgressExits_stillConsumeThePartialRetryBudget() { - for (String stuck : new String[]{ - "end-of-path", - "not-near-path", - "player-location-null", - "click-failed-off-minimap", - "door-edge-waiting-retry", - "door-edge-nearby-waiting-retry", - "door-recovery-suppressed", - "local-reachability-miss-no-click", - null}) { - assertFalse("'" + stuck + "' is not route progress and must still spend a retry", - Rs2Walker.isRouteProgressExit(stuck)); - } - } + // The partial-retry budget classification moved to WalkExit; its cases, including this + // underground-goal regression, now live in WalkExitTest as explicit sets. /** * Off-path recovery must be able to step BACKWARD onto the route. When the player is pushed off diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java index 09fa9b9e78e..314f7a21939 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java @@ -12,11 +12,12 @@ import static org.junit.Assert.assertTrue; /** - * Characterization of {@link WalkExit} against the string predicates it replaced. + * The meaning of every walk-loop exit reason, held as data. * - *

This test exists to make the {@code String exitReason} → enum refactor provably inert. - * For every constant it asserts that the enum's three flags agree with the legacy predicates - * evaluated on that constant's wire name. Green means the refactor changed no behaviour. + *

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

When a classification is deliberately corrected, the expectation moves here, and the * diff to this file is the record of exactly what changed — which is precisely what the string @@ -25,14 +26,6 @@ */ public class WalkExitTest { - /** Detail used when exercising the parameterized reason; the legacy form always had a suffix. */ - private static final String OFF_PATH_DETAIL = "recent-click"; - - private static String legacyWireName(WalkExit exit) - { - return exit == WalkExit.OFF_PATH_DEFERRED ? exit.wireName(OFF_PATH_DETAIL) : exit.wireName(); - } - /** * The fourteen reasons whose route-progress classification was deliberately corrected once the * enum made the set enumerable. Every one of them means the walker either just advanced the @@ -65,32 +58,72 @@ private static String legacyWireName(WalkExit exit) WalkExit.RECOVERY_POSITION_STALE)); /** - * Pins the correction: the enum must differ from the legacy predicate on exactly the reasons in - * {@link #RECLASSIFIED_AS_PROGRESS}, and agree with it everywhere else. A drift in either - * direction — an unlisted reason quietly changing meaning, or a listed one silently reverting — - * fails here. + * The full progress classification, as data. + * + *

This started as a comparison against the string predicates the enum replaced. Those have now + * been deleted, so the historical baseline lives here instead: every reason is either listed as + * progress or it is not, and a constant that changes side has to change this list too. */ + private static final Set PROGRESS = new HashSet<>(Arrays.asList( + // an obstacle handler acted + WalkExit.DOOR_HANDLED, + WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK, + WalkExit.DOOR_HANDLED_DURING_INTERIM, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN, + WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR, + WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN, + WalkExit.PATH_BLOCKER_HANDLED, + WalkExit.ROCKFALL_HANDLED, + WalkExit.TRANSPORT_HANDLED, + WalkExit.CURRENT_TILE_TRANSPORT_HANDLED, + WalkExit.POST_CLICK_CURRENT_TILE_TRANSPORT_HANDLED, + WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.ROUTE_FOLD_CONTINUATION_CLICK, + // recovery resolved the blocked frontier, or issued movement + WalkExit.FRONTIER_OBSTACLE_HANDLED, + WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY, + WalkExit.LOCAL_RECOVERY_CLICK, + WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK, + WalkExit.RECENT_DOOR_EDGE_NUDGE, + WalkExit.RECOVERY_POSITION_STALE, + // the door opened + WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT, + WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT, + // waiting on an action we issued + WalkExit.INTERIM_IN_FLIGHT_ROUTE, + WalkExit.INTERIM_IN_FLIGHT_RECOVERY, + WalkExit.INTERIM_IN_FLIGHT_CLICK, + WalkExit.RECOVERY_MOVE_IN_FLIGHT, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + WalkExit.DOOR_SETTLING_YIELD, + WalkExit.DOOR_TRAVERSAL_PENDING_YIELD, + WalkExit.TRANSPORT_SETTLING_YIELD, + WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION)); + @Test - @SuppressWarnings("deprecation") - public void progressClassificationDivergesFromLegacyExactlyWhereIntended() + public void progressClassificationIsExactlyThisSet() { for (WalkExit exit : WalkExit.values()) { - String wire = legacyWireName(exit); - boolean legacy = Rs2Walker.isRouteProgressExit(wire); - if (RECLASSIFIED_AS_PROGRESS.contains(exit)) - { - assertFalse(exit.name() + " is listed as reclassified but the legacy predicate already " - + "called it progress — remove it from the list", legacy); - assertTrue(exit.name() + " was reclassified as route progress and must report it", - exit.isProgress()); - } - else - { - assertEquals(exit.name() + " (\"" + wire + "\") changed its route-progress meaning " - + "without being listed as a deliberate reclassification", - legacy, exit.isProgress()); - } + assertEquals(exit.name() + " changed its route-progress meaning; if that is deliberate, " + + "move it in PROGRESS and say why in the commit", + PROGRESS.contains(exit), exit.isProgress()); + } + } + + /** Every reason listed as reclassified must in fact be progress; the list documents the change. */ + @Test + public void theReclassifiedReasonsAreAllProgress() + { + for (WalkExit exit : RECLASSIFIED_AS_PROGRESS) + { + assertTrue(exit.name() + " was reclassified as route progress and must report it", + exit.isProgress()); + assertTrue(exit.name() + " must also appear in the full PROGRESS set", + PROGRESS.contains(exit)); } } @@ -119,27 +152,45 @@ public void reasonsThatMeanStuckStillConsumeTheBudget() } } + /** Benign yields that refund their own tail charge, so long waits cannot exhaust the cap. */ + private static final Set TAIL_EXEMPT = new HashSet<>(Arrays.asList( + WalkExit.INTERIM_IN_FLIGHT_ROUTE, + WalkExit.INTERIM_IN_FLIGHT_RECOVERY, + WalkExit.INTERIM_IN_FLIGHT_CLICK, + WalkExit.RECOVERY_MOVE_IN_FLIGHT, + WalkExit.ROUTE_MOVE_IN_FLIGHT, + WalkExit.ROUTE_FOLD_CONTINUATION_CLICK, + WalkExit.OFF_PATH_DEFERRED)); + + /** Exits that owe the post-door canvas nudge and its minimap hold-off. */ + private static final Set DOOR_LIKE = new HashSet<>(Arrays.asList( + WalkExit.DOOR_HANDLED, + WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK, + WalkExit.DOOR_HANDLED_DURING_INTERIM, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY, + WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN, + WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR, + WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN, + WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED, + WalkExit.POST_CLICK_RAW_PATH_SCENE_OBJECT_HANDLED)); + @Test - @SuppressWarnings("deprecation") - public void tailExemptionMatchesTheLegacyPredicate() + public void tailExemptionIsExactlyThisSet() { for (WalkExit exit : WalkExit.values()) { - String wire = legacyWireName(exit); - assertEquals(exit.name() + " (\"" + wire + "\") changed its tail-exemption meaning", - Rs2Walker.isTailExemptExit(wire), exit.isTailExempt()); + assertEquals(exit.name() + " changed its tail-exemption meaning", + TAIL_EXEMPT.contains(exit), exit.isTailExempt()); } } @Test - @SuppressWarnings("deprecation") - public void doorLikeClassificationMatchesTheLegacyPredicate() + public void doorLikeClassificationIsExactlyThisSet() { for (WalkExit exit : WalkExit.values()) { - String wire = legacyWireName(exit); - assertEquals(exit.name() + " (\"" + wire + "\") changed its door-like meaning", - Rs2Walker.shouldCanvasNudgeAfterDoorLikeExit(wire), exit.isDoorLike()); + assertEquals(exit.name() + " changed its door-like meaning", + DOOR_LIKE.contains(exit), exit.isDoorLike()); } } @@ -184,7 +235,9 @@ public void theReasonSetIsComplete() "door-edge-resolved-after-nearby-wait", "door-edge-waiting-retry", "door-edge-nearby-waiting-retry", - "interim-in-flight", + "interim-in-flight:route", + "interim-in-flight:recovery", + "interim-in-flight:click", "recovery-move-in-flight", "route-move-in-flight", "door-settling-yield", diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java index dbfbb75aa0f..6cd92d43c82 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java @@ -39,7 +39,7 @@ public void completeRouteContinues() public void completeRouteExemptsBenignYieldsFromTheIterationCap() { assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, - TailDecision.decide(false, false, WalkExit.INTERIM_IN_FLIGHT, 0, MAX)); + TailDecision.decide(false, false, WalkExit.INTERIM_IN_FLIGHT_ROUTE, 0, MAX)); assertEquals(TailAction.CONTINUE_TAIL_EXEMPT, TailDecision.decide(false, false, WalkExit.OFF_PATH_DEFERRED, 0, MAX)); } From 983ce8aaa2965ca54799926e3dcf6f661b9468fc Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 23:06:57 +0100 Subject: [PATCH 48/53] perf(walker): cut the time a motionless walker sits there from 36s to 15s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things padded the stall clock, and the first was papering over the sensor that has since been fixed. Every pass refreshed the clock outright for 12 seconds after any successful minimap click, on the reasoning that a click can outrun the tile delta. It cannot matter when it is right: while the player is walking, checkIfStuck already refreshes the clock on every real tile change. The blanket only ever had an effect while the player was NOT moving — precisely the state the clock exists to measure. It is deleted, not shortened, because there is no residue left for it to cover. The interim multiplier was the same mistake in smaller print. A sticky waypoint bought a 1.75x threshold in case a long segment outlasted the base stall, but a player walking toward an interim refreshes the clock the whole way, so the multiplier only ever bound the stationary-with-interim case — which the idle nudge already rescues within a second or two, long before any stall threshold comes into view. Now 1.25, for the tick or two between issuing a click and the first step. The base stays at 12s and should stay there. The longest LEGITIMATE motionless stretch measured across four live farm runs is ~7.1s, waiting out a transport handoff with nothing wrong. Cutting the base is the obvious way to make recovery snappier and the wrong one: it buys a walker that interrupts its own ships. Resulting budget, now pinned as wall-clock seconds rather than as multipliers, because seconds are the thing anyone actually cares about: 12s plain, 15s with an interim live (the common case for most of a walk), 24s worst case with everything applying at once. Was 12s + up to 24s = 36s. processWalk 1630 -> 1623; the guard ratchets down a fourth time. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 39 ++++++++++--------- .../util/walker/Rs2WalkerUnitTest.java | 35 +++++++++++++++++ 2 files changed, 55 insertions(+), 19 deletions(-) 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 7dcc80542a5..3b61fe60cce 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 @@ -2047,13 +2047,6 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } boolean shouldIssueActiveRouteIdleNudge = shouldIssueActiveRouteIdleNudge(); - long nowTickGraceMs = System.currentTimeMillis(); - if (lastAttemptedMinimapClickOk && lastAttemptedMinimapClickAtMs > 0L - && !shouldIssueActiveRouteIdleNudge - && nowTickGraceMs - lastAttemptedMinimapClickAtMs < MINIMAP_CLICK_STALL_GRACE_MS) { - routeState.lastMovedTimeMs = nowTickGraceMs; - } - checkIfStuck(); if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; @@ -11882,18 +11875,26 @@ private static void checkIfStuck() { // Base stall threshold. See stallThresholdMs() for activity-aware scaling. // RuneLite exposes no real-time ping, so we skip pure latency scaling and rely on // observable activity states that also correlate with legitimately-stuck players. - private static final long STALL_BASE_MS = 12_000; - private static final double STALL_COMBAT_MULTIPLIER = 2.0; - private static final double STALL_ANIMATING_MULTIPLIER = 1.5; - private static final double STALL_MOVING_MULTIPLIER = 1.35; - /** While a sticky minimap interim waypoint is active, path segments can exceed base stall easily. */ - private static final double STALL_INTERIM_MINIMAP_MULTIPLIER = 1.75; - private static final double STALL_INTERACTING_MULTIPLIER = 1.5; - /** - * After a successful minimap walk click, refresh the stall clock this long — blocked tiles / long - * segments sometimes delay tile deltas without {@link Rs2Player#isMoving()} flipping immediately. - */ - private static final long MINIMAP_CLICK_STALL_GRACE_MS = 12_000L; + // + // Held at 12s deliberately. The longest LEGITIMATE stationary stretch measured across four live + // farm runs is ~7.1s, during a transport handoff — the player is standing still while a ship or + // teleport resolves and nothing is wrong. 12s keeps roughly five seconds of margin over that. + // Cutting the base is the obvious way to make recovery snappier and the wrong one: it trades a + // slow recovery for a walker that interrupts its own transports. + static final long STALL_BASE_MS = 12_000; + static final double STALL_COMBAT_MULTIPLIER = 2.0; + static final double STALL_ANIMATING_MULTIPLIER = 1.5; + static final double STALL_MOVING_MULTIPLIER = 1.35; + /** + * A sticky interim waypoint used to buy a 1.75x threshold, on the reasoning that a long segment + * can outlast the base stall. It cannot: while the player is walking toward the interim, every + * tile change refreshes the clock. The multiplier only ever bound the case where the player is + * STATIONARY with an interim live — and the idle nudge already rescues that within ~1-2s, long + * before any stall threshold is in sight. Kept above 1.0 for the tick or two between issuing a + * click and the first step. + */ + static final double STALL_INTERIM_MINIMAP_MULTIPLIER = 1.25; + static final double STALL_INTERACTING_MULTIPLIER = 1.5; /** * How recently the player must have actually changed tile for the pose-based movement flag to * count as route progress. A walking step is ~600ms and a running one ~300ms, so a healthy walk 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 25223655d33..c9487c93f20 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java @@ -1,4 +1,5 @@ package net.runelite.client.plugins.microbot.util.walker; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; import net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; @@ -986,6 +987,40 @@ public void rockfallGateStaysClosedAwayFromTheMine() { // The partial-retry budget classification moved to WalkExit; its cases, including this // underground-goal regression, now live in WalkExitTest as explicit sets. + /** + * How long the walker will actually tolerate a motionless player before recovering. + * + *

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

The base stays 12s on purpose: the longest legitimate motionless stretch measured across + * four live farm runs is ~7.1s, waiting out a transport handoff. Cutting the base is how you get + * a walker that interrupts its own ships. + */ + @Test + public void stallBudgetStaysWithinItsMeasuredEnvelope() { + long plain = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, false, false); + long withInterim = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, true, false); + long worst = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, true, true, true, true, true); + + assertEquals("plain stall budget", 12_000L, plain); + assertEquals("the common case: a sticky interim is live for most of a walk", 15_000L, withInterim); + assertEquals("worst case, everything applying at once", 24_000L, worst); + assertTrue("must stay clear of the ~7.1s transport handoff measured live", plain >= 10_000L); + } + /** * Off-path recovery must be able to step BACKWARD onto the route. When the player is pushed off * the path (e.g. stuck flush against a castle wall) and nothing ahead is reachable, the rejoin From 154569dec90402d0e535381b4c3163a2f8c4dd81 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Mon, 10 Aug 2026 23:17:16 +0100 Subject: [PATCH 49/53] fix(walker): recapture reachability when the player has moved under it (B2 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reachable-tile set is captured once at the top of each pass, and then the obstacle handlers run: opening a door, waiting out a transport, each blocking for seconds inside interaction awaits. By the time the recovery cascade reads the verdict the player can be standing somewhere else entirely, and reachability computed from where we USED to be is not evidence about where we are. reachableTilesCacheOrigin has been declared, assigned twice and read never since it was introduced. Reading it is the fix: recapture whenever the player is no longer standing where the set was built. Two bugs fall out of the recapture that was already there. It ran at a SMALLER radius than the original capture — 18 steps against 39 — so it could answer "unreachable" for a tile the wider map had already reached. A double-check that manufactures the verdict it exists to question is worse than no double-check. And it was gated on the tile being within ~15 tiles of the player rather than on anything having changed, so it rebuilt the map when nothing had moved and left it stale when everything had. Precisely inverted. Scope note: the audit proposed also moving the recovery_position_stale guard to the top of this branch. On reading it that is wrong, and the guard should stay where it is. It does not duplicate this fix — it covers a different window, the seconds the door cascade itself spends between the verdict and the recovery click. Moving it earlier would defeat its purpose, the same way B1's suggested exit-path enumeration turned out unnecessary once the single entry point was found. The remaining WalkTick slices (one snapshot threaded through the segment, frontier and click-selection reads) are untouched; this is the behaviour half of B2, not the structural half. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 3b61fe60cce..c9abfeef256 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 @@ -2509,18 +2509,18 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } boolean tileReachable = reachableTilesCache.containsKey(currentWorldPoint); + // The handlers above block for seconds, so reachability computed from where we USED + // to be is not evidence about where we are. Recapture when the origin no longer + // matches — what reachableTilesCacheOrigin was declared for and never did. Same + // radius as the original capture: the old recapture used a smaller one and could + // answer "unreachable" for a tile the wider map had already reached. if (!tileReachable && !inInstance) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc != null) { - int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); - if (unreachableDist <= HANDLER_RANGE + 2) { - reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE + 5); - reachableTilesCacheOrigin = playerLoc; - tileReachable = reachableTilesCache.containsKey(currentWorldPoint); - if (tileReachable) { - log.debug("[Walker] tile {} reachable after cache refresh from {}", currentWorldPoint, playerLoc); - } - } + if (playerLoc != null && !playerLoc.equals(reachableTilesCacheOrigin)) { + reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE * 3); + reachableTilesCacheOrigin = playerLoc; + tileReachable = reachableTilesCache.containsKey(currentWorldPoint); + WebWalkLog.spDebug("reachable_recapture | from={} tile={} reachableNow={}", compactWorldPoint(playerLoc), compactWorldPoint(currentWorldPoint), tileReachable); } } if (!tileReachable && !inInstance) { From 50945afac8231de7ddebc23bad07f65643d528dc Mon Sep 17 00:00:00 2001 From: infuse21 Date: Wed, 12 Aug 2026 13:03:00 +0100 Subject: [PATCH 50/53] refactor(walker): give the frontier cascade a decision table it can be read from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things walker-fix was owed, hand-applied against its own copy rather than cherry-picked — the earlier scripted pick silently dropped a slice's tests. Drops the two changes that made things worse in live runs: the short-walk fast path (which broke `distance = 0` — the walker stopped wanting to end on the goal tile) and the zoom-aware minimap stride. Every call site is back on NORMAL_MINIMAP_REACH_EUCLIDEAN and both helpers are gone. Then lifts the frontier cascade's judgement out of processWalk and into FrontierDecision, where it can be stated as a table instead of inferred from 1600 lines of control flow: which route index is the earliest blocked one, which edge the frontier sits on, what a door wait means once it returns, when to yield before touching a door at all, how far back a recovery index may be clamped, which of three candidate targets wins and what happens when the winner is dangerous, and whether a scene click is worth trying at the tail. 41 rows pin it, seeded from incidents rather than invented: the Clock Tower rewind, the stepping-stone origin precedence, the fall-through door wait, and the hazard asymmetry between the raw-gated target and the shortcut origin. Four latent issues surfaced writing them that reading the cascade had not. processWalk 1623 -> 1599. Interactions stay in the shell; nothing here touches the game. Guardrail baseline regenerated for this branch — the delta is pure lambda renumbering, 0 non-lambda lines. Full suite green. Co-Authored-By: Claude Opus 5 --- .../microbot/util/walker/Rs2Walker.java | 296 +++------- .../walker/recovery/FrontierDecision.java | 466 ++++++++++++++++ .../util/walker/Rs2WalkerUnitTest.java | 46 -- .../walker/recovery/FrontierDecisionTest.java | 527 ++++++++++++++++++ .../client-thread-guardrail-baseline.txt | 46 +- 5 files changed, 1083 insertions(+), 298 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java 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 c9abfeef256..c711507c4a2 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 @@ -65,6 +65,7 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.MineableResolver; import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; +import net.runelite.client.plugins.microbot.util.walker.recovery.FrontierDecision; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate; import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; @@ -210,43 +211,6 @@ public static WorldPoint getCurrentTarget() { */ private static final int LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS = 48; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; - /** - * Ceiling for zoom-extended minimap strides. NOT the minimap's limit — zoomed out it shows ~38 - * tiles — but the walled-click net's: every stride target must sit inside the player-origin - * reachability BFS ({@link #CLOSEST_INDEX_REACHABLE_STEP_BUDGET} = 20 steps), or a wall between - * could not be detected and the Clock Tower click-through-the-wall class comes back. 18 leaves - * two steps of path-vs-Euclidean slack inside that budget. - */ - private static final int ZOOMED_OUT_MINIMAP_REACH_CAP = 18; - - /** - * How far a minimap stride may reach at {@code minimapZoom}, in tiles. - *

- * The minimap shows {@code 20 * 4 / zoom} tiles of radius (the scale Perspective.localToMinimap - * uses), so the old flat reach of {@value #NORMAL_MINIMAP_REACH_EUCLIDEAN} — tuned for the days - * the walker pinned zoom at 5, a 16-tile window — wastes most of a zoomed-out minimap: a player - * zoomed out clicks big strides, and since the zoom un-pinning that choice belongs to the user. - * Two tiles are kept off the rim so the click never lands on the very edge, and the floor keeps - * a fully zoomed-IN minimap exactly as reachable as today. - */ - static int zoomAwareMinimapReach(double minimapZoom, int floorTiles, int capTiles) { - if (minimapZoom <= 0) { - return floorTiles; - } - int visibleRadius = (int) Math.floor(20.0 * 4.0 / minimapZoom) - 2; - return Math.max(floorTiles, Math.min(visibleRadius, capTiles)); - } - - /** Shell wrapper: the live zoom read, floored at today's reach, capped at the BFS horizon. */ - private static int normalMinimapReach() { - try { - return zoomAwareMinimapReach(Microbot.getClient().getMinimapZoom(), - NORMAL_MINIMAP_REACH_EUCLIDEAN, ZOOMED_OUT_MINIMAP_REACH_CAP); - } catch (Exception e) { - return NORMAL_MINIMAP_REACH_EUCLIDEAN; - } - } - // UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES moved into recovery/RouteRecovery (P1) /** * Stationary window before an active route issues a recovery nudge. *

@@ -1337,10 +1301,6 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } try { - WalkerState shortWalk = tryShortWalkFastPath(target, distance); - if (shortWalk != null) { - return shortWalk; - } return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() ? walkWithBankedTransportsAndStateLocked(target, distance, false) : walkWithStateInternal(target, distance)); @@ -1349,104 +1309,6 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } - /** The fast path only ever helps within minimap reach; beyond it the full pipeline is correct. */ - private static final int SHORT_WALK_FAST_PATH_MAX_TILES = 12; - /** Position unchanged this long means the click did not take; the full pipeline takes over. */ - private static final long SHORT_WALK_STALL_MS = 1_800L; - - /** Walking pace plus slack; anything longer means something interfered and the pipeline should own it. */ - static long shortWalkBudgetMs(int euclideanTiles) { - return 600L * Math.max(1, euclideanTiles) + 2_400L; - } - - /** - * One click IS the whole walk, when that can be proven up front. - *

- * Scripts call {@link #walkTo} for five-tile hops, and every such call paid the pipeline's fixed - * head — transport refresh, pathfinder, session setup, the startup handler pass — measured at - * 1.3-1.5s before the first click, for moves a human does with one click in ~0.3s. The existing - * short-circuit ({@code tryDirectShortWalk}) sits INSIDE the pipeline and only saves its tail. - *

- * The gate is a reachability proof, not a distance guess: a CLOSE target that is BFS-reachable on - * the client's live collision flags needs no door, no transport and no plan — a shut door on the - * way reads as blocked and fails the gate, so anything that needs the pipeline still gets it. - * Deliberately strict: the target TILE itself must be reachable. Walk-beside-an-object calls - * (bank booths, trees) decline and take the full pipeline, because "within distance" with a wall - * between is exactly the false-arrival the pipeline's richer checks exist to refuse. - *

- * Declining ({@code null}) always falls through to today's behaviour, and so does a click that - * stalls — the budget and stall checks make the degraded case "what always happened", never a - * new failure mode. - */ - private static WalkerState tryShortWalkFastPath(WorldPoint target, int distance) { - WorldPoint start = Rs2Player.getWorldLocation(); - if (start == null || target == null || start.getPlane() != target.getPlane()) { - return null; - } - int euclidean = start.distanceTo2D(target); - if (euclidean > SHORT_WALK_FAST_PATH_MAX_TILES) { - return null; - } - // Already within range: the internal arrival checks answer richer questions (unwalkable - // targets, reachable neighbours) than this path should re-implement. - if (start.distanceTo(target) <= distance) { - return null; - } - if (!Rs2Tile.isTileReachable(target)) { - return null; - } - - manageRunEnergy(euclidean); - long startedAt = System.currentTimeMillis(); - boolean clicked = walkFastCanvas(target); - if (!clicked) { - clicked = walkMiniMap(target); - } - if (!clicked) { - return null; - } - - WalkCompletionContext completion = walkCompletionContext.get(); - final WorldPoint[] lastPos = {start}; - final long[] lastMoveAt = {System.currentTimeMillis()}; - sleepUntil(() -> { - if (Thread.currentThread().isInterrupted()) { - return true; - } - if (completion != null && evaluateWalkCompletion(completion)) { - return true; - } - WorldPoint now = Rs2Player.getWorldLocation(); - if (now == null) { - return false; - } - if (!now.equals(lastPos[0])) { - lastPos[0] = now; - lastMoveAt[0] = System.currentTimeMillis(); - } - if (now.distanceTo(target) <= distance) { - return true; - } - // Position-diffed, not isMoving(): the pose-based read stays true while turning on the - // spot, and a stalled click must hand over to the pipeline promptly. - return System.currentTimeMillis() - lastMoveAt[0] > SHORT_WALK_STALL_MS; - }, (int) shortWalkBudgetMs(euclidean)); - - WorldPoint end = Rs2Player.getWorldLocation(); - boolean arrived = end != null && end.distanceTo(target) <= distance; - boolean completionMet = completion != null && completion.met; - WebWalkLog.spInfo("short_walk | result={} to={} euclid={} elapsedMs={} from={}", - arrived ? "arrived" : completionMet ? "completion" : "handoff", - compactWorldPoint(target), euclidean, System.currentTimeMillis() - startedAt, - compactWorldPoint(start)); - if (arrived || completionMet) { - return WalkerState.ARRIVED; - } - // Not there: the click stalled, or something interfered. The pipeline owns it from here, - // exactly as if this path had never existed. - return null; - } - /** * Like {@link #walkWithState} but bounds how long this thread waits for {@link #walkerLock}. * Use when another walk may hold the lock during Leagues UI (see {@link Rs2LeaguesTransport#leaguesTeleport}) @@ -1673,7 +1535,7 @@ public static WalkerState walkStep(WorldPoint target, int distance) { // target nor a planned-path point is clickable (e.g. the route needs a transport walkStep can't // cross), no click is issued and we hold on the line rather than wander off it — walkStep is not // built for transport routes; use the blocking walkTo/walkUntil for those. - int walkStepReach = normalMinimapReach(); + int walkStepReach = NORMAL_MINIMAP_REACH_EUCLIDEAN; boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= walkStepReach; clickMiniMapOrFallback(rawPath, target, playerLoc, walkStepReach - 1, allowDirectionalFallback, -1); return WalkerState.MOVING; @@ -2557,23 +2419,22 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // tile: that is the first edge the walk actually cannot cross, which is where the // door (or other obstacle) really is. Every recovery path below exits the loop, // so rebinding i/currentWorldPoint here is contained. - for (int fi = Math.max(0, indexOfStartPoint); fi < i; fi++) { - WorldPoint ft = path.get(fi); - if (ft != null && ft.getPlane() == currentPlayerPlane - && reachableTilesCache != null && !reachableTilesCache.containsKey(ft)) { - log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", - fi, ft, i); - i = fi; - currentWorldPoint = ft; - break; - } + int rewoundIdx = FrontierDecision.earliestBlockedIndex( + path, indexOfStartPoint, i, currentPlayerPlane, reachableTilesCache); + if (rewoundIdx != FrontierDecision.NO_EARLIER_BLOCKED_INDEX) { + log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", + rewoundIdx, path.get(rewoundIdx), i); + i = rewoundIdx; + currentWorldPoint = path.get(rewoundIdx); } - int edgeIdx = Math.max(indexOfStartPoint, i - 1); - int rawEdgeStart = (edgeIdx < smoothedToRaw.length) ? smoothedToRaw[edgeIdx] : 0; - int rawEdgeEnd = (i < smoothedToRaw.length) ? smoothedToRaw[i] + 1 : rawPath.size(); - WorldPoint edgeFrom = rawEdgeStart >= 0 && rawEdgeStart < rawPath.size() ? rawPath.get(rawEdgeStart) : null; - WorldPoint edgeTo = rawEdgeEnd - 1 >= 0 && rawEdgeEnd - 1 < rawPath.size() ? rawPath.get(rawEdgeEnd - 1) : null; + FrontierDecision.FrontierEdge frontier = + FrontierDecision.frontierEdge(rawPath, smoothedToRaw, indexOfStartPoint, i); + int edgeIdx = frontier.edgeIndex(); + int rawEdgeStart = frontier.rawStart(); + int rawEdgeEnd = frontier.rawEndExclusive(); + WorldPoint edgeFrom = frontier.from(); + WorldPoint edgeTo = frontier.to(); // Unified obstacle dispatch for the blocked frontier (P2). One call resolves both // a rockfall to mine here and a reachable transport/agility-shortcut origin to step @@ -2597,53 +2458,40 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } if (hasRecentDoorAttemptOnEdge(edgeFrom, edgeTo)) { - boolean resolvedAfterWait = waitForDoorEdgeResolution(edgeFrom, edgeTo, + boolean edgeResolved = waitForDoorEdgeResolution(edgeFrom, edgeTo, obstaclePolicy.edgeResolutionWaitTimeoutMs()); - if (resolvedAfterWait && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target)) { - exit = WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK; - } else { - exit = resolvedAfterWait ? WalkExit.DOOR_EDGE_RESOLVED_AFTER_WAIT : WalkExit.DOOR_EDGE_WAITING_RETRY; - } + boolean clickedEdge = FrontierDecision.shouldFastClickAfterEdgeWait(edgeResolved) + && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target); + exit = FrontierDecision.afterEdgeWait(edgeResolved, clickedEdge).exit(); break; } if (hasRecentDoorAttemptNearIndex(rawPath, rawEdgeStart)) { - boolean resolvedAfterNearbyWait = waitForRecentDoorEdgeResolutionNearIndex(rawPath, rawEdgeStart, + boolean nearbyResolved = waitForRecentDoorEdgeResolutionNearIndex(rawPath, rawEdgeStart, obstaclePolicy.edgeResolutionWaitTimeoutMs()); WorldPoint afterNearbyWait = Rs2Player.getWorldLocation(); - boolean progressedAfterNearbyWait = afterNearbyWait != null + boolean playerMoved = afterNearbyWait != null && !afterNearbyWait.equals(playerLoc); - if (resolvedAfterNearbyWait && progressedAfterNearbyWait) { - if (tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target)) { - exit = WalkExit.DOOR_EDGE_RESOLVED_FAST_CLICK; - } else { - exit = WalkExit.DOOR_EDGE_RESOLVED_AFTER_NEARBY_WAIT; - } - break; - } - if (!resolvedAfterNearbyWait) { - exit = WalkExit.DOOR_EDGE_NEARBY_WAITING_RETRY; + boolean clickedNearby = FrontierDecision.shouldFastClickAfterNearbyWait(nearbyResolved, playerMoved) + && tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target); + FrontierDecision.DoorWaitOutcome nearbyOutcome = + FrontierDecision.afterNearbyWait(nearbyResolved, playerMoved, clickedNearby); + if (nearbyOutcome.endsPass()) { + exit = nearbyOutcome.exit(); break; } + // FALL_THROUGH: a nearby door opened but we did not move, so nothing was + // learned about THIS frontier — carry on to the settle checks below. } - boolean gateDoorInteraction = isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown(); - long recentDoorAgeMs = recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart); - boolean pendingDoorTraversal = recentDoorAgeMs >= 0 - && recentDoorAgeMs <= DOOR_TRAVERSAL_RECOVERY_BLOCK_MS - && !Rs2Player.isMoving(); - if (gateDoorInteraction) { - // Avoid any follow-up door probing right after an interaction; - // resolver is still settling and re-probes can loop. - exit = WalkExit.DOOR_SETTLING_YIELD; - break; - } - if (pendingDoorTraversal) { - // Keep one-shot behavior after door open: let traversal finish - // before issuing fallback path-adj/recovery actions. - exit = WalkExit.DOOR_TRAVERSAL_PENDING_YIELD; - break; - } - if (shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())) { - exit = WalkExit.INTERIM_IN_FLIGHT_RECOVERY; + FrontierDecision.FrontierYield frontierYield = + FrontierDecision.yieldBeforeDoorActions( + isDoorInteractionSettling(), + isDoorEdgePassSkipCoolingDown(), + recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart), + DOOR_TRAVERSAL_RECOVERY_BLOCK_MS, + Rs2Player.isMoving(), + shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())); + if (frontierYield.yields()) { + exit = frontierYield.exit(); break; } if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { @@ -2670,8 +2518,9 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + // No !gateDoorInteraction re-check: reaching here means the yield above + // returned NONE, which already proved the door-settling window closed. + if (unresolvedDoorNearRawPath && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, playerLoc, @@ -2684,8 +2533,7 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, // Fallback: only interact with objects on/adjacent to blocked path edges // within ~15 tiles. Prevents clicking already-open / unrelated doors. final long nowMs = System.currentTimeMillis(); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + if (unresolvedDoorNearRawPath && obstaclePolicy.allowNearbyFallback() && nowMs - routeState.lastDoorPathAdjAttemptAtMs > 1200) { routeState.lastDoorPathAdjAttemptAtMs = nowMs; @@ -2785,7 +2633,7 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { recoveryMinimapReach); } int minRecoveryIdx = Math.max(indexOfStartPoint, i); - recoverIdx = Math.min(Math.max(recoverIdx, minRecoveryIdx), path.size() - 1); + recoverIdx = FrontierDecision.clampRecoveryIndex(recoverIdx, indexOfStartPoint, i, path.size()); WorldPoint recoverTarget = path.get(recoverIdx); if (euclideanSq(recoverTarget, playerLoc) > recoveryMinimapReach * recoveryMinimapReach) { @@ -2801,13 +2649,9 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { // but this runtime fallback would otherwise strand us in melee. Step the // target back along the path to the nearest non-hazard tile. if (Rs2PathApi.shouldAvoidDangerousTile(recoverTarget)) { - int safeIdx = recoverIdx; - while (safeIdx > minRecoveryIdx - && Rs2PathApi.shouldAvoidDangerousTile(path.get(safeIdx))) { - safeIdx--; - } - recoverIdx = safeIdx; - recoverTarget = path.get(safeIdx); + recoverIdx = FrontierDecision.stepBackFromDanger(path, recoverIdx, minRecoveryIdx, + Rs2PathApi::shouldAvoidDangerousTile); + recoverTarget = path.get(recoverIdx); } int rawAnchorIndex = rawIndexForSmoothedIndex(recoverIdx, smoothedToRaw, rawPath); WorldPoint rawRecoveryTarget = inInstance ? null : findFurthestRawPathPointMatchingGated( @@ -2816,21 +2660,15 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { recoveryMinimapReach - 1, rawAnchorIndex, Rs2Walker::isKnownWalkableOrUnloaded); - if (rawRecoveryTarget != null - && !rawRecoveryTarget.equals(playerLoc) - && !Rs2PathApi.shouldAvoidDangerousTile(rawRecoveryTarget)) { - recoverTarget = rawRecoveryTarget; - } - // Prefer walking onto the reachable transport / agility-shortcut origin the unified - // dispatch resolved above (e.g. a stepping stone) over the furthest-walkable target. - // The transport only dispatches while the player stands on its origin, so clicking - // the far side of the shortcut just loops on the near bank; stepping onto the origin - // lets the normal transport handler cross next tick. - if (frontierObstacle.kind() == ObstacleResolution.Kind.WALK_TO_ORIGIN - && frontierObstacle.walkTarget() != null - && !frontierObstacle.walkTarget().equals(playerLoc)) { - recoverTarget = frontierObstacle.walkTarget(); - } + WorldPoint shortcutOrigin = + frontierObstacle.kind() == ObstacleResolution.Kind.WALK_TO_ORIGIN + ? frontierObstacle.walkTarget() + : null; + recoverTarget = FrontierDecision.chooseRecoveryTarget(recoverTarget, + rawRecoveryTarget, shortcutOrigin, playerLoc, + Rs2PathApi::shouldAvoidDangerousTile); + // Precedence (base < raw-gated < shortcut origin) and the hazard asymmetry + // between them live with the decision, pinned by its table. // The click decision (preemption vs walled vs cooldown vs click) is PURE and // decision-table-tested in RouteRecovery — this shell only carries out the // chosen action. Guard rationale (long recovery pass, walled end-snap, cooldown @@ -2858,11 +2696,10 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt WebWalkLog.spInfo("recovery_target_walled | to={} player={} replanning", compactWorldPoint(recoverTarget), compactWorldPoint(playerLoc)); recalculatePathForRecovery(); - exit = WalkExit.RECOVERY_TARGET_WALLED_REPLAN; - break; } - if (clickAction == RouteRecovery.RecoveryClickAction.WAIT_WALLED) { - exit = WalkExit.RECOVERY_TARGET_WALLED_WAITING; + WalkExit recoveryClickExit = FrontierDecision.exitForRecoveryClick(clickAction); + if (recoveryClickExit != null) { + exit = recoveryClickExit; break; } WorldPoint clickedRecoveryTarget = null; @@ -2878,8 +2715,9 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt // last resort, not the primary recovery path. if (!clicked && recoverTarget != null && target != null - && playerLoc.distanceTo2D(target) <= Math.max(2, distance + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) - && playerLoc.distanceTo2D(recoverTarget) <= DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER + && FrontierDecision.shouldTrySceneClickFallback(playerLoc, target, recoverTarget, + distance, FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV, + DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER) && Rs2Tile.isTileReachable(recoverTarget) && walkFastCanvas(recoverTarget)) { clicked = true; @@ -2947,7 +2785,7 @@ && walkFastCanvas(recoverTarget)) { // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). WorldPoint playerLoc = Rs2Player.getWorldLocation(); - final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); + final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; // Checkpoint-style walking: once we set a minimap flag, let the player actually // travel toward it. Do not keep recalculating/clicking new targets mid-run. @@ -3316,7 +3154,7 @@ && walkFastCanvas(recoverTarget)) { if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, - normalMinimapReach() - 1, rawAnchorIndex); + NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); } else { finalClick = Rs2Walker.walkFastCanvas(canvasClickWp); } @@ -3543,7 +3381,7 @@ public static WorldPoint getPointWithWallDistance(WorldPoint target, WorldPoint Set reachableFromPlayer = playerLoc == null ? Collections.emptySet() : Rs2Tile.getReachableTilesFromTile(playerLoc, - Math.max(2, normalMinimapReach())).keySet(); + Math.max(2, NORMAL_MINIMAP_REACH_EUCLIDEAN)).keySet(); if (hasMinimapRelevantMovementFlag(localPoint, flags)) { WorldPoint best = bestWallDistanceNeighbor(tiles.keySet(), playerLoc, reachableFromPlayer, @@ -4312,7 +4150,7 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, return false; } return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", - normalMinimapReach(), false); + NORMAL_MINIMAP_REACH_EUCLIDEAN, false); } private static boolean tryIssueRouteMovementClick(List rawPath, diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java new file mode 100644 index 00000000000..98c8aa6d6ce --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java @@ -0,0 +1,466 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +import java.util.List; +import java.util.Map; + +/** + * The blocked-frontier cascade's pure decisions: WHERE the route is actually blocked, and which raw + * edge that frontier corresponds to. + * + *

Functional core of the recovery cascade — the same split {@link RouteRecovery} and + * {@code segment.SegmentGate} already use. The caller keeps every interaction (waiting on doors, + * mining, clicking); this only answers questions about the route and the reachable set, so the + * answers can be pinned in a decision table instead of rediscovered on a live walk. + */ +public final class FrontierDecision +{ + /** No route tile before the miss is blocked. */ + public static final int NO_EARLIER_BLOCKED_INDEX = -1; + + private FrontierDecision() + { + } + + /** + * The earliest route tile at or after {@code fromIndex} and before {@code missIndex} that the + * player cannot reach, or {@link #NO_EARLIER_BLOCKED_INDEX}. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The edge starts one smoothed index BEFORE the frontier — the blocked edge is the step INTO + * the unreachable tile, not the step out of it — clamped so it can never precede the route + * position the pass started from. + */ + public static FrontierEdge frontierEdge(List rawPath, + int[] smoothedToRaw, + int fromIndex, + int frontierIndex) + { + int rawSize = rawPath == null ? 0 : rawPath.size(); + int edgeIndex = Math.max(fromIndex, frontierIndex - 1); + int rawStart = smoothedToRaw != null && edgeIndex < smoothedToRaw.length + ? smoothedToRaw[edgeIndex] + : 0; + int rawEndExclusive = smoothedToRaw != null && frontierIndex < smoothedToRaw.length + ? smoothedToRaw[frontierIndex] + 1 + : rawSize; + WorldPoint from = rawStart >= 0 && rawStart < rawSize ? rawPath.get(rawStart) : null; + WorldPoint to = rawEndExclusive - 1 >= 0 && rawEndExclusive - 1 < rawSize + ? rawPath.get(rawEndExclusive - 1) + : null; + return new FrontierEdge(edgeIndex, rawStart, rawEndExclusive, from, to); + } +} diff --git a/runelite-client/src/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 c9487c93f20..2d63af8207c 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 @@ -2557,52 +2557,6 @@ public void postDoorTarget_respectsTheCapAndThePlane() { upstairs.get(1), upstairsReachable, 13)); } - // ---- zoom-aware minimap reach -------------------------------------------------------------------- - - /** - * The minimap shows 20*4/zoom tiles of radius. Reach scales with what the USER's zoom makes - * visible, floored at the flat reach the walker always had, and capped at the reachability BFS - * horizon — beyond it a wall between could not be detected and the click-through-wall class - * returns. - */ - @Test - public void zoomAwareReach_zoomedOutStridesFurther() { - assertEquals(18, Rs2Walker.zoomAwareMinimapReach(4.0, 11, 18)); // default zoom: 20-2 -> cap - assertEquals(18, Rs2Walker.zoomAwareMinimapReach(2.0, 11, 18)); // fully out: 38 -> cap - } - - @Test - public void zoomAwareReach_zoomedInKeepsTheOldFloor() { - assertEquals(14, Rs2Walker.zoomAwareMinimapReach(5.0, 11, 18)); // pinned-era zoom: 16-2 - assertEquals(11, Rs2Walker.zoomAwareMinimapReach(8.0, 11, 18)); // fully in: 10-2 -> floor - } - - @Test - public void zoomAwareReach_degenerateZoomFallsBackToTheFloor() { - assertEquals(11, Rs2Walker.zoomAwareMinimapReach(0.0, 11, 18)); - assertEquals(11, Rs2Walker.zoomAwareMinimapReach(-1.0, 11, 18)); - } - - // ---- short-walk fast path budget ----------------------------------------------------------------- - - /** - * The budget bounds how long a single proven-reachable click may own the walk before the full - * pipeline takes over: walking pace per tile plus slack. Too tight hands healthy walks to the - * pipeline mid-stride; too loose delays recovery when the click did not take. - */ - @Test - public void shortWalkBudget_scalesWithDistanceAtWalkingPace() { - assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(1)); - assertEquals(5 * 600L + 2_400L, Rs2Walker.shortWalkBudgetMs(5)); - assertEquals(12 * 600L + 2_400L, Rs2Walker.shortWalkBudgetMs(12)); - } - - @Test - public void shortWalkBudget_neverBelowTheOneTileFloor() { - assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(0)); - assertEquals(600L + 2_400L, Rs2Walker.shortWalkBudgetMs(-3)); - } - @Test public void postDoorTarget_toleratesMissingInputs() { java.util.List route = northRoute(3200, 4); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java new file mode 100644 index 00000000000..947c3910332 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java @@ -0,0 +1,527 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The frontier cascade's pure decisions, seeded with the incidents that produced them. + * + *

D2 slice 1 of the walker fix plan: these two answers used to be inline in a 1,600-line loop + * with no way to exercise them except by walking to Clock Tower. + */ +public class FrontierDecisionTest +{ + private static List route(int count, int plane) + { + List path = new ArrayList<>(); + for (int i = 0; i < count; i++) + { + path.add(new WorldPoint(3200, 3200 + i, plane)); + } + return path; + } + + private static Map reachable(WorldPoint... tiles) + { + Map map = new HashMap<>(); + for (int i = 0; i < tiles.length; i++) + { + map.put(tiles[i], i); + } + return map; + } + + // ---- earliestBlockedIndex ------------------------------------------------------------------- + + /** + * THE CLOCK TOWER INCIDENT. The route's tail folds back beside the player, so the reachability + * miss fires on a late index while the real blockage — a door at mid-route — sits earlier and was + * never examined. Recovery must rewind to the earliest blocked tile or it camps on the end, + * probing the wrong raw segment. + */ + @Test + public void rewindsToTheEarliestBlockedTileNotTheMissedOne() + { + List path = route(10, 0); + // Everything reachable except index 3 (the door) — the miss was reported at index 9. + List open = new ArrayList<>(path); + open.remove(3); + Map reach = reachable(open.toArray(new WorldPoint[0])); + + assertEquals(3, FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, reach)); + } + + /** Nothing before the miss is blocked: the miss index stands, no rewind. */ + @Test + public void noEarlierBlockageLeavesTheFrontierAlone() + { + List path = route(10, 0); + Map reach = reachable(path.toArray(new WorldPoint[0])); + + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, reach)); + } + + /** The scan starts at the pass's route position — tiles already walked are not re-examined. */ + @Test + public void doesNotRewindBehindTheRoutePosition() + { + List path = route(10, 0); + List open = new ArrayList<>(path); + open.remove(1); // blocked, but behind indexOfStartPoint + Map reach = reachable(open.toArray(new WorldPoint[0])); + + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 5, 9, 0, reach)); + } + + /** + * A route that climbs a staircase legitimately holds tiles the player's plane cannot reach. + * Treating those as blocked would send recovery at a staircase that is working perfectly. + */ + @Test + public void skipsTilesOnAnotherPlaneInsteadOfCallingThemBlocked() + { + List path = new ArrayList<>(route(4, 0)); + path.addAll(route(4, 1)); // indices 4-7 upstairs + Map reach = reachable(path.get(0), path.get(1), path.get(2), path.get(3)); + + // Upstairs tiles are absent from the reachable map but must NOT be chosen as the frontier. + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 8, 0, reach)); + } + + /** No reachability evidence must not read as "everything is blocked". */ + @Test + public void missingReachabilityDisablesTheRewind() + { + List path = route(10, 0); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 9, 0, null)); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(null, 0, 9, 0, reachable())); + } + + /** A miss at the very start has nothing before it to rewind to. */ + @Test + public void missAtTheStartHasNoEarlierTile() + { + List path = route(10, 0); + assertEquals(FrontierDecision.NO_EARLIER_BLOCKED_INDEX, + FrontierDecision.earliestBlockedIndex(path, 0, 0, 0, reachable())); + } + + // ---- door-attempt waits --------------------------------------------------------------------- + + @Test + public void edgeWaitMapsResolutionAndFollowThrough() + { + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_FAST_CLICK, + FrontierDecision.afterEdgeWait(true, true)); + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_AFTER_WAIT, + FrontierDecision.afterEdgeWait(true, false)); + assertEquals(FrontierDecision.DoorWaitOutcome.WAITING_RETRY, + FrontierDecision.afterEdgeWait(false, false)); + } + + /** The click is an interaction: it must only be attempted when the edge actually opened. */ + @Test + public void edgeWaitOnlyClicksWhenResolved() + { + assertTrue(FrontierDecision.shouldFastClickAfterEdgeWait(true)); + assertFalse(FrontierDecision.shouldFastClickAfterEdgeWait(false)); + } + + /** + * THE SUBTLE ONE. A door NEAR this edge opened but the player did not move: the wait proved + * nothing about the frontier in front of us, so the cascade must carry on to the settle checks + * and the real recovery. Every other outcome ends the pass. Reporting progress here would credit + * a route advance that never happened. + */ + @Test + public void nearbyWaitFallsThroughWhenResolvedButNobodyMoved() + { + FrontierDecision.DoorWaitOutcome outcome = + FrontierDecision.afterNearbyWait(true, false, false); + + assertEquals(FrontierDecision.DoorWaitOutcome.FALL_THROUGH, outcome); + assertFalse("fall-through must not end the pass", outcome.endsPass()); + assertNull("fall-through records no exit", outcome.exit()); + } + + @Test + public void nearbyWaitMapsTheResolvedAndMovedCases() + { + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_FAST_CLICK, + FrontierDecision.afterNearbyWait(true, true, true)); + assertEquals(FrontierDecision.DoorWaitOutcome.RESOLVED_AFTER_NEARBY_WAIT, + FrontierDecision.afterNearbyWait(true, true, false)); + assertEquals(FrontierDecision.DoorWaitOutcome.NEARBY_WAITING_RETRY, + FrontierDecision.afterNearbyWait(false, false, false)); + assertEquals("unresolved wins over movement", + FrontierDecision.DoorWaitOutcome.NEARBY_WAITING_RETRY, + FrontierDecision.afterNearbyWait(false, true, false)); + } + + /** A nearby door that opened while the player stood still says nothing about this frontier. */ + @Test + public void nearbyWaitRequiresMovementBeforeClicking() + { + assertTrue(FrontierDecision.shouldFastClickAfterNearbyWait(true, true)); + assertFalse(FrontierDecision.shouldFastClickAfterNearbyWait(true, false)); + assertFalse(FrontierDecision.shouldFastClickAfterNearbyWait(false, true)); + } + + /** Every outcome that records an exit must also end the pass, and vice versa. */ + @Test + public void onlyFallThroughContinuesTheCascade() + { + for (FrontierDecision.DoorWaitOutcome outcome : FrontierDecision.DoorWaitOutcome.values()) + { + assertEquals(outcome + " exit/endsPass must agree", + outcome.endsPass(), outcome.exit() != null); + } + } + + // ---- frontier yields ------------------------------------------------------------------------ + + private static final long BLOCK_MS = 2_200L; + + @Test + public void noYieldWhenNothingIsInFlight() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, false)); + } + + /** Settling is the broadest "we just touched a door" window, so it outranks the narrower two. */ + @Test + public void settlingOutranksTraversalAndInterim() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_SETTLING, + FrontierDecision.yieldBeforeDoorActions(true, false, 100L, BLOCK_MS, false, true)); + assertEquals("the pass-skip cooldown is the same window by another name", + FrontierDecision.FrontierYield.DOOR_SETTLING, + FrontierDecision.yieldBeforeDoorActions(false, true, 100L, BLOCK_MS, false, true)); + } + + @Test + public void traversalPendingOutranksInterim() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_TRAVERSAL_PENDING, + FrontierDecision.yieldBeforeDoorActions(false, false, 100L, BLOCK_MS, false, true)); + } + + /** + * A player already moving is walking through the door they just opened — there is nothing to + * wait for, and yielding would stall the pass behind their own successful traversal. + */ + @Test + public void aMovingPlayerIsNotWaitingToTraverse() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, 100L, BLOCK_MS, true, false)); + } + + /** A negative age means there was NO recent attempt; reading it as "0ms ago" would yield forever. */ + @Test + public void negativeAgeMeansNoRecentDoorNotAnInstantOne() + { + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, false)); + } + + @Test + public void traversalWindowIsInclusiveAndExpires() + { + assertEquals(FrontierDecision.FrontierYield.DOOR_TRAVERSAL_PENDING, + FrontierDecision.yieldBeforeDoorActions(false, false, BLOCK_MS, BLOCK_MS, false, false)); + assertEquals(FrontierDecision.FrontierYield.NONE, + FrontierDecision.yieldBeforeDoorActions(false, false, BLOCK_MS + 1, BLOCK_MS, false, false)); + } + + @Test + public void interimYieldsWhenNothingDoorRelatedApplies() + { + assertEquals(FrontierDecision.FrontierYield.INTERIM_IN_FLIGHT, + FrontierDecision.yieldBeforeDoorActions(false, false, -1L, BLOCK_MS, false, true)); + } + + @Test + public void everyYieldReasonCarriesAnExitAndNoneDoesNot() + { + for (FrontierDecision.FrontierYield yield : FrontierDecision.FrontierYield.values()) + { + assertEquals(yield + " exit/yields must agree", yield.yields(), yield.exit() != null); + } + } + + // ---- recovery target selection -------------------------------------------------------------- + + /** Recovering to a tile BEHIND the blockage walks the player away from the goal. */ + @Test + public void recoveryIndexNeverGoesBehindTheFrontierOrTheRoutePosition() + { + assertEquals(7, FrontierDecision.clampRecoveryIndex(3, 5, 7, 20)); + assertEquals(5, FrontierDecision.clampRecoveryIndex(2, 5, 4, 20)); + assertEquals(9, FrontierDecision.clampRecoveryIndex(9, 5, 7, 20)); + } + + @Test + public void recoveryIndexNeverRunsOffTheEnd() + { + assertEquals(19, FrontierDecision.clampRecoveryIndex(999, 0, 0, 20)); + } + + /** Recovery must not park the player next to an aggressive NPC — step back along the route. */ + @Test + public void stepsBackOutOfAHazard() + { + List path = route(10, 0); + java.util.Set hazards = new java.util.HashSet<>( + Arrays.asList(path.get(7), path.get(8), path.get(9))); + + assertEquals(6, FrontierDecision.stepBackFromDanger(path, 9, 2, hazards::contains)); + } + + /** + * If every tile back to the floor is hazardous the index stops AT the floor rather than + * retreating past the frontier — walking backwards off the route is the worse failure. + */ + @Test + public void stepBackStopsAtTheFloorEvenIfStillHazardous() + { + List path = route(10, 0); + assertEquals(4, FrontierDecision.stepBackFromDanger(path, 9, 4, tile -> true)); + } + + @Test + public void stepBackLeavesASafeIndexAlone() + { + List path = route(10, 0); + assertEquals(9, FrontierDecision.stepBackFromDanger(path, 9, 2, tile -> false)); + assertEquals(9, FrontierDecision.stepBackFromDanger(path, 9, 2, null)); + } + + /** + * THE STEPPING-STONE INCIDENT. A transport only dispatches while the player STANDS on its + * origin, so clicking the far side of a shortcut loops on the near bank forever. The origin + * therefore outranks both the route tile and the raw-gated point. + */ + @Test + public void walkToOriginWinsOverEveryOtherCandidate() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint origin = new WorldPoint(3210, 3210, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(origin, + FrontierDecision.chooseRecoveryTarget(base, raw, origin, player, tile -> false)); + } + + @Test + public void rawGatedBeatsTheBaseWhenItIsUsable() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(raw, + FrontierDecision.chooseRecoveryTarget(base, raw, null, player, tile -> false)); + } + + /** A candidate equal to where we already stand is no recovery at all. */ + @Test + public void candidatesAtThePlayersOwnTileAreIgnored() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(base, + FrontierDecision.chooseRecoveryTarget(base, player, player, player, tile -> false)); + } + + @Test + public void rawGatedIsRejectedWhenHazardous() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint raw = new WorldPoint(3205, 3205, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(base, + FrontierDecision.chooseRecoveryTarget(base, raw, null, player, raw::equals)); + } + + /** + * Documents an ASYMMETRY carried over from the original rather than endorsing it: the raw-gated + * candidate is hazard-checked, the shortcut origin is not. Changing that is a behaviour change + * and needs its own commit and its own live evidence — pinned here so it cannot drift silently. + */ + @Test + public void walkToOriginIsNotHazardChecked() + { + WorldPoint base = new WorldPoint(3200, 3200, 0); + WorldPoint origin = new WorldPoint(3210, 3210, 0); + WorldPoint player = new WorldPoint(3190, 3190, 0); + + assertEquals(origin, + FrontierDecision.chooseRecoveryTarget(base, null, origin, player, tile -> true)); + } + + // ---- recovery click outcome + scene fallback ------------------------------------------------ + + @Test + public void blockedClickOutcomesEndThePass() + { + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_CLICK_PREEMPTED_BY_ACTION, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.YIELD_ACTION_IN_FLIGHT)); + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_TARGET_WALLED_REPLAN, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.REPLAN_WALLED)); + assertEquals(net.runelite.client.plugins.microbot.util.walker.state.WalkExit.RECOVERY_TARGET_WALLED_WAITING, + FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.WAIT_WALLED)); + } + + /** + * Two outcomes continue, for different reasons: CLICK because the click is about to happen, + * NO_TARGET because there is nothing worth clicking and the rejoin logic should get its turn. + * NO_TARGET was never mentioned in the loop — it fell through by omission, which reads exactly + * like a forgotten case. + */ + @Test + public void clickAndNoTargetBothContinueTheCascade() + { + assertNull(FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.CLICK)); + assertNull(FrontierDecision.exitForRecoveryClick(RouteRecovery.RecoveryClickAction.NO_TARGET)); + assertNull(FrontierDecision.exitForRecoveryClick(null)); + } + + /** Every action is classified — a new one must not default into "continue" unnoticed. */ + @Test + public void everyRecoveryClickActionIsClassified() + { + for (RouteRecovery.RecoveryClickAction action : RouteRecovery.RecoveryClickAction.values()) + { + boolean continues = action == RouteRecovery.RecoveryClickAction.CLICK + || action == RouteRecovery.RecoveryClickAction.NO_TARGET; + assertEquals(action + " classification", + continues, FrontierDecision.exitForRecoveryClick(action) == null); + } + } + + /** The canvas fallback is a last resort for the final approach, not a second click source. */ + @Test + public void sceneFallbackOnlyOnTheFinalApproach() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goalNear = new WorldPoint(3201, 3200, 0); + WorldPoint goalFar = new WorldPoint(3230, 3200, 0); + WorldPoint recover = new WorldPoint(3205, 3200, 0); + + assertTrue(FrontierDecision.shouldTrySceneClickFallback(player, goalNear, recover, 0, 1, 15)); + assertFalse("goal still far: the minimap owns this", + FrontierDecision.shouldTrySceneClickFallback(player, goalFar, recover, 0, 1, 15)); + } + + @Test + public void sceneFallbackRejectsADistantRecoveryTarget() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goal = new WorldPoint(3201, 3200, 0); + WorldPoint farTarget = new WorldPoint(3230, 3200, 0); + + assertFalse(FrontierDecision.shouldTrySceneClickFallback(player, goal, farTarget, 0, 1, 15)); + } + + /** The near-goal bound never drops below 2 tiles, however tight the caller's arrival distance. */ + @Test + public void sceneFallbackKeepsAMinimumNearGoalBound() + { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint goal = new WorldPoint(3202, 3200, 0); + WorldPoint recover = new WorldPoint(3203, 3200, 0); + + assertTrue(FrontierDecision.shouldTrySceneClickFallback(player, goal, recover, 0, 0, 15)); + } + + @Test + public void sceneFallbackToleratesMissingInputs() + { + WorldPoint p = new WorldPoint(3200, 3200, 0); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(null, p, p, 0, 1, 15)); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(p, null, p, 0, 1, 15)); + assertFalse(FrontierDecision.shouldTrySceneClickFallback(p, p, null, 0, 1, 15)); + } + + // ---- frontierEdge --------------------------------------------------------------------------- + + /** + * The blocked edge is the step INTO the unreachable tile, so it starts one smoothed index before + * the frontier — addressing the raw segment the door actually sits on. + */ + @Test + public void edgeStartsOneIndexBeforeTheFrontier() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4, 8, 12, 16}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 0, 3); + + assertEquals(2, edge.edgeIndex()); + assertEquals(8, edge.rawStart()); + assertEquals(13, edge.rawEndExclusive()); + assertEquals(raw.get(8), edge.from()); + assertEquals(raw.get(12), edge.to()); + } + + /** The edge can never precede the route position the pass started from. */ + @Test + public void edgeIsClampedToTheRoutePosition() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4, 8, 12, 16}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 3, 1); + + assertEquals("clamped to fromIndex, not frontier-1", 3, edge.edgeIndex()); + } + + /** A frontier past the mapping table falls back to the whole remaining raw path. */ + @Test + public void frontierBeyondTheMappingUsesTheRawTail() + { + List raw = route(20, 0); + int[] smoothedToRaw = {0, 4}; + + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(raw, smoothedToRaw, 0, 5); + + assertEquals(20, edge.rawEndExclusive()); + assertEquals(raw.get(19), edge.to()); + } + + @Test + public void toleratesMissingInputs() + { + FrontierDecision.FrontierEdge edge = FrontierDecision.frontierEdge(null, null, 0, 2); + assertEquals(0, edge.rawStart()); + assertEquals(0, edge.rawEndExclusive()); + assertNull(edge.from()); + assertNull(edge.to()); + + FrontierDecision.FrontierEdge empty = + FrontierDecision.frontierEdge(Arrays.asList(), new int[]{0}, 0, 0); + assertNull(empty.from()); + assertNull(empty.to()); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 0418cc1bfe5..df754155e18 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 @@ -807,36 +807,36 @@ 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$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$17(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$189(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$192(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$194(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$214(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$183(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$185(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$152(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$193(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$213(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$184(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$151(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$159(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$159(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$130(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$165(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$166(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$167(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$8(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$40(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$72(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$71(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 From 2935da0d66da8ee12fd97234abc68082157e18a1 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Wed, 12 Aug 2026 23:09:09 +0100 Subject: [PATCH 51/53] =?UTF-8?q?fix(walker):=20the=202026-08-12=20batch?= =?UTF-8?q?=20=E2=80=94=20strike-out,=20oscillation=20bounds,=20forward-on?= =?UTF-8?q?ly=20recovery,=20minecart=20947,=20zoom=20strides,=20B2=20slice?= =?UTF-8?q?=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batched sync of six PluginTesting commits (8b49bdba02, 8aabe7cceb, 160a5fbe4f, ed7d5333e8, 2ea5c902e6 and the raw watermark), applied as a three-way content merge per the branch policy — plus three OLDER fixes the merge exposed as never having reached this branch at all: the Dwarf Cannon catalog-transport guard in learnWalledRouteEdge, and the adventure-log letter-prefix / null-safety / keyboard-shortcut fixes in interactWithAdventureLog. The batch, live-verified on PluginTesting before travelling: - Door strike-out: three concluded-but-uncrossed attempts session-block the edge WALK-scoped (withdrawn before the next walk's first plan) and replan. Ends the Tithe Farm seed-gate class in ~25s instead of 4+ minutes. - Route stagnation bound, ENFORCED: 60s without raw-index advance replans (twice), then honest UNREACHABLE. Fed by the raw watermark, which advances tile-by-tile on healthy walks and refuses oscillation. - Recovery may only attack the obstacle AHEAD: the scan anchor is forward-corrected past raw-passed route tiles, and a wall door whose face the player is already beyond is never clicked (door_skip_crossed) — the Stronghold of Security paired-gate bounce, both organs. - Tail dither: no re-clicks while moving inside the final band; canvas precision for the finish. - Minecart destination selection: the Lovakengj menu is interface 947, not the adventure log; select by verbatim tsv displayInfo text. - Zoom-aware minimap strides, both directions: reach follows the visible radius, 8 tiles fully zoomed in to the 18-tile BFS-vouched cap. - B2 slice 2: WalkLoopSnapshot carries moving/animating/interacting, states its re-capture contract, and the stuck-sidestep stale-position read is fixed by re-capturing after its sleep. Residual 55-line divergence from PluginTesting's copy is this branch's own (door-wait local naming, WALK_TO_ORIGIN comment placement, no isWalkSuperseded) and is deliberate. Guardrail baseline regenerated here — delta is one pure lambda renumber. Full suite green on this branch. Co-Authored-By: Claude Opus 5 --- .../pathfinder/PathfinderConfig.java | 24 + .../microbot/util/walker/Rs2PathApi.java | 19 + .../microbot/util/walker/Rs2Walker.java | 483 ++++++++++++++++-- .../util/walker/door/Rs2DoorGeometry.java | 43 ++ .../util/walker/door/Rs2DoorHandler.java | 51 ++ .../walker/recovery/FrontierDecision.java | 32 ++ .../util/walker/recovery/TailDecision.java | 56 ++ .../util/walker/state/WalkerRouteState.java | 4 + .../walker/RouteProgressWatermarkTest.java | 107 ++++ .../util/walker/Rs2WalkerUnitTest.java | 37 ++ .../walker/WalkSessionStateResetTest.java | 2 + .../util/walker/door/Rs2DoorGeometryTest.java | 64 +++ .../util/walker/door/Rs2DoorHandlerTest.java | 71 +++ .../walker/recovery/FrontierDecisionTest.java | 42 ++ .../walker/recovery/TailDecisionTest.java | 72 +++ .../client-thread-guardrail-baseline.txt | 2 +- 16 files changed, 1060 insertions(+), 49 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java 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 19cfa395ab1..e008f00886e 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 @@ -892,6 +892,30 @@ public boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, Strin return true; } + /** + * Reverse of {@link #learnBlockedEdge}: removes the learned block so the edge is plannable again. + * Exists for condition-scoped blocks (a door that refused to open for game-state reasons) that the + * walker withdraws at the next walk session start. Static rows from blocked_edges.tsv are not + * touched — they were never in {@code learnedBlockedEdgeKeys}, and {@code blockedTransportEdgesPacked} + * only drops the key when it was a learned one. + */ + public boolean unlearnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) { + if (origin == null || destination == null) { + return false; + } + long key = transportEdgeKey( + WorldPointUtil.packWorldPoint(origin), + WorldPointUtil.packWorldPoint(destination)); + if (!learnedBlockedEdgeKeys.remove(key)) { + return false; + } + if (!STATIC_BLOCKED_EDGES_PACKED.contains(key)) { + blockedTransportEdgesPacked.remove(key); + } + log.info("[Walker] Unlearned blocked edge {} -> {} ({})", origin, destination, reason); + return true; + } + private void addBlockedEdge(WorldPoint origin, WorldPoint destination) { blockedTransportEdgesPacked.add(transportEdgeKey( WorldPointUtil.packWorldPoint(origin), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java index a8c5005095f..ee449c4d3ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java @@ -1756,6 +1756,25 @@ public static boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination } } + /** + * Remove a learned block again. For blocks whose cause is condition-scoped rather than stable — + * a door that refused to open for game-state reasons — the walker unlearns them at the next walk + * session start so a later walk under changed conditions (the Tithe Farm seed gate with seeds in + * the inventory) gets the door back. + */ + public static boolean unlearnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + return config.unlearnBlockedEdge(origin, destination, reason); + } + } + /** Whether runtime recovery policy should avoid this dangerous-NPC adjacency tile. */ public static boolean shouldAvoidDangerousTile(WorldPoint tile) { 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 c711507c4a2..e958884655c 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 @@ -211,6 +211,49 @@ public static WorldPoint getCurrentTarget() { */ private static final int LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS = 48; private static final int NORMAL_MINIMAP_REACH_EUCLIDEAN = 11; + /** + * Ceiling for zoom-extended minimap strides. NOT the minimap's limit — zoomed out it shows ~38 + * tiles — but the walled-click net's: every stride target must sit inside the player-origin + * reachability BFS ({@link #CLOSEST_INDEX_REACHABLE_STEP_BUDGET} = 20 steps), or a wall between + * could not be detected and the Clock Tower click-through-the-wall class comes back. 18 leaves + * two steps of path-vs-Euclidean slack inside that budget. + */ + private static final int ZOOMED_OUT_MINIMAP_REACH_CAP = 18; + /** + * Floor for zoom-shrunk strides. The first cut of zoom awareness floored at the flat + * {@link #NORMAL_MINIMAP_REACH_EUCLIDEAN}, which quietly broke the zoomed-IN half of the + * feature: a fully zoomed-in minimap shows ~8 tiles of radius, so an 11-tile stride selected a + * point on or past the rim. The floor exists only to keep the walker functional at degenerate + * zooms, not to preserve the old reach. + */ + private static final int MIN_MINIMAP_REACH_EUCLIDEAN = 5; + + /** + * How far a minimap stride may reach at {@code minimapZoom}, in tiles — for EVERY zoom level, in + * both directions. The minimap shows {@code 20 * 4 / zoom} tiles of radius (the scale + * Perspective.localToMinimap uses), so reach follows what the user's zoom makes visible: zoomed + * out, big strides (capped at the BFS horizon); zoomed in, short ones (a click must land inside + * the visible circle, two tiles off the rim). An unreadable zoom falls back to the flat reach + * the walker always had. + */ + static int zoomAwareMinimapReach(double minimapZoom, int minTiles, int capTiles, int fallbackTiles) { + if (minimapZoom <= 0) { + return fallbackTiles; + } + int visibleRadius = (int) Math.floor(20.0 * 4.0 / minimapZoom) - 2; + return Math.max(minTiles, Math.min(visibleRadius, capTiles)); + } + + /** Shell wrapper: the live zoom read, clamped to [functional floor, BFS horizon]. */ + private static int normalMinimapReach() { + try { + return zoomAwareMinimapReach(Microbot.getClient().getMinimapZoom(), + MIN_MINIMAP_REACH_EUCLIDEAN, ZOOMED_OUT_MINIMAP_REACH_CAP, + NORMAL_MINIMAP_REACH_EUCLIDEAN); + } catch (Exception e) { + return NORMAL_MINIMAP_REACH_EUCLIDEAN; + } + } /** * Stationary window before an active route issues a recovery nudge. *

@@ -456,17 +499,36 @@ private enum WalkerPhase { STEADY } + /** + * One consistent view of the world per loop pass (B2). Captured at the top of the pass and + * RE-CAPTURED after any branch that blocks (a click-and-sleep, a handler wait) — a pass-start + * position is a lie after a second of sleeping, which is the same staleness class the + * reachable-recapture above the recovery scan exists for. Consumers between blocking points + * share the snapshot instead of re-reading the client, so they cannot disagree about where the + * player is — the disagreement that produced the Stronghold gate bounce. + */ private static final class WalkLoopSnapshot { private final WorldPoint playerLoc; + private final boolean moving; + private final boolean animating; + private final boolean interacting; private final HashMap closestReachableTiles; - private WalkLoopSnapshot(WorldPoint playerLoc) { + private WalkLoopSnapshot(WorldPoint playerLoc, boolean moving, boolean animating, boolean interacting) { this.playerLoc = playerLoc; + this.moving = moving; + this.animating = animating; + this.interacting = interacting; this.closestReachableTiles = getClosestIndexReachableTiles(playerLoc); } private static WalkLoopSnapshot capture() { - return new WalkLoopSnapshot(Rs2Player.getWorldLocation()); + return new WalkLoopSnapshot(Rs2Player.getWorldLocation(), + Rs2Player.isMoving(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + } + + private boolean idle() { + return !moving && !animating && !interacting; } private int closestTileIndex(List path) { @@ -1384,6 +1446,12 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance log.warn("Please do not call the walker from the main thread"); return WalkerState.EXIT; } + // BEFORE any planning. The first version withdrew these inside markWalkSessionStart, which + // runs after setTarget has already kicked the pathfinder off — measured live at the Tithe + // door: the retry's plan ran against the previous walk's blocks (SEARCH_EXHAUSTED against a + // sealed goal), collapsed to a 1-tile path, and the retry burned itself on it while the + // unlearn arrived two lines later. + withdrawWalkScopedDoorBlocks(); WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -1535,7 +1603,7 @@ public static WalkerState walkStep(WorldPoint target, int distance) { // target nor a planned-path point is clickable (e.g. the route needs a transport walkStep can't // cross), no click is issued and we hold on the line rather than wander off it — walkStep is not // built for transport routes; use the blocking walkTo/walkUntil for those. - int walkStepReach = NORMAL_MINIMAP_REACH_EUCLIDEAN; + int walkStepReach = normalMinimapReach(); boolean allowDirectionalFallback = playerLoc.distanceTo(target) <= walkStepReach; clickMiniMapOrFallback(rawPath, target, playerLoc, walkStepReach - 1, allowDirectionalFallback, -1); return WalkerState.MOVING; @@ -1594,6 +1662,51 @@ private static void reportWalkBudgetIfExhausted(WorldPoint target, long nowMs, i Rs2Player.getWorldLocation(), processWalkTail); } + /** How long the route progress index may hold still before the route is declared stagnant. */ + private static final long ROUTE_STAGNATION_BUDGET_MS = 60_000L; + /** Stagnation replans per walk before the goal is called unreachable. */ + private static final int MAX_ROUTE_STAGNATION_REPLANS = 2; + + /** + * The enforced oscillation bound (TailDecision.decideRouteStagnation). Unlike the two observe-only + * budgets above, this one acts: the wall-clock budget is sized for whole journeys and the + * exempt-run counter resets on any movement, so a walk ping-ponging between two tiles — the Tithe + * Farm door/recovery oscillation ran 4+ minutes until a human cancelled it — trips neither. + * Returns null to continue the loop (spending a replan restarts the clock), or the honest + * terminal state. + */ + private static WalkerState handleRouteStagnation(WorldPoint target, int distance, List path) { + long now = System.currentTimeMillis(); + TailDecision.StagnationAction action = TailDecision.decideRouteStagnation( + routeState.routeProgressAdvancedAtMs, now, ROUTE_STAGNATION_BUDGET_MS, + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS); + if (action == TailDecision.StagnationAction.NONE) { + return null; + } + if (action == TailDecision.StagnationAction.REPLAN) { + routeState.stagnationReplansSpent++; + // Restart the clock by hand: a replan that returns the identical route never trips the + // route-changed re-stamp, and each replan is owed a full budget of its own. + routeState.routeProgressAdvancedAtMs = now; + WebWalkLog.spInfo("route_stagnation_replan | spent={}/{} idx={} at={} goal={}", + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS, + routeState.routeProgressIdx, compactWorldPoint(Rs2Player.getWorldLocation()), + compactWorldPoint(target)); + recalculatePath(); + return null; + } + WorldPoint endpoint = path == null || path.isEmpty() ? null : path.get(path.size() - 1); + WebWalkLog.spInfo("route_stagnation_exhausted | idx={} replans={} at={} goal={} — route index " + + "never advanced; movement without progress is not progress", + routeState.routeProgressIdx, routeState.stagnationReplansSpent, + compactWorldPoint(Rs2Player.getWorldLocation()), compactWorldPoint(target)); + Telemetry.recordUnreachable("route-stagnation-exhausted", Rs2Player.getWorldLocation(), + target, endpoint, path == null ? 0 : path.size(), distance, + Rs2PathApi.getActiveRouteStatus().getMetrics().orElse(null)); + setTarget(null, "rs2walker:processWalk:route-stagnation-exhausted"); + return WalkerState.UNREACHABLE; + } + /** Player tile at the last tail-exempt iteration; a change means the run was making progress. */ private static volatile WorldPoint lastExemptRunLocation = null; @@ -1841,7 +1954,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int rawSize = rawPath == null ? -1 : rawPath.size(); int walkSize = path == null ? -1 : path.size(); markStartupPhase("path_snapshot", target, "raw=" + rawSize + " walk=" + walkSize); - final WalkLoopSnapshot walkLoop = WalkLoopSnapshot.capture(); + WalkLoopSnapshot walkLoop = WalkLoopSnapshot.capture(); final WorldPoint dst; if (path == null || path.isEmpty()) { dst = walkLoop.playerLoc; @@ -1897,7 +2010,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } - int earlyRouteStartIdx = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); + int earlyRouteStartIdx = stabilizeRouteProgressWithRawWatermark(rawPath, path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); boolean immediateRouteTransportPending = hasImmediatePlannedTransportStep(path, earlyRouteStartIdx, walkLoop.playerLoc); // Do not clear walk target while a sticky minimap interim is active — breaks @@ -1923,9 +2036,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } long sinceMoved = System.currentTimeMillis() - routeState.lastMovedTimeMs; long threshold = stallThresholdMs(); - Telemetry.recordStallRecalc(sinceMoved, Rs2Player.getWorldLocation()); + Telemetry.recordStallRecalc(sinceMoved, walkLoop.playerLoc); WebWalkLog.stallRecalc(sinceMoved, threshold, - Rs2Player.isInCombat(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + Rs2Player.isInCombat(), walkLoop.animating, walkLoop.interacting); if (lastAttemptedMinimapClick != null) { WebWalkLog.stallContextDebug( lastAttemptedMinimapClick, @@ -1938,7 +2051,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part clearInterimTarget("stall-recalc"); if (immediateRouteTransportPending) { WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); - } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { + } else if (walkLoop.idle()) { recalculatePathForRecovery(); tryIssueRouteRecoveryClick(rawPath, path, target, distance, "stall recovery click"); continue; @@ -1957,7 +2070,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part routeState.lastActiveRouteIdleNudgeAtMs = System.currentTimeMillis(); } if (routeState.stuckCount > 10) { - var reachable = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 5).keySet(); + var reachable = Rs2Tile.getReachableTilesFromTile(walkLoop.playerLoc, 5).keySet(); if (!reachable.isEmpty()) { // Rank sidestep candidates by distance-toward-target so recovery // biases toward the goal instead of wandering. Keep a top-K pool @@ -1967,10 +2080,13 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int poolSize = Math.min(3, ranked.size()); WorldPoint sidestep = ranked.get(Rs2Random.between(0, poolSize)); log.info("[Walker] stuck sidestep: clicked to={} player={} routeState.stuckCount={}", - sidestep, Rs2Player.getWorldLocation(), routeState.stuckCount); + sidestep, walkLoop.playerLoc, routeState.stuckCount); walkMiniMap(sidestep); sleepGaussian(1000, 300); routeState.stuckCount = 0; + // The sleep above made the pass-start snapshot a lie; every read below this + // point (playerLocForIndex first among them) must see the post-sidestep world. + walkLoop = WalkLoopSnapshot.capture(); } } @@ -1978,10 +2094,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int indexOfStartPoint = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, playerLocForIndex); indexOfStartPoint = advanceIndexPastRecentTransportEdge(path, indexOfStartPoint, playerLocForIndex); if (indexOfStartPoint == -1) { - walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", - path.size(), - playerLocForIndex, - path.isEmpty() ? null : path.get(0), + walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", path.size(), + playerLocForIndex, path.isEmpty() ? null : path.get(0), path.isEmpty() ? null : path.get(path.size() - 1)); traceProcessWalkExit("closest-index-none", target, processWalkTail); setTarget(null, "rs2walker:processWalk:closest-index-none"); @@ -2011,8 +2125,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // here: the player stops, the "moving" deferral ends, and OFFPATH_RECALC replans properly. if (clearedInterimTarget && isNearPath() - && !Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + && !walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { @@ -2390,16 +2504,15 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { + int recoveryScanStart = forwardRecoveryScanStart(rawPath, smoothedToRaw, indexOfStartPoint, playerLoc); boolean candidateOnCurrentRouteFrontier = RouteRecovery.isLocalRecoveryCandidateOnForwardRoute( rawPath, smoothedToRaw, - indexOfStartPoint, + recoveryScanStart, i, LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS); if (!candidateOnCurrentRouteFrontier) { - log.info("[Walker] spatially-near future route branch ignored for local recovery: " - + "tile={} idx={}/{} routeStart={} player={}", - currentWorldPoint, i, path.size(), indexOfStartPoint, playerLoc); + log.info("[Walker] spatially-near future route branch ignored for local recovery: tile={} idx={}/{} routeStart={} player={}", currentWorldPoint, i, path.size(), recoveryScanStart, playerLoc); if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { exit = WalkExit.ROUTE_FOLD_CONTINUATION_CLICK; } else { @@ -2420,7 +2533,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // door (or other obstacle) really is. Every recovery path below exits the loop, // so rebinding i/currentWorldPoint here is contained. int rewoundIdx = FrontierDecision.earliestBlockedIndex( - path, indexOfStartPoint, i, currentPlayerPlane, reachableTilesCache); + path, recoveryScanStart, i, currentPlayerPlane, reachableTilesCache); if (rewoundIdx != FrontierDecision.NO_EARLIER_BLOCKED_INDEX) { log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", rewoundIdx, path.get(rewoundIdx), i); @@ -2429,7 +2542,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } FrontierDecision.FrontierEdge frontier = - FrontierDecision.frontierEdge(rawPath, smoothedToRaw, indexOfStartPoint, i); + FrontierDecision.frontierEdge(rawPath, smoothedToRaw, recoveryScanStart, i); int edgeIdx = frontier.edgeIndex(); int rawEdgeStart = frontier.rawStart(); int rawEdgeEnd = frontier.rawEndExclusive(); @@ -2785,7 +2898,7 @@ && walkFastCanvas(recoverTarget)) { // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). WorldPoint playerLoc = Rs2Player.getWorldLocation(); - final int MINIMAP_REACH_EUCLIDEAN = NORMAL_MINIMAP_REACH_EUCLIDEAN; + final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); // Checkpoint-style walking: once we set a minimap flag, let the player actually // travel toward it. Do not keep recalculating/clicking new targets mid-run. @@ -3154,7 +3267,7 @@ && walkFastCanvas(recoverTarget)) { if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, - NORMAL_MINIMAP_REACH_EUCLIDEAN - 1, rawAnchorIndex); + normalMinimapReach() - 1, rawAnchorIndex); } else { finalClick = Rs2Walker.walkFastCanvas(canvasClickWp); } @@ -3190,15 +3303,13 @@ && walkFastCanvas(recoverTarget)) { || (retryLoc != null && !retryLoc.equals(lastPartialRetryAtLoc)); if (TailDecision.shouldRefillPartialRetryBudget(partialRetriesWorking, movedSinceLastRetry, routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs)) { - walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", - routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); + walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); partialRetriesWorking = 0; } TailDecision.TailAction partialAction = TailDecision.decide(false, true, exit, partialRetriesWorking, TailDecision.MAX_PARTIAL_RETRIES); if (partialAction == TailDecision.TailAction.PARTIAL_PROGRESS_REPLAN) { - walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", - exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); + walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); recalculatePath(); continue; } @@ -3223,6 +3334,10 @@ && walkFastCanvas(recoverTarget)) { setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { + WalkerState stagnated = handleRouteStagnation(target, distance, path); + if (stagnated != null) { + return stagnated; + } if (exit == WalkExit.OFF_PATH_DEFERRED) { // Wait briefly for the player to re-enter the path or for the progress signal // that deferred the recalc to expire. Prevents a tight loop around isNearPath(). @@ -3273,10 +3388,8 @@ && walkFastCanvas(recoverTarget)) { consecutiveExemptIterations = 0; } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", - processWalkTail + 1, - exit.wireName(offPathDeferDetail), - Rs2Player.getWorldLocation().distanceTo(target), - partialPath); + processWalkTail + 1, exit.wireName(offPathDeferDetail), + Rs2Player.getWorldLocation().distanceTo(target), partialPath); continue; } } catch (Exception ex) { @@ -3381,7 +3494,7 @@ public static WorldPoint getPointWithWallDistance(WorldPoint target, WorldPoint Set reachableFromPlayer = playerLoc == null ? Collections.emptySet() : Rs2Tile.getReachableTilesFromTile(playerLoc, - Math.max(2, NORMAL_MINIMAP_REACH_EUCLIDEAN)).keySet(); + Math.max(2, normalMinimapReach())).keySet(); if (hasMinimapRelevantMovementFlag(localPoint, flags)) { WorldPoint best = bestWallDistanceNeighbor(tiles.keySet(), playerLoc, reachableFromPlayer, @@ -3846,6 +3959,32 @@ private static void learnWalledRouteEdge(List rawPath, WorldPoint pl if (edge == null) { return; } + // A shut door is not a wall. The catalog already says this edge is crossable BY ACTION, so a + // refused click across it means the door is closed, not that the way is blocked — and learning + // it poisons the exact edge the route depends on. Dwarf Cannon showed this: Captain Lawgof's + // outpost gates ship as transports 15604 and 15605 in both directions, and both were learned as + // walled at strike 1 of 2 while the quester tried to reach him through the fence. A second + // independent strike would have persisted them and routed around that outpost permanently. + // + // The sibling fix for this ("a shut transport door is not a blocked route step") taught the + // route-step VALIDATOR the same thing; the learning path was never covered. + if (Rs2PathApi.hasCatalogTransportEdge(edge[0], edge[1])) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — catalog transport, a shut door is not a wall", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + return; + } + // The same rule for ORDINARY scene doors, which have no catalog row to hit the guard above. + // A refused click across a shut door means the door is closed, not that the way is walled — + // the door pipeline (and its strike-out) owns that edge. Without this, the Tithe Farm run + // (2026-08-12) learned the lobby door edge as walled for the WHOLE SESSION one second after + // the strike-out had deliberately scoped its own block to the walk — so the plugin's later + // seeded walk-in would have found the door unroutable until a client restart. + if (findDoorNearSegmentTimed(edge[0], edge[1], + List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass")) != null) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — scene door on the edge, the door pipeline owns it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + return; + } // Via the Rs2PathApi wrapper rather than the config directly: it takes the pathfinder mutex, // which matters because the replan below runs straight after. Same return contract — true only // when the edge was newly blocked for this session. @@ -4082,6 +4221,23 @@ static int rawPathForwardAnchorIndex(List rawPath, WorldPoint player ROUTE_PROGRESS_FORWARD_SEARCH_TILES, () -> getClosestTileIndex(rawPath, playerLoc)); } + /** + * The local-recovery scan anchor, forward-corrected past route tiles the player has already + * passed (FrontierDecision.forwardScanStartIndex). The player's raw position is found with the + * forward-window search, not plain-nearest, so a route tail folding back beside the player + * (Clock Tower) cannot yank the anchor to the end of the route. + */ + private static int forwardRecoveryScanStart(List rawPath, int[] smoothedToRaw, + int indexOfStartPoint, WorldPoint playerLoc) { + if (rawPath == null || rawPath.isEmpty() || smoothedToRaw == null || playerLoc == null + || indexOfStartPoint < 0 || indexOfStartPoint >= smoothedToRaw.length + || smoothedToRaw[indexOfStartPoint] < 0) { + return indexOfStartPoint; + } + int playerRawIdx = rawPathForwardAnchorIndex(rawPath, playerLoc, smoothedToRaw[indexOfStartPoint]); + return FrontierDecision.forwardScanStartIndex(smoothedToRaw, indexOfStartPoint, playerRawIdx); + } + private static boolean shouldIssueActiveRouteIdleNudge() { WorldPoint playerLoc = Rs2Player.getWorldLocation(); long now = System.currentTimeMillis(); @@ -4149,8 +4305,12 @@ private static boolean tryIssueRouteContinuationClick(List rawPath, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { return false; } + if (target != null && TailDecision.suppressTailReclick(Rs2Player.isMoving(), + playerLoc.distanceTo2D(target), INTERIM_CLOSE_TILES)) { + return false; + } return tryIssueRouteMovementClick(rawPath, path, target, configuredDistance, "interim close route click", - NORMAL_MINIMAP_REACH_EUCLIDEAN, false); + normalMinimapReach(), false); } private static boolean tryIssueRouteMovementClick(List rawPath, @@ -4200,9 +4360,21 @@ private static boolean tryIssueRouteMovementClick(List rawPath, WorldPoint clickedTarget = null; if (clickTarget != null && !clickTarget.equals(playerLoc)) { clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, maxEuclidean - 1); - clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, - maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); - clicked = clickedTarget != null; + // The finish needs scene precision, not minimap reach. A minimap tile is a few pixels + // wide, so a click at the goal from 1-2 tiles out routinely quantizes onto a neighbour — + // measured as the last-tile dance (1784,3559 -> 1786,3559 -> 1784,3560 around a + // 1785,3560 goal). Inside the final band, click the exact tile on screen instead. + if (target != null && playerLoc.distanceTo2D(target) <= INTERIM_CLOSE_TILES + && clickTarget.getPlane() == target.getPlane() + && clickTarget.distanceTo2D(target) <= 1 + && walkFastCanvas(clickTarget)) { + clickedTarget = clickTarget; + clicked = true; + } else { + clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, + maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + clicked = clickedTarget != null; + } } // EVERY movement click logs at info. The interim-continuation label used to log at debug only, // which made its clicks invisible: the walker appeared to "randomly click far from the path" @@ -5953,6 +6125,15 @@ private static void addForwardPathIndices(Map forwardIndex, private static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; private static final Map recentDoorAttemptByEdge = new ConcurrentHashMap<>(); private static final long DOOR_ATTEMPT_EDGE_COOLDOWN_MS = 2_500; + // Concluded-but-uncrossed attempts per door edge (Rs2DoorHandler.registerDoorCrossFailure). + // Conditionally locked doors (Tithe Farm seed gate) refuse silently: no dialogue, no traversal, + // no collision change — three strikes session-blocks the edge and replans instead of retrying forever. + private static final Map doorCrossFailuresByEdge = new ConcurrentHashMap<>(); + private static final long DOOR_CROSS_FAILURE_DECAY_MS = 300_000; + private static final int DOOR_CROSS_FAILURE_STRIKE_LIMIT = 3; + // Edges blocked by a door strike-out, withdrawn again at the next walk session start. + private static final java.util.concurrent.ConcurrentLinkedQueue walkScopedDoorBlocks = + new java.util.concurrent.ConcurrentLinkedQueue<>(); private static final Map recentCurrentTileTransportByEdge = new ConcurrentHashMap<>(); private static final long CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS = 2_200; private static final long DOOR_INTERACTION_GLOBAL_COOLDOWN_MS = 1_800; @@ -6203,6 +6384,11 @@ private static boolean handleDoors(List path, int index, boolean all // merely beside the path. isDoorOnSegment walks the segment against the wall's // real edge, matching the GameObject branch and findDoorNearSegment. if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { + if (isPlayerBeyondDoorFace((WallObject) object, fromWp)) { + WebWalkLog.spInfo("door_skip_crossed | mode=segment-door probe={} from={} — already past the face; clicking would carry us back", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; } else { @@ -6282,6 +6468,8 @@ private static boolean handleDoors(List path, int index, boolean all if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); } else { markStationaryDoorOpened(probe); if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, path)) { @@ -6291,6 +6479,7 @@ private static boolean handleDoors(List path, int index, boolean all } return false; } + clearDoorCrossFailures(fromWp, toWp); markStationaryDoorOpened(probe); markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); } @@ -6337,6 +6526,11 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, if (searchNeighborPoint(orientation, probe, fromWp) || searchNeighborPoint(orientation, probe, toWp) || (allowSegmentProbe && Rs2DoorGeometry.wallDoorTouchesSegment((WallObject) object, fromWp, toWp))) { + if (isPlayerBeyondDoorFace((WallObject) object, fromWp)) { + WebWalkLog.spInfo("door_skip_crossed | mode=segment-probe probe={} from={} — already past the face; clicking would carry us back", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; } @@ -6389,6 +6583,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (traversed) { + clearDoorCrossFailures(fromWp, toWp); markStationaryDoorOpened(probe); markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); return true; @@ -6412,6 +6607,8 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, if (doorStillHasActionTimed(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", probe, fromWp, toWp); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); } else { markStationaryDoorOpened(probe); if (tryDoorEdgeCrossNudge(fromWp, toWp, currentTarget, routePath)) { @@ -6548,6 +6745,18 @@ private static void markStationaryDoorOpened(WorldPoint doorTile) { Rs2DoorHandler.markStationaryDoorOpened(recentlyOpenedStationaryDoors, doorTile); } + /** + * Whether the player already stands on the far side of this wall door's face relative to the + * segment's approach tile — in which case the crossing has happened and clicking the door again + * can only undo it (a moves-you gate carries the player straight back). Shell wrapper over + * {@link Rs2DoorGeometry#playerBeyondWallFace}; see there for the Stronghold bounce this exists + * to prevent. + */ + private static boolean isPlayerBeyondDoorFace(WallObject wall, WorldPoint fromWp) { + return Rs2DoorGeometry.playerBeyondWallFace(wall.getOrientationA(), wall.getWorldLocation(), + fromWp, Rs2Player.getWorldLocation()); + } + private static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { return Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp); } @@ -6753,9 +6962,17 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, target, before, "from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); routeState.lastMovedTimeMs = System.currentTimeMillis(); routeState.stuckCount = 0; + clearDoorCrossFailures(fromWp, toWp); } else { WebWalkLog.spInfo("door_edge_nudge_unresolved | from={} to={} before={} after={}", compactWorldPoint(fromWp), compactWorldPoint(toWp), compactWorldPoint(before), compactWorldPoint(after)); + // A stationary player who clicked past an "open" door and moved nowhere is the seed-gate + // signature: the door reads open (or opens and instantly re-shuts) while the game refuses + // the crossing. A cancelled wait or an in-flight sample proves nothing. + registerDoorCrossFailure(fromWp, toWp, + before.equals(after) && !Rs2Player.isMoving() + && (target == null || !isWalkCancelled(target)), + "cross-nudge"); } return progressed; } @@ -7297,6 +7514,76 @@ private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, Worl } } + /** + * Registers a door attempt that concluded without crossing its edge; on the third such failure + * the edge is blocked in the planner and the route recalculated, so the walk routes around or + * ends honestly instead of ping-ponging. The block is scoped to the CURRENT walk, not the + * session: a door that refuses for game-state reasons (Tithe Farm's seed gate) opens the moment + * the condition is met, and a session block would stop the Tithe plugin's own seeded walk-in + * from ever routing through it — the museum lesson, where one layer's block silently broke the + * other layer's fix. {@link #withdrawWalkScopedDoorBlocks} returns the edges at the next walk + * session start. Not a door-tile blacklist either: the planner, not the door handler, owes the + * reroute. + */ + private static void registerDoorCrossFailure(WorldPoint fromWp, WorldPoint toWp, + boolean conclusiveSample, String mode) { + if (fromWp == null || toWp == null) { + return; + } + Rs2DoorHandler.DoorStrike strike = Rs2DoorHandler.registerDoorCrossFailure( + doorCrossFailuresByEdge, + doorAttemptKey(null, fromWp, toWp), + conclusiveSample, + System.currentTimeMillis(), + DOOR_CROSS_FAILURE_DECAY_MS, + DOOR_CROSS_FAILURE_STRIKE_LIMIT); + if (strike != Rs2DoorHandler.DoorStrike.STRIKE_OUT) { + return; + } + String reason = "door-strike-out (" + mode + ")"; + if (Rs2PathApi.learnBlockedEdge(fromWp, toWp, reason)) { + walkScopedDoorBlocks.add(new WorldPoint[]{fromWp, toWp}); + } + if (Rs2PathApi.learnBlockedEdge(toWp, fromWp, reason)) { + walkScopedDoorBlocks.add(new WorldPoint[]{toWp, fromWp}); + } + WebWalkLog.spInfo("door_strike_out | from={} to={} mode={} — {} concluded attempts never crossed; " + + "blocking edge for this walk and replanning", + compactWorldPoint(fromWp), compactWorldPoint(toWp), mode, DOOR_CROSS_FAILURE_STRIKE_LIMIT); + recalculatePath(); + } + + /** + * Withdraws every strike-out block the previous walk earned. Called at walk session start: the + * new walk may run under changed conditions (seeds acquired, key obtained), so each refused door + * gets a fresh chance — and a walk retried without the condition just re-earns the strike-out in + * a few attempts, loudly, instead of inheriting a stale block silently. + */ + private static void withdrawWalkScopedDoorBlocks() { + WorldPoint[] edge; + while ((edge = walkScopedDoorBlocks.poll()) != null) { + Rs2PathApi.unlearnBlockedEdge(edge[0], edge[1], "walk-scoped door strike-out expired"); + } + } + + private static void clearDoorCrossFailures(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + Rs2DoorHandler.clearDoorCrossFailures(doorCrossFailuresByEdge, doorAttemptKey(null, fromWp, toWp)); + } + } + + /** + * A refused-open only counts when the attempt genuinely concluded AT the door: player stationary + * on (or beside) the near-side tile. A ranged click whose wait expired mid-approach samples a + * player still tiles away and proves nothing about the door. + */ + private static boolean isConclusiveRefusedOpenSample(WorldPoint posAfter, WorldPoint fromWp) { + return posAfter != null && fromWp != null + && !Rs2Player.isMoving() + && posAfter.getPlane() == fromWp.getPlane() + && posAfter.distanceTo2D(fromWp) <= 1; + } + private static boolean shouldThrottleCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { if (fromWp == null || toWp == null) { return false; @@ -8909,6 +9196,9 @@ static int stabilizeRouteProgressIndex(List path, int closestIdx, Wo routeState.routeProgressPathSize = path.size(); routeState.routeProgressIdx = closestIdx; routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + // A new route means new raw indices; a stale high-water mark from the old route would + // silently disable the raw watermark for the rest of the walk. + routeState.rawProgressHighIdx = -1; return closestIdx; } @@ -9015,6 +9305,31 @@ private static void resetRouteProgress() { routeState.routeProgressPathEnd = null; routeState.routeProgressPathSize = -1; routeState.routeProgressAdvancedAtMs = 0L; + routeState.stagnationReplansSpent = 0; + routeState.rawProgressHighIdx = -1; + } + + /** + * Per-pass progress update with RAW granularity. The smoothed index alone starves the stagnation + * clock on healthy walks: the entire Varrock west approach — fifty tiles and three doors — sits + * inside the final smoothed segment, so the index held one value through ~50s of honest walking + * (measured 2026-08-12) against a 60s budget. The player's furthest-yet raw index advances tile + * by tile on exactly that walk, and still refuses to advance during the Tithe ping-pong: two + * tiles oscillating can set a high-water mark once, never repeatedly. + */ + static int stabilizeRouteProgressWithRawWatermark(List rawPath, List path, + int closestIdx, WorldPoint target, WorldPoint playerLoc) { + int stabilized = stabilizeRouteProgressIndex(path, closestIdx, target, playerLoc); + if (rawPath != null && !rawPath.isEmpty() && playerLoc != null) { + // Plain nearest-by-distance (no reachability BFS): a monotone high-water mark only needs + // consistency with itself, and this runs once per loop pass. + int rawIdx = WalkerPathGeometry.getClosestTileIndex(rawPath, playerLoc, null); + if (rawIdx > routeState.rawProgressHighIdx) { + routeState.rawProgressHighIdx = rawIdx; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + } + } + return stabilized; } private static void recordRouteProgressAdvanced() { @@ -12689,24 +13004,96 @@ private static void confirmCharterTravelIfPrompted() { * * @param transport */ - private static boolean interactWithAdventureLog(Transport transport) { + /** The Lovakengj minecart destination list: TEXT entries under 947:9, one per station. */ + private static final int MINECART_MENU_GROUP = 947; + private static final int MINECART_MENU_LIST_CHILD = 9; + + private static boolean isMinecartMenuVisible() { + return !Rs2Widget.isHidden(MINECART_MENU_GROUP, MINECART_MENU_LIST_CHILD); + } + + private static boolean interactWithAdventureLog(Transport transport) { if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; - // Wait for the widget to become visible - boolean isAdventureLogVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER), Rs2Player::isMoving, 100, 10000); + // Two menus arrive here, and they are different interfaces: spirit trees and their kin open + // the adventure log (187), but the Lovakengj minecart opens its own list (947, "Minecart + // rides: 20 coins"). Waiting on 187 alone made every minecart trip time out for 10s and + // return false without ever seeing its menu — the user-visible "it never selects the + // destination". Verified live at Hosidius South: 947:9 holds "1: Arceuus".."C: Shayzien + // West" as plain TEXT entries, and clicking the row by its verbatim displayInfo rides. + boolean menuVisible = sleepUntilTrue( + () -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER) || isMinecartMenuVisible(), + Rs2Player::isMoving, 100, 10000); - if (!isAdventureLogVisible) { - log.error("Widget did not become visible within the timeout."); + if (!menuVisible) { + log.warn("[Walker] destination menu (187/947) did not open for {}", transport.getDisplayInfo()); return false; } + if (isMinecartMenuVisible()) { + return selectMinecartDestination(transport); + } - String destinationString = transport.getDisplayInfo().replaceAll("^\\d+:\\s*", ""); - Widget destinationWidget = Rs2Widget.findWidget(destinationString, List.of(Rs2Widget.getWidget(187, 3))); - if (destinationWidget == null) return false; + String displayInfo = transport.getDisplayInfo(); + // The menu prefixes every option with its shortcut key — digits for the first nine entries + // and LETTERS after that (the Lovakengj minecart runs 1-9 then A: Port Piscarilius through + // C: Shayzien West, read off the live interface). The old strip handled only digit prefixes, + // so letter-keyed destinations searched for "A: Port Piscarilius" verbatim and could never + // match a widget that stores the name apart from its key. + String destinationString = displayInfo.replaceAll("^[0-9A-Za-z]:\\s*", ""); + + // Null-safe on purpose: the old List.of(getWidget(187, 3)) THREW on a null child rather than + // returning false, and the null branch below used to return with no log at all — this class + // of failure reached the user as "it just doesn't select". + Widget optionsRoot = Rs2Widget.getWidget(187, 3); + Widget destinationWidget = optionsRoot == null ? null + : Rs2Widget.findWidget(destinationString, List.of(optionsRoot)); + if (destinationWidget != null) { + Rs2Widget.clickWidget(destinationWidget); + log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); + } + + // Text lookup failed. This menu is BUILT for keyboard selection — child 187:1 is literally + // named "keylisteners" in the cache, and every option's shortcut key is the displayInfo + // prefix we just stripped. Pressing it is also what a human at this menu actually does. + char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); + boolean hasShortcut = displayInfo.length() > 1 && displayInfo.charAt(1) == ':' + && Character.isLetterOrDigit(shortcutKey); + if (hasShortcut) { + log.warn("[Walker] destination '{}' not found by text in menu 187:3 (rootNull={}); pressing shortcut '{}'", + destinationString, optionsRoot == null, shortcutKey); + Rs2Keyboard.keyPress(shortcutKey); + log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); + } + + log.warn("[Walker] destination '{}' not found in menu 187:3 and displayInfo '{}' carries no shortcut key", + destinationString, displayInfo); + return false; + } - Rs2Widget.clickWidget(destinationWidget); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); + /** + * Selects a station in the minecart list (947:9). The tsv displayInfo is the row's verbatim text + * ("7: Lovakengj"), so a text click is the primary path — verified live to ride. The rows are + * also keyboard-built (the prefix is the shortcut), so a failed click falls back to the key. + */ + private static boolean selectMinecartDestination(Transport transport) { + String displayInfo = transport.getDisplayInfo(); + boolean selected = Rs2Widget.clickWidget(displayInfo, + Optional.of(MINECART_MENU_GROUP), MINECART_MENU_LIST_CHILD, true); + if (!selected && displayInfo.length() > 1 && displayInfo.charAt(1) == ':' + && Character.isLetterOrDigit(displayInfo.charAt(0))) { + char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); + log.warn("[Walker] minecart row '{}' not clickable; pressing shortcut '{}'", displayInfo, shortcutKey); + Rs2Keyboard.keyPress(shortcutKey); + selected = true; + } + if (!selected) { + log.warn("[Walker] minecart destination '{}' not found in menu 947:9", displayInfo); + return false; + } + log.info("Traveling to {} - ({}) via minecart menu", displayInfo, transport.getDestination()); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 10000); } private static boolean handleGlider(Transport transport) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java index ca0f0dec77a..d78e28c16d6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometry.java @@ -43,6 +43,49 @@ public static boolean crossedDoorAxis(WorldPoint from, WorldPoint to, WorldPoint return false; } + /** + * Whether {@code at} already stands on the FAR side of this wall door's face relative to + * {@code from} — the crossing the door exists to produce has happened, so clicking it again can + * only carry the player backward. + * + *

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

Corner walls (orientation 16..128) answer false: their face does not divide the plane + * along a single axis. + * + * @param orientationA the wall's {@code getOrientationA()}: 1=west, 2=north, 4=east, 8=south + */ + public static boolean playerBeyondWallFace(int orientationA, WorldPoint wallTile, + WorldPoint from, WorldPoint at) { + if (wallTile == null || from == null || at == null + || wallTile.getPlane() != from.getPlane() || at.getPlane() != from.getPlane()) { + return false; + } + switch (orientationA) { + case 1: // west face: boundary between x = wallTile.x-1 and x = wallTile.x + return sidesDiffer(from.getX(), at.getX(), wallTile.getX()); + case 4: // east face: boundary between x = wallTile.x and x = wallTile.x+1 + return sidesDiffer(from.getX(), at.getX(), wallTile.getX() + 1); + case 2: // north face: boundary between y = wallTile.y and y = wallTile.y+1 + return sidesDiffer(from.getY(), at.getY(), wallTile.getY() + 1); + case 8: // south face: boundary between y = wallTile.y-1 and y = wallTile.y + return sidesDiffer(from.getY(), at.getY(), wallTile.getY()); + default: + return false; + } + } + + /** Opposite sides of the boundary that lies just before {@code boundary} ({@code >=} vs {@code <}). */ + private static boolean sidesDiffer(int a, int b, int boundary) { + return (a >= boundary) != (b >= boundary); + } + /** As above, with the object's location supplied (see {@link #wallDoorTouchesSegment}). */ public static boolean isDoorOnSegment(TileObject object, WorldPoint objectLocation, WorldPoint fromWp, WorldPoint toWp) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java index 288b8d4143f..0e146cd471b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -84,6 +84,57 @@ public static long markGlobalDoorInteractionCooldown(long cooldownMs) { return System.currentTimeMillis() + cooldownMs; } + /** Outcome of registering one concluded-but-uncrossed door attempt against an edge. */ + public enum DoorStrike { + /** The sample cannot prove a refusal (player still moving, or the walk was cancelled mid-wait). */ + NOT_COUNTED, + /** Counted; the edge has strikes left. */ + COUNTED, + /** The edge has struck out: session-block it and replan. */ + STRIKE_OUT + } + + /** + * Counts attempts that CONCLUDED at the door without crossing it — a click that opened nothing + * (action still present), or a cross-click past an apparently open door that moved the player + * nowhere. Doors that refuse for game-state reasons (Tithe Farm's seed gate, key doors, favour + * gates) produce exactly this signature and nothing else: no dialogue, no traversal, no collision + * change. Without a strike-out the walker retries the same edge forever — measured at 4+ minutes + * of door/recovery ping-pong on Farm door 27445 before a human cancelled it. + *

+ * {@code conclusiveSample} is the caller's evidence gate: the player must be stationary at the + * near side when sampled. A moving sample proves only that the approach was still in flight — + * the same trap that once blacklisted Wydin's door off a mid-walk position. + * + * @param strikesByEdge edge key -> {count, lastStrikeAtMs}; entries older than {@code decayMs} reset + * @param conclusiveSample whether the failed attempt ended with the player stationary at the edge + */ + public static DoorStrike registerDoorCrossFailure(Map strikesByEdge, + String edgeKey, + boolean conclusiveSample, + long nowMs, + long decayMs, + int strikeLimit) { + if (!conclusiveSample || edgeKey == null) { + return DoorStrike.NOT_COUNTED; + } + strikesByEdge.entrySet().removeIf(entry -> nowMs - entry.getValue()[1] > decayMs); + long[] entry = strikesByEdge.compute(edgeKey, (k, v) -> + v == null ? new long[]{1, nowMs} : new long[]{v[0] + 1, nowMs}); + if (entry[0] >= strikeLimit) { + strikesByEdge.remove(edgeKey); + return DoorStrike.STRIKE_OUT; + } + return DoorStrike.COUNTED; + } + + /** A successful crossing forgives the edge's strikes (transient refusals should not accumulate). */ + public static void clearDoorCrossFailures(Map strikesByEdge, String edgeKey) { + if (edgeKey != null) { + strikesByEdge.remove(edgeKey); + } + } + private static String compactWorldPoint(WorldPoint wp) { if (wp == null) { return "?"; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java index 98c8aa6d6ce..2eed9d8315e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java @@ -24,6 +24,38 @@ private FrontierDecision() { } + /** + * The recovery scan anchor: the first route index whose raw mapping is at or past the player's + * own raw position. Route tiles mapped BEHIND the player are spent — the walk never needs to + * stand on them again — and recovery must not chase them. + * + *

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

Unmapped entries ({@code smoothedToRaw[i] < 0}) stop the advance: no evidence of "behind" + * must not read as "spent". + */ + public static int forwardScanStartIndex(int[] smoothedToRaw, int startIndex, int playerRawIdx) + { + if (smoothedToRaw == null || startIndex < 0 || startIndex >= smoothedToRaw.length + || playerRawIdx <= 0) + { + return startIndex; + } + int index = startIndex; + while (index < smoothedToRaw.length - 1 + && smoothedToRaw[index] >= 0 + && smoothedToRaw[index] < playerRawIdx) + { + index++; + } + return index; + } + /** * The earliest route tile at or after {@code fromIndex} and before {@code missIndex} that the * player cannot reach, or {@link #NO_EARLIER_BLOCKED_INDEX}. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java index 078db6c91a2..50645b7c211 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java @@ -86,6 +86,62 @@ public static TailAction decide(boolean withinFinishThreshold, : TailAction.CONTINUE; } + /** What to do about a route whose progress index has stopped advancing. */ + public enum StagnationAction + { + /** Progress is recent (or there is no route yet): nothing to do. */ + NONE, + /** Stagnant: spend one stagnation replan and restart the clock. */ + REPLAN, + /** Stagnant with the replan budget spent: end the walk honestly. */ + EXHAUSTED + } + + /** + * The oscillation bound the other two budgets cannot provide. The wall-clock budget is sized for + * whole journeys (minutes), and the exempt-run counter resets on any movement — so a walk that + * ping-pongs between two tiles forever (measured: 4+ minutes of door/recovery oscillation at the + * Tithe Farm door until a human cancelled it) trips neither. Movement is not progress; the route + * progress index is. When the index has not advanced for a full budget, the route is not working: + * replan it, and when replanning has been given its chances, call the goal unreachable instead of + * letting the loop run unbounded. + * + *

The budget must dwarf every legitimate index hold: ranged door waits (≤8s), transport + * settles (~2s), off-path deferrals (~10s) — 60s is over six times the largest. + * + * @param routeProgressAdvancedAtMs when the stabilized route index last advanced (0 = no route yet; + * the caller restarts this clock when it spends a REPLAN, so each + * replan gets a full budget even when the new route is identical) + */ + public static StagnationAction decideRouteStagnation(long routeProgressAdvancedAtMs, + long nowMs, + long stagnationBudgetMs, + int stagnationReplansSpent, + int maxStagnationReplans) + { + if (routeProgressAdvancedAtMs <= 0L || stagnationBudgetMs <= 0L + || nowMs - routeProgressAdvancedAtMs <= stagnationBudgetMs) + { + return StagnationAction.NONE; + } + return stagnationReplansSpent < maxStagnationReplans + ? StagnationAction.REPLAN + : StagnationAction.EXHAUSTED; + } + + /** + * Whether a continuation re-click at the route tail is churn rather than flow. Mid-route, + * clicking the next stretch while still moving is exactly how the walker chains minimap clicks — + * that must stay. But inside the final band the click in flight already ends at (or beside) the + * goal, and re-clicking every pass fights it: measured as ~10 clicks in 7 seconds on the last + * tile, each minimap click quantizing onto a neighbour of the goal and restarting the dance. + * Let the in-flight click land; a stationary miss gets one precise follow-up instead. + */ + public static boolean suppressTailReclick(boolean playerMoving, int distanceToGoal, int tailBandTiles) + { + return playerMoving && distanceToGoal >= 0 && distanceToGoal <= tailBandTiles; + } + /** * Whether the walk has run past its wall-clock budget. * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index 8d640f6daf3..9716f3cf4e4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -62,6 +62,10 @@ public void clearRecentTransportContext() { public volatile int routeProgressPathSize = -1; /** Wall-clock ms when route progress last advanced. */ public volatile long routeProgressAdvancedAtMs = 0L; + /** Stagnation replans this walk has spent (TailDecision.decideRouteStagnation). */ + public volatile int stagnationReplansSpent = 0; + /** Furthest raw-path index the player has stood at on the current route; -1 when none. */ + public volatile int rawProgressHighIdx = -1; // ---- interim target: a reachable point clicked toward when the true next tile is off the minimap; // held until the player gets close or progress stalls. ---- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java new file mode 100644 index 00000000000..7f8146902c5 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/RouteProgressWatermarkTest.java @@ -0,0 +1,107 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * The raw watermark that feeds the stagnation clock. + * + *

Seeded from the first post-restart live log (2026-08-12, Lovakengj → Varrock): the entire + * Varrock west approach — fifty tiles and three doors — sat inside the final smoothed segment, so + * the smoothed progress index held one value through ~50 seconds of honest walking against a 60s + * stagnation budget. The raw index advances tile by tile on exactly that walk; the Tithe Farm + * ping-pong (the incident the budget exists for) still cannot advance it more than once. + */ +public class RouteProgressWatermarkTest { + + private final WalkerRouteState routeState = Rs2Walker.routeStateForTesting(); + + private static final WorldPoint GOAL = new WorldPoint(3049, 3341, 0); + + /** Fifty collinear raw tiles; the smoothed path keeps only the endpoints. */ + private static List rawLine() { + List raw = new ArrayList<>(); + for (int i = 0; i <= 49; i++) { + raw.add(new WorldPoint(3000 + i, 3341, 0)); + } + return raw; + } + + private static List smoothedEndpoints() { + return Arrays.asList(new WorldPoint(3000, 3341, 0), GOAL); + } + + @Before + public void reset() { + Rs2Walker.resetWalkSessionState(); + } + + @Test + public void rawAdvanceKeepsTheClockAliveWhileTheSmoothedIndexHolds() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + + // First pass initializes tracking (routeChanged stamps unconditionally). + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(0)); + int smoothedIdxAtStart = routeState.routeProgressIdx; + + for (int i = 1; i <= 20; i++) { + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(i)); + assertNotEquals("tile " + i + ": a new furthest raw tile must stamp the clock", + 0L, routeState.routeProgressAdvancedAtMs); + assertEquals("the smoothed index is expected to hold still in this scenario", + smoothedIdxAtStart, routeState.routeProgressIdx); + } + assertEquals(20, routeState.rawProgressHighIdx); + } + + @Test + public void oscillationStampsAtMostOnce() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + + // Walk to tile 4, establishing the high-water mark. + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(0)); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(4)); + assertEquals(4, routeState.rawProgressHighIdx); + + // The Tithe ping-pong: bounce between tiles 2 and 4 forever. No pass may stamp. + for (int bounce = 0; bounce < 10; bounce++) { + WorldPoint at = raw.get(bounce % 2 == 0 ? 2 : 4); + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, at); + assertEquals("bounce " + bounce + ": oscillation must not feed the stagnation clock", + 0L, routeState.routeProgressAdvancedAtMs); + } + } + + @Test + public void aReplansNewRouteResetsTheHighWaterMark() { + List raw = rawLine(); + List smoothed = smoothedEndpoints(); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(raw, smoothed, 0, GOAL, raw.get(30)); + assertEquals(30, routeState.rawProgressHighIdx); + + // Replan: a different (shorter) route. The stale mark of 30 must not gag the watermark. + List newRaw = raw.subList(28, 49); + List newSmoothed = Arrays.asList(newRaw.get(0), GOAL); + Rs2Walker.stabilizeRouteProgressWithRawWatermark(newRaw, newSmoothed, 0, GOAL, newRaw.get(1)); + assertTrue("post-replan raw indices are small again and must still stamp", + routeState.rawProgressHighIdx >= 0 && routeState.rawProgressHighIdx <= 2); + + routeState.routeProgressAdvancedAtMs = 0L; + Rs2Walker.stabilizeRouteProgressWithRawWatermark(newRaw, newSmoothed, 0, GOAL, newRaw.get(5)); + assertNotEquals(0L, routeState.routeProgressAdvancedAtMs); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index 2d63af8207c..5d63e79381f 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 @@ -2570,4 +2570,41 @@ public void postDoorTarget_toleratesMissingInputs() { assertNull(Rs2Walker.selectPostDoorRouteTarget(route, p, route.get(1), p, new java.util.HashMap<>(), 13)); } + + // ---- zoom-aware minimap reach -------------------------------------------------------------------- + // + // The minimap shows 20*4/zoom tiles of radius. Reach follows what the USER's zoom makes visible + // in BOTH directions: zoomed out, big strides (capped at the reachability BFS horizon — beyond + // it a wall between could not be detected); zoomed in, SHORT strides. The first cut of this + // floored at the old flat 11, which quietly broke the zoomed-in half: an 11-tile stride on a + // minimap showing ~8 tiles of radius selects a point on or past the rim. + + private static final int MIN_REACH = 5; + private static final int CAP = 18; + private static final int FALLBACK = 11; + + @Test + public void zoomAwareReach_zoomedOutStridesFurtherUpToTheBfsHorizon() { + assertEquals(18, Rs2Walker.zoomAwareMinimapReach(4.0, MIN_REACH, CAP, FALLBACK)); // default: 20-2 -> cap + assertEquals(18, Rs2Walker.zoomAwareMinimapReach(2.0, MIN_REACH, CAP, FALLBACK)); // fully out: 38 -> cap + } + + @Test + public void zoomAwareReach_zoomedInStridesShorter() { + assertEquals(14, Rs2Walker.zoomAwareMinimapReach(5.0, MIN_REACH, CAP, FALLBACK)); // pinned-era zoom: 16-2 + assertEquals(11, Rs2Walker.zoomAwareMinimapReach(6.0, MIN_REACH, CAP, FALLBACK)); // 13-2 + // Fully zoomed in the visible radius is ~8: the stride must SHRINK below the old flat 11. + assertEquals(8, Rs2Walker.zoomAwareMinimapReach(8.0, MIN_REACH, CAP, FALLBACK)); + } + + @Test + public void zoomAwareReach_extremeZoomStopsAtTheFunctionalFloor() { + assertEquals(MIN_REACH, Rs2Walker.zoomAwareMinimapReach(16.0, MIN_REACH, CAP, FALLBACK)); // 5-2=3 -> floor + } + + @Test + public void zoomAwareReach_degenerateZoomFallsBackToTheFlatReach() { + assertEquals(FALLBACK, Rs2Walker.zoomAwareMinimapReach(0.0, MIN_REACH, CAP, FALLBACK)); + assertEquals(FALLBACK, Rs2Walker.zoomAwareMinimapReach(-1.0, MIN_REACH, CAP, FALLBACK)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java index 5bc06513de2..c8a6ad751f5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java @@ -93,10 +93,12 @@ public void startingAWalkResetsRouteProgress() { routeState.routeProgressIdx = 42; routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + routeState.stagnationReplansSpent = 2; Rs2Walker.resetWalkSessionState(); assertEquals(-1, routeState.routeProgressIdx); assertEquals(0L, routeState.routeProgressAdvancedAtMs); + assertEquals("a fresh walk owes a fresh stagnation budget", 0, routeState.stagnationReplansSpent); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java index 2d066ecf5cc..267d585c48b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java @@ -108,4 +108,68 @@ public void interactionRejectsNonPositiveRangeAndNullPlayer() { wp(3200, 3200), 0)); assertFalse(Rs2DoorGeometry.isDoorInteractionWithinRange(null, wp(3200, 3200), null, null, null, 2)); } + + // ---- playerBeyondWallFace -------------------------------------------------------------------- + // + // THE STRONGHOLD GATE BOUNCE (2026-08-12). A west-facing moves-you Gate of War at (1887,5244); + // the route step (1886,5244)->(1887,5243) crossed its face DIAGONALLY, and the gate deposited + // the player at (1887,5244) — off the planned to-tile — so the segment-based crossing test + // answered false while the raw scan's backtrack window kept re-finding the gate. Each re-click + // carried the player back through it. + + private static final int WEST = 1; + private static final int NORTH = 2; + private static final int EAST = 4; + private static final int SOUTH = 8; + + /** The bounce itself: carried past the face, even a tile off the planned to-tile, is crossed. */ + @Test + public void depositedBeyondTheFaceIsCrossedEvenOffThePlannedTile() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1887, 5244))); + } + + /** Approaching from the near side — including standing ON the approach tile — is not crossed. */ + @Test + public void approachingTheFaceIsNotCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1885, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1886, 5244))); + } + + /** Standing on the wall's own tile counts as its side of the face: the second Stronghold gate. */ + @Test + public void standingOnTheWallTileIsBeyondAWestFaceApproachedFromTheWest() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1904, 5242), + wp(1903, 5242), wp(1904, 5242))); + } + + /** The same boundary read from the other direction: crossing east-to-west is symmetric. */ + @Test + public void crossingIsSymmetricAcrossTheFace() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1886, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1888, 5244))); + } + + @Test + public void everyCardinalFaceDividesAlongItsOwnAxis() { + // East face of (10,10): boundary between x=10 and x=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(9, 10))); + // North face of (10,10): boundary between y=10 and y=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(NORTH, wp(10, 10), wp(10, 10), wp(10, 11))); + // South face of (10,10): boundary between y=9 and y=10. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 9))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 10))); + } + + /** A corner wall's face does not divide the plane along one axis: never claim crossed. */ + @Test + public void cornerWallsNeverReadAsCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(16, wp(10, 10), wp(9, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(128, wp(10, 10), wp(9, 10), wp(11, 10))); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java index 4b72dc07b9f..ff836aa7a86 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java @@ -3,6 +3,7 @@ import org.junit.Test; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; /** @@ -44,4 +45,74 @@ public void expiredWindowThrottlesNothing() { assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( CLICKED_AT, 0L, false, FULL, CROSS)); } + + // --- Door strike-out: registerDoorCrossFailure ------------------------------------------------- + // + // Seeded from the Tithe Farm incident (2026-08-12): Farm door 27445 refused to pass a seedless + // player, and with no strike-out the walker ping-ponged door->recovery for 4+ minutes until a + // human cancelled it. Three concluded-but-uncrossed attempts must strike the edge out. + + private static final long DECAY = 300_000L; + private static final int LIMIT = 3; + private static final String EDGE = "1804,3501,p0->1805,3501,p0"; + + private static java.util.Map strikes() { + return new java.util.HashMap<>(); + } + + @Test + public void thirdConclusiveFailureStrikesOut() { + java.util.Map map = strikes(); + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT)); + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 13_000L, DECAY, LIMIT)); + assertSame(Rs2DoorHandler.DoorStrike.STRIKE_OUT, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 25_000L, DECAY, LIMIT)); + // The strike-out consumed the entry: the edge starts fresh if it is ever attempted again. + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 26_000L, DECAY, LIMIT)); + } + + /** A moving or cancelled sample proves only that the approach was in flight — the Wydin lesson. */ + @Test + public void inconclusiveSamplesNeverCount() { + java.util.Map map = strikes(); + for (int i = 0; i < 10; i++) { + assertSame(Rs2DoorHandler.DoorStrike.NOT_COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, false, 1_000L + i, DECAY, LIMIT)); + } + assertTrue(map.isEmpty()); + } + + /** Strikes older than the decay window reset; two failures an hour apart are not a pattern. */ + @Test + public void staleStrikesDecay() { + java.util.Map map = strikes(); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); + // Third failure arrives after the decay window: the old two evaporate, count restarts at 1. + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L + DECAY + 1, DECAY, LIMIT)); + } + + @Test + public void edgesCountIndependently() { + java.util.Map map = strikes(); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, "other-edge", true, 3_000L, DECAY, LIMIT)); + } + + /** A successful crossing forgives accumulated strikes (transient refusals must not accrue). */ + @Test + public void successfulCrossingClearsStrikes() { + java.util.Map map = strikes(); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); + Rs2DoorHandler.clearDoorCrossFailures(map, EDGE); + assertSame(Rs2DoorHandler.DoorStrike.COUNTED, + Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 3_000L, DECAY, LIMIT)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java index 947c3910332..91fa0fa63d8 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java @@ -42,6 +42,48 @@ private static Map reachable(WorldPoint... tiles) return map; } + // ---- forwardScanStartIndex ------------------------------------------------------------------ + // + // THE STRONGHOLD GATE BOUNCE (2026-08-12). A moves-you gate carried the player one raw tile + // through; the next smoothed point sat nine tiles out, so the closest smoothed index stayed on + // the near-side start tile — which now read unreachable through the auto-closed gate. Recovery + // chased the spent tile, clicked the same gate from the far side, and bounced every ~6s. + + /** Player one raw tile past the start: the anchor must advance off the spent tile. */ + @Test + public void anchorAdvancesPastRouteTilesTheRawPositionHasPassed() + { + int[] smoothedToRaw = {0, 9, 18, 27}; + assertEquals(1, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 1)); + // Deeper in: raw position 19 has spent indices 0..2. + assertEquals(3, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 19)); + } + + /** Standing at (or before) the start tile's raw position: nothing is spent. */ + @Test + public void anchorHoldsWhenTheRawPositionHasNotPassedTheStart() + { + int[] smoothedToRaw = {0, 9, 18}; + assertEquals(0, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 0)); + assertEquals(0, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, -1)); + } + + /** No evidence of "behind" must not read as "spent": an unmapped entry stops the advance. */ + @Test + public void unmappedEntriesStopTheAdvance() + { + int[] smoothedToRaw = {0, -1, 18}; + assertEquals(1, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 19)); + } + + /** Everything behind: the anchor clamps to the last index rather than running off the route. */ + @Test + public void anchorClampsToTheLastIndex() + { + int[] smoothedToRaw = {0, 9, 18}; + assertEquals(2, FrontierDecision.forwardScanStartIndex(smoothedToRaw, 0, 999)); + } + // ---- earliestBlockedIndex ------------------------------------------------------------------- /** diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java index 6cd92d43c82..eccb393b561 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecisionTest.java @@ -123,4 +123,76 @@ public void exemptRunIsBoundedSeparatelyFromTheIterationCap() assertTrue(TailDecision.isExemptRunTooLong(25, 24)); assertFalse("a disabled cap must not fire", TailDecision.isExemptRunTooLong(1_000, 0)); } + + // --- Route stagnation: the oscillation bound ------------------------------------------------- + // + // Seeded from the Tithe Farm incident (2026-08-12): the walker ping-ponged between two tiles for + // 4+ minutes. The wall-clock budget (observe-only, sized for whole journeys) and the exempt-run + // counter (resets on any movement) both missed it; the signal that never lied was the route + // progress index, which sat at 7/10 the entire time. + + private static final long STAGNATION_BUDGET = 60_000L; + + @Test + public void recentProgressIsNotStagnation() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(100_000L, 100_000L + STAGNATION_BUDGET, STAGNATION_BUDGET, 0, 2)); + } + + @Test + public void noRouteYetIsNotStagnation() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(0L, 10_000_000L, STAGNATION_BUDGET, 0, 2)); + } + + @Test + public void aDisabledBudgetNeverFires() + { + assertEquals(TailDecision.StagnationAction.NONE, + TailDecision.decideRouteStagnation(100_000L, 10_000_000L, 0L, 0, 2)); + } + + /** One millisecond past the budget: replan while replans remain, exhaust when they are spent. */ + @Test + public void stagnationSpendsReplansThenExhausts() + { + long stale = 100_000L; + long now = stale + STAGNATION_BUDGET + 1; + assertEquals(TailDecision.StagnationAction.REPLAN, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 0, 2)); + assertEquals(TailDecision.StagnationAction.REPLAN, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 1, 2)); + assertEquals(TailDecision.StagnationAction.EXHAUSTED, + TailDecision.decideRouteStagnation(stale, now, STAGNATION_BUDGET, 2, 2)); + } + + // --- Tail re-click suppression ---------------------------------------------------------------- + // + // Seeded from the distance=0 dither (2026-08-12): ~10 re-clicks in 7 seconds on the last tile, + // each minimap click quantizing onto a neighbour of the goal while the player was already moving. + + /** Moving inside the band: the click in flight already ends at the goal — leave it alone. */ + @Test + public void movingInsideTheBandSuppressesTheReclick() + { + assertTrue(TailDecision.suppressTailReclick(true, 0, 5)); + assertTrue(TailDecision.suppressTailReclick(true, 5, 5)); + } + + /** Mid-route chaining while moving is how the walker flows; only the tail band suppresses. */ + @Test + public void movingBeyondTheBandStillChains() + { + assertFalse(TailDecision.suppressTailReclick(true, 6, 5)); + } + + /** A stationary player near the goal needs the follow-up click — never suppress it. */ + @Test + public void stationaryPlayersAreNeverSuppressed() + { + assertFalse(TailDecision.suppressTailReclick(false, 0, 5)); + assertFalse(TailDecision.suppressTailReclick(false, 3, 5)); + } } diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index df754155e18..5ba010db79c 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 @@ -812,7 +812,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWa net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$193(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$213(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$215(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$184(String): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$151(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int From 31f65afd53ab5540b6760edcc6c60084124a0b9a Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 13 Aug 2026 18:43:00 +0100 Subject: [PATCH 52/53] =?UTF-8?q?fix(walker):=20the=202026-08-13=20batch?= =?UTF-8?q?=20=E2=80=94=20B2=20slices=203a-3c,=20door=20ledger=20slices=20?= =?UTF-8?q?1-3,=20Stronghold=20latency,=20goal-object=20and=20wing=20guard?= =?UTF-8?q?s,=20fold=20stall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batched sync of PluginTesting 2ea5c902e6..a46b2a5768 (walker/API scope), all live-verified on the Stronghold of Security corridor: - B2 slices 3a-3c: the walk pass reads one world snapshot, re-captured at every blocking branch and per segment iteration - Door-attempt ledger slices 1-3: DoorAttemptLedger owns ATTEMPTED (cooldowns + latest claim), REFUSED (strikes + walk-scoped blocks) and the tile facets (opened-suppression + session blacklist); Rs2DoorHandler reduced to the key builder and global-cooldown pair - Stronghold gate latency: door-leg stage instrumentation, crossed-face release in the await, dialogue-or-crossing release with distance-scaled budget in handleStrongholdOfSecurityAnswer (5.4s/gate constant -> 1.5-3s) - A crossed door satisfies nothing (conquered-door fall-through) and an unreachable fallback click arms nothing - Goal-object rule: an object on the goal tile is the destination, not an obstacle (door_skip_goal_object) - Walled-net learning defers to doors ADJACENT to the edge (double-gate wing) - Fold stall ended: scan past behind/branch tiles, conquered doors resolve in both route-door classifiers Method: three-way merge-file against base 2ea5c902e6 (Rs2Walker merged clean, walker-fix's three recovered fixes preserved); guardrail baseline regenerated on this branch, delta confined to walker entries. Full suite green here. Co-Authored-By: Claude Opus 5 --- docs/walker-fix-plan-2026-08-10.md | 571 ++++++++++++++++++ .../microbot/util/walker/Rs2Walker.java | 488 ++++++++++----- .../util/walker/door/DoorAttemptLedger.java | 257 ++++++++ .../util/walker/door/Rs2DoorHandler.java | 92 --- .../util/walker/door/Rs2DoorProbe.java | 14 +- .../util/walker/door/Rs2WalkerAwaits.java | 20 + .../util/walker/state/WalkerRouteState.java | 4 - .../util/walker/Rs2WalkerUnitTest.java | 99 ++- .../walker/WalkSessionStateResetTest.java | 25 +- .../walker/door/DoorAttemptLedgerTest.java | 289 +++++++++ .../util/walker/door/Rs2DoorHandlerTest.java | 69 --- .../client-thread-guardrail-baseline.txt | 53 +- 12 files changed, 1616 insertions(+), 365 deletions(-) create mode 100644 docs/walker-fix-plan-2026-08-10.md create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java diff --git a/docs/walker-fix-plan-2026-08-10.md b/docs/walker-fix-plan-2026-08-10.md new file mode 100644 index 00000000000..1d418013d31 --- /dev/null +++ b/docs/walker-fix-plan-2026-08-10.md @@ -0,0 +1,571 @@ +# Walker Fix Plan — 2026-08-10 + +_Companion to [`walker-audit-2026-08-10.md`](walker-audit-2026-08-10.md). Fixes the five defects it +found, and closes the loop that keeps producing them._ + +## Goal + +Stop the walker producing new failure modes every time an old one is fixed. + +The audit's five defects are worth fixing on their own, but the reason they existed is that +`processWalk` has no test seam, so each fix is a guard added blind and verified by walking around +in-game. This plan fixes the defects **in an order that builds the seam as a side effect**, so the +sixth defect is caught by a test instead of by the user. + +## Non-goals + +- **No from-scratch rewrite.** 13.7k lines of load-bearing walker; the previous audit already + rejected big-bang and it was right. +- **No door-cascade unification.** Assessed twice as net-negative (`walker-migration-harness.md`): + the cascade's complexity is essential scenario diversity, not accident. Leave it. +- **No more leaf-helper extraction.** That well is dry — the decision layer is already decomposed and + tested. The missing seam is for the *loop*. +- **No new guards inside `processWalk`.** If a fix needs one, it belongs behind a phase-A/B seam. + +## Rules this plan follows + +Earned the hard way in this repo; each has a scar behind it. + +1. **Refactor and fix are separate commits.** A refactor must be provably inert before the behaviour + change lands on top of it. +2. **Throttle actions, never gate correctness checks on a throttle.** (The Port Sarim wall-click bug.) +3. **Every walker incident gets a corpus row** in `WalkerRouteCorpusTest`. +4. **Preserve log wire strings.** Live debugging here is log-driven; renaming an exit reason blinds + the only diagnostic that works. +5. **Run the FULL suite, not a filtered one** — filtered walker runs miss ordering interactions. +6. **Regenerate the client-thread guardrail baseline deliberately**, diffing +/- with lambda indices + stripped, never blind. + +--- + +## Phase A — Type the control flow, fix termination + +Findings #1 (`isRouteProgressExit` under-covers) and #4 (tail budget is not a bound). +**All headless-verifiable. No live walk needed for A1.** + +### A1. `WalkExit` enum — provably inert + +Replace `String exitReason` with an enum in `util/walker/state/WalkExit.java`. + +``` +enum WalkExit { + END_OF_PATH("end-of-path", …), + INTERIM_IN_FLIGHT("interim-in-flight", …), + … + ; + String wireName(); // EXACT existing string — logs must not change + boolean isProgress(); // today's isRouteProgressExit + boolean isTailExempt(); // today's :3401-3405 list + boolean isDoorLike(); // today's shouldCanvasNudgeAfterDoorLikeExit +} +``` + +- **42 constants**, from the 47 assignment sites at `:2156`–`:3299`. Note `door-edge-waiting-retry` + (`:2568`) is produced inside a ternary and does **not** appear in a grep for `exitReason = "…"` — + enumerate from the audit's list, not from a fresh grep, or you will miss it. +- `off-path-deferred:` becomes `OFF_PATH_DEFERRED` plus a separate local `String + offPathDeferDetail`; `wireName()` for logging is `"off-path-deferred:" + detail`. +- Keep the three legacy `String` predicates **unchanged and package-private** for one commit. + +**Verification (this is the point of the step):** `WalkExitTest` asserts, for every constant, that +`isProgress()/isTailExempt()/isDoorLike()` equal the legacy predicates evaluated on `wireName()`. +Green = the refactor changed nothing. This is a characterization test, and it is what makes A2 safe. + +**Risk:** near zero. **Rollback:** single commit revert. + +### A2. Fix the classification + +Now a one-line change per constant, visible in review, each with a comment saying why. + +Reclassify as progress (`isProgress() == true`): `transport-handled-local-reachability`, +`frontier-obstacle-handled`, `local-recovery-click`, `door-suppressed-approach-click`, +`recent-door-edge-nudge`, `door-edge-resolved-fast-click`, `door-edge-resolved-after-wait`, +`door-edge-resolved-after-nearby-wait`, `route-move-in-flight`, `route-fold-continuation-pending`, +and the three in-flight yields `door-settling-yield`, `door-traversal-pending-yield`, +`transport-settling-yield`. + +Update `WalkExitTest` deliberately — the diff to that test **is** the record of what behaviour +changed, which is exactly what the old `String` version could never give you. + +Then delete the legacy `String` predicates and the `startsWith("door-handled")` prefix rule. That +prefix is the trap that hid half of these; it must not survive. + +**Verification:** headless. Plus one live walk on a known partial route (Tempoross cove is already a +corpus pin) confirming it no longer reports `UNREACHABLE` while advancing. + +**Effort:** A1+A2 ≈ half a day. **This is the highest value-per-risk work in the plan — do it first.** + +### A3. Extract the epilogue as a pure decision + +Lines `:3236`–`:3415` (partial-retry accounting, off-path wait sizing, tail exemption) become +`TailDecision.decide(WalkExit exit, TailState) -> TailAction {CONTINUE, CONTINUE_EXEMPT, REPLAN_RETRY, +UNREACHABLE, ARRIVED, WAIT_OFF_PATH}` in `util/walker/recovery/`. Pure, fully injected, no statics. + +Fold the **wall-clock budget** (finding #4) in here, since this is the only place that decides +whether the loop goes round again: + +- add `walkStartedAtMs` + `WALK_WALL_CLOCK_BUDGET_MS` (generous — 5 min — this catches livelocks, + not slow walks) and a **separate cap on consecutive exempt iterations** (~24), so + `processWalkTail--` can no longer produce an unbounded loop; +- **ship it WARN-only for one iteration of live testing** (log `walk_budget_exceeded`, don't act), + then enforce. A budget that aborts a working long walk is worse than the livelock. + +**Verification:** `TailDecisionTest`, decision table. Pin as rows: the partial-route door case from +A2, a permanently-exempt interim loop, and a genuine unreachable. + +--- + +## Phase B — One consistent world per iteration + +Findings #2 (stale reachability drives recovery) and #5 (post-transport leak). **Needs live walks.** + +### B1. Stop the post-transport window leaking across walks + +Small and independent — land it early. + +- `markWalkSessionStart` (`:395-397`): call `clearRecentTransportContext()` instead of nulling only + the three location fields. The timestamp `lastTransportHandledAtMs` is what every window check + actually reads. +- Clear it on the exits that currently don't: exception (`:3436`), tail-exceeded (`:3449`), and the + `walkCancelledDiag` returns. +- Delete `lastTransportHandledAtLocation` (finding #6, write-only). + +**Verification:** unit test on the session-start/exit paths asserting the timestamp is zero. +Live: take a staircase, interrupt the walk, immediately start a second walk — the second must not +log `post_transport_segment_handler_skip` / `post_transport_raw_scene_scan_skip`. + +**Risk:** low, but it *re-enables* handlers that were being skipped, so the second walk now does more +work than before. That is the intent; watch for door double-handling in the first live pass. + +> **B2 slice 1 DONE** — `PluginTesting` b25e436043, `walker-fix` 154569dec9. This is the *behaviour* +> half: the reachable set is now recaptured whenever the player is no longer standing where it was +> built, by reading the `reachableTilesCacheOrigin` that had been declared, assigned twice and read +> never. Two bugs fell out of the recapture that already existed — it used a **smaller** radius than +> the original capture (18 vs 39), so it could manufacture "unreachable" for a tile the wider map had +> already reached; and it was gated on tile *proximity* rather than on anything having *changed*, so +> it rebuilt when nothing had moved and stayed stale when everything had. Exactly inverted. +> +> **The plan's other suggestion here was wrong** and is not done: moving `recovery_position_stale` to +> the top of the branch would defeat it. It does not duplicate the recapture — it covers a different +> window, the seconds the door cascade itself spends between the verdict and the recovery click. +> +> **The structural half below — one `WalkTick` threaded through the segment, frontier and +> click-selection reads — remains open.** + +### B2. `WalkTick` — capture the world once + +`WalkLoopSnapshot` (`:466-482`) already exists and already captures `playerLoc` + +`closestReachableTiles`. Grow it rather than inventing a new type: + +``` +WalkTick { // captured once per tail iteration + WorldPoint playerLoc; int plane; + Map reachable; // was reachableTilesCache + List path, rawPath; int[] smoothedToRaw; int indexOfStartPoint; + boolean nearPath, moving, doorSettling, transportSettling, recoveryInFlight, + postTransportWindow, partialPath, inInstance; + long capturedAtMs; +} +``` + +Migrate in **slices**, one commit each, so a regression bisects to a small diff: + +1. the segment-loop gating reads (`:2337`–`:2471`), +2. the unreachable-frontier reads (`:2474`–`:2882`), +3. the click-selection reads (`:2896`–`:3210`). + +Two behaviour changes ride along, and they are the finding-#2 fix: + +- **Invalidate on player-tile change.** `reachableTilesCacheOrigin` is already assigned at `:2252` + and `:2481` and never read — wire it: when the live player tile differs from the tick's origin, the + tick is stale. Re-capture rather than deciding on it. +- **Move the `recovery_position_stale` check to the TOP** of the unreachable branch (`:2489`), + before the door cascade at `:2562`–`:2710`. Today it sits at `:2733`, downstream of everything it + is supposed to protect. + +**Verification:** headless for the tick construction; **live walk required** for each slice — a door +route, a multi-transport route, and an MLM rockfall (the standing three). + +**Risk:** highest in the plan. Mitigation: slice it, one live walk per slice, revert per slice. + +--- + +## Phase C — Fix the movement sensor + +Finding #3. **Needs live walks.** + +> **C1 DONE** — `PluginTesting` 29c93dfafb, `walker-fix` a1659793d4. Implemented as a tile-change +> *recency* window rather than the per-sample position diff sketched below: a walking step is ~600ms +> and the check samples faster, so demanding a delta every sample would declare every healthy walk +> stalled. The pose flag is now credited only when a real tile change happened within 2.5s — several +> steps of slack, no help at all to a player who is only rotating. Tracked on its own +> `lastTileChangeAtMs` because `lastMovedTimeMs` is deliberately refreshed elsewhere to buy grace and +> therefore cannot answer "is the player really covering ground". **C2 remains open** and should wait +> for live logs showing C1 behaving. + +### C1. Walker-local `isPlayerAdvancing()` + +**Do not change `Rs2Player.isMoving()`** — 65 walker call sites and every other plugin depend on +today's pose semantics, and changing it globally is an unrelated blast radius. + +Add, in the walker only, a position-diff sensor: player tile changed within the last N ms, OR pose +says moving AND the tile changed at some point in this window. Use it **only in stall accounting** +first (`checkIfStuck` `:11838-11847`), where the pose reading is actively wrong — a player wedged at +a door who keeps turning currently reads as "moving near path" and resets the stall clock forever. + +> **C2 DONE** — `PluginTesting` b735b615b3, `walker-fix` 983ce8aaa2. The 12s post-click grace was +> **deleted** rather than shortened: after C1 it is pure residue, because `checkIfStuck` already +> refreshes the clock on every real tile change, so the blanket only ever bound the case where the +> player was *not* moving — precisely what the clock exists to measure. The interim multiplier is the +> same mistake in smaller print (walking toward an interim refreshes the clock; the +> stationary-with-interim case is rescued by the idle nudge in ~1–2s) and goes 1.75 → 1.25. +> **The 12s base stays**: the longest *legitimate* motionless stretch measured across four live runs +> is ~7.1s waiting out a transport handoff, and cutting the base buys a walker that interrupts its own +> ships. Budget is now **12s plain / 15s with an interim live / 24s worst case**, down from 36s, and +> pinned in wall-clock seconds so it cannot drift silently. `processWalk` 1630 → **1623**. + +### C2. Re-tune the stall budget + +Once C1 lands, the 12 s `MINIMAP_CLICK_STALL_GRACE_MS` blanket refresh at `:1998-2002` exists to +paper over the bad sensor. Reduce it, and re-check `stallThresholdMs()` multipliers. Target: worst +case from **~36 s down to ~15 s** before recovery engages. + +**Verification:** live. Deliberately wedge the player (stand behind a closed door mid-route) and +confirm recovery engages inside the new budget. Add a corpus/telemetry row. + +**Risk:** moderate — a too-aggressive stall clock causes premature replans, which is its own +pathology. Change one number at a time; C2 is the item most likely to need a second pass. + +--- + +## Phase D — The seam that ends the whack-a-mole + +Only worth doing after A–C prove the pattern. This is where the audit's P1 lands. + +- ~~**D1. Segment gating policy.**~~ **DONE** — `PluginTesting` afa96cca60, `walker-fix` 819866a317. + `segment/SegmentGate` owns the decision as one enum-returning function with a 12-case table. The two + skip reasons and their precedence are pinned, the log strings live on the constants instead of a + ternary, and `mayDispatchDoorAtRange` is named for the invariant it protects — a *skipped* segment + was never examined, so it withdraws the right to click a door at range, which is exactly the + coupling that produced the Falador U-turn out of two booleans that never appeared in the same + expression. Behaviour-preserving. `processWalk` 1641 → **1630**. +- **D2. Frontier cascade.** `:2489`–`:2882` → pure `FrontierDecision`, interactions stay in + `Rs2Walker` (the proven functional-core/imperative-shell split from + `RouteRecovery.decideRecoveryClick`). ~10 ordered branches → one decision table. Seed it with + **every pinned incident** already written up in `walker-migration-harness.md`: Clock Tower + backtrack, Port Sarim cooldown wall-click, Wydin door poisoning, Falador U-turn, stepping stones. + +After D, `processWalk` should be materially under the guard ceiling. + +## Phase E — Make it stick + +- Ratchet `MAX_LEGACY_PROCESS_WALK_LINES` **down** as lines leave, and treat it as a hard gate. It + was raised 1628→1647 to accommodate growth; that must not happen again. +- Keep the corpus rule: every walker incident gets a `WalkerRouteCorpusTest` row. + +--- + +## Sequencing and effort + +| # | Item | Verify | Live walk? | Effort | +|---|---|---|---|---| +| ✅ A1 | `WalkExit` enum, inert | characterization test | no | done | +| ✅ A2 | Fix classification | headless ✓ / partial route pending | **yes — pending** | done | +| ✅ B1 | Transport-context clear | headless ✓ / interrupted walk pending | **yes — pending** | done | +| ✅ A3 | `TailDecision` + budget (observe-only) | decision table ✓ | observe logs | done | +| C1 | `isPlayerAdvancing()` in stall | live wedge test | yes (1) | 2h | +| C2 | Re-tune stall budget | live | yes (1) | 2h | +| B2 | `WalkTick`, 3 slices | headless + live per slice | yes (3) | 2d | +| D1 | Segment policy pure | decision table | yes (1) | 1d | +| D2 | Frontier cascade pure | decision table | yes (2) | 2–3d | +| E | Guard ratchet down | CI | no | — | + +**A1 → A2 → B1 is the first slice**: half a day, kills the `UNREACHABLE`-while-walking bug and the +cross-walk suppression leak, and leaves a characterization test that makes everything after it safer. + +### Progress log + +- **A1 landed** — `PluginTesting` 48e1df11a0, `walker-fix` 85211d9622. Provably inert: the + characterization test passes on both branches. The architecture guard caught the three lines it + added and was ratcheted **down** 1647 → 1646 (that guard does not exist on `walker-fix`, so it was + dropped from that cherry-pick rather than resurrected). +- **A2 landed** — `PluginTesting` 31dd7f7f37, `walker-fix` 4a5b49f562. Fourteen reasons + reclassified; divergence from the old classification pinned in both directions. + **Still owed: one live walk on a partial route** to confirm the `UNREACHABLE`-while-advancing + report is gone. +- **B1 landed** — `PluginTesting` 52e3f9b73e, `walker-fix` 8c4f63fa63. Clearing at walk-session + start turned out to be sufficient on its own: `walkWithStateInternal` is the only caller of + `markWalkSessionStart` and the only route into `processWalk` (banked walks included), so the + walk-ending paths did not each need their own clear and `processWalk` was not touched at all. + Also deleted the write-only `lastTransportHandledAtLocation`, which removes a + `Rs2Player.getWorldLocation()` client-thread hop from the transport handoff. + **Still owed: one live walk** — take a staircase, interrupt the walk, start a second walk + immediately, and confirm no `post_transport_segment_handler_skip` / + `post_transport_raw_scene_scan_skip` in the new walk's log. +- **A3 landed** — `PluginTesting` 970e6ab320, `walker-fix` 7595dcac67. The epilogue's partial-retry + accounting and tail exemption moved into a pure `TailDecision` with a 13-case decision table. The + wall-clock budget and the consecutive-exempt-iteration cap ship **observe-only** (they log, they + do not abort) — decide enforcement from live logs. `processWalk` 1646 → **1641**; guard ratcheted + down again. + +**Phase A is complete.** The full walker/route/door/pathfinder/collision suite is green on both +branches — the first clean full run of this effort. + +### First live log (2026-08-10 farm run) — two corrections, one new finding + +`PluginTesting` a4a6addfdb / `walker-fix` f8b90cf3eb. The run succeeded end to end; a walk arriving +is not evidence that it was right. + +- **The walled-route net was learning blocked edges from the BFS frontier.** Its proximity guard is + Chebyshev while the BFS budget counts *steps*, so a tile thirteen tiles away as the crow flies but + thirty steps away around a building reads as walled. At the Port Sarim / Land's End docks a click + to (2760,3238) was refused and the edge (2759,3230)→(2759,3231) learned — and nine seconds later + the walker was standing on (2760,3238). Refusing the click is conservative and has fallbacks; + writing it into the learned-blocked-edge store poisons routing for the session. An edge is now only + convicted when its near end is strictly *inside* the frontier. +- **`MAX_CONSECUTIVE_EXEMPT_ITERATIONS = 24` was wrong.** A healthy Catherby→Ardougne leg yielded + `interim-in-flight` **28 times in a row** while steadily covering ground — that is just what + travelling between minimap clicks looks like. A bound on yields is a bound on walking. The run now + resets whenever the player tile changes, so it bounds yielding *while stationary*. Shipping this + observe-only is what made the mistake cost a log line rather than an aborted walk. + +**Still-unexplained, worth watching:** ~4s of total log silence during walk startup after a +walled-edge replan, *including* the 1/second heartbeat. Per the heartbeat's own contract that means +the thread was blocked inside a wait, not spinning. The startup tmarks (`pf_wait_retry`, `pf_ready`, +`path_snapshot`) are deduped once-per-walk, so a walk that replans during startup goes blind exactly +when it is slowest. Removing the false convictions removes most occurrences; the blind spot remains. + +**Not exercised by this log:** every route came back `TARGET_REACHED`, so no partial path and no A2 +coverage; and no walk began inside a previous walk's 15s post-transport window, so no B1 coverage +either. Both still owe a live test. + +### Second live log (2026-08-10 18:32, Ardougne) — the fixes hold, and a stile costs 20s + +`PluginTesting` b9b22b370d / `walker-fix` f26c1496c5. + +**Confirmed working:** not one `walled_edge_learned` line in the whole run, against three in the +earlier log — the frontier fix and the catalog-transport guard are both holding. And +`early_exit r=frontier-obstacle-handled` appears, so the A2 reclassification is live and firing. + +**New defect: a moves-you obstacle was owned by the door cascade.** `"stile"` is a door-name +fragment, so a catalog transport at (2637,3350) with action `Climb-over` classified as door-like on +its NAME and went to the door handler — whose completion contract is "the blocked edge became +passable", which is unsatisfiable for something you climb over. It logged +`door_edge_post_unresolved` and the walk then spent **twenty seconds**: six refused route clicks, a +recovery click onto the far side of the fence, a stall, a replan and an idle nudge, before the +transport handler got the same object and crossed it in a single action. + +The action now wins over the name: a catalog row whose action moves the player *across* +(Climb-over / Climb-through / Squeeze-through / Cross) is not door-like, so +`shouldDeferDoorHandlingToTransport` gives it to the handler that can complete it. Opening actions +are untouched. + +This is the third obstacle of this class to need correcting — the Varrock museum guard barrier and +the Port Sarim back-room door were both fixed as individual data rows. Deciding on the action turns +a growing list of coordinates into a rule. + +### Watch the logs for these two + +Both are new and deliberately inert. If either appears on a healthy walk, the threshold is wrong and +should be raised before anyone considers enforcing it: + +- `walk exceeded its 300000ms budget … probable livelock` +- `N consecutive tail-exempt iterations (exit=…) … yielding without advancing` + +If one appears on a walk that really is stuck, that is the livelock finding #4 predicted, and the +`exit=` value names which yield is spinning. + +### Found in passing: a false alarm, now defused + +`RouteClickTargetRegressionTest > theHistoricDeviatingClickIsNotOnTheRawRoute` went red during this +work and was **wrongly reported here as a PluginTesting-specific route-data regression**. It is not. +It is a **starvation false positive**, and chasing it cost a round of investigation. + +`calculationCutoffMillis` is a *no-progress* guard. Under CPU contention — a full-suite run, or the +client running alongside the build, which is the normal state of this machine — the search gets +starved and returns a best-effort **partial** path. A partial path wanders through tiles the test +requires to be absent, so it fails in exactly the shape of a real routing change. The apparent +"green on `walker-fix`, red on `PluginTesting`" split was an artifact: the clean-worktree runs were +isolated, the main-tree runs were not. A clean worktree at the *same* PluginTesting commit passes. + +Fixed rather than documented-around: the cutoff goes 10s → 30s, and the route is now verified to +actually reach the goal before any content is asserted, with one retry and then an explicit +`pathfinder starved — INCONCLUSIVE, not a route regression` failure. A starved run can no longer +masquerade as a routing change. + +The deeper flakiness — the pathfinder's per-node random tiebreaker varying equal-cost routes — is +untouched here; a hermetic rework of this test exists on another branch and should not be duplicated. + +### D2 landed (2026-08-12) — `PluginTesting` 89c46b22e7, `walker-fix` 50945afac8 + +Five slices, each compiled and suite-green before the next. `FrontierDecision` now owns: the earliest +blocked route index, the frontier edge, what a door wait *means* once it returns (a six-value outcome +enum that carries its own exit and whether it ends the pass), the yield taken before any door action, +the recovery-index clamp, the step-back out of a hazard, the three-way target precedence, the exit for +a recovery click, and whether a tail scene click is worth trying. 41 rows, seeded from named incidents +— the Clock Tower rewind, the stepping-stone origin precedence, the fall-through wait, the hazard +asymmetry. `processWalk` 1623 → **1598**; the guard was ratcheted down after each slice. + +Four things surfaced that reading the cascade had not: two `!gateDoorInteraction` guards that could +never be false at their call sites, a precedence that only worked because the raw-gated target +happened to be checked for hazards while the shortcut origin was not, an off-by-one in the clamp's +lower bound when the frontier sat at the route position, and a door-wait path with no exit assigned. + +**What is still stateful and therefore still in the shell:** `findForwardReachableRecoveryIndex`, the +interim/sticky bookkeeping, and rejoin. Those read and write route state across iterations, so they +want B2's `WalkTick` snapshot first — extracting them ahead of it would just move the mutation. + +### The two regressions, reverted (2026-08-12) + +The short-walk fast path and the zoom-aware minimap stride are both gone from both branches. The fast +path broke `distance = 0` — the walker stopped wanting to end *on* the goal tile, which the user +caught in a live run before any test did; the ceiling for "short" is not the problem, the assumption +that a short walk needs no arrival check is. The zoom stride is reverted alongside it because it landed +in the same pair and its benefit was never measured. All five call sites are back on +`NORMAL_MINIMAP_REACH_EUCLIDEAN`. + +### The Tithe Farm battery (2026-08-12 evening) — 8b49bdba02 and the follow-up + +An agent-server test session produced four fixes in one commit (door strike-out, route stagnation +bound, tail dither, minecart menu 947), then two post-restart live walks validated and corrected them: + +**Lovakengj → Varrock (healthy long walk).** Minecart selected its destination through the walker for +the first time ("via minecart menu", handoff expected), ship + gangplank + three ranged doors chained +clean, 2:22 end to end. It also exposed a margin problem in the new stagnation bound: the smoothed +progress index held ONE value (the final segment) through ~50s of honest walking against a 60s budget +— the whole west approach lives inside it, and it loops away from the path end before coming back, so +"closer to the next point" is not a fix either. The signal is now the player's furthest-yet RAW path +index (`stabilizeRouteProgressWithRawWatermark`), which advances tile by tile on that exact walk and +still refuses to advance for a two-tile ping-pong. Pinned in `RouteProgressWatermarkTest`. + +**Varrock → Tithe lobby (the strike-out's first real encounter).** Three concluded attempts → +`door_strike_out` → honest sealed-goal answer in ~25s instead of the previous 4+ minutes. Two +interaction defects surfaced and were fixed: +1. **Withdrawal ordering.** The walk-scoped unlearn ran inside `markWalkSessionStart`, which follows + `setTarget` — so a retry's plan ran against the previous walk's blocks, collapsed to a 1-tile path + and burned the retry. The withdrawal now runs at the top of `walkWithStateInternal`, before any + planning. +2. **The walled-net re-blocked the same edge for the SESSION.** `route_click_walled` learned the door + edge one second after the strike-out had deliberately walk-scoped its own block — the museum + lesson through the side door: the Tithe plugin's later seeded walk-in would find the door + unroutable until restart. `learnWalledRouteEdge` now skips edges hosting a scene door (the same + rule its catalog-transport guard already encoded: a shut door is not a wall). + +### Sync note — hand-apply, do not cherry-pick + +The scripted cherry-pick of these slices onto `walker-fix` produced a commit that *built* while having +silently dropped slice 2's test additions. The branches' `Rs2Walker` copies have diverged enough +(~400 lines, different section ordering, mixed line endings) that patch application succeeds against +the wrong context. The surgical route — content-search boundaries, file-by-file, compile and full suite +on the target branch — is the only one that is honest here. Guardrail baselines are per-branch and were +regenerated on `walker-fix`, not copied; the delta was verified as pure lambda renumbering, 0 non-lambda +lines, before accepting it. + +## Phase D3 — the door-attempt lifecycle (planned 2026-08-12, late) + +> "currently you change 1 thing to fix something, you rip the patch off something else." — the +> user, after watching the Stronghold corridor expose four serial pass-consumers in one evening. +> That is what implicit contracts between eleven independent door-state holders guarantee, and what +> one owner with an explicit lifecycle makes structurally impossible. + + +The audit called `Rs2Walker` a god-class where the stalls live; the Stronghold of Security's chained +gates spent one evening proving it empirically. Four pass-consumers were found and fixed serially — +the raw scan's backtrack window (crossed-face guard, 160a5fbe4f), the recovery anchor +(8aabe7cceb), the recent-attempt nudge's victory lap over a conquered door, and the fallback click +arming dead interims (e192da4a46) — and every one was a DISAGREEMENT between the door subsystem's +scattered state stores. There are TEN independent ones: `recentDoorAttemptByEdge`, +`recentlyOpenedStationaryDoors`, `sessionBlacklistedDoors`, `doorCrossFailuresByEdge`, +`walkScopedDoorBlocks`, `routeState.lastDoorAttempt*`, the door settle window, the global +interaction cooldown, `rawScanFocusedDoorIdx`, and `doorEdgesAttemptedThisTail`. Any two of them +can hold contradictory beliefs about one door, and the corridor is dense enough to manifest each +contradiction as a stall. + +The cure is one owner: a **door-attempt ledger** — per-edge records with a lifecycle +(DETECTED → ATTEMPTED → CROSSED | REFUSED | EXPIRED), transitions driven by the geometric truths +that ended tonight's bugs (`playerBeyondWallFace`, `crossedDoorAxis`, the conclusive-sample rule), +strike counting and walk-scoped blocks folded in, and a pure `DoorLifecycle.decide(...)` table +answering the one question every entry path currently answers privately: *may I act on this door, +and if not, why not.* The three entry paths (segment handler, segment probe, raw scan) and the +recovery consumers become reporters and readers of the ledger instead of keepers of private maps. + +> **D3 slice 1 DONE** — `PluginTesting` 2e4df33d14. `DoorAttemptLedger` owns ATTEMPTED: +> `recentDoorAttemptByEdge` and `routeState.lastDoorAttempt*` were the same fact stored twice with +> different lifetimes (the victory-lap disagreement), now two facets of one record set — per-edge +> cooldowns survive walk boundaries, the latest claim is withdrawn at walk start and on an observed +> crossing. Characterization table in `DoorAttemptLedgerTest` (direction-blind cooldown vs +> direction-aware same-edge check, withdraw-claim-keeps-cooldown). Deleted both Rs2DoorHandler +> map-shufflers and the WalkerRouteState triple. Eight stores remain. Live gate PASSED 2026-08-13 +> 12:45: twelve gates, one attempt each, 101s — identical signature to the pre-fold run. + +> **D3 slice 2 DONE** — `PluginTesting` 6730e68e2f. The ledger owns REFUSED: +> `doorCrossFailuresByEdge` + `walkScopedDoorBlocks` folded in as strike counting and +> once-only-draining walk-scoped blocks; Rs2DoorHandler's pass-the-map statics and DoorStrike enum +> deleted. Strike table migrated with two new rows (direction-blind strike accumulation, +> drain-exactly-once). Planner learn/unlearn stays in the shell. Six stores remain: +> `recentlyOpenedStationaryDoors`, `sessionBlacklistedDoors`, the door settle window, the global +> interaction cooldown, `rawScanFocusedDoorIdx`, `doorEdgesAttemptedThisTail`. Live gate: shares the +> next corridor run with whatever slice follows (refactor-only, same wire behaviour). + +> **D3 slice 3 DONE** — `PluginTesting` 58f16db0d9. The ledger owns the tile facets: +> `recentlyOpenedStationaryDoors` (suppress-reclick window; locality, expiry and expire-on-read +> pinned) and `sessionBlacklistedDoors` (session-permanent quest locks; plane-identity pins kept). +> Rs2DoorProbe consults the ledger instead of carrying a Set+Map through its signature; +> Rs2DoorHandler is down to the key builder and the global-cooldown pair. Guardrail baseline: one +> pure rename. Six stores folded, four remain: door settle window, global interaction cooldown, +> `rawScanFocusedDoorIdx`, `doorEdgesAttemptedThisTail`. + +> **D3 requirement #1 LANDED early** — `PluginTesting` b12f9e9944. The goal-object rule +> (`goalTileObjectIsNotAnObstacle`, wire line `door_skip_goal_object`) shipped as a pure guard ahead +> of the ledger's decide table after the Gift of Peace chest cost ~9s on three consecutive corridor +> runs. Narrow by design: wall doors on the goal edge stay handleable, distance-0 walks still open +> honestly, and the skip requires the walk be allowed to finish from the near side (same +> tightFinishThreshold as arrival). The rule folds INTO DoorLifecycle.decide when that table exists. +> Requirement #1 LIVE-VERIFIED 2026-08-13 14:20 — door_skip_goal_object fired at the goal chest, +> walk finished within-distance immediately; the ~9s tax is gone. Same run surfaced requirement #3 +> (NEW): the segment-door site classifies ANY Open-actioned GameObject as a route door — a second +> Gift of Peace chest EN ROUTE (not on the goal) was clicked for 7s. The probe site requires a +> door-ish name; the segment site doesn't (large gates are sometimes GameObjects named "Gate", so a +> naive name filter is wrong). Belongs to the ledger's decide table / DoorLifecycle classification, +> not another point patch — strike-out contains repeats meanwhile. +> Requirement #2 LANDED — 5e18923361: walled-net learning defers to ACTIONED doors ADJACENT to +> the edge (double-gate slave-wing lesson, both live shapes as decision rows; suppression errs safe). Live gate: next corridor run should +> show door_skip_goal_object at the chest and an arrival ~9s sooner. + +> **Fold stall FIXED, LIVE-VERIFIED 2026-08-13 17:42** — `PluginTesting` 716ca779dd. Branch tiles +> now log once and the same pass handles the forward gate (observed twice, including a two-tile +> skip); zero pending exits, zero idle-nudge rescues. Same run: the double-gate wing guard fired on +> the exact 14:00 edge (walled_edge_not_learned), and a mid-walk network logout was recovered by the +> script's auto-retry from mid-corridor without walker pathology. Two missing truths: the pass must not END at a +> behind/branch tile (continue scanning; the next gate gets handled the same pass), and a wall door +> whose face the player is beyond is RESOLVED in both route-door classifiers (conquered moves-you +> gates keep their Open action forever and were vetoing the continuation click from the backtrack +> window). Live gate: route-fold-continuation-pending should stop repeating; no idle-nudge rescues +> at gate deposits; corridor drops by the stall cost (~4-26s/run). The CROSSED-event formalization +> still belongs to the ledger's decide table; this fix uses the geometric truth directly. + +Sequencing: after B2's remaining live checks settle. Same slice discipline — one store folded into +the ledger per slice, characterization first, the Stronghold corridor as the live gate for every +slice. The file is 14,003 lines as of tonight (GROWN ~2k since the audit measured 12k, even as +processWalk shrank under its guard): D3 is the first phase whose success metric is the file getting +SMALLER, because each folded store deletes its scattered call sites. + +## Branch policy — settled 2026-08-12 + +**`PluginTesting` is authoritative.** All walker work lands there first; it is the branch actually +run day to day, so it is the branch that produces the live evidence every fix here depends on. + +**`walker-fix` receives batched merges of walker and API-layer changes only — never plugins.** That +is the whole of its remit: `util/walker/**`, `util/pathfinder` / `shortestpath/**`, the walker's data +files, and the shared API/util layer the walker sits on. Plugin work (farming, questing, kudos, +thieving, hunting, …) stays on `PluginTesting` and does not travel, even when it is in the same +commit range. Batch the merge so this costs one sync per group of fixes rather than one per fix. + +Two mechanical rules that have already been paid for once each: + +- **Run `git rev-parse --abbrev-ref HEAD` immediately before every commit.** A cherry-pick has landed + on the wrong walker branch once. +- **Hand-apply; do not cherry-pick.** See the sync note above — the copies have diverged enough that + a patch can apply against the wrong context and drop changes silently while still building. + +Do **not** sync to `Fix-The-Walker` or `WalkerRewrite`; those hold the rewrite, not the fixes. 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 e958884655c..851dcddcaf1 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 @@ -55,6 +55,7 @@ import net.runelite.client.plugins.microbot.util.leaguetransport.LeaguesRegion; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.walker.door.DoorAttemptLedger; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier; import net.runelite.client.plugins.microbot.util.walker.door.DoorProbeContext; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection; @@ -116,6 +117,8 @@ public class Rs2Walker { public static ShortestPathConfig config; // stuck/movement tracking state migrated to WalkerRouteState (see routeState) static volatile WorldPoint currentTarget; + /** The active walk's configured finish distance — the goal-object guard needs it outside processWalk. */ + private static volatile int currentWalkDistance; static int nextWalkingDistance = 10; /** @@ -434,13 +437,12 @@ static void resetWalkSessionState() { // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. clearInterimTarget("walk-start"); - // Same staleness, door flavour: the recent-attempt edge belongs to the PREVIOUS walk, and its + // Same staleness, door flavour: the latest door claim belongs to the PREVIOUS walk, and its // 6s window comfortably spans a script's walk-to-walk gap. A fresh walk re-nudged the old // door — observed as a first_door_edge_nudge pointing BACKWARD at walk start, ~2s of standing - // still (or worse, a step the wrong way) before the new route's first click. - routeState.lastDoorAttemptFrom = null; - routeState.lastDoorAttemptTo = null; - routeState.lastDoorAttemptAtMs = 0L; + // still (or worse, a step the wrong way) before the new route's first click. Per-edge + // cooldowns survive on purpose: hammering one door across two walks is still hammering. + doorAttemptLedger.clearLatestAttempt(); resetRouteProgress(); synchronized (expectedTransportDestinations) { expectedTransportDestinations.clear(); @@ -452,6 +454,11 @@ static WalkerRouteState routeStateForTesting() { return routeState; } + /** Same package (e.g. unit tests) only — not part of the script API. */ + static DoorAttemptLedger doorAttemptLedgerForTesting() { + return doorAttemptLedger; + } + private static void clearRecentTransportContext() { routeState.clearRecentTransportContext(); } @@ -512,14 +519,15 @@ private static final class WalkLoopSnapshot { private final boolean moving; private final boolean animating; private final boolean interacting; - private final HashMap closestReachableTiles; + // Lazy: capture() is cheap enough to run once per SEGMENT iteration; the reachability BFS + // only runs if a consumer actually asks for the closest index (once per snapshot). + private HashMap closestReachableTiles; private WalkLoopSnapshot(WorldPoint playerLoc, boolean moving, boolean animating, boolean interacting) { this.playerLoc = playerLoc; this.moving = moving; this.animating = animating; this.interacting = interacting; - this.closestReachableTiles = getClosestIndexReachableTiles(playerLoc); } private static WalkLoopSnapshot capture() { @@ -532,6 +540,9 @@ private boolean idle() { } private int closestTileIndex(List path) { + if (closestReachableTiles == null) { + closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + } return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, closestReachableTiles); } } @@ -711,6 +722,34 @@ private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalk return cfg; } + /** + * An object standing ON the walk target is the destination, not an obstacle en route. The + * Stronghold's Gift of Peace chest sits on the corridor walk's goal tile: the plan honestly ends + * on the chest's tile, the tile reads sealed, and the blocker scan "opened" the goal itself — + * ~9s of failed traversal per corridor run before arrived-within-distance conceded (observed on + * three consecutive runs, 2026-08-13). Wall doors are exempt: a door on the goal tile's EDGE may + * genuinely need opening to step onto the goal. The skip only applies when the walk is allowed + * to finish from the near side without crossing, so a distance-0 walk onto an openable tile + * still attempts the open honestly. + */ + static boolean goalTileObjectIsNotAnObstacle(boolean wallDoor, WorldPoint target, int configuredDistance, + WorldPoint probe, WorldPoint fromWp, WorldPoint toWp) { + if (wallDoor || target == null || fromWp == null || fromWp.getPlane() != target.getPlane()) { + return false; + } + if (!target.equals(probe) && !target.equals(toWp)) { + return false; + } + int finishThreshold = tightFinishThreshold(target, target, configuredDistance); + return fromWp.distanceTo2D(target) <= finishThreshold; + } + + private static boolean isGoalTileObjectNotObstacle(TileObject object, WorldPoint probe, + WorldPoint fromWp, WorldPoint toWp) { + return goalTileObjectIsNotAnObstacle(object instanceof WallObject, currentTarget, currentWalkDistance, + probe, fromWp, toWp); + } + /** * After opening a door, if the walk goal is still close, scene-click a random walkable tile near the * goal so the next movement is not an immediate minimap path segment (less robotic than @@ -1442,6 +1481,7 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long */ private static WalkerState walkWithStateInternal(WorldPoint target, int distance) { Objects.requireNonNull(target, "walk target"); + currentWalkDistance = Math.max(0, distance); if (isClientThread()) { log.warn("Please do not call the walker from the main thread"); return WalkerState.EXIT; @@ -2017,7 +2057,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // isWalkCancelled and forces EXIT while the flag is still carrying the player. // Partial paths end at an intermediate waypoint (dst still far from {@code target}); // clearing here would drop currentTarget before the partial-path retry/recalc branch. - if (!partialPath && isNear(dst) && routeState.interimTargetWp == null) { + if (!partialPath && isNear(dst, walkLoop.playerLoc) && routeState.interimTargetWp == null) { setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); } @@ -2124,7 +2164,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // walker can run minutes in the wrong corridor without ever replanning. Off-path, do nothing // here: the player stops, the "moving" deferral ends, and OFFPATH_RECALC replans properly. if (clearedInterimTarget - && isNearPath() + && isNearPath(walkLoop.playerLoc) && !walkLoop.interacting && !walkLoop.animating && !isDoorInteractionSettling() @@ -2180,27 +2220,26 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { Map doorEdgesAttemptedThisTail = new HashMap<>(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); - WorldPoint activeInterimPlayer = Rs2Player.getWorldLocation(); + // Re-capture: the widget dialogs above sleep for seconds when they fire. + walkLoop = WalkLoopSnapshot.capture(); long activeInterimNowMs = System.currentTimeMillis(); - if (!Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + if (!walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && (target == null - || activeInterimPlayer == null - || activeInterimPlayer.distanceTo(target) > immediateFinishTh) - && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { + || walkLoop.playerLoc == null + || walkLoop.playerLoc.distanceTo(target) > immediateFinishTh) + && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs)) { exit = WalkExit.INTERIM_IN_FLIGHT_ROUTE; WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), - activeInterimPlayer, + walkLoop.playerLoc, target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("tail exempt exitReason=%s tailBefore=%d early=true interim=%s", - exit.wireName(offPathDeferDetail), - processWalkTail, - routeState.interimTargetWp); + exit.wireName(offPathDeferDetail), processWalkTail, routeState.interimTargetWp); processWalkTail--; continue; } @@ -2219,7 +2258,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM "reason=transport_settling"); } if (allowRawSceneScan && postTransportWindow - && !hasUpcomingNearbyTransportStep(path, rawScanTransportLookaheadStartIdx, Rs2Player.getWorldLocation(), + && !hasUpcomingNearbyTransportStep(path, rawScanTransportLookaheadStartIdx, walkLoop.playerLoc, POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { allowRawSceneScan = false; tmarkPostTransport("post_transport_raw_scene_scan_skip", target, @@ -2243,8 +2282,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM : (startupPolicy.allowBroadRawHandlers() ? "gated-outer" : "policy-startup"); boolean rawSceneHandled = allowRawSceneScan && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target, true); - tmarkPostTransport("post_transport_raw_scene_scan_why", target, - "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); + tmarkPostTransport("post_transport_raw_scene_scan_why", target, "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); tmarkPostTransport("post_transport_raw_scene_scan", target, "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); if (rawSceneHandled) { @@ -2270,7 +2308,9 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } } - WorldPoint currentPlayerLoc = Rs2Player.getWorldLocation(); + // Re-capture: the raw scan, current-tile transport and direct-short-walk above block. + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint currentPlayerLoc = walkLoop.playerLoc; reachableTilesCache = Rs2Tile.getReachableTilesFromTile(currentPlayerLoc, HANDLER_RANGE * 3); reachableTilesCacheOrigin = currentPlayerLoc; final int currentPlayerPlane = currentPlayerLoc != null ? currentPlayerLoc.getPlane() : -1; @@ -2303,21 +2343,20 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean recentTransportWindow = routeState.lastTransportHandledAtMs > 0 && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; - WorldPoint playerForPathCheck = Rs2Player.getWorldLocation(); + // One world per segment iteration: the previous iteration's handlers may have blocked. + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerForPathCheck = walkLoop.playerLoc; if (isTransportInteractionSettling()) { - tmarkPostTransport("post_transport_settling_yield", target, - "at=" + compactWorldPoint(playerForPathCheck)); + tmarkPostTransport("post_transport_settling_yield", target, "at=" + compactWorldPoint(playerForPathCheck)); exit = WalkExit.TRANSPORT_SETTLING_YIELD; break; } - boolean nearPath = isNearPath(); + boolean nearPath = isNearPath(walkLoop.playerLoc); boolean nearPathByVariance = !nearPath && isNearPathByVariance(path, playerForPathCheck); if (recentTransportWindow && !nearPath) { WebWalkLog.tmark("post_transport_nearpath_gate", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "nearPath=false variance=" + nearPathByVariance); + target, playerForPathCheck, "nearPath=false variance=" + nearPathByVariance); } if (!nearPath && !recentTransportWindow && !nearPathByVariance) { // Avoid mid-walk recalculation while recent clicks, route progress, or busy state @@ -2330,19 +2369,16 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { WebWalkLog.tmark("post_transport_offpath_moving_yield", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "defer=" + deferReason); + target, playerForPathCheck, "defer=" + deferReason); } exit = WalkExit.OFF_PATH_DEFERRED; offPathDeferDetail = deferReason; break; } - Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); + Telemetry.recordOffPathRecalc(walkLoop.playerLoc, path.size()); // Distinguish the drift signature in logs: off-path while still moving with no // walker action in flight = something external is steering the player. - WebWalkLog.recalc(Rs2Player.isMoving() - ? "off_path_unowned_movement" : "no_longer_near_path"); + WebWalkLog.recalc(walkLoop.moving ? "off_path_unowned_movement" : "no_longer_near_path"); if (config.cancelInstead()) { setTarget(null, "rs2walker:processWalk:off-path-cancel-instead"); } else { @@ -2361,7 +2397,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // Gate scene-object handlers to segments near the player. Doors/rockfalls/transports // can only be interacted with when the object is in the loaded scene (near the player), // and these calls do scene-object scans that add up across 100+ segment paths. - WorldPoint playerNearSeg = Rs2Player.getWorldLocation(); + WorldPoint playerNearSeg = walkLoop.playerLoc; if (playerNearSeg == null) { exit = WalkExit.PLAYER_LOCATION_NULL; break; @@ -2486,12 +2522,14 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean tileReachable = reachableTilesCache.containsKey(currentWorldPoint); // The handlers above block for seconds, so reachability computed from where we USED - // to be is not evidence about where we are. Recapture when the origin no longer - // matches — what reachableTilesCacheOrigin was declared for and never did. Same - // radius as the original capture: the old recapture used a smaller one and could - // answer "unreachable" for a tile the wider map had already reached. + // to be is not evidence about where we are. Re-capture the snapshot and, when the + // origin no longer matches, the cache with it — same radius as the original capture: + // the old recapture used a smaller one and could answer "unreachable" for a tile the + // wider map had already reached. One capture serves both this block and the miss + // branch below, which previously took its own fresh read microseconds later. if (!tileReachable && !inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerLoc = walkLoop.playerLoc; if (playerLoc != null && !playerLoc.equals(reachableTilesCacheOrigin)) { reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE * 3); reachableTilesCacheOrigin = playerLoc; @@ -2500,7 +2538,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM } } if (!tileReachable && !inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); + WorldPoint playerLoc = walkLoop.playerLoc; if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { @@ -2515,13 +2553,13 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM log.info("[Walker] spatially-near future route branch ignored for local recovery: tile={} idx={}/{} routeStart={} player={}", currentWorldPoint, i, path.size(), recoveryScanStart, playerLoc); if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { exit = WalkExit.ROUTE_FOLD_CONTINUATION_CLICK; - } else { - exit = WalkExit.ROUTE_FOLD_CONTINUATION_PENDING; + break; } - break; + // Fold stall fix: ending the pass at a behind/branch tile left nobody to + // handle the NEXT gate (4-26s pending per corridor). Keep scanning forward. + continue; } - log.debug("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", - currentWorldPoint, i, path.size(), playerLoc, target); + log.debug("[Walker] local reachability miss near player; checking blockers/recovery: tile={} idx={}/{} player={} target={}", currentWorldPoint, i, path.size(), playerLoc, target); // Anti-end-camping frontier rewind. The near-player reachability check skips // far-away route tiles, so on a route whose tail folds back beside the player @@ -3985,6 +4023,19 @@ private static void learnWalledRouteEdge(List rawPath, WorldPoint pl compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); return; } + // ADJACENCY, not just the exact edge. Double gates (Stronghold "Gate of War") are two wall + // objects: only the primary wing carries the Open action; the slave wing is actionless. A raw + // route step through the slave wing's line finds no door ON its own segment — the check above + // passes — and the edge gets learned as walled while the door pipeline is opening the primary + // wing one tile away. Measured 2026-08-13 14:00: edges (1875,5240)->(1876,5240) (parallel + // beside the gate) and (1903,5242)->(1904,5243) (diagonal sharing the gate's corner) both + // learned mid-corridor, each costing a replan. Not learning is always recoverable — the + // refused click just falls back as before; learning wrongly poisons routing for the session. + if (sceneDoorAdjacentToEdge(edge[0], edge[1])) { + WebWalkLog.spInfo("walled_edge_not_learned | {} -> {} — scene door adjacent to the edge (double-gate wing), the door pipeline owns it", + compactWorldPoint(edge[0]), compactWorldPoint(edge[1])); + return; + } // Via the Rs2PathApi wrapper rather than the config directly: it takes the pathfinder mutex, // which matters because the replan below runs straight after. Same return contract — true only // when the edge was newly blocked for this session. @@ -3995,6 +4046,34 @@ private static void learnWalledRouteEdge(List rawPath, WorldPoint pl } } + /** + * Whether an ACTIONED scene door sits within one tile of either endpoint of the edge — the + * double-gate wing case above. One scene scan (this path is rare and about to replan anyway), + * geometric filter via {@link #doorTileAdjacentToEdgeEndpoints}. + */ + private static boolean sceneDoorAdjacentToEdge(WorldPoint a, WorldPoint b) { + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); + return !Rs2GameObject.getAll(o -> { + WorldPoint loc = o.getWorldLocation(); + if (!doorTileAdjacentToEdgeEndpoints(loc, a, b)) { + return false; + } + if (!Rs2DoorDetection.isDoorLikeSceneObject(o)) { + return false; + } + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(o); + return Rs2DoorClassifier.getDoorAction(comp, doorActions) != null; + }, a, 3).isEmpty(); + } + + /** Pure geometry: same plane, and the door tile within one tile (Chebyshev) of either endpoint. */ + static boolean doorTileAdjacentToEdgeEndpoints(WorldPoint doorTile, WorldPoint a, WorldPoint b) { + if (doorTile == null || a == null || b == null || doorTile.getPlane() != a.getPlane()) { + return false; + } + return doorTile.distanceTo2D(a) <= 1 || doorTile.distanceTo2D(b) <= 1; + } + /** * First raw-path step that leaves the player-origin BFS: {@code a} reachable, {@code b} not. *

@@ -4354,6 +4433,17 @@ private static boolean tryIssueRouteMovementClick(List rawPath, maxEuclidean - 1, Rs2Walker::isKnownWalkableOrUnloaded); } + // The primary selector reaches here only after refusing every route point (e.g. the + // walled net saw a shut door between), and this fallback vets candidates by + // WALKABILITY, not reachability. Clicking a walkable-but-unreachable tile moves the + // player nowhere while still arming an interim — at the Stronghold's chained gates the + // idle nudge did exactly that every ~2s beyond the shut second gate, and the dead + // interim's in-flight yields starved the pass that would have opened it. + if (clickTarget != null && !Rs2Tile.isTileReachable(clickTarget)) { + WebWalkLog.spDebug("route_click_fallback_unreachable | to={} player={}", + compactWorldPoint(clickTarget), compactWorldPoint(playerLoc)); + return false; + } } boolean clicked = false; @@ -5805,12 +5895,42 @@ private static boolean interactDoorTimed(TileObject object, String action) { try { return Rs2GameObject.interact(object, action); } finally { + long tookMs = System.currentTimeMillis() - startedAt; if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorInteractMs += System.currentTimeMillis() - startedAt; + rawScanDoorInteractMs += tookMs; } + doorLegInteractMs += tookMs; } } + // ---- Per-leg door stage accumulators (every door path, not just raw scans). The eleven-gate + // Stronghold run produced a suspiciously CONSTANT ~5.4s per door_interaction_done with zero + // slow-await lines, so the time lives outside the await, and the raw-scan breakdown only covers + // one of the three entry paths. Reset at handleDoorsWithTimeout entry; printed on its tmark. + private static volatile long doorLegFindMs; + private static volatile long doorLegInteractMs; + private static volatile long doorLegAwaitMs; + private static volatile long doorLegVerifyMs; + private static volatile long doorLegNudgeMs; + private static volatile long doorLegExceptionMs; + + private static void resetDoorLegStages() { + doorLegFindMs = 0L; + doorLegInteractMs = 0L; + doorLegAwaitMs = 0L; + doorLegVerifyMs = 0L; + doorLegNudgeMs = 0L; + doorLegExceptionMs = 0L; + } + + private static String doorLegStageDetail(long totalMs) { + long accounted = doorLegFindMs + doorLegInteractMs + doorLegAwaitMs + doorLegVerifyMs + + doorLegNudgeMs + doorLegExceptionMs; + return " find=" + doorLegFindMs + " interact=" + doorLegInteractMs + " await=" + doorLegAwaitMs + + " verify=" + doorLegVerifyMs + " nudge=" + doorLegNudgeMs + " exception=" + doorLegExceptionMs + + " other=" + Math.max(0L, totalMs - accounted); + } + /** * "Is THIS door still shut?" — a radius-{@link #HANDLER_RANGE} rescan that resolves a composition per * candidate OUTSIDE the scan-scoped memo, so nothing is cached. Only runs when traversal failed, but @@ -5829,9 +5949,11 @@ private static boolean doorStillHasActionTimed(WorldPoint probe, WorldPoint from try { return doorStillHasAction(probe, fromWp, toWp, doorActions, action, true); } finally { + long tookMs = System.currentTimeMillis() - startedAt; if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorVerifyMs += System.currentTimeMillis() - startedAt; + rawScanDoorVerifyMs += tookMs; } + doorLegVerifyMs += tookMs; } } @@ -5844,12 +5966,14 @@ private static boolean doorStillHasActionTimed(WorldPoint probe, WorldPoint from private static TileObject findDoorNearSegmentTimed(WorldPoint fromWp, WorldPoint toWp, List doorActions) { long startedAt = System.currentTimeMillis(); try { - return Rs2DoorProbe.findDoorNearSegment(doorProbeContext(), sessionBlacklistedDoors, - recentlyOpenedStationaryDoors, STATIONARY_DOOR_SUPPRESS_MS, fromWp, toWp, doorActions); + return Rs2DoorProbe.findDoorNearSegment(doorProbeContext(), doorAttemptLedger, + STATIONARY_DOOR_SUPPRESS_MS, fromWp, toWp, doorActions); } finally { + long tookMs = System.currentTimeMillis() - startedAt; if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorFindMs += System.currentTimeMillis() - startedAt; + rawScanDoorFindMs += tookMs; } + doorLegFindMs += tookMs; } } @@ -6117,23 +6241,16 @@ private static void addForwardPathIndices(Map forwardIndex, } } - // Session-local set of door tiles the walker detected as quest/stat-locked after a - // failed interact. Cleared when the client restarts. Prevents infinite retry loops - // through the same restricted door when the restriction isn't in restrictions.tsv. - static final Set sessionBlacklistedDoors = ConcurrentHashMap.newKeySet(); - private static final Map recentlyOpenedStationaryDoors = new ConcurrentHashMap<>(); + // D3 slice 3: the session blacklist (quest/stat-locked doors) and the recently-opened + // suppression map live in the ledger as tile-keyed facets. private static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; - private static final Map recentDoorAttemptByEdge = new ConcurrentHashMap<>(); + // D3 slice 1: ATTEMPTED lives in the ledger — one owner for the per-edge cooldown facts AND + // the latest-claim fact that used to sit in routeState.lastDoorAttempt* and disagree with them. + private static final DoorAttemptLedger doorAttemptLedger = new DoorAttemptLedger(); private static final long DOOR_ATTEMPT_EDGE_COOLDOWN_MS = 2_500; - // Concluded-but-uncrossed attempts per door edge (Rs2DoorHandler.registerDoorCrossFailure). - // Conditionally locked doors (Tithe Farm seed gate) refuse silently: no dialogue, no traversal, - // no collision change — three strikes session-blocks the edge and replans instead of retrying forever. - private static final Map doorCrossFailuresByEdge = new ConcurrentHashMap<>(); + // D3 slice 2: cross-failure strikes and walk-scoped blocks live in the ledger (REFUSED facet). private static final long DOOR_CROSS_FAILURE_DECAY_MS = 300_000; private static final int DOOR_CROSS_FAILURE_STRIKE_LIMIT = 3; - // Edges blocked by a door strike-out, withdrawn again at the next walk session start. - private static final java.util.concurrent.ConcurrentLinkedQueue walkScopedDoorBlocks = - new java.util.concurrent.ConcurrentLinkedQueue<>(); private static final Map recentCurrentTileTransportByEdge = new ConcurrentHashMap<>(); private static final long CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS = 2_200; private static final long DOOR_INTERACTION_GLOBAL_COOLDOWN_MS = 1_800; @@ -6232,8 +6349,8 @@ private static boolean handleDoors(List path, int index, boolean all // avoid re-triggering the same failed interact loop this session. WorldPoint skipFrom = path.get(index); WorldPoint skipTo = index + 1 < path.size() ? path.get(index + 1) : null; - if (sessionBlacklistedDoors.contains(skipFrom) - || (skipTo != null && sessionBlacklistedDoors.contains(skipTo))) { + if (doorAttemptLedger.isDoorBlacklisted(skipFrom) + || (skipTo != null && doorAttemptLedger.isDoorBlacklisted(skipTo))) { return false; } @@ -6395,6 +6512,11 @@ private static boolean handleDoors(List path, int index, boolean all Telemetry.recordDoorReject("orient-mismatch"); } } else { + if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_skip_goal_object | mode=segment-door probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; @@ -6437,14 +6559,14 @@ private static boolean handleDoors(List path, int index, boolean all return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (!traversed && isQuestLockedDoorDialogue()) { String dialogue = Rs2Dialogue.getDialogueText(); log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", probe, name, action, dialogue); - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); Rs2Dialogue.clickContinue(); Rs2PathApi.refreshPlanningConfiguration(); recalculatePath(); @@ -6455,7 +6577,7 @@ private static boolean handleDoors(List path, int index, boolean all } if (!traversed) { if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", probe, fromWp, toWp, posBefore, posAfter); // Wrong-traversal is a stable map property (one-way / mis-encoded door geometry), @@ -6535,6 +6657,11 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, found = true; } } else if (name != null && name.toLowerCase().contains("door")) { + if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_skip_goal_object | mode=segment-probe probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; @@ -6579,7 +6706,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (traversed) { @@ -6589,7 +6716,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return true; } if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", probe, fromWp, toWp, posBefore, posAfter); } @@ -6597,7 +6724,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, String dialogue = Rs2Dialogue.getDialogueText(); log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", probe, name, action, dialogue); - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); Rs2Dialogue.clickContinue(); Rs2PathApi.refreshPlanningConfiguration(); recalculatePath(); @@ -6742,7 +6869,7 @@ private static boolean doorObjectStillHasAction(TileObject object, WorldPoint pr } private static void markStationaryDoorOpened(WorldPoint doorTile) { - Rs2DoorHandler.markStationaryDoorOpened(recentlyOpenedStationaryDoors, doorTile); + doorAttemptLedger.markStationaryDoorOpened(doorTile, System.currentTimeMillis()); } /** @@ -6762,12 +6889,8 @@ private static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, Wor } private static boolean shouldThrottleDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.shouldThrottleDoorAttempt( - recentDoorAttemptByEdge, - DOOR_ATTEMPT_EDGE_COOLDOWN_MS, - doorTile, - fromWp, - toWp); + return doorAttemptLedger.shouldThrottleAttempt(doorTile, fromWp, toWp, + DOOR_ATTEMPT_EDGE_COOLDOWN_MS, System.currentTimeMillis()); } private static boolean hasRecentDoorAttemptOnEdge(WorldPoint fromWp, WorldPoint toWp) { @@ -6826,7 +6949,7 @@ private static long recentDoorAttemptAgeNearIndex(List path, int edg if (!isLikelyDoorEdgeTransition(from, to)) { continue; } - Long attemptedAt = recentDoorAttemptByEdge.get(doorAttemptKey(null, from, to)); + Long attemptedAt = doorAttemptLedger.attemptAtMs(from, to); if (attemptedAt != null) { newestAttemptAt = Math.max(newestAttemptAt, attemptedAt); } @@ -6906,6 +7029,16 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, */ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, WorldPoint target, List routePath) { + long nudgeStartedAt = System.currentTimeMillis(); + try { + return tryDoorEdgeCrossNudgeInner(fromWp, toWp, target, routePath); + } finally { + doorLegNudgeMs += System.currentTimeMillis() - nudgeStartedAt; + } + } + + private static boolean tryDoorEdgeCrossNudgeInner(WorldPoint fromWp, WorldPoint toWp, WorldPoint target, + List routePath) { if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return false; } @@ -6989,17 +7122,9 @@ private static boolean tryDoorEdgeCrossNudge(WorldPoint fromWp, WorldPoint toWp, * cover doors the walker handles purely as scene objects. */ public static boolean isActiveDoorEdge(WorldPoint a, WorldPoint b) { - WorldPoint from = routeState.lastDoorAttemptFrom; - WorldPoint to = routeState.lastDoorAttemptTo; - long attemptedAt = routeState.lastDoorAttemptAtMs; - if (a == null || b == null || from == null || to == null || attemptedAt <= 0L) { - return false; - } - long ageMs = System.currentTimeMillis() - attemptedAt; - if (ageMs < 0L || ageMs > ACTIVE_DOOR_EDGE_CLAIM_MS) { - return false; - } - return (a.equals(from) && b.equals(to)) || (a.equals(to) && b.equals(from)); + DoorAttemptLedger.Attempt claim = + doorAttemptLedger.latestAttempt(ACTIVE_DOOR_EDGE_CLAIM_MS, System.currentTimeMillis()); + return claim != null && claim.matchesEdge(a, b); } private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target) { @@ -7008,14 +7133,20 @@ private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, World private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target, List routePath) { - WorldPoint from = routeState.lastDoorAttemptFrom; - WorldPoint to = routeState.lastDoorAttemptTo; - long attemptedAt = routeState.lastDoorAttemptAtMs; - if (playerLoc == null || from == null || to == null || attemptedAt <= 0L) { + DoorAttemptLedger.Attempt claim = + doorAttemptLedger.latestAttempt(POST_DOOR_NUDGE_RECENT_ATTEMPT_MS, System.currentTimeMillis()); + if (playerLoc == null || claim == null) { return false; } - long ageMs = System.currentTimeMillis() - attemptedAt; - if (ageMs < 0L || ageMs > POST_DOOR_NUDGE_RECENT_ATTEMPT_MS) { + WorldPoint from = claim.from; + WorldPoint to = claim.to; + // A crossing that has ALREADY happened satisfies nothing: at the Stronghold's chained gates + // (2026-08-12) the player stood two tiles past gate 1 while gate 2 blocked the route ahead, + // and this branch kept ending the pass "resolved" over the conquered door — starving the + // miss branch that would have probed gate 2. Same principle as the crossed-face guard: + // done means fall through, and the spent attempt is cleared so it cannot fire again. + if (Rs2DoorGeometry.crossedDoorAxis(from, to, playerLoc)) { + doorAttemptLedger.clearLatestAttempt(); return false; } if (playerLoc.getPlane() != to.getPlane() || playerLoc.distanceTo2D(to) > POST_DOOR_EDGE_NUDGE_MAX_FROM_PLAYER) { @@ -7385,9 +7516,9 @@ private static void clearInterimTarget(String reason) { * defer is unconditional either way — an open quest dialogue blocks every door equally. */ private static boolean shouldThrottleGlobalDoorInteraction(WorldPoint fromWp, WorldPoint toWp) { - boolean sameEdge = fromWp != null && toWp != null - && fromWp.equals(routeState.lastDoorAttemptFrom) - && toWp.equals(routeState.lastDoorAttemptTo); + DoorAttemptLedger.Attempt lastClaim = doorAttemptLedger.latestAttempt(); + boolean sameEdge = fromWp != null && toWp != null && lastClaim != null + && lastClaim.isSameDirectedEdge(fromWp, toWp); return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(System.currentTimeMillis(), routeState.nextDoorInteractionAllowedAtMs, sameEdge, DOOR_INTERACTION_GLOBAL_COOLDOWN_MS, DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS) @@ -7506,12 +7637,7 @@ private static void markGlobalDoorInteractionCooldown() { } private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { - Rs2DoorHandler.markDoorAttempt(recentDoorAttemptByEdge, doorTile, fromWp, toWp); - if (fromWp != null && toWp != null) { - routeState.lastDoorAttemptFrom = fromWp; - routeState.lastDoorAttemptTo = toWp; - routeState.lastDoorAttemptAtMs = System.currentTimeMillis(); - } + doorAttemptLedger.markAttempt(doorTile, fromWp, toWp, System.currentTimeMillis()); } /** @@ -7530,22 +7656,21 @@ private static void registerDoorCrossFailure(WorldPoint fromWp, WorldPoint toWp, if (fromWp == null || toWp == null) { return; } - Rs2DoorHandler.DoorStrike strike = Rs2DoorHandler.registerDoorCrossFailure( - doorCrossFailuresByEdge, - doorAttemptKey(null, fromWp, toWp), + DoorAttemptLedger.Strike strike = doorAttemptLedger.registerCrossFailure( + fromWp, toWp, conclusiveSample, System.currentTimeMillis(), DOOR_CROSS_FAILURE_DECAY_MS, DOOR_CROSS_FAILURE_STRIKE_LIMIT); - if (strike != Rs2DoorHandler.DoorStrike.STRIKE_OUT) { + if (strike != DoorAttemptLedger.Strike.STRIKE_OUT) { return; } String reason = "door-strike-out (" + mode + ")"; if (Rs2PathApi.learnBlockedEdge(fromWp, toWp, reason)) { - walkScopedDoorBlocks.add(new WorldPoint[]{fromWp, toWp}); + doorAttemptLedger.recordWalkScopedBlock(fromWp, toWp); } if (Rs2PathApi.learnBlockedEdge(toWp, fromWp, reason)) { - walkScopedDoorBlocks.add(new WorldPoint[]{toWp, fromWp}); + doorAttemptLedger.recordWalkScopedBlock(toWp, fromWp); } WebWalkLog.spInfo("door_strike_out | from={} to={} mode={} — {} concluded attempts never crossed; " + "blocking edge for this walk and replanning", @@ -7560,16 +7685,13 @@ private static void registerDoorCrossFailure(WorldPoint fromWp, WorldPoint toWp, * a few attempts, loudly, instead of inheriting a stale block silently. */ private static void withdrawWalkScopedDoorBlocks() { - WorldPoint[] edge; - while ((edge = walkScopedDoorBlocks.poll()) != null) { + for (WorldPoint[] edge : doorAttemptLedger.drainWalkScopedBlocks()) { Rs2PathApi.unlearnBlockedEdge(edge[0], edge[1], "walk-scoped door strike-out expired"); } } private static void clearDoorCrossFailures(WorldPoint fromWp, WorldPoint toWp) { - if (fromWp != null && toWp != null) { - Rs2DoorHandler.clearDoorCrossFailures(doorCrossFailuresByEdge, doorAttemptKey(null, fromWp, toWp)); - } + doorAttemptLedger.clearCrossFailures(fromWp, toWp); } /** @@ -7606,27 +7728,13 @@ private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoin } private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( - recentlyOpenedStationaryDoors, - STATIONARY_DOOR_SUPPRESS_MS, - fromWp, - toWp); + return doorAttemptLedger.recentlyOpenedDoorOnSegment( + fromWp, toWp, STATIONARY_DOOR_SUPPRESS_MS, System.currentTimeMillis()); } private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { - if (doorTile == null) { - return false; - } - Long openedAt = recentlyOpenedStationaryDoors.get(doorTile); - if (openedAt == null) { - return false; - } - long ageMs = System.currentTimeMillis() - openedAt; - if (ageMs > STATIONARY_DOOR_SUPPRESS_MS) { - recentlyOpenedStationaryDoors.remove(doorTile); - return false; - } - return true; + return doorAttemptLedger.wasStationaryDoorOpenedWithin( + doorTile, STATIONARY_DOOR_SUPPRESS_MS, System.currentTimeMillis()); } /** Exact selected transport step retained by the completed active route. */ @@ -7831,7 +7939,7 @@ && isAdjacentSamePlaneTransport(t) */ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { - waitForDoorInteractionProgress(fromWp, toWp, null, null, null); + waitForDoorInteractionProgress(fromWp, toWp, null, null, null, null); } /** @@ -7844,6 +7952,12 @@ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp, WorldPoint probe, List doorActions, String action) { + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, null); + } + + private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp, + WorldPoint probe, List doorActions, + String action, TileObject object) { long startedAt = System.currentTimeMillis(); AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); java.util.function.BooleanSupplier doorOpened = @@ -7870,12 +7984,27 @@ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint Object plannerNow = Rs2PathApi.getPathfinder(); return plannerAtClick != null && plannerNow != null && plannerNow != plannerAtClick; }; + // The wall-face reading that stays true when a moves-you gate deposits the player a tile + // off the planned route -- the case every positional release condition goes blind on. + // Orientation and tile are captured ONCE: both are immutable for the object's lifetime, and + // reading a TileObject inside a poll loop risks a stale scene reference mid-await. + java.util.function.BooleanSupplier doorCrossed = null; + if (object instanceof WallObject) { + final int wallOrientation = ((WallObject) object).getOrientationA(); + final WorldPoint wallTile = object.getWorldLocation(); + doorCrossed = () -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && Rs2DoorGeometry.playerBeyondWallFace(wallOrientation, wallTile, fromWp, now); + }; + } try { - Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation, cancelled); + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, observation, cancelled, doorCrossed); } finally { + long tookMs = System.currentTimeMillis() - startedAt; if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { - rawScanDoorInteractionWaitMs += System.currentTimeMillis() - startedAt; + rawScanDoorInteractionWaitMs += tookMs; } + doorLegAwaitMs += tookMs; } } @@ -8083,6 +8212,13 @@ private static boolean isUnresolvedRouteDoorObject(TileObject object, WorldPoint || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { return false; } + // A wall door whose face the player is already beyond is resolved, not unresolved: conquered + // moves-you gates keep their Open action forever, and counting one as an obstacle vetoed the + // continuation click that ends the fold stall. Same truth as door_skip_crossed. + if (object instanceof WallObject && Rs2DoorGeometry.playerBeyondWallFace( + ((WallObject) object).getOrientationA(), location, fromWp, playerLoc)) { + return false; + } ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); if (comp == null @@ -8103,11 +8239,17 @@ private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fr WorldPoint location = object.getWorldLocation(); if (location.getPlane() != playerLoc.getPlane() || location.distanceTo2D(playerLoc) > radiusTiles - || sessionBlacklistedDoors.contains(location) + || doorAttemptLedger.isDoorBlacklisted(location) || (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) || !Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { return false; } + // Same crossed-face resolution as isUnresolvedRouteDoorObject: a conquered gate behind the + // player must not defer short walks as a "pending" route door. + if (object instanceof WallObject && Rs2DoorGeometry.playerBeyondWallFace( + ((WallObject) object).getOrientationA(), location, fromWp, playerLoc)) { + return false; + } ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); if (comp == null @@ -8144,6 +8286,7 @@ private static boolean handleDoorsWithTimeout(List path, int index, ? doorAttemptKey(null, segment[0], segment[1]) : null; WorldPoint playerBeforeAttempt = Rs2Player.getWorldLocation(); + resetDoorLegStages(); if (!markDoorEdgeAttemptThisPass(attemptedDoorEdgesThisPass, segment, playerBeforeAttempt)) { routeState.lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); @@ -8159,7 +8302,7 @@ private static boolean handleDoorsWithTimeout(List path, int index, return false; } WebWalkLog.tmark("door_interaction_done", System.currentTimeMillis() - start, currentTarget, playerBeforeAttempt, - "idx=" + index); + "idx=" + index + doorLegStageDetail(System.currentTimeMillis() - start)); long remaining = timeoutMs - (System.currentTimeMillis() - start); if (remaining <= 0) { return true; @@ -8983,10 +9126,15 @@ private static boolean tryHandleBlockingPathObjectsWithTimeout( } private static boolean handleDoorException(TileObject object, String action) { - if (isInStrongholdOfSecurity()) { - return handleStrongholdOfSecurityAnswer(object, action); + long startedAt = System.currentTimeMillis(); + try { + if (isInStrongholdOfSecurity()) { + return handleStrongholdOfSecurityAnswer(object, action); + } + return false; + } finally { + doorLegExceptionMs += System.currentTimeMillis() - startedAt; } - return false; } private static boolean isInStrongholdOfSecurity() { @@ -8995,11 +9143,36 @@ private static boolean isInStrongholdOfSecurity() { } private static boolean handleStrongholdOfSecurityAnswer(TileObject object, String action) { + // Captured before the click: crossing is judged against where the approach started, and the + // wall's orientation/tile are immutable for the object's lifetime. + final WorldPoint before = Rs2Player.getWorldLocation(); + final int wallOrientation = object instanceof WallObject ? ((WallObject) object).getOrientationA() : -1; + final WorldPoint wallTile = object.getWorldLocation(); Rs2GameObject.interact(object, action); - boolean isInDialogue = Rs2Dialogue.sleepUntilInDialogue(); + // The gates only ask their question until it has been answered; every later crossing just + // carries the player through. The old sleepUntilInDialogue here waited its FULL flat timeout + // on every questionless gate — the leg breakdown traced the corridor's constant ~5.4s per + // gate (find=0 interact=0 await=0 verify=0 nudge=0, all of it "other") to this one line, + // ~60 seconds of sleeps across eleven gates for dialogues that never came. Wait for + // whichever actually happens: the dialogue, or the crossing itself. + // Distance-scaled, like the door await's traversal budget: a ranged click spends its first + // seconds being server-walked to the gate, and the flat 5s expired MID-APPROACH — measured + // as every far-clicked gate paying the full budget and then a duplicate re-attempt from up + // close (5399ms + 576ms for one gate), while near clicks released in ~0.3-2.7s. + final int clickDistance = before != null && wallTile != null && before.getPlane() == wallTile.getPlane() + ? before.distanceTo2D(wallTile) : 0; + final int strongholdWaitMs = 5000 + Math.min(6000, clickDistance * 600); + sleepUntil(() -> { + if (Rs2Dialogue.isInDialogue()) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + return wallOrientation > 0 && now != null + && Rs2DoorGeometry.playerBeyondWallFace(wallOrientation, wallTile, before, now); + }, strongholdWaitMs); // Not all the doors ask questions, so only if dialogue is shown we will attempt to get the answer - if (!isInDialogue) return true; + if (!Rs2Dialogue.isInDialogue()) return true; // Skip over first door dialogue & don't forget to set up two-factor warning if (Rs2Dialogue.getDialogueText().toLowerCase().contains("two-factor authentication options") || Rs2Dialogue.getDialogueText().toLowerCase().contains("hopefully you will learn
much from us.")) { @@ -11809,18 +11982,31 @@ public static boolean isNear() { * @return */ public static boolean isNear(WorldPoint target) { - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.equals(target); + return isNear(target, Rs2Player.getWorldLocation()); + } + + /** Snapshot variant (B2): the walk loop passes its pass-start position instead of re-reading. */ + private static boolean isNear(WorldPoint target, WorldPoint playerLoc) { + return playerLoc != null && playerLoc.equals(target); } public static boolean isNearPath() { + return isNearPath(Rs2Player.getWorldLocation()); + } + + /** + * Snapshot variant (B2). The two hidden client reads become the caller's {@code loc}, so the + * walk loop's continuation gate answers from the same world as its neighbours. Note the + * deliberate side effect carried over unchanged: {@code lastPosition} updates to {@code loc} + * while comparing against its previous value. + */ + private static boolean isNearPath(WorldPoint loc) { final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); if (!routeStatus.isPresent()) return true; final List path = routeStatus.getWalkablePath(); if (path.isEmpty()) return true; - final WorldPoint loc = Rs2Player.getWorldLocation(); if (loc == null) return true; if (config.recalculateDistance() < 0 || routeState.lastPosition.equals(routeState.lastPosition = loc)) { @@ -11832,7 +12018,7 @@ public static boolean isNearPath() { return true; } - var reachableTiles = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), config.recalculateDistance() - 1); + var reachableTiles = Rs2Tile.getReachableTilesFromTile(loc, config.recalculateDistance() - 1); for (WorldPoint point : path) { if (reachableTiles.containsKey(point)) { return true; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java new file mode 100644 index 00000000000..53d3e16bb12 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java @@ -0,0 +1,257 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The single owner of "which door edges has this walker ATTEMPTED, and when" — D3 slice 1 of the + * door-attempt lifecycle (DETECTED → ATTEMPTED → CROSSED | REFUSED | EXPIRED). + * + *

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

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

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

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

{@code conclusiveSample} is the caller's evidence gate: the player must be stationary at the + * near side when sampled. A moving sample proves only that the approach was still in flight — + * the same trap that once blacklisted Wydin's door off a mid-walk position. Strikes are keyed by + * the normalized (direction-blind) edge, decay after {@code decayMs}, and a strike-out consumes + * the entry so a re-attempted edge starts fresh. + */ + public Strike registerCrossFailure(WorldPoint fromWp, WorldPoint toWp, boolean conclusiveSample, + long nowMs, long decayMs, int strikeLimit) { + if (!conclusiveSample || fromWp == null || toWp == null) { + return Strike.NOT_COUNTED; + } + String edgeKey = Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp); + crossFailuresByEdgeKey.entrySet().removeIf(entry -> nowMs - entry.getValue()[1] > decayMs); + long[] entry = crossFailuresByEdgeKey.compute(edgeKey, (k, v) -> + v == null ? new long[]{1, nowMs} : new long[]{v[0] + 1, nowMs}); + if (entry[0] >= strikeLimit) { + crossFailuresByEdgeKey.remove(edgeKey); + return Strike.STRIKE_OUT; + } + return Strike.COUNTED; + } + + /** A successful crossing forgives the edge's strikes (transient refusals should not accumulate). */ + public void clearCrossFailures(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + crossFailuresByEdgeKey.remove(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp)); + } + } + + /** + * Remembers a planner edge-block earned by a strike-out so the NEXT walk can withdraw it. The + * block is walk-scoped, not session-scoped: a door that refuses for game-state reasons opens the + * moment the condition is met, and a session block would stop the owning plugin's own walk-in + * from ever routing through it — the museum lesson. + */ + public void recordWalkScopedBlock(WorldPoint fromWp, WorldPoint toWp) { + walkScopedBlocks.add(new WorldPoint[]{fromWp, toWp}); + } + + /** Returns and forgets every walk-scoped block — called once at walk session start. */ + public java.util.List drainWalkScopedBlocks() { + java.util.List drained = new java.util.ArrayList<>(); + WorldPoint[] edge; + while ((edge = walkScopedBlocks.poll()) != null) { + drained.add(edge); + } + return drained; + } + + // ---- tile-keyed facets (D3 slice 3): recently-opened suppression and the session blacklist ---- + + /** + * Records that a stationary (non-moves-you) door at this tile was just opened. For the suppress + * window that follows, probes must not re-find it — re-clicking an open door closes it, which + * was the original two-clicks-per-door bug. + */ + public void markStationaryDoorOpened(WorldPoint doorTile, long nowMs) { + if (doorTile != null) { + stationaryDoorOpenedAtByTile.put(doorTile, nowMs); + } + } + + /** + * Whether a recently-opened stationary door sits on (within 2 tiles of either end of) the + * {@code fromWp -> toWp} segment. Purges expired entries as a side effect, as the map-based + * version always did. + */ + public boolean recentlyOpenedDoorOnSegment(WorldPoint fromWp, WorldPoint toWp, long suppressMs, long nowMs) { + if (fromWp == null || toWp == null) { + return false; + } + final int segmentDoorSuppressDist = 2; + stationaryDoorOpenedAtByTile.entrySet().removeIf(entry -> nowMs - entry.getValue() > suppressMs); + return stationaryDoorOpenedAtByTile.keySet().stream() + .anyMatch(door -> door != null + && door.getPlane() == fromWp.getPlane() + && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist + || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); + } + + /** Exact-tile variant; expires the entry on a stale read exactly as the old direct-map read did. */ + public boolean wasStationaryDoorOpenedWithin(WorldPoint doorTile, long suppressMs, long nowMs) { + if (doorTile == null) { + return false; + } + Long openedAt = stationaryDoorOpenedAtByTile.get(doorTile); + if (openedAt == null) { + return false; + } + if (nowMs - openedAt > suppressMs) { + stationaryDoorOpenedAtByTile.remove(doorTile); + return false; + } + return true; + } + + /** + * Session-permanent refusal: a door proven quest/stat-locked by a failed interact (dialogue with + * lock keywords, or a locked message). Unlike the walk-scoped strike-out blocks, these never + * come back within the session — the lock will not open because the walker retried. + */ + public void blacklistDoor(WorldPoint doorTile) { + if (doorTile != null) { + blacklistedDoorTiles.add(doorTile); + } + } + + public boolean isDoorBlacklisted(WorldPoint doorTile) { + return doorTile != null && blacklistedDoorTiles.contains(doorTile); + } + + /** Test hook: the blacklist is session-permanent by design, so only tests may empty it. */ + public void clearBlacklist() { + blacklistedDoorTiles.clear(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java index 0e146cd471b..8b13f757ac0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -16,47 +16,6 @@ public static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, Worl return compactWorldPoint(doorTile) + "|" + compactWorldPoint(fromWp) + "->" + compactWorldPoint(toWp); } - public static boolean shouldThrottleDoorAttempt(Map recentDoorAttemptByEdge, - long cooldownMs, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - String key = doorAttemptKey(doorTile, fromWp, toWp); - long now = System.currentTimeMillis(); - recentDoorAttemptByEdge.entrySet().removeIf(entry -> now - entry.getValue() > cooldownMs); - Long last = recentDoorAttemptByEdge.get(key); - return last != null && now - last < cooldownMs; - } - - public static void markDoorAttempt(Map recentDoorAttemptByEdge, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - recentDoorAttemptByEdge.put(doorAttemptKey(doorTile, fromWp, toWp), System.currentTimeMillis()); - } - - public static void markStationaryDoorOpened(Map recentlyOpenedStationaryDoors, WorldPoint doorTile) { - if (doorTile != null) { - recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); - } - } - - public static boolean recentlyOpenedStationaryDoorOnSegment(Map recentlyOpenedStationaryDoors, - long suppressMs, - WorldPoint fromWp, - WorldPoint toWp) { - if (fromWp == null || toWp == null) { - return false; - } - final int segmentDoorSuppressDist = 2; - long now = System.currentTimeMillis(); - recentlyOpenedStationaryDoors.entrySet().removeIf(entry -> now - entry.getValue() > suppressMs); - return recentlyOpenedStationaryDoors.keySet().stream() - .anyMatch(door -> door != null - && door.getPlane() == fromWp.getPlane() - && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); - } - public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; } @@ -84,57 +43,6 @@ public static long markGlobalDoorInteractionCooldown(long cooldownMs) { return System.currentTimeMillis() + cooldownMs; } - /** Outcome of registering one concluded-but-uncrossed door attempt against an edge. */ - public enum DoorStrike { - /** The sample cannot prove a refusal (player still moving, or the walk was cancelled mid-wait). */ - NOT_COUNTED, - /** Counted; the edge has strikes left. */ - COUNTED, - /** The edge has struck out: session-block it and replan. */ - STRIKE_OUT - } - - /** - * Counts attempts that CONCLUDED at the door without crossing it — a click that opened nothing - * (action still present), or a cross-click past an apparently open door that moved the player - * nowhere. Doors that refuse for game-state reasons (Tithe Farm's seed gate, key doors, favour - * gates) produce exactly this signature and nothing else: no dialogue, no traversal, no collision - * change. Without a strike-out the walker retries the same edge forever — measured at 4+ minutes - * of door/recovery ping-pong on Farm door 27445 before a human cancelled it. - *

- * {@code conclusiveSample} is the caller's evidence gate: the player must be stationary at the - * near side when sampled. A moving sample proves only that the approach was still in flight — - * the same trap that once blacklisted Wydin's door off a mid-walk position. - * - * @param strikesByEdge edge key -> {count, lastStrikeAtMs}; entries older than {@code decayMs} reset - * @param conclusiveSample whether the failed attempt ended with the player stationary at the edge - */ - public static DoorStrike registerDoorCrossFailure(Map strikesByEdge, - String edgeKey, - boolean conclusiveSample, - long nowMs, - long decayMs, - int strikeLimit) { - if (!conclusiveSample || edgeKey == null) { - return DoorStrike.NOT_COUNTED; - } - strikesByEdge.entrySet().removeIf(entry -> nowMs - entry.getValue()[1] > decayMs); - long[] entry = strikesByEdge.compute(edgeKey, (k, v) -> - v == null ? new long[]{1, nowMs} : new long[]{v[0] + 1, nowMs}); - if (entry[0] >= strikeLimit) { - strikesByEdge.remove(edgeKey); - return DoorStrike.STRIKE_OUT; - } - return DoorStrike.COUNTED; - } - - /** A successful crossing forgives the edge's strikes (transient refusals should not accumulate). */ - public static void clearDoorCrossFailures(Map strikesByEdge, String edgeKey) { - if (edgeKey != null) { - strikesByEdge.remove(edgeKey); - } - } - private static String compactWorldPoint(WorldPoint wp) { if (wp == null) { return "?"; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index 29fe886d35a..52488462ead 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java @@ -99,7 +99,7 @@ private static boolean isDoorLikeTransportAction(String action) { } /** Whether {@code object} (at {@code objectLocation}) is a walk-through door lying on the segment. */ - public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set blacklist, + public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, DoorAttemptLedger ledger, TileObject object, WorldPoint objectLocation, WorldPoint playerLoc, WorldPoint fromWp, WorldPoint toWp, List doorActions, int searchDistance) { @@ -111,7 +111,7 @@ public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set searchDistance - || blacklist.contains(loc) + || ledger.isDoorBlacklisted(loc) || (!(object instanceof WallObject) && !(object instanceof GameObject))) { return false; } @@ -145,14 +145,14 @@ private static boolean isNonDoorCatalogTransport(DoorProbeContext ctx, TileObjec } /** Nearest walk-through door lying on the {@code fromWp -> toWp} segment, using scan snapshots when present. */ - public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set blacklist, - Map recentlyOpened, long stationaryDoorSuppressMs, + public static TileObject findDoorNearSegment(DoorProbeContext ctx, DoorAttemptLedger ledger, + long stationaryDoorSuppressMs, WorldPoint fromWp, WorldPoint toWp, List doorActions) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null || fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return null; } - if (Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment(recentlyOpened, stationaryDoorSuppressMs, fromWp, toWp)) { + if (ledger.recentlyOpenedDoorOnSegment(fromWp, toWp, stationaryDoorSuppressMs, System.currentTimeMillis())) { return null; } @@ -180,7 +180,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, locations.get(o), + .filter(o -> isDoorCandidateOnSegment(ctx, ledger, o, locations.get(o), playerLoc, fromWp, toWp, doorActions, searchDistance)) .min(Comparator.comparingInt(o -> locations.get(o).distanceTo2D(playerLoc))) .orElse(null); @@ -189,7 +189,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, o.getWorldLocation(), + return Rs2GameObject.getAll(o -> isDoorCandidateOnSegment(ctx, ledger, o, o.getWorldLocation(), playerLoc, fromWp, toWp, doorActions, searchDistance), playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 27cb4e79f3d..0eb376b4bb2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -104,6 +104,22 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f java.util.function.BooleanSupplier doorOpened, java.util.function.Supplier doorObservation, java.util.function.BooleanSupplier cancelled) { + awaitDoorInteractionProgress(ticket, fromWp, toWp, doorOpened, doorObservation, cancelled, null); + } + + /** + * @param doorCrossed observes the WALL FACE: the player already stands on the far side of the + * door's own face relative to the approach tile. The one reading that stays + * true when a moves-you gate deposits the player DIAGONALLY off the planned + * to-tile — where arrived-far-side and crossedDoorAxis both go blind (the + * attempt edge itself can be diagonal, and the deposit tile is not toWp). + * Cheap per poll; may be {@code null} when the door is not a wall object. + */ + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp, + java.util.function.BooleanSupplier doorOpened, + java.util.function.Supplier doorObservation, + java.util.function.BooleanSupplier cancelled, + java.util.function.BooleanSupplier doorCrossed) { if (ticket == null) { return; } @@ -170,6 +186,10 @@ public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint f releasedBy[0] = "edge-resolved"; return true; } + if (doorCrossed != null && doorCrossed.getAsBoolean()) { + releasedBy[0] = "crossed-face"; + return true; + } // The one positional reading a ranged hold may trust: we are ON the far side, or past the // door along its own axis. Near-side proximity stays disabled for ranged clicks — that was // the premature release — but "past" is unambiguous, and it is how a hold ends when the diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index 9716f3cf4e4..aa0fd18511c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -137,10 +137,6 @@ public void clearRecentTransportContext() { public volatile long lastDoorEdgePassSkipAtMs = 0L; /** Cooldown for the expensive path-adjacent door scan on unreachable tiles. */ public volatile long lastDoorPathAdjAttemptAtMs = 0L; - /** Origin/destination/time of the last door interaction attempt (wrong-traversal detection reads these). */ - public volatile WorldPoint lastDoorAttemptFrom = null; - public volatile WorldPoint lastDoorAttemptTo = null; - public volatile long lastDoorAttemptAtMs = 0L; /** Global door-interaction throttle: no door interaction may fire before this wall-clock ms. */ public volatile long nextDoorInteractionAllowedAtMs = 0L; /** 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 5d63e79381f..91bae166134 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 @@ -358,14 +358,14 @@ public void collisionFreeRouteIndexFallbackIsBoundedAndDistanceTagged() { public void resetTelemetry() { Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); - Rs2Walker.sessionBlacklistedDoors.clear(); + Rs2Walker.doorAttemptLedgerForTesting().clearBlacklist(); } @After public void tearDown() { Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); - Rs2Walker.sessionBlacklistedDoors.clear(); + Rs2Walker.doorAttemptLedgerForTesting().clearBlacklist(); } @Test @@ -2185,6 +2185,87 @@ public void questLock_detectsBareQuestMention() { assertTrue(Rs2Walker.hasQuestLockKeywords("Only those who have finished the holy quest may pass.")); } + // --------------------------------------------------------------------------- + // Goal-tile object guard (D3 requirement #1 — the Gift of Peace lesson) + // --------------------------------------------------------------------------- + // + // An object standing ON the walk target is the destination, not an obstacle en route. Seeded + // from the Stronghold corridor (2026-08-13): the goal chest was Open-clicked and its failed + // traversal waited out on three consecutive runs, ~9s each, before arrived-within-distance. + + @Test + public void goalTileChestIsNotAnObstacleWhenTheWalkMayFinishBesideIt() { + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5224, 0); + assertTrue(Rs2Walker.goalTileObjectIsNotAnObstacle(false, goal, 4, goal, beside, goal)); + } + + @Test + public void aWallDoorOnTheGoalEdgeIsStillAnObstacle() { + // A door on the goal tile's EDGE may genuinely need opening to step onto the goal. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5223, 0); + assertFalse(Rs2Walker.goalTileObjectIsNotAnObstacle(true, goal, 4, goal, beside, goal)); + } + + @Test + public void aDistanceZeroWalkStillAttemptsTheGoalTileObject() { + // The walk MUST end on the tile itself; if an openable object seals it, opening is honest. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint beside = new WorldPoint(1906, 5224, 0); + assertFalse(Rs2Walker.goalTileObjectIsNotAnObstacle(false, goal, 0, goal, beside, goal)); + } + + @Test + public void anObjectShortOfTheGoalIsStillAnObstacle() { + // Only the goal tile's own object is exempt; a chest two tiles early still blocks the route + // even when its near side is adjacent to it. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint doorTile = new WorldPoint(1905, 5225, 0); + WorldPoint besideDoor = new WorldPoint(1904, 5226, 0); + assertFalse(Rs2Walker.goalTileObjectIsNotAnObstacle(false, goal, 4, doorTile, besideDoor, doorTile)); + } + + @Test + public void aFarNearSideDoesNotQualifyForTheGoalSkip() { + // The skip is only honest when the walk can FINISH from the near side; a ranged detection + // several tiles out must still be handled as an obstacle if crossing is required later. + WorldPoint goal = new WorldPoint(1907, 5223, 0); + WorldPoint farAway = new WorldPoint(1900, 5230, 0); + assertFalse(Rs2Walker.goalTileObjectIsNotAnObstacle(false, goal, 4, goal, farAway, goal)); + } + + // --------------------------------------------------------------------------- + // Walled-net door adjacency (D3 requirement #2 — the double-gate wing lesson) + // --------------------------------------------------------------------------- + + @Test + public void aGateWingParallelBesideTheEdgeCountsAsAdjacent() { + // Stronghold 2026-08-13 14:00: primary wing at (1875,5239); the slave wing's edge + // (1875,5240)->(1876,5240) was learned as walled while the primary was being opened. + assertTrue(Rs2Walker.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5239, 0), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); + } + + @Test + public void aGateSharingTheDiagonalEdgesCornerCountsAsAdjacent() { + // Same run: primary wing at (1903,5243); the diagonal step (1903,5242)->(1904,5243) learned. + assertTrue(Rs2Walker.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1903, 5243, 0), new WorldPoint(1903, 5242, 0), new WorldPoint(1904, 5243, 0))); + } + + @Test + public void aDoorTwoTilesAwayDoesNotSuppressLearning() { + assertFalse(Rs2Walker.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5237, 0), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); + } + + @Test + public void aDoorOnAnotherPlaneDoesNotSuppressLearning() { + assertFalse(Rs2Walker.doorTileAdjacentToEdgeEndpoints( + new WorldPoint(1875, 5239, 1), new WorldPoint(1875, 5240, 0), new WorldPoint(1876, 5240, 0))); + } + // --------------------------------------------------------------------------- // Session blacklist invariants (#19 support) // --------------------------------------------------------------------------- @@ -2192,20 +2273,20 @@ public void questLock_detectsBareQuestMention() { @Test public void sessionBlacklist_addAndMembership() { WorldPoint door = new WorldPoint(3210, 3220, 0); - assertFalse(Rs2Walker.sessionBlacklistedDoors.contains(door)); - Rs2Walker.sessionBlacklistedDoors.add(door); - assertTrue(Rs2Walker.sessionBlacklistedDoors.contains(door)); + assertFalse(Rs2Walker.doorAttemptLedgerForTesting().isDoorBlacklisted(door)); + Rs2Walker.doorAttemptLedgerForTesting().blacklistDoor(door); + assertTrue(Rs2Walker.doorAttemptLedgerForTesting().isDoorBlacklisted(door)); } @Test public void sessionBlacklist_worldPointEqualityDrivesMembership() { // Two WorldPoints built from the same coords must hash/equal the same way — // otherwise the blacklist guard at handleDoors entry would miss re-attempts. - Rs2Walker.sessionBlacklistedDoors.add(new WorldPoint(3210, 3220, 0)); - assertTrue(Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3220, 0))); - assertFalse(Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3221, 0))); + Rs2Walker.doorAttemptLedgerForTesting().blacklistDoor(new WorldPoint(3210, 3220, 0)); + assertTrue(Rs2Walker.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3220, 0))); + assertFalse(Rs2Walker.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3221, 0))); assertFalse("different plane must not collide", - Rs2Walker.sessionBlacklistedDoors.contains(new WorldPoint(3210, 3220, 1))); + Rs2Walker.doorAttemptLedgerForTesting().isDoorBlacklisted(new WorldPoint(3210, 3220, 1))); } // --------------------------------------------------------------------------- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java index c8a6ad751f5..8c356893e0e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkSessionStateResetTest.java @@ -1,12 +1,14 @@ package net.runelite.client.plugins.microbot.util.walker; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.door.DoorAttemptLedger; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; import org.junit.Before; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; /** * The post-transport window must not survive into the next walk. @@ -72,19 +74,26 @@ public void clearingTheTransportContextClearsTheTimestampNotJustTheLocations() assertNull(routeState.lastTransportDestinationLocation); } - /** Walk start also drops the previous walk's door attempt, for the same staleness reason. */ + /** + * Walk start also withdraws the previous walk's door claim, for the same staleness reason — + * but deliberately NOT the per-edge cooldowns: hammering one door across two walks is still + * hammering. The two lifetimes used to live in two stores; the ledger keeps both facts and + * this test pins that the reset touches only the claim. + */ @Test - public void startingAWalkDropsThePreviousWalksDoorAttempt() + public void startingAWalkDropsThePreviousWalksDoorClaimButKeepsTheEdgeCooldown() { - routeState.lastDoorAttemptFrom = new WorldPoint(3010, 3204, 0); - routeState.lastDoorAttemptTo = new WorldPoint(3011, 3204, 0); - routeState.lastDoorAttemptAtMs = System.currentTimeMillis(); + DoorAttemptLedger ledger = Rs2Walker.doorAttemptLedgerForTesting(); + WorldPoint from = new WorldPoint(3010, 3204, 0); + WorldPoint to = new WorldPoint(3011, 3204, 0); + long now = System.currentTimeMillis(); + ledger.markAttempt(null, from, to, now); Rs2Walker.resetWalkSessionState(); - assertNull(routeState.lastDoorAttemptFrom); - assertNull(routeState.lastDoorAttemptTo); - assertEquals(0L, routeState.lastDoorAttemptAtMs); + assertNull("the latest claim belongs to the previous walk", ledger.latestAttempt()); + assertTrue("the anti-hammer cooldown must survive the walk boundary", + ledger.shouldThrottleAttempt(null, from, to, 2_500, now + 100)); } /** Route progress belongs to the route that made it. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java new file mode 100644 index 00000000000..f7fdabf5e59 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java @@ -0,0 +1,289 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Characterization of the ATTEMPTED facet of the door-attempt ledger (D3 slice 1). Every row pins a + * behaviour the two pre-ledger stores ({@code recentDoorAttemptByEdge} and + * {@code routeState.lastDoorAttempt*}) exhibited live — the fold must change where the facts live, + * not what they say. + */ +public class DoorAttemptLedgerTest +{ + private static final long COOLDOWN_MS = 2_500; + private static final long T0 = 1_000_000L; + + private final WorldPoint near = new WorldPoint(1875, 5240, 0); + private final WorldPoint far = new WorldPoint(1876, 5239, 0); + private final WorldPoint otherNear = new WorldPoint(1879, 5239, 0); + private final WorldPoint otherFar = new WorldPoint(1879, 5240, 0); + + private DoorAttemptLedger ledger; + + @Before + public void setUp() + { + ledger = new DoorAttemptLedger(); + } + + // ---- the anti-hammer cooldown (formerly recentDoorAttemptByEdge) ---- + + @Test + public void attemptThrottlesTheSameEdgeWithinTheCooldown() + { + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownIsDirectionBlind() + { + // The edge key normalizes direction: clicking the gate from the far side one second after + // clicking it from the near side is still hammering the same door. + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, far, near, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownExpires() + { + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + COOLDOWN_MS + 1)); + } + + @Test + public void aDifferentEdgeIsNeverThrottledByThisOne() + { + // Chaining is not hammering — the Stronghold's gates are three tiles apart and the walk + // must be free to attempt the NEXT gate immediately. + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, otherNear, otherFar, COOLDOWN_MS, T0 + 100)); + } + + @Test + public void tileKeyedAttemptsFeedTheCooldownButNeverBecomeTheClaim() + { + // A probe-only door (no resolved edge) has always been cooldown-tracked by its tile without + // becoming "the door the walker is working on". + WorldPoint doorTile = new WorldPoint(1859, 5239, 0); + ledger.markAttempt(doorTile, null, null, T0); + assertTrue(ledger.shouldThrottleAttempt(doorTile, null, null, COOLDOWN_MS, T0 + 100)); + assertNull(ledger.latestAttempt()); + } + + @Test + public void attemptTimesAreReadableForTheAgeHeuristics() + { + ledger.markAttempt(null, near, far, T0); + assertEquals(Long.valueOf(T0), ledger.attemptAtMs(near, far)); + assertEquals("age reads are direction-blind like the cooldown", + Long.valueOf(T0), ledger.attemptAtMs(far, near)); + assertNull(ledger.attemptAtMs(otherNear, otherFar)); + } + + // ---- the latest claim (formerly routeState.lastDoorAttempt*) ---- + + @Test + public void theLatestClaimAnswersTheActiveEdgeQuestionInBothDirections() + { + // The live-collision route validator asks "does the executor own this edge" without caring + // which way the crossing runs (fightarena_door1 lesson). + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 1_000); + assertNotNull(claim); + assertTrue(claim.matchesEdge(near, far)); + assertTrue(claim.matchesEdge(far, near)); + assertFalse(claim.matchesEdge(otherNear, otherFar)); + } + + @Test + public void theClaimGoesStale() + { + ledger.markAttempt(null, near, far, T0); + assertNull("a claim older than its window satisfies nothing", + ledger.latestAttempt(6_000, T0 + 6_001)); + assertNotNull("but the un-aged read still sees it (same-edge cooldown semantics)", + ledger.latestAttempt()); + } + + @Test + public void theSameEdgeCooldownCheckIsDirectionAware() + { + // shouldThrottleGlobalDoorInteraction's same-edge test was always directional — approaching + // the door from the other side is a new interaction context, not a re-click. + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(); + assertTrue(claim.isSameDirectedEdge(near, far)); + assertFalse(claim.isSameDirectedEdge(far, near)); + } + + @Test + public void aNewerAttemptReplacesTheClaim() + { + // Stronghold 2026-08-12: once gate 2 is attempted, gate 1 must no longer be "the door the + // walker is working on" — the victory-lap bug was exactly a stale claim outliving its door. + ledger.markAttempt(null, near, far, T0); + ledger.markAttempt(null, otherNear, otherFar, T0 + 500); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 600); + assertTrue(claim.matchesEdge(otherNear, otherFar)); + assertFalse(claim.matchesEdge(near, far)); + } + + // ---- the REFUSED facet: strike counting (formerly Rs2DoorHandler.registerDoorCrossFailure) ---- + // + // Seeded from the Tithe Farm incident (2026-08-12): Farm door 27445 refused to pass a seedless + // player, and with no strike-out the walker ping-ponged door->recovery for 4+ minutes until a + // human cancelled it. Three concluded-but-uncrossed attempts must strike the edge out. + + private static final long DECAY_MS = 300_000L; + private static final int STRIKE_LIMIT = 3; + + @Test + public void thirdConclusiveFailureStrikesOut() + { + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 12_000, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 24_000, DECAY_MS, STRIKE_LIMIT)); + // The strike-out consumed the entry: the edge starts fresh if it is ever attempted again. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 25_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes are direction-blind like every other edge fact: refusing to pass is a property of the door. */ + @Test + public void strikesAccumulateAcrossDirections() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(far, near, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A moving or cancelled sample proves only that the approach was in flight — the Wydin lesson. */ + @Test + public void inconclusiveSamplesNeverCount() + { + for (int i = 0; i < 10; i++) + { + assertEquals(DoorAttemptLedger.Strike.NOT_COUNTED, + ledger.registerCrossFailure(near, far, false, T0 + i, DECAY_MS, STRIKE_LIMIT)); + } + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 100, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes older than the decay window reset; two failures an hour apart are not a pattern. */ + @Test + public void staleStrikesDecay() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + // Third failure arrives after the decay window: the old two evaporate, count restarts at 1. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 1_000 + DECAY_MS + 1, DECAY_MS, STRIKE_LIMIT)); + } + + @Test + public void edgesStrikeIndependently() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(otherNear, otherFar, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A successful crossing forgives accumulated strikes (transient refusals must not accrue). */ + @Test + public void successfulCrossingClearsStrikes() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + ledger.clearCrossFailures(near, far); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + // ---- the tile facets: recently-opened suppression and the session blacklist ---- + + private static final long SUPPRESS_MS = 10_000L; + + /** Re-clicking a just-opened door closes it again — the original two-clicks-per-door bug. */ + @Test + public void aJustOpenedDoorSuppressesProbesOnItsSegment() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertTrue("segment ending beside the opened door must be suppressed", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + assertTrue(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void theSuppressionExpires() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertFalse(ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + assertFalse(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + } + + @Test + public void aFarAwayOpenedDoorSuppressesNothing() + { + ledger.markStationaryDoorOpened(new WorldPoint(1990, 5300, 0), T0); + assertFalse("suppression is local (within 2 tiles of a segment end), not global", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void blacklistedDoorsAreSessionPermanent() + { + WorldPoint doorTile = new WorldPoint(1907, 5223, 0); + assertFalse(ledger.isDoorBlacklisted(doorTile)); + ledger.blacklistDoor(doorTile); + assertTrue(ledger.isDoorBlacklisted(doorTile)); + assertFalse("plane is part of the tile identity", + ledger.isDoorBlacklisted(new WorldPoint(1907, 5223, 1))); + } + + // ---- the REFUSED facet: walk-scoped blocks ---- + + /** The museum lesson: a strike-out blocks the edge for THIS walk only; the next walk withdraws it. */ + @Test + public void walkScopedBlocksDrainOnceAndInOrder() + { + ledger.recordWalkScopedBlock(near, far); + ledger.recordWalkScopedBlock(far, near); + + java.util.List drained = ledger.drainWalkScopedBlocks(); + assertEquals(2, drained.size()); + assertEquals(near, drained.get(0)[0]); + assertEquals(far, drained.get(0)[1]); + assertEquals(far, drained.get(1)[0]); + assertEquals(near, drained.get(1)[1]); + assertTrue("a second drain must find nothing — blocks are withdrawn exactly once", + ledger.drainWalkScopedBlocks().isEmpty()); + } + + @Test + public void withdrawingTheClaimLeavesTheCooldownStanding() + { + // The crossed-axis clearing (conquered door) and the walk-start reset both withdraw the + // claim; neither may forgive the anti-hammer cooldown. Two lifetimes, one owner. + ledger.markAttempt(null, near, far, T0); + ledger.clearLatestAttempt(); + assertNull(ledger.latestAttempt()); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 100)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java index ff836aa7a86..6325d7df8ae 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java @@ -46,73 +46,4 @@ public void expiredWindowThrottlesNothing() { CLICKED_AT, 0L, false, FULL, CROSS)); } - // --- Door strike-out: registerDoorCrossFailure ------------------------------------------------- - // - // Seeded from the Tithe Farm incident (2026-08-12): Farm door 27445 refused to pass a seedless - // player, and with no strike-out the walker ping-ponged door->recovery for 4+ minutes until a - // human cancelled it. Three concluded-but-uncrossed attempts must strike the edge out. - - private static final long DECAY = 300_000L; - private static final int LIMIT = 3; - private static final String EDGE = "1804,3501,p0->1805,3501,p0"; - - private static java.util.Map strikes() { - return new java.util.HashMap<>(); - } - - @Test - public void thirdConclusiveFailureStrikesOut() { - java.util.Map map = strikes(); - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT)); - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 13_000L, DECAY, LIMIT)); - assertSame(Rs2DoorHandler.DoorStrike.STRIKE_OUT, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 25_000L, DECAY, LIMIT)); - // The strike-out consumed the entry: the edge starts fresh if it is ever attempted again. - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 26_000L, DECAY, LIMIT)); - } - - /** A moving or cancelled sample proves only that the approach was in flight — the Wydin lesson. */ - @Test - public void inconclusiveSamplesNeverCount() { - java.util.Map map = strikes(); - for (int i = 0; i < 10; i++) { - assertSame(Rs2DoorHandler.DoorStrike.NOT_COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, false, 1_000L + i, DECAY, LIMIT)); - } - assertTrue(map.isEmpty()); - } - - /** Strikes older than the decay window reset; two failures an hour apart are not a pattern. */ - @Test - public void staleStrikesDecay() { - java.util.Map map = strikes(); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); - // Third failure arrives after the decay window: the old two evaporate, count restarts at 1. - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L + DECAY + 1, DECAY, LIMIT)); - } - - @Test - public void edgesCountIndependently() { - java.util.Map map = strikes(); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, "other-edge", true, 3_000L, DECAY, LIMIT)); - } - - /** A successful crossing forgives accumulated strikes (transient refusals must not accrue). */ - @Test - public void successfulCrossingClearsStrikes() { - java.util.Map map = strikes(); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 1_000L, DECAY, LIMIT); - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 2_000L, DECAY, LIMIT); - Rs2DoorHandler.clearDoorCrossFailures(map, EDGE); - assertSame(Rs2DoorHandler.DoorStrike.COUNTED, - Rs2DoorHandler.registerDoorCrossFailure(map, EDGE, true, 3_000L, DECAY, LIMIT)); - } } 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 5ba010db79c..cc564bb2e52 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 @@ -788,6 +788,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTranspo net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleStrongholdOfSecurityAnswer(TileObject, String): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene @@ -809,34 +810,35 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$193(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$215(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$182(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$184(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$151(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$157(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$158(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$119(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$165(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$166(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$194(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$196(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$218(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$185(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$187(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$160(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$160(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$161(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$161(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$131(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$131(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$132(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$168(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$169(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$71(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$40(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$sceneDoorAdjacentToEdge$23(WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#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#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 @@ -867,6 +869,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorB net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#waitForDoorInteractionProgress(WorldPoint, WorldPoint, WorldPoint, List, String, TileObject): void -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint @@ -898,7 +901,7 @@ net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorInte net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorOnSegment(TileObject, WorldPoint, WorldPoint): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, Set, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, DoorAttemptLedger, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$5(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.TileObject#getId(): int From 78ba79fe0f42c9c0505a72a0bed6261060e0b1bb Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 13 Aug 2026 23:05:23 +0100 Subject: [PATCH 53/53] =?UTF-8?q?fix(walker):=20the=202026-08-13=20evening?= =?UTF-8?q?=20batch=20=E2=80=94=20ledger=20complete,=20one=20door=20classi?= =?UTF-8?q?fication,=20fold=20stall,=20E1=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batched sync of PluginTesting a46b2a5768..da951b48d5 (walker/API scope; tithefarming and Rs2Farming stay home per policy): - D3 slice 4: ALL TEN door-state stores folded — the walk-runtime quartet (pass budget, settle window, global cooldown, raw-scan focus) joins DoorAttemptLedger; WalkerRouteState loses seven fields, door signatures lose their threaded Map parameter - Requirement #3: one route-door classification for all ten sites (Rs2DoorClassifier.isRouteDoorObject — a chest is never a door; GameObject gates by name, tollgates by traversal verb; wall semantics unchanged) - Fold stall ended live-verified; goal-object and double-gate wing guards live-verified - keyDetail sub-timers for the cold-login transport refresh (PathfinderConfig) - E1: the transport component (dispatcher + ~90 handlers, 3,090 lines) moves to Rs2WalkerTransports; Rs2Walker 14.1k -> 11.2k lines; baseline verified an exact multiset re-home on PluginTesting Method: three-way merge-file against base a46b2a5768 (clean; walker-fix's deliberate local lines preserved), baseline regenerated on this branch. Full suite green here. Co-Authored-By: Claude Opus 5 --- docs/walker-e1-closure.md | 119 + docs/walker-fix-plan-2026-08-10.md | 51 + .../pathfinder/PathfinderConfig.java | 17 + .../microbot/util/walker/Rs2Walker.java | 3662 ++--------------- .../util/walker/Rs2WalkerTransports.java | 3090 ++++++++++++++ .../util/walker/door/DoorAttemptLedger.java | 109 + .../util/walker/door/Rs2DoorClassifier.java | 44 +- .../util/walker/door/Rs2DoorDetection.java | 4 +- .../util/walker/door/Rs2DoorProbe.java | 8 +- .../util/walker/state/WalkerRouteState.java | 19 +- .../util/walker/Rs2WalkerUnitTest.java | 171 +- .../walker/door/DoorAttemptLedgerTest.java | 77 + .../walker/door/Rs2DoorClassifierTest.java | 40 + .../client-thread-guardrail-baseline.txt | 127 +- 14 files changed, 4064 insertions(+), 3474 deletions(-) create mode 100644 docs/walker-e1-closure.md create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java diff --git a/docs/walker-e1-closure.md b/docs/walker-e1-closure.md new file mode 100644 index 00000000000..041dca0f41b --- /dev/null +++ b/docs/walker-e1-closure.md @@ -0,0 +1,119 @@ +# Phase E1 closure — transport component extraction (computed 2026-08-13) + +Input for the E1 executing session. The METHOD closure below is reliable (call-graph reachability +from `handleSelectedTransport`, exclusive = no callers outside the component). The FIELD partition +computed alongside it was NOT reliable (the field scanner misread multi-line declarations and +classified HANDLER_RANGE / doorLeg* / STALL_* as transport-exclusive) — recompute fields with a +proper parse, or classify per-field by grep during the move. Mechanism decided: new class +`Rs2WalkerTransports` in the SAME package (util.walker), moved members package-private, shared +Rs2Walker members de-privated and consumed via static imports (compiler arbitrates collisions, +e.g. Rs2Walker.sleepUntil vs Global.sleepUntil). Field misplacement is cosmetic under this +mechanism (statics are statics); the real risks are static-initializer order and import +collisions. Full suite + corridor gate as always. + +## Methods to move (92, ~2848 lines) + +- `adjacentSamePlaneTransportSuppressionPoints` (13) +- `applyWalkerDestination` (3) +- `attemptObserved` (19) +- `attemptObservedWithoutAttemptRecord` (14) +- `awaitTerminalTravelLanding` (16) +- `canoeMapDestinationsComponentId` (9) +- `canoeMapMainComponentId` (9) +- `charterWidgetMatchesDestination` (18) +- `clickQuetzalMapDestination` (44) +- `confirmCharterTravelIfPrompted` (5) +- `consumeExpectedTransportDestination` (20) +- `ensureRequiredItemBeforeTransport` (16) +- `equipTransportProvider` (8) +- `findCharterDestinationTextWidget` (34) +- `findCharterDestinationWidget` (16) +- `findClickableCharterWidget` (13) +- `findQuetzalMapDestinationWidget` (30) +- `findTerminalTravelObject` (16) +- `finishHandledTransport` (53) +- `finishQuetzalWhistleTransport` (20) +- `getDesiredRotation` (22) +- `getFirstWidgetAction` (10) +- `getTransportActionOptions` (16) +- `handleAlKharidTollGate` (35) +- `handleCanoe` (114) +- `handleCharterShip` (20) +- `handleFairyRing` (77) +- `handleGlider` (63) +- `handleInventoryTeleports` (80) +- `handleMagicCarpet` (11) +- `handleMasterScrollBook` (21) +- `handleMinigameTeleport` (82) +- `handleObject` (107) +- `handleObjectExceptions` (177) +- `handlePohTransport` (6) +- `handleQuetzal` (25) +- `handleSeasonalTransport` (68) +- `handleSelectedTransport` (639) +- `handleSpiritTree` (52) +- `handleTeleportItem` (26) +- `handleTeleportSpell` (30) +- `handleWearableTeleports` (22) +- `handleWildernessObelisk` (16) +- `hasPrecomputedContinuationFromTransport` (26) +- `hasReachedAlKharidTollDestination` (5) +- `hasReachedTerminalTravelLanding` (26) +- `hasWidgetActions` (4) +- `incrementSeasonalHandlerMiss` (3) +- `interactWithAdventureLog` (59) +- `invokeCharterDestinationWidget` (23) +- `isAlKharidTollGateCompositionCandidate` (14) +- `isAlKharidTollGateObjectId` (3) +- `isAlKharidTollGateSceneCandidate` (13) +- `isAlKharidTollGateTransport` (6) +- `isClientThreadReadTimeout` (10) +- `isDialogueBasedTeleportItem` (14) +- `isExplicitShipMenuAction` (7) +- `isLumbridgeHomeTeleport` (4) +- `isMinecartMenuVisible` (3) +- `isPayTollAction` (3) +- `isPlayerWithinChebyshevInclusive` (8) +- `isPlayerWithinChebyshevOf` (8) +- `isQuetzalMapInterfaceVisible` (10) +- `isQuetzalWhistleItemId` (6) +- `isSettledNearAdjacentSamePlaneLanding` (37) +- `isTeleportAllowedAtWildernessLevel` (3) +- `isTerminalTravelObjectCompositionCandidate` (21) +- `isTerminalTravelObjectSceneCandidate` (15) +- `isTerminalTravelTransport` (5) +- `logRouteClear` (9) +- `markAdjacentSamePlaneTransportHandled` (5) +- `markTerminalTravelAttempt` (11) +- `nearbyTilesIgnoringCollision` (20) +- `normalizeCharterWidgetText` (9) +- `prepareTeleportSpellProviders` (51) +- `prepareTransportObjectForInteraction` (9) +- `quetzalMapLabelForDestination` (23) +- `recordTransportAttempt` (4) +- `recordTransportResult` (12) +- `resolveQuetzalMapOptionLabel` (21) +- `resolveTerminalNpcInteractionAction` (15) +- `resolveTransportObjectAction` (23) +- `rotateSlotToDesiredRotation` (30) +- `sameOrNearTransportDestination` (6) +- `selectMinecartDestination` (18) +- `selectTerminalTravelDialogueDestination` (39) +- `shouldRecalculatePathAfterTransport` (13) +- `teleportItemLeafAction` (7) +- `terminalNpcInteractionCandidates` (12) +- `transportSettlePending` (16) +- `waitForPostHandleObjectLanding` (45) +- `walkReachableMiniMapToward` (19) + +## Shared Rs2Walker members the component calls (stay, de-private) + +`clearRecentTransportContext`, `compactWorldPoint`, `euclideanSq`, `getClosestIndexReachableTiles`, +`getClosestTileIndex`, `info`, `isAdjacentSamePlaneTransport`, `isDoorInteractionSettling`, +`isNearPath`, `isNearSamePlane`, `isRecentEvent`, `isTransportInteractionSettling`, +`isWalkCancelled`, `markStationaryDoorOpened`, `rangedTransportEdgeKey`, `recalculatePath`, +`recentlyOpenedStationaryDoorOnSegment`, `setTarget`, `sleepUntil`, `spInfo`, `walkFastCanvas`, +`walkFastLocal`, `walkMiniMap`, `walkMiniMapToward`, plus fields `routeState`, `currentTarget`, +`currentWalkDistance`, `config`, `debug`, `doorAttemptLedger`, `expectedTransportDestinations`, +`recentCurrentTileTransportByEdge`, `TERMINAL_TRAVEL_ATTEMPTED_EDGES`, `seasonalTransportHandlers` +(verify each at move time). diff --git a/docs/walker-fix-plan-2026-08-10.md b/docs/walker-fix-plan-2026-08-10.md index 1d418013d31..812de8e9cbf 100644 --- a/docs/walker-fix-plan-2026-08-10.md +++ b/docs/walker-fix-plan-2026-08-10.md @@ -544,12 +544,63 @@ recovery consumers become reporters and readers of the ledger instead of keepers > at gate deposits; corridor drops by the stall cost (~4-26s/run). The CROSSED-event formalization > still belongs to the ledger's decide table; this fix uses the geometric truth directly. +> **D3 slice 4 DONE — ALL TEN STORES FOLDED** — `PluginTesting` b145854b0a. The walk-runtime +> quartet (per-tail pass budget, settle window, global cooldown, raw-scan focus) joins the ledger; +> WalkerRouteState loses seven fields and every door-handling signature loses its threaded Map +> parameter (the budgeted/unbudgeted split survives as an explicit boolean). The ledger is now the +> single owner of door state. Remaining D3 work: the DoorLifecycle.decide table (requirement #3's +> home — chest-as-door classification) and pointing the three entry paths at it. Live gate PASSED +> 2026-08-13 19:41: twelve gates, 115s, identical behaviour; the wing guard fired twice more (once +> on the gate's OWN edge that the exact-edge check missed on snapshot timing — the adjacency net is +> defense in depth). Same run: the slow-login refresh_transports instrumentation finally fired — +> total=833ms with key=658ms, so the cost is the CACHE-KEY computation, not the filtering (task #13's +> diagnosis, banked). + +> **Requirement #3 LANDED** — `PluginTesting` d555583c19. `Rs2DoorClassifier.isRouteDoorObject` is +> the decide table's first column: walls open by action (unchanged), GAME objects need a door-like +> name or a traversal-proof verb — bare Open on a non-door name is scenery. The walker previously +> held FOUR different answers to this question across ten sites; all ten now call the one rule. +> Live gate: chest-adjacent walks log gameobject-not-a-door rejects instead of Open-clicks. + Sequencing: after B2's remaining live checks settle. Same slice discipline — one store folded into the ledger per slice, characterization first, the Stronghold corridor as the live gate for every slice. The file is 14,003 lines as of tonight (GROWN ~2k since the audit measured 12k, even as processWalk shrank under its guard): D3 is the first phase whose success metric is the file getting SMALLER, because each folded store deletes its scattered call sites. +## Phase E — transport-handler extraction + +> **E1 DONE** — `PluginTesting` a8ee6893e4. Rs2Walker 14,119 -> 11,245 lines (-20%); +> ~90 methods / 3,090 lines into `Rs2WalkerTransports` (same package, static-import sharing, +> package-private dispatcher). The compiler corrected the static closure: seven methods moved back +> (callers behind multi-line signatures), one restored to the nested Telemetry class. Baseline delta +> verified an exact multiset re-home (61 out = 61 in, zero new/vanished violations). Full suite +> green. Corridor gate 2026-08-13 22:55: NO REGRESSION (12 door legs, 120s, normal signature) — +> but the walk started inside the corridor, so the moved dispatcher itself was not exercised; that +> half of the gate rides the next walk that takes any transport. Also noted: the spawn-side first +> gate logs did-not-traverse then crosses on continuation EVERY run (4/4, ~5s each; conclusive-gate +> correctly refuses the strike — cosmetic cost, minor open item). E2/E3 subsumed — the whole +> component moved in one verified step. + +## Phase E — original scope (superseded by E1-complete above) + +The line-count phase. ~2,400 lines of self-contained transport executors live inside Rs2Walker: +`handleSelectedTransport` (639), `handleObjectExceptions` (177), `handleCanoe` (114), +`handleObject` (104), `handleMinigameTeleport` (82), `handleInventoryTeleports` (80), +`handleFairyRing` (77), `handleSeasonalTransport` (68), `handleGlider` (63), +`interactWithAdventureLog` (59, + the minecart-947 machinery), `handleSpiritTree` (52), +`handleAlKharidTollGate` (35), plus their private helpers. + +Slice discipline, one executor family per slice, biggest first: (E1) `handleSelectedTransport` + +`handleObject`/`handleObjectExceptions` into `util/walker/transport/Rs2TransportExecutor`; (E2) the +widget-flow teleports (fairy ring, glider, minecart/adventure log, spirit tree, minigame, +inventory); (E3) canoe + seasonal + toll + Stronghold answer. Dependencies to thread: +`expectedTransportDestinations`, route-state stamps, `WebWalkLog` tmarks, `currentTarget`. Each +slice: characterization where a pure core exists, full suite, corridor unchanged, guardrail +baseline regenerated deliberately (lambda renumbering will be extensive). DO THIS IN A FRESH +SESSION — it is mechanical but chimera-prone, and it is the phase whose success metric is +Rs2Walker finally getting SMALLER (14.1k today). + ## Branch policy — settled 2026-08-12 **`PluginTesting` is authoritative.** All walker work lands there first; it is the branch actually 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 e008f00886e..4735e1a54f4 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 @@ -479,6 +479,7 @@ private void refreshTransports(WorldPoint target) { long keyStart = System.currentTimeMillis(); final Rs2LeaguesTransport.LeaguesContext leaguesCtx = Rs2LeaguesTransport.leaguesContext(); + lastKeyLeaguesMs = System.currentTimeMillis() - keyStart; final int refreshCacheKeyHash = computeTransportRefreshCacheKeyHash(target, leaguesCtx); long keyTime = System.currentTimeMillis() - keyStart; @@ -785,6 +786,8 @@ private void refreshTransports(WorldPoint target) { entryTime, keyTime, mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, verifyTime, captureTime, similarTime, totalTransports, checkedTransports, varbitIds.size(), varplayerIds.size()); + WebWalkLog.cfgSlow("slow refresh_transports keyDetail leagues={}ms inv={}ms equip={}ms bank={}ms", + lastKeyLeaguesMs, lastKeyInvMs, lastKeyEquipMs, lastKeyBankMs); typeStats.entrySet().stream() .sorted((a, b) -> Integer.compare(b.getValue()[2], a.getValue()[2])) .limit(3) @@ -2091,19 +2094,32 @@ private static int currencyItemId(String currencyName) { } } + // The cold-login key phase measured 658ms of an 833ms client-thread refresh (2026-08-13 19:40, + // reason=no_snapshot; warm refreshes read 1ms) — these name which read pays it. Written on every + // fingerprint, printed only on the slow log. + private volatile long lastKeyLeaguesMs; + private volatile long lastKeyInvMs; + private volatile long lastKeyEquipMs; + private volatile long lastKeyBankMs; + private int fingerprintInventoryEquipmentBank() { final Set ids = transportRelevantItemIds; final int[] h = {1}; + long t = System.currentTimeMillis(); Rs2Inventory.items().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; h[0] = 31 * h[0] + item.getId(); h[0] = 31 * h[0] + item.getQuantity(); }); + lastKeyInvMs = System.currentTimeMillis() - t; + t = System.currentTimeMillis(); Rs2Equipment.all().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; h[0] = 31 * h[0] + item.getId(); h[0] = 31 * h[0] + item.getQuantity(); }); + lastKeyEquipMs = System.currentTimeMillis() - t; + t = System.currentTimeMillis(); if (useBankItems) { Rs2Bank.getAll().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; @@ -2111,6 +2127,7 @@ private int fingerprintInventoryEquipmentBank() { h[0] = 31 * h[0] + item.getQuantity(); }); } + lastKeyBankMs = System.currentTimeMillis() - t; return h[0]; } 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 851dcddcaf1..99241f1f5f0 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 @@ -180,7 +180,7 @@ public static WorldPoint getCurrentTarget() { /** Longest the walker will hold off re-clicking a door while an unanswered option menu is up. */ private static final long DOOR_DIALOGUE_DEFER_MAX_MS = 5_000L; /** Above this, a single transport object scan is worth naming in the log. */ - private static final long TRANSPORT_OBJECT_SCAN_SLOW_MS = 400L; + static final long TRANSPORT_OBJECT_SCAN_SLOW_MS = 400L; /** Furthest a door may be and still be opened while the player is mid-walk toward it. */ private static final int DOOR_APPROACH_INTERACT_MAX_TILES = 4; private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 3_500L; @@ -197,8 +197,8 @@ public static WorldPoint getCurrentTarget() { // 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; + static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; + static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; private static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; private static final int PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP = 6; private static final int PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP = 6; @@ -279,7 +279,7 @@ private static int normalMinimapReach() { private static final long DOOR_SUPPRESS_NUDGE_HOLDOFF_MS = 6_000L; private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; /** Floor for the post-plane-change settle sleep, so an unbounded Gaussian draw cannot go negative. */ - private static final int MIN_PLANE_CHANGE_SETTLE_MS = 60; + static final int MIN_PLANE_CHANGE_SETTLE_MS = 60; private static final int ROUTE_PROGRESS_FORWARD_SEARCH_TILES = 40; /** @@ -295,11 +295,11 @@ private static int normalMinimapReach() { private static final int PATHFINDER_NULL_WAIT_MS = 6_000; private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; - private static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; + static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; private static final int PATH_VARIANCE_TOLERANCE_CHEBYSHEV = 6; private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6; private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15; - private static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L; + static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L; private static final long RECENT_TRANSPORT_EDGE_SUPPRESS_MS = 8_000L; // door-interaction state migrated to WalkerRouteState (see routeState) /** @@ -310,23 +310,23 @@ private static int normalMinimapReach() { * when the transport was marked handled (always true while standing at the destination) and the door * settle had no early exit at all. */ - private static final long POST_INTERACT_SETTLE_MIN_MS = 300L; + static final long POST_INTERACT_SETTLE_MIN_MS = 300L; // misc route-timer state migrated to WalkerRouteState (see routeState) /** * Consolidated route state (P1 walker decomposition, enabling step). Fields are migrated here in * cohesive clusters; first cluster: transport handoff. See {@link WalkerRouteState}. */ - private static final WalkerRouteState routeState = new WalkerRouteState(); + static final WalkerRouteState routeState = new WalkerRouteState(); // idle-nudge state migrated to WalkerRouteState (see routeState) // route-progress state migrated to WalkerRouteState (see routeState) - private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); + static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); - private static final Set AL_KHARID_TOLL_GATE_OBJECT_IDS = Set.of( + static final Set AL_KHARID_TOLL_GATE_OBJECT_IDS = Set.of( net.runelite.api.ObjectID.CITY_GATE_2786, net.runelite.api.ObjectID.CITY_GATE_2787, net.runelite.api.ObjectID.CITY_GATE_2788, net.runelite.api.ObjectID.CITY_GATE_2789); - private static final Set AL_KHARID_TOLL_GATE_POINTS = Set.of( + static final Set AL_KHARID_TOLL_GATE_POINTS = Set.of( new WorldPoint(3267, 3227, 0), new WorldPoint(3267, 3228, 0), new WorldPoint(3268, 3227, 0), @@ -345,22 +345,22 @@ private static int normalMinimapReach() { static final int OFFSET = 10; /** Post-travel poll/timeout for Spirit Tree, Quetzal, glider, fairy ring, and other same-plane landing waits. */ - private static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; - private static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; + static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; + static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; /** Ship / charter / glider — landing predicate uses {@link #isPlayerWithinChebyshevOf} with this exclusive bound. */ - private static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; + static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; /** Max wait after ship/NPC/boat dialogue until near destination (must match {@link #sleepUntil} timeout + warn text). */ - private static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; + static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ - private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; - private static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; - private static final int AL_KHARID_TOLL_INTERACTION_START_WAIT_MS = 2_500; + static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; + static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; + static final int AL_KHARID_TOLL_INTERACTION_START_WAIT_MS = 2_500; /** Teleport “already near destination” skip in path loop — same semantics as prior {@code distanceTo2D < 3}. */ - private static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; + static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; /** * When the last walkable path tile is within this Chebyshev distance of the goal, treat the leg as a @@ -387,7 +387,7 @@ private static void walkerDiag(String format, Object... args) { * Compact {@code x,y,p} for logs (world API coords). Similar comma coords exist in test harnesses — keep here until * a shared microbot util is justified. */ - private static String compactWorldPoint(WorldPoint wp) { + static String compactWorldPoint(WorldPoint wp) { if (wp == null) { return "?"; } @@ -657,7 +657,7 @@ private static boolean isClientThread() { return client != null && client.isClientThread(); } - private static int reachedDistanceOrDefault() { + static int reachedDistanceOrDefault() { return config != null ? config.reachedDistance() : 10; } @@ -672,31 +672,7 @@ static boolean shouldRunActiveRouteIdleNudge(boolean idleNudgeDue, return idleNudgeDue && !immediateRouteTransportPending; } - /** - * Same-plane Chebyshev distance from player to {@code dest} strictly less than {@code maxChebyshevExclusive}. - * Requires matching {@link WorldPoint#getPlane()} before using {@link WorldPoint#distanceTo2D} — that method only - * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. - */ - private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) < maxChebyshevExclusive; - } - /** - * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). - */ - private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; - } /** * Caps configured finish distance when the route already ends very close to the marked goal. @@ -970,15 +946,6 @@ public static long getLastRouteClearAtMs() { return routeState.lastRouteClearAtMs; } - private static void logRouteClear(String reason) { - routeState.lastRouteClearReason = reason == null ? "" : reason; - routeState.lastRouteClearAtMs = System.currentTimeMillis(); - if (reason == null || reason.isBlank()) { - WebWalkLog.routeClearMissingReason(Thread.currentThread().getName()); - } else { - WebWalkLog.routeClear(reason); - } - } /** Substrings for game-object names treated like doors (pathing heuristics). */ @@ -1069,11 +1036,11 @@ private WalkCompletionContext(WorldPoint target, BooleanSupplier condition) { * then truncated {@code displayInfo} plus {@code |h} + hex {@link String#hashCode()} so long-prefix collisions split by dest. * At most {@link #SEASONAL_HANDLER_MISS_LOG_CAP} distinct keys ever log — then new misses are silent until JVM restart. */ - private static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); - private static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); - private static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; + static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); + static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); + static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; /** Terminal NPC edges already clicked during the current top-level walk invocation. */ - private static final Set TERMINAL_TRAVEL_ATTEMPTED_EDGES = ConcurrentHashMap.newKeySet(); + static final Set TERMINAL_TRAVEL_ATTEMPTED_EDGES = ConcurrentHashMap.newKeySet(); /** * One-shot DEBUG when {@link WorldMapPointManager} is null during route clear (shutdown race). * Later races same JVM stay silent — intentional noise cap. @@ -1093,7 +1060,7 @@ static void clearWalkerDedupeForTesting() resetRouteProgress(); } - private static volatile List seasonalTransportHandlers = + static volatile List seasonalTransportHandlers = SeasonalTransportHandlers.defaultHandlerList(); /** @@ -1124,6 +1091,10 @@ public static List getSeasonalTransportHandlers() * without a stall-triggered or off-path-triggered recalculation mid-walk. */ public static final class Telemetry { + public static void incrementSeasonalHandlerMiss() { + seasonalHandlerMissCount.incrementAndGet(); + } + public static final AtomicInteger offPathRecalcCount = new AtomicInteger(); public static final AtomicInteger offPathRecalcDeferredCount = new AtomicInteger(); public static final AtomicInteger stallRecalcCount = new AtomicInteger(); @@ -1173,9 +1144,6 @@ public static void incrementLeaguesLockParseMiss() { leaguesLockParseMissCount.incrementAndGet(); } - public static void incrementSeasonalHandlerMiss() { - seasonalHandlerMissCount.incrementAndGet(); - } public static void recordOffPathRecalc(WorldPoint playerPos, int pathSize) { offPathRecalcCount.incrementAndGet(); @@ -1259,7 +1227,7 @@ public static int totalRecalcs() { } // Trapdoor and manhole mappings for open/closed states - private static final Map OPEN_TO_CLOSED_MAPPINGS = Map.of( + static final Map OPEN_TO_CLOSED_MAPPINGS = Map.of( 1581, 1579, // open trapdoor -> closed trapdoor 882, 881 // open manhole -> closed manhole ); @@ -1800,8 +1768,8 @@ private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { compactWorldPoint(routeState.interimTargetWp), routeState.interimSetAtMs > 0L ? now - routeState.interimSetAtMs : -1L, routeState.lastMovedTimeMs > 0L ? now - routeState.lastMovedTimeMs : -1L, - routeState.doorInteractionSettleStartedAtMs > 0L - ? now - routeState.doorInteractionSettleStartedAtMs : -1L, + doorAttemptLedger.settleStartedAtMs() > 0L + ? now - doorAttemptLedger.settleStartedAtMs() : -1L, reachableBfsCalls.get(), reachableBfsMillis.get()); } @@ -2217,7 +2185,7 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); WalkExit exit = WalkExit.END_OF_PATH; String offPathDeferDetail = ""; - Map doorEdgesAttemptedThisTail = new HashMap<>(); + doorAttemptLedger.beginTailPass(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); // Re-capture: the widget dialogs above sleep for seconds when they fire. @@ -2456,7 +2424,7 @@ && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs if (!startupImmediateTransportOnly && doorMovementGateOk && !isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { doorOrTransportResult = handleDoorsInRawSegment(rawPath, rawI, rawEnd, - obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.segmentDoorTimeoutMs(), reachableTilesCache); } if (doorOrTransportResult) { @@ -2474,7 +2442,7 @@ && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs && !Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() && allowPathAdjacentProbe) { if (tryHandleBlockingPathObjectsWithTimeout(rawPath, rawI, 5, 10, - obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { + obstaclePolicy.pathAdjacentProbeTimeoutMs())) { tmarkPostTransport("post_transport_segment_handler", target, "stage=path_adj handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); exit = WalkExit.PATH_BLOCKER_HANDLED; @@ -2650,12 +2618,12 @@ && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs break; } if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), - doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { + playerLoc, 2, 14)) { exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN; break; } if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), null)) { exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY; break; @@ -2673,7 +2641,7 @@ && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs // returned NONE, which already proved the door-settling window closed. if (unresolvedDoorNearRawPath && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), playerLoc, UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, @@ -2950,7 +2918,7 @@ && walkFastCanvas(recoverTarget)) { // rather than spinning without issuing movement commands. if (Rs2Player.isMoving()) { if (!inInstance && handlePendingDoorDuringInterim(rawPath, - obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.segmentDoorTimeoutMs(), playerLoc)) { routeState.interimTargetWp = null; routeState.interimTargetIdx = -1; @@ -3101,7 +3069,7 @@ && walkFastCanvas(recoverTarget)) { } } if (!inInstance && handlePendingDoorBeforeRouteClick(rawPath, path, i, targetIdx, - smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), playerLoc)) { doorOrTransportResult = true; exit = WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK; @@ -3903,25 +3871,6 @@ static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int ma return false; } - private static boolean walkReachableMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { - int currentDistance = euclideanSq(playerLoc, target); - return Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet().stream() - .filter(tile -> tile != null - && tile.getPlane() == playerLoc.getPlane() - && !tile.equals(playerLoc) - && euclideanSq(playerLoc, tile) <= maxEuclidean * maxEuclidean - && euclideanSq(tile, target) < currentDistance) - .sorted(Comparator - .comparingInt((WorldPoint tile) -> euclideanSq(tile, target)) - .thenComparing(Comparator.comparingInt((WorldPoint tile) -> euclideanSq(playerLoc, tile)).reversed())) - .filter(Rs2Walker::walkMiniMap) - .findFirst() - .map(tile -> { - log.info("[Walker] Minimap click target {} was outside clip; used reachable fallback {}", target, tile); - return true; - }) - .orElse(false); - } // findFurthestRawPathPointMatching (pure) moved to geometry/WalkerPathGeometry (P1); this game-coupled // wrapper supplies the constant forward-search window and the lazy reachable-closest fallback. UNGATED — @@ -5449,7 +5398,6 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat int targetPathIdx, int[] smoothedToRaw, long timeoutMs, - Map attempted, WorldPoint playerLoc) { if (rawPath == null || rawPath.size() < 2 || path == null || path.isEmpty() || playerLoc == null || targetPathIdx < fromPathIdx) { @@ -5485,7 +5433,7 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { return true; } } @@ -5494,7 +5442,6 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat private static boolean handlePendingDoorDuringInterim(List rawPath, long timeoutMs, - Map attempted, WorldPoint playerLoc) { if (rawPath == null || rawPath.size() < 2 || playerLoc == null || isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown() @@ -5502,12 +5449,11 @@ private static boolean handlePendingDoorDuringInterim(List rawPath, return false; } - return handlePendingDoorNearRawPath(rawPath, timeoutMs, attempted, playerLoc, 2, 14); + return handlePendingDoorNearRawPath(rawPath, timeoutMs, playerLoc, 2, 14); } private static boolean handlePendingDoorNearRawPath(List rawPath, long timeoutMs, - Map attempted, WorldPoint playerLoc, int backtrackEdges, int lookaheadEdges) { @@ -5543,7 +5489,7 @@ private static boolean handlePendingDoorNearRawPath(List rawPath, if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { return true; } } @@ -5553,7 +5499,6 @@ private static boolean handlePendingDoorNearRawPath(List rawPath, private static boolean handleUnresolvedDoorNearRawPath(List rawPath, int rawEdgeStart, long timeoutMs, - Map attempted, WorldPoint playerLoc, int backtrackEdges, int lookaheadEdges, @@ -5582,7 +5527,7 @@ private static boolean handleUnresolvedDoorNearRawPath(List rawPath, if (!hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { continue; } - if (handleDoorsWithTimeout(rawPath, ri, timeoutMs, attempted, true)) { + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, timeoutMs, true)) { return true; } } @@ -5646,8 +5591,8 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, } if (shouldUseFocusedRawDoorIndex(rawPath, rawStart)) { - int idx = routeState.rawScanFocusedDoorIdx; - routeState.rawScanFocusedDoorAttempts++; + int idx = doorAttemptLedger.rawScanFocusDoorIdx(); + doorAttemptLedger.recordRawScanFocusAttempt(); if (handleDoors(rawPath, idx, true)) { log.info("[Walker] Raw path focused door handler resolved obstacle near {}", playerLoc); return true; @@ -6080,23 +6025,21 @@ private static boolean hasDoorCandidateOnRawSegment(List rawPath, in } private static void setRawScanDoorFocus(int index) { - routeState.rawScanFocusedDoorIdx = index; - routeState.rawScanFocusedDoorSetAtMs = System.currentTimeMillis(); - routeState.rawScanFocusedDoorAttempts = 0; + doorAttemptLedger.setRawScanFocus(index, System.currentTimeMillis()); } private static boolean shouldUseFocusedRawDoorIndex(List rawPath, int rawStartIdx) { - Integer idx = routeState.rawScanFocusedDoorIdx; + Integer idx = doorAttemptLedger.rawScanFocusDoorIdx(); if (idx == null) { return false; } if (routeState.interimTargetWp != null) { return false; } - if (System.currentTimeMillis() - routeState.rawScanFocusedDoorSetAtMs > RAW_SCAN_DOOR_FOCUS_MAX_MS) { + if (System.currentTimeMillis() - doorAttemptLedger.rawScanFocusSetAtMs() > RAW_SCAN_DOOR_FOCUS_MAX_MS) { return false; } - if (routeState.rawScanFocusedDoorAttempts >= RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS) { + if (doorAttemptLedger.rawScanFocusAttempts() >= RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS) { return false; } if (idx < 0 || idx >= rawPath.size() - 1) { @@ -6109,12 +6052,10 @@ private static boolean shouldUseFocusedRawDoorIndex(List rawPath, in } private static void clearRawScanDoorFocus(String reason) { - if (routeState.rawScanFocusedDoorIdx != null && debug) { + if (doorAttemptLedger.rawScanFocusDoorIdx() != null && debug) { walkerDiag("clear raw door focus: %s", reason); } - routeState.rawScanFocusedDoorIdx = null; - routeState.rawScanFocusedDoorSetAtMs = 0L; - routeState.rawScanFocusedDoorAttempts = 0; + doorAttemptLedger.clearRawScanFocus(); } private static boolean handleCurrentTileTransportTowardPath(List rawPath, List path, WorldPoint target) { @@ -6192,7 +6133,7 @@ private static boolean handleCurrentTileTransportTowardPath(List raw // Pass the transport's own origin so handleTransports walks the short hop to it before // interacting (NPC dispatch already auto-walks via canWalkTo + interact); object/door // interactions that can't be reached from here simply return false and we fall through. - if (handleSelectedTransport(Arrays.asList(origin, transport.getDestination()), 0, selection)) { + if (Rs2WalkerTransports.handleSelectedTransport(Arrays.asList(origin, transport.getDestination()), 0, selection)) { if (didCurrentTileTransportProgress(before, transport.getDestination(), target)) { log.info("[Walker] Nearby transport handler resolved obstacle: origin={} dest={} (player {})", origin, transport.getDestination(), playerLoc); @@ -6329,7 +6270,7 @@ private static boolean isMiniMapRecoveryClickable(WorldPoint worldPoint) { // clampToEuclideanRadius extracted to recovery/RouteRecovery (P1) - private static int euclideanSq(WorldPoint a, WorldPoint b) { + static int euclideanSq(WorldPoint a, WorldPoint b) { int dx = a.getX() - b.getX(); int dy = a.getY() - b.getY(); return dx * dx + dy * dy; @@ -6512,6 +6453,10 @@ private static boolean handleDoors(List path, int index, boolean all Telemetry.recordDoorReject("orient-mismatch"); } } else { + if (!Rs2DoorClassifier.isRouteDoorObject(false, name, action)) { + Telemetry.recordDoorReject("gameobject-not-a-door"); + continue; + } if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { WebWalkLog.spInfo("door_skip_goal_object | mode=segment-door probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", compactWorldPoint(probe), compactWorldPoint(fromWp)); @@ -6656,7 +6601,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, log.debug("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; } - } else if (name != null && name.toLowerCase().contains("door")) { + } else if (Rs2DoorClassifier.isRouteDoorObject(false, name, action)) { if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { WebWalkLog.spInfo("door_skip_goal_object | mode=segment-probe probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", compactWorldPoint(probe), compactWorldPoint(fromWp)); @@ -6868,7 +6813,7 @@ private static boolean doorObjectStillHasAction(TileObject object, WorldPoint pr return currentAction != null && currentAction.equalsIgnoreCase(action); } - private static void markStationaryDoorOpened(WorldPoint doorTile) { + static void markStationaryDoorOpened(WorldPoint doorTile) { doorAttemptLedger.markStationaryDoorOpened(doorTile, System.currentTimeMillis()); } @@ -7520,7 +7465,7 @@ private static boolean shouldThrottleGlobalDoorInteraction(WorldPoint fromWp, Wo boolean sameEdge = fromWp != null && toWp != null && lastClaim != null && lastClaim.isSameDirectedEdge(fromWp, toWp); return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(System.currentTimeMillis(), - routeState.nextDoorInteractionAllowedAtMs, sameEdge, + doorAttemptLedger.globalCooldownUntilMs(), sameEdge, DOOR_INTERACTION_GLOBAL_COOLDOWN_MS, DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS) || shouldDeferDoorInteractionForDialogue(); } @@ -7560,19 +7505,18 @@ static boolean doorDialogueDeferActive(long deferSinceMs, long nowMs, long maxDe private static boolean isDoorInteractionSettling() { long now = System.currentTimeMillis(); - if (now >= routeState.doorInteractionSettleUntilMs) { + if (now >= doorAttemptLedger.settleUntilMs()) { return false; } // Early exit: the interaction's purpose was opening the door — once its far side is reachable, // the edge is open and there is nothing left to settle (previously this was a flat 900ms freeze // after every door). One-tick floor for object-state flux; the window is cleared on success so // repeated checks this tick don't re-run the reachability probe. - WorldPoint farSide = routeState.doorSettleFarSideWp; + WorldPoint farSide = doorAttemptLedger.settleFarSide(); if (farSide != null - && now - routeState.doorInteractionSettleStartedAtMs >= POST_INTERACT_SETTLE_MIN_MS + && now - doorAttemptLedger.settleStartedAtMs() >= POST_INTERACT_SETTLE_MIN_MS && Rs2Tile.isTileReachable(farSide)) { - routeState.doorInteractionSettleUntilMs = 0L; - routeState.doorSettleFarSideWp = null; + doorAttemptLedger.endSettleEarly(); return false; } return true; @@ -7590,31 +7534,6 @@ private static boolean isTransportInteractionSettling() { Rs2Player.isAnimating()); } - /** - * Pure settle decision after a handled transport. Settling ends as soon as the player is confirmed - * ARRIVED — standing at/next to the transport's planned destination, neither moving nor animating — - * after a one-tick floor for post-action state flux; {@link #TRANSPORT_POST_INTERACT_SETTLE_MS} is - * only the ceiling for when arrival never confirms (unknown destination, drawn-out travel). The old - * check compared against where the player stood when the transport was MARKED handled, which after - * landing is always true while standing still — so the settle could only ever end by timeout, a fixed - * ~900ms freeze after every single transport. - */ - static boolean transportSettlePending(long ageMs, WorldPoint now, WorldPoint plannedDestination, - boolean moving, boolean animating) { - if (ageMs < 0L || ageMs > TRANSPORT_POST_INTERACT_SETTLE_MS) { - return false; - } - if (ageMs < POST_INTERACT_SETTLE_MIN_MS) { - return true; - } - if (now == null || plannedDestination == null) { - return ageMs <= TRANSPORT_POST_INTERACT_SETTLE_MS / 2; - } - boolean arrivedIdle = now.getPlane() == plannedDestination.getPlane() - && now.distanceTo2D(plannedDestination) <= 1 - && !moving && !animating; - return !arrivedIdle; - } private static boolean isDoorEdgePassSkipCoolingDown() { return System.currentTimeMillis() - routeState.lastDoorEdgePassSkipAtMs < DOOR_EDGE_SKIP_COOLDOWN_MS; @@ -7624,16 +7543,13 @@ private static boolean isRecoveryMovementInFlight() { return System.currentTimeMillis() - routeState.lastUnreachableRecoveryClickAtMs < RECOVERY_MOVEMENT_IN_FLIGHT_MS; } - /** Starts the door settle window, remembering the far-side tile so it can end when the edge opens. */ private static void markDoorInteractionSettling(WorldPoint farSideWp) { - long now = System.currentTimeMillis(); - routeState.doorInteractionSettleStartedAtMs = now; - routeState.doorInteractionSettleUntilMs = now + DOOR_POST_INTERACT_SETTLE_MS; - routeState.doorSettleFarSideWp = farSideWp; + doorAttemptLedger.markSettling(farSideWp, System.currentTimeMillis(), DOOR_POST_INTERACT_SETTLE_MS); } private static void markGlobalDoorInteractionCooldown() { - routeState.nextDoorInteractionAllowedAtMs = Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS); + doorAttemptLedger.markGlobalCooldownUntil( + Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS)); } private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { @@ -7727,7 +7643,7 @@ private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoin System.currentTimeMillis()); } - private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { return doorAttemptLedger.recentlyOpenedDoorOnSegment( fromWp, toWp, STATIONARY_DOOR_SUPPRESS_MS, System.currentTimeMillis()); } @@ -8227,8 +8143,7 @@ private static boolean isUnresolvedRouteDoorObject(TileObject object, WorldPoint return false; } String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); } private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fromWp, WorldPoint toWp, @@ -8258,8 +8173,7 @@ private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fr return false; } String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); } @@ -8269,25 +8183,24 @@ private static boolean isPendingRouteDoorObject(TileObject object, WorldPoint fr * loop can continue (stall detection / replans). */ private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs) { - return handleDoorsWithTimeout(path, index, timeoutMs, null); + return handleDoorsWithTimeout(path, index, timeoutMs, false, false); } - private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, - Map attemptedDoorEdgesThisPass) { - return handleDoorsWithTimeout(path, index, timeoutMs, attemptedDoorEdgesThisPass, false); + private static boolean handleDoorsWithTimeoutBudgeted(List path, int index, long timeoutMs, + boolean allowSegmentProbe) { + return handleDoorsWithTimeout(path, index, timeoutMs, true, allowSegmentProbe); } private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, - Map attemptedDoorEdgesThisPass, - boolean allowSegmentProbe) { + boolean passBudgeted, boolean allowSegmentProbe) { long start = System.currentTimeMillis(); WorldPoint[] segment = resolveDoorSegment(path, index); - String edgeKey = segment != null && segment.length >= 2 && segment[0] != null && segment[1] != null - ? doorAttemptKey(null, segment[0], segment[1]) - : null; + boolean claimableSegment = segment != null && segment.length >= 2 + && segment[0] != null && segment[1] != null; WorldPoint playerBeforeAttempt = Rs2Player.getWorldLocation(); resetDoorLegStages(); - if (!markDoorEdgeAttemptThisPass(attemptedDoorEdgesThisPass, segment, playerBeforeAttempt)) { + if (passBudgeted && claimableSegment + && !doorAttemptLedger.tryClaimEdgeThisPass(segment[0], segment[1], playerBeforeAttempt)) { routeState.lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); return false; @@ -8296,8 +8209,8 @@ private static boolean handleDoorsWithTimeout(List path, int index, if (!handled) { // Do not consume one-shot budget when no interaction happened; allow // a later resolver in the same pass to attempt this edge. - if (attemptedDoorEdgesThisPass != null && edgeKey != null) { - attemptedDoorEdgesThisPass.remove(edgeKey); + if (passBudgeted && claimableSegment) { + doorAttemptLedger.releaseEdgeThisPass(segment[0], segment[1]); } return false; } @@ -8350,24 +8263,6 @@ private static WorldPoint[] resolveDoorSegment(List path, int index) return new WorldPoint[] {convertedFrom, convertedTo}; } - static boolean markDoorEdgeAttemptThisPass(Map attemptedDoorEdgesThisPass, - WorldPoint[] segment, - WorldPoint playerBeforeAttempt) { - if (attemptedDoorEdgesThisPass == null || segment == null || segment.length < 2 - || segment[0] == null || segment[1] == null) { - return true; - } - String edgeKey = doorAttemptKey(null, segment[0], segment[1]); - WorldPoint previousAttemptPos = attemptedDoorEdgesThisPass.get(edgeKey); - if (previousAttemptPos != null && playerBeforeAttempt != null - && previousAttemptPos.getPlane() == playerBeforeAttempt.getPlane() - && previousAttemptPos.distanceTo2D(playerBeforeAttempt) <= 1) { - return false; - } - attemptedDoorEdgesThisPass.put(edgeKey, playerBeforeAttempt); - return true; - } - /** * Last-resort door resolver for "tile unreachable near player" stalls. * Scans a very small radius around the player for door-like wall/game objects @@ -8392,8 +8287,7 @@ private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int rad if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; candidates++; @@ -8418,8 +8312,7 @@ private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int rad if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; candidates++; @@ -8484,8 +8377,7 @@ private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, Li String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; @@ -8521,8 +8413,7 @@ private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, Li String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; @@ -8616,8 +8507,7 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(true, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(w) && !Rs2DoorDetection.isDoorLikeSceneObject(w)) continue; @@ -8655,8 +8545,7 @@ private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List< if (Rs2DoorClassifier.doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(false, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(g) && !Rs2DoorDetection.isDoorLikeSceneObject(g)) continue; @@ -9059,8 +8948,7 @@ private static boolean tryHandleBlockingPathObjectsWithTimeout( int startIdx, int radiusTiles, int maxEdges, - long timeoutMs, - Map attemptedDoorEdgesThisPass) + long timeoutMs) { if (path == null || path.size() < 2) return false; if (startIdx < 0) return false; @@ -9111,13 +8999,13 @@ private static boolean tryHandleBlockingPathObjectsWithTimeout( .filter(act -> Rs2DoorClassifier.doorActionPriorityIndex(act) < Integer.MAX_VALUE) .min(Comparator.comparingInt(Rs2DoorClassifier::doorActionPriorityIndex)) .orElse(null); - boolean doorLike = Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) || action != null; + boolean doorLike = Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), action); if (!doorLike) continue; if (Rs2DoorProbe.isCatalogTransportObject(object) && !Rs2DoorDetection.isDoorLikeSceneObject(object)) continue; // Found a likely blocker on-path: hand off to existing door handler (which // includes quest-lock detection, blacklisting, and recalculation). - if (handleDoorsWithTimeout(path, j, timeoutMs, attemptedDoorEdgesThisPass)) { + if (handleDoorsWithTimeoutBudgeted(path, j, timeoutMs, false)) { return true; } } @@ -9271,7 +9159,7 @@ static int getClosestTileIndex(List path, WorldPoint playerLoc) { // 3-arg getClosestTileIndex (pure) moved to geometry/WalkerPathGeometry (P1) /** Step budget of {@link #getClosestIndexReachableTiles}'s BFS; also the route-blocked scan gate's bound. */ - private static final int CLOSEST_INDEX_REACHABLE_STEP_BUDGET = 20; + static final int CLOSEST_INDEX_REACHABLE_STEP_BUDGET = 20; /** * Calls and milliseconds spent in the player-origin BFS since the current walk started. @@ -9317,37 +9205,7 @@ private static HashMap getClosestIndexReachableTiles(WorldP return tiles; } - static boolean isClientThreadReadTimeout(Throwable failure) { - Throwable current = failure; - while (current != null) { - if (current instanceof TimeoutException) { - return true; - } - current = current.getCause(); - } - return false; - } - static HashMap nearbyTilesIgnoringCollision( - WorldPoint origin, int radius) { - HashMap result = new HashMap<>(); - if (origin == null || radius < 0) { - return result; - } - int boundedRadius = Math.min(radius, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); - for (int dx = -boundedRadius; dx <= boundedRadius; dx++) { - for (int dy = -boundedRadius; dy <= boundedRadius; dy++) { - int distance = Math.max(Math.abs(dx), Math.abs(dy)); - if (distance <= boundedRadius) { - result.put(new WorldPoint( - origin.getX() + dx, - origin.getY() + dy, - origin.getPlane()), distance); - } - } - } - return result; - } static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { if (path == null || path.isEmpty() || closestIdx < 0 || closestIdx >= path.size()) { @@ -9521,7 +9379,7 @@ private static boolean isRecentTransportEdgeWindow() { return ageMs >= 0L && ageMs <= RECENT_TRANSPORT_EDGE_SUPPRESS_MS; } - private static boolean isNearSamePlane(WorldPoint a, WorldPoint b, int distance) { + static boolean isNearSamePlane(WorldPoint a, WorldPoint b, int distance) { return a != null && b != null && a.getPlane() == b.getPlane() @@ -9610,13 +9468,6 @@ private static void recalculatePath(Rs2PlannerShadowContext.Invocation invocatio Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal, invocation); } - /** - * Updates world-map marker and restarts pathfinding for {@code target}. Does not assign - * {@link #currentTarget}; callers set it when appropriate. - */ - private static void applyWalkerDestination(WorldPoint target) { - Rs2WalkerLifecycleRuntime.applyWalkerDestination(target); - } /** * @param target destination, or {@code null} to clear (prefer {@link #clearWalkingRoute(String)} for observability) @@ -9739,1009 +9590,86 @@ private static boolean handleTransports(List path, int indexOfStartP if (selection.isEmpty()) { return false; } - return handleSelectedTransport(path, indexOfStartPoint, selection.get()); + return Rs2WalkerTransports.handleSelectedTransport(path, indexOfStartPoint, selection.get()); } - /** - * Executes the exact transport retained by the active route through its registered Microbot executor. - * Candidate discovery must happen through immutable route steps, never by rescanning the mutable - * transport catalog. The local transport payload is isolated here because POH execution still carries - * subtype behavior that is not part of the planner-independent edge value. - */ - private static boolean handleSelectedTransport(List path, - int indexOfStartPoint, - Rs2PathApi.ActiveTransportSelection selection) { - if (selection == null || !selection.isExecutable()) { - if (selection != null) { - WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", - selection.getEdge().getType(), - compactWorldPoint(selection.getEdge().getOrigin()), - compactWorldPoint(selection.getEdge().getDestination())); - } - return false; - } - Transport selectedTransport = selection.getLocalExecutionTransport(); - Rs2TerminalTravelMode terminalTravelMode = selection.getEdge().getTerminalTravelMode(); - if (path == null || selectedTransport == null - || indexOfStartPoint < 0 || indexOfStartPoint >= path.size()) { - return false; - } - if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 - && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { - return false; - } - if (log.isDebugEnabled()) { - log.debug("[Walker] handleTransports at {}: exact planned candidate — {} executor={}", - path.get(indexOfStartPoint), selectedTransport.getDisplayInfo(), selection.getExecutor()); - } - // When the player is inside a POH instance, the player's raw world-location plane is - // the instance-template plane and has no relationship to the POH-transport origin plane. - // Skip the plane guard in that case so POH transports can actually be considered. - boolean inPohInstance = Microbot.getClient().getTopLevelWorldView().getScene().isInstance() - && net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile() != null; - - // Pre-compute path point index map for O(1) lookups instead of repeated O(n) scans - Map pathFirstIndex = new HashMap<>(path.size()); - for (int idx = 0; idx < path.size(); idx++) { - pathFirstIndex.putIfAbsent(path.get(idx), idx); - } - - for (Transport transport : Collections.singletonList(selectedTransport)) { - Collection worldPointCollections; - //in some cases the getOrigin is null, for teleports that start the player location - if (transport.getOrigin() == null) { - worldPointCollections = Collections.singleton(null); - } else if (inPohInstance && transport.getType() == TransportType.POH) { - // POH fix: when the player is inside a POH instance, the transport's exit-portal - // origin is an overworld tile that doesn't map into the player's instance chunks, - // so toLocalInstance() returns an empty collection and the inner loop never runs. - // Pass the origin through directly so the per-i dispatch below can execute. - worldPointCollections = Collections.singleton(transport.getOrigin()); - } else { - worldPointCollections = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), transport.getOrigin()); - } - log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", - transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); - originLoop: - for (WorldPoint origin : worldPointCollections) { - WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); - if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null - && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { - continue; - } - // Hoist path-constant checks out of the inner loop: destination must exist in path - if (!pathFirstIndex.containsKey(transport.getDestination())) { - log.debug("[Walker] skip {}: destination {} not in path", transport.getDisplayInfo(), transport.getDestination()); - continue; - } - // QUETZAL is not {@link TransportType#isTeleport} — without this, stall/off-path recalc can re-open the map and - // click the same landing repeatedly while already there (no movement → infinite stall loop). - if (transport.getType() == TransportType.QUETZAL) { - if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { - log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", - transport.getDisplayInfo(), OFFSET, transport.getDestination()); - continue; - } - } - if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { - if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { - log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); - continue; - } - } - // Pre-compute origin/destination indices once per transport (not per inner iteration) - int precomputedIndexOfOrigin = -1; - int precomputedIndexOfDest = -1; - if (!TransportType.isTeleport(transport.getType(), transport.getOrigin())) { - Integer originIdx = pathFirstIndex.get(transport.getOrigin()); - Integer destIdx = pathFirstIndex.get(transport.getDestination()); - precomputedIndexOfOrigin = originIdx != null ? originIdx : -1; - precomputedIndexOfDest = destIdx != null ? destIdx : -1; - if (log.isDebugEnabled()) { - log.debug("[Walker] filter4 {}: indexOfOrigin={}, indexOfDestination={}, pathSize={}, originInPath={}, destInPath={}", - transport.getDisplayInfo(), precomputedIndexOfOrigin, precomputedIndexOfDest, path.size(), - precomputedIndexOfOrigin != -1, precomputedIndexOfDest != -1); - } - if (precomputedIndexOfDest == -1) continue; - if (precomputedIndexOfOrigin == -1) continue; - if (precomputedIndexOfDest < precomputedIndexOfOrigin) continue; - } - for (int i = indexOfStartPoint; i < path.size(); i++) { - WorldPoint plPathLoop = Rs2Player.getWorldLocation(); - if (plPathLoop == null) { - // Cannot verify plane / dispatch — do not burn remaining path indices this tick. - break; - } - if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { - log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); - break; // plane won't change across iterations, so break instead of continue - } - if (i == indexOfStartPoint) { - log.debug("[Walker] reached pre-dispatch for {}: i={}, path[i]={}, origin={}, equalsOrigin={}", - transport.getDisplayInfo(), i, path.get(i), origin, path.get(i).equals(origin)); - } - if (path.get(i).equals(origin)) { - if (selection.getExecutor() == Rs2TransportExecutor.BARROWS_DIG) { - WorldPoint digOrigin = transport.getOrigin(); - WorldPoint playerAtMound = Rs2Player.getWorldLocation(); - if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { - // Digging is tile-sensitive. Let the ordinary path click finish the - // approach instead of firing the spade from an adjacent mound tile. - return false; - } - boolean dug = attemptObserved(transport, - () -> Rs2Inventory.interact(ItemID.SPADE, "Dig")); - if (!dug) { - return false; - } - boolean enteredCrypt = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf( - transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (enteredCrypt) { - return finishHandledTransport(transport); - } - WebWalkLog.spWarn( - "Barrows dig post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - return false; - } - if (isTerminalTravelTransport(transport.getType())) { - if (terminalTravelMode == Rs2TerminalTravelMode.UNSUPPORTED) { - WebWalkLog.spWarn( - "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", - transport.getType(), compactWorldPoint(transport.getOrigin()), - compactWorldPoint(transport.getDestination())); - break originLoop; - } - Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); - if (npc != null && Rs2Npc.canWalkTo(npc, 20)) { - String npcAction = resolveTerminalNpcInteractionAction( - npc, transport); - if (npcAction.isEmpty()) { - WebWalkLog.spWarn( - "terminal NPC has no supported interaction action name={} configured={} dest={}", - transport.getName(), transport.getAction(), transport.getDisplayInfo()); - break originLoop; - } - if (!markTerminalTravelAttempt(transport)) { - log.debug("[Walker] terminal travel edge already attempted this walk: {}", - transport.getDisplayInfo()); - break originLoop; - } - if (!npcAction.equalsIgnoreCase(transport.getAction())) { - WebWalkLog.spInfo( - "terminal NPC action fallback name={} configured={} selected={} dest={}", - transport.getName(), transport.getAction(), npcAction, - transport.getDisplayInfo()); - } - // Wrap with observation so Leagues blocked-region chat can attribute this attempt. - if (attemptObserved(transport, () -> Rs2Npc.interact(npc, npcAction))) { - Rs2Player.waitForWalking(); - sleepUntil(Rs2Dialogue::isInDialogue, 600 * 2); - - if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption("Can you take me somewhere?"); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - } - if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - } - if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")) { - sleepTickJitter(2); - Rs2Dialogue.clickContinue(); - } - // Right-clicking the destination is always preferred and needs no - // dialogue — that is what DIRECT means. But the mode is decided - // statically from a name whitelist, so an NPC whose row names a - // destination it no longer offers (Veos: the row says - // "Port Piscarilius", the game now asks in conversation) resolved - // to DIRECT, skipped destination selection entirely, and left the - // walker staring at the destination menu. - // - // resolveTerminalNpcInteractionAction already told us which action - // the NPC actually offered. If it had to fall back to a generic one - // then the destination was NOT chosen by the click and has to be - // chosen in the dialogue, whatever the static mode says. - Rs2TerminalTravelMode effectiveTravelMode = terminalTravelMode; - if (!npcAction.equalsIgnoreCase(transport.getAction()) - && transport.getDisplayInfo() != null - && !transport.getDisplayInfo().isBlank()) { - effectiveTravelMode = Rs2TerminalTravelMode.DIALOGUE_DESTINATION; - } - if (!selectTerminalTravelDialogueDestination( - transport, effectiveTravelMode)) { - break originLoop; - } - final int terminalDestinationIndex = precomputedIndexOfDest; - if (awaitTerminalTravelLanding( - transport, path, terminalDestinationIndex)) { - return finishHandledTransport(transport); - } - } - } else { - TileObject terminalObject = findTerminalTravelObject(transport); - if (terminalObject != null) { - String objectAction = resolveTransportObjectAction( - terminalObject, - Collections.singletonList(transport.getAction())) - .orElse(""); - if (objectAction.isEmpty()) { - WebWalkLog.spWarn( - "terminal object has no supported interaction action name={} configured={} dest={}", - transport.getName(), transport.getAction(), transport.getDisplayInfo()); - break originLoop; - } - if (!markTerminalTravelAttempt(transport)) { - log.debug("[Walker] terminal travel edge already attempted this walk: {}", - transport.getDisplayInfo()); - break originLoop; - } - prepareTransportObjectForInteraction(terminalObject); - final TileObject selectedTerminalObject = terminalObject; - if (attemptObserved(transport, () -> Rs2GameObject.interact( - selectedTerminalObject, objectAction))) { - if (!selectTerminalTravelDialogueDestination( - transport, terminalTravelMode)) { - break originLoop; - } - final int terminalDestinationIndex = precomputedIndexOfDest; - if (awaitTerminalTravelLanding( - transport, path, terminalDestinationIndex)) { - return finishHandledTransport(transport); - } - } - } else { - WorldPoint originTile = path.get(i); - boolean clicked = Rs2Walker.walkFastCanvas(originTile); - if (!clicked) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc != null) { - clicked = walkMiniMapToward(originTile, playerLoc, 13); - } - } - if (!clicked) { - clicked = Rs2Walker.walkMiniMap(originTile); - } - if (!clicked) { - log.debug("[Walker] terminal travel fallback click failed for {}", originTile); - } - sleep(1200, 1600); - } - } - // Terminal travel is terminal for this transport scan. The exact edge can be - // clicked at most once in one top-level walk invocation; callers can start - // a fresh walk after a surfaced failure, but this invocation never spams the - // target for later path indices or another local-instance copy of the origin. - break originLoop; - } + static boolean isAdjacentSamePlaneTransport(Transport transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } - if (transport.getType() == TransportType.CHARTER_SHIP) { - if (attemptObserved(transport, () -> handleCharterShip(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean charterLanded = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!charterLanded) { - WebWalkLog.spWarn( - "charter ship post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - sleepTickJitter(4); // wait 4 extra ticks before walking - return finishHandledTransport(transport); - } - } - } + static boolean isAdjacentSamePlaneTransport(Rs2TransportEdge transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } - log.debug("[Walker] Handling {} transport: {} (i={}, path[i]={}, origin={})", - transport.getType(), transport.getDisplayInfo(), i, path.get(i), origin); - if (transport.getType() == TransportType.POH) { - boolean pohResult = attemptObserved(transport, () -> handlePohTransport(transport)); - log.debug("[Walker] handlePohTransport({}) returned {}", transport.getDisplayInfo(), pohResult); - if (pohResult) { - // Shares ship/NPC/boat 10s landing budget — intentional single timeout constant. - boolean pohNearDest = sleepUntil( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - SHIP_NPC_BOAT_LANDING_WAIT_MS); - if (!pohNearDest) { - WebWalkLog.spWarn( - "POH post-travel wait timed out ({}ms) dest={} at={}", - SHIP_NPC_BOAT_LANDING_WAIT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - if (pohNearDest) { - return finishHandledTransport(transport); - } - } - } + private static int[] mapSmoothedToRaw(List smoothed, List raw) { + if (smoothed == null || raw == null || smoothed.isEmpty() || raw.isEmpty()) { + return new int[0]; + } + int[] mapping = new int[smoothed.size()]; + int rawIdx = 0; + for (int si = 0; si < smoothed.size(); si++) { + WorldPoint sp = smoothed.get(si); + while (rawIdx < raw.size() && !raw.get(rawIdx).equals(sp)) { + rawIdx++; + } + mapping[si] = Math.min(rawIdx, raw.size() - 1); + } + return mapping; + } - if (transport.getType() == TransportType.CANOE) { - if (attemptObserved(transport, () -> handleCanoe(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } + private static int rawEndForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, + List rawPath, List path) { + if (smoothedIdx + 1 < path.size() && smoothedIdx + 1 < smoothedToRaw.length) { + return smoothedToRaw[smoothedIdx + 1]; + } + return rawPath.size(); + } - if (transport.getType() == TransportType.HOT_AIR_BALLOON) { - if (attemptObserved(transport, () -> Rs2HotAirBalloon.handle(selection.getEdge()))) { - boolean balloonLanded = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (balloonLanded) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - WebWalkLog.spWarn( - "hot-air balloon post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - // This is a specialized map interaction. Do not fall through to the generic - // object handler and click the same basket again during this walker tick. - return false; - } - - if (transport.getType() == TransportType.SPIRIT_TREE) { - if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { - log.debug("[Walker] skip spirit tree transport — setting is off"); - continue; - } - if (attemptObserved(transport, () -> handleSpiritTree(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean spiritLanded = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!spiritLanded) { - WebWalkLog.spWarn( - "spirit tree post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - if (spiritLanded) { - return finishHandledTransport(transport); - } - } - } - - if (transport.getType() == TransportType.QUETZAL) { - if (attemptObserved(transport, () -> handleQuetzal(transport))) { - boolean landedNearDest = Rs2WalkerRuntimeAwaits.awaitCondition( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, - TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!landedNearDest) { - WebWalkLog.spWarn( - "quetzal post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.MAGIC_CARPET) { - if (attemptObserved(transport, () -> handleMagicCarpet(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.WILDERNESS_OBELISK) { - if (attemptObserved(transport, () -> handleWildernessObelisk(transport))) { - sleepTickJitter(2); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.GNOME_GLIDER) { - if (attemptObserved(transport, () -> handleGlider(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), - TRANSPORT_NEAR_LANDING_CHEBYSHEV), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - sleepTickJitter(3); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.FAIRY_RING) { - WorldPoint plFairy = Rs2Player.getWorldLocation(); - WorldPoint tdFairy = transport.getDestination(); - boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); - if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_MINIGAME) { - if (attemptObserved(transport, () -> handleMinigameTeleport(transport))) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_ITEM) { - if (attemptObserved(transport, () -> handleTeleportItem(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.TELEPORTATION_SPELL) { - if (attemptObserved(transport, () -> handleTeleportSpell(transport))) { - if (isLumbridgeHomeTeleport(transport)) { - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 600, 35000); - } else { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - } - Rs2Tab.switchTo(InterfaceTab.INVENTORY); - return finishHandledTransport(transport); - } - } - - if (transport.getType() == TransportType.SEASONAL_TRANSPORT) { - if (attemptObservedWithoutAttemptRecord(transport, () -> handleSeasonalTransport(transport))) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - return finishHandledTransport(transport); - } - } - - if (transport.getObjectId() <= 0) break; - - final int transportObjectId = transport.getObjectId(); - final String transportAction = transport.getAction(); - final List transportActions = getTransportActionOptions(transportAction); - // Climb-down transports have a closed-variant (trapdoor/manhole/grate/hatch) - // that shares the same tile but a different object ID. Infer the closed - // variant from ObjectComposition (any nearby object with an "Open" action - // and a matching name) rather than a hardcoded ID pair, so new variants - // work without a code change. - final boolean allowClosedVariant = "Climb-down".equalsIgnoreCase(transportAction) - || "Climb down".equalsIgnoreCase(transportAction); - - final boolean allowAlKharidTollGateVariant = isAlKharidTollGateObjectId(transportObjectId); - // The FIRST transport of a walk costs ~12.7s in the segment handler while the same - // transport mid-route costs ~1.8s, and the plane-change waits account for only - // ~1.5s of it (measured over three Falador castle runs). This scan runs once per - // CANDIDATE transport at the tile, and a staircase tile carries several rows, so - // the suspicion is N scans rather than one. Time it and say how many candidates - // were queued, so the next run distinguishes "one slow scan" from "many scans". - long objectScanStartedAt = System.currentTimeMillis(); - final Integer legacyClosedId = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); - // Most catalog transports can use their stable object id. The Al Kharid gate cannot: - // its historical catalog ids collide with unrelated live objects in newer injected-client - // revisions. Select that edge by its transformed live composition and route geometry instead. - // This deliberately has no id fallback: clicking an unrelated object is worse than failing - // closed and replanning. - List matched; - if (allowAlKharidTollGateVariant) { - matched = Rs2GameObject.getAll( - o -> isAlKharidTollGateSceneCandidate(transport, o), - transport.getOrigin(), 3); - } else { - // Id-only first: these are plain field reads, no composition resolution. - matched = Rs2GameObject.getAll(o -> { - int id = o.getId(); - if (id == transportObjectId) return true; - return legacyClosedId != null && id == legacyClosedId; - }, transport.getOrigin(), 10); - } - if (matched.isEmpty() && allowClosedVariant) { - // Only now pay for compositions, and only on the transport's own tile: a closed - // variant (trapdoor/manhole/grate/hatch) sits where the transport is, never ten - // tiles away. Previously this ran for EVERY object within 10 tiles whenever the - // action was Climb-down, one client-thread hop each — measured at 5.5-10.9 - // SECONDS for a single scan inside Falador castle, and the reason descending - // stairs was slow while ascending was not. - matched = Rs2GameObject.getAll(o -> { - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); - if (comp == null || comp.getActions() == null) return false; - String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); - boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") - || nm.contains("grate") || nm.contains("hatch"); - if (!nameMatches) return false; - return Arrays.stream(comp.getActions()).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase("Open")); - }, transport.getOrigin(), 2); - } - List objects = matched.stream() - .sorted(Comparator - .comparingInt((TileObject o) -> resolveTransportObjectAction(o, transportActions).isPresent() ? 0 : 1) - .thenComparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) - .collect(Collectors.toList()); - - long objectScanMs = System.currentTimeMillis() - objectScanStartedAt; - if (objectScanMs >= TRANSPORT_OBJECT_SCAN_SLOW_MS) { - WebWalkLog.spInfo("transport_object_scan | slow scanMs={} objectId={} candidatesAtTile={} matches={} origin={}", - objectScanMs, transportObjectId, 1, objects.size(), - compactWorldPoint(transport.getOrigin())); - } - TileObject object = objects.stream().findFirst().orElse(null); - if (object instanceof GroundObject) { - object = objects.stream() - .filter(o -> !Objects.equals(o.getWorldLocation(), Rs2Player.getWorldLocation())) - .min(Comparator.comparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getOrigin())) - .thenComparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getDestination()))).orElse(null); - } - - if (object != null) { - // Skip reachability check for GroundObjects and Magic Mushtrees - if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { - if (!Rs2Tile.isTileReachable(transport.getOrigin())) { - break; - } - } - - // Closed variant detection: if the found object doesn't advertise the - // transport action but does advertise "Open", open it first and re-find - // the now-open object before invoking handleObject. - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); - if (comp != null && comp.getActions() != null) { - String[] actions = comp.getActions(); - boolean hasTransportAction = resolveTransportObjectAction(actions, transportActions).isPresent(); - boolean hasOpen = Arrays.stream(actions).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase("Open")); - if (!hasTransportAction && hasOpen) { - log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", - transport.getOrigin(), object.getId(), comp.getName(), transportAction); - final int closedId = object.getId(); - Rs2GameObject.interact(object, "Open"); - Rs2Player.waitForAnimation(2000); - TileObject reopened = Rs2GameObject.getAll(o -> { - if (o.getId() == closedId) return false; - ObjectComposition c = Rs2GameObject.convertToObjectComposition(o); - if (c == null || c.getActions() == null) return false; - return resolveTransportObjectAction(c.getActions(), transportActions).isPresent(); - }, transport.getOrigin(), 3).stream() - .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) - .orElse(null); - if (reopened != null) object = reopened; - } - } - - String interactionAction = resolveTransportObjectAction(object, transportActions) - .orElse(transportAction); - if (!Objects.equals(interactionAction, transportAction)) { - log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", - interactionAction, transportAction, object.getWorldLocation(), object.getId()); - } - prepareTransportObjectForInteraction(object); - if (!handleObject(transport, object, interactionAction)) { - return false; - } - sleepUntil(() -> !Rs2Player.isAnimating()); - WorldPoint destWait = transport.getDestination(); - int maxInclusive = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; - if (destWait == null) { - return false; - } - boolean landedAfterObject = waitForPostHandleObjectLanding(transport, destWait, maxInclusive); - if (!landedAfterObject) { - WorldPoint afterInteraction = Rs2Player.getWorldLocation(); - // Adjacent same-plane transports demand landing on the EXACT destination - // tile (maxInclusive == 0), and agility shortcuts routinely deposit the - // player a tile off it — so a crossing can physically succeed while this - // check still fails. Suppression previously ran only on the success path, - // which left the inverse transport immediately eligible: the walker - // crossed, took the same shortcut straight back, and stranded itself. If - // we are no longer on the origin we did cross, so suppress both tiles - // regardless of the landing verdict. The landing result itself is - // unchanged — this still returns false and replans. - if (isAdjacentSamePlaneTransport(transport) - && afterInteraction != null - && !afterInteraction.equals(transport.getOrigin())) { - markAdjacentSamePlaneTransportHandled(transport, object); - } - WebWalkLog.spWarn( - "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", - POST_HANDLE_OBJECT_LANDING_WAIT_MS, - compactWorldPoint(destWait), - compactWorldPoint(afterInteraction)); - } - if (landedAfterObject) { - markAdjacentSamePlaneTransportHandled(transport, object); - return finishHandledTransport(transport); - } - return false; - } - } - } - } - return false; - } - - private static boolean waitForPostHandleObjectLanding(Transport transport, - WorldPoint destWait, - int maxInclusive) { - long waitStartedAt = System.currentTimeMillis(); - AtomicBoolean settledAwayFromAdjacentDestination = new AtomicBoolean(false); - AtomicBoolean settledNearAdjacentDestination = new AtomicBoolean(false); - boolean completed = sleepUntil(() -> { - if (isPlayerWithinChebyshevInclusive(destWait, maxInclusive)) { - return true; - } - if (!isAdjacentSamePlaneTransport(transport) - || System.currentTimeMillis() - waitStartedAt < POST_HANDLE_OBJECT_FAILED_SETTLE_MS) { - return false; - } - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc == null || destWait == null || playerLoc.getPlane() != destWait.getPlane() - || Rs2Player.isMoving() || Rs2Player.isAnimating()) { - return false; - } - if (isSettledNearAdjacentSamePlaneLanding(transport, playerLoc, destWait, maxInclusive)) { - settledNearAdjacentDestination.set(true); - return true; - } - WorldPoint origin = transport == null ? null : transport.getOrigin(); - boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; - if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) - && settledAwayFromOrigin) { - settledAwayFromAdjacentDestination.set(true); - return true; - } - return false; - }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); - - if (settledNearAdjacentDestination.get()) { - WebWalkLog.spInfo("post-handleObject adjacent landing accepted | dest={} at={}", - compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); - return true; - } - if (settledAwayFromAdjacentDestination.get()) { - WebWalkLog.spInfo("post-handleObject adjacent landing failed | dest={} at={}", - compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); - return false; - } - return completed; - } - - static boolean isSettledNearAdjacentSamePlaneLanding(Transport transport, - WorldPoint playerLoc, - WorldPoint destWait, - int maxInclusive) { - if (!isAdjacentSamePlaneTransport(transport) - || playerLoc == null - || destWait == null - || playerLoc.getPlane() != destWait.getPlane()) { - return false; - } - WorldPoint origin = transport.getOrigin(); - if (origin == null || playerLoc.equals(origin)) { - return false; - } - int destinationDistance = playerLoc.distanceTo2D(destWait); - if (destinationDistance <= Math.max(1, maxInclusive) - && playerLoc.distanceTo2D(origin) > 0) { - return true; - } - if (transport.getType() != TransportType.AGILITY_SHORTCUT) { - return false; - } - - // Some adjacent shortcut catalogues describe a multi-object animation as one-tile - // hops. The Falador stepping stones, for example, can carry 3154 -> 3149 while the - // selected edge says 3154 -> 3153. Accept only a tightly bounded forward, collinear - // overshoot; sideways movement, reverse movement, and arbitrary teleports still fail. - int edgeX = destWait.getX() - origin.getX(); - int edgeY = destWait.getY() - origin.getY(); - int movedX = playerLoc.getX() - origin.getX(); - int movedY = playerLoc.getY() - origin.getY(); - int forwardProgress = movedX * edgeX + movedY * edgeY; - int lateralOffset = Math.abs(movedX * edgeY - movedY * edgeX); - return forwardProgress > 0 - && forwardProgress <= 6 - && lateralOffset <= 1; - } - - /** - * Handles the transportation process specifically for instances of PohTransport. - * Any Transport param that reaches this is assumed to be a PohTransport. - * - * @param transport the transport object to be checked and processed - * @return true if the transport is an instance of PohTransport and its transport method executes successfully, false otherwise - */ - private static boolean handlePohTransport(Transport transport) { - if(!(transport instanceof PohTransport)) { - throw new IllegalStateException("handlePohTransport should not be called for non-PohTransports"); - } - return ((PohTransport)transport).execute(); - } - - private static List getTransportActionOptions(String action) { - if (action == null || action.isBlank()) { - return Collections.emptyList(); - } - - List actions = new ArrayList<>(); - actions.add(action); - if ("Bottom-floor".equalsIgnoreCase(action)) { - actions.add("Climb-down"); - actions.add("Climb down"); - } else if ("Top-floor".equalsIgnoreCase(action)) { - actions.add("Climb-up"); - actions.add("Climb up"); - } - return actions; - } - - private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - if (comp == null || comp.getActions() == null) { - return Optional.empty(); - } - return resolveTransportObjectAction(comp.getActions(), actionOptions); - }).orElse(Optional.empty()); - } - - private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { - if (objectActions == null || actionOptions == null || actionOptions.isEmpty()) { - return Optional.empty(); - } - - for (String desired : actionOptions) { - for (String actual : objectActions) { - if (actual != null && desired.equalsIgnoreCase(Rs2UiHelper.stripColTags(actual))) { - return Optional.of(actual); - } - } - } - return Optional.empty(); - } - - private static void prepareTransportObjectForInteraction(TileObject tileObject) { - if (tileObject == null || tileObject.getLocalLocation() == null) { - return; - } - if (!Rs2Camera.isTileOnScreen(tileObject)) { - Rs2Camera.turnTo(tileObject); - sleepUntil(() -> Rs2Camera.isTileOnScreen(tileObject), 1200); - } - } - - private static boolean handleObject(Transport transport, TileObject tileObject) { - return handleObject(transport, tileObject, transport.getAction()); - } - - /** - * A transport may be gated on an item that its own vendor sells on the spot (the Shantay pass - * pattern: the gate wants a ticket, Shantay sells tickets two tiles away). The catalog rows in - * {@code purchasable_items.tsv} say which item, which vendor, and how close the vendor must be - * to the transport origin; the transports.tsv duplicate-row OR (item row + currency-twin row) - * already made the planner route through such transports for players holding only the coins. - * This pre-step completes the currency variant: buy the item before interacting. Free rows - * (e.g. a gate's exit direction) carry neither item nor currency requirements and never match. - * - *

Vendor interaction is by NPC id — a name lookup once partial-matched the nearer - * "Shantay Guard" (Actions=[Talk-to, null, Pass]) and the buy silently failed. - */ - private static void ensureRequiredItemBeforeTransport(Transport transport) { - PurchasableItemCatalog.PurchasableItem purchasable = PurchasableItemCatalog.forTransport(transport); - if (purchasable == null || Rs2Inventory.hasItem(purchasable.itemId)) { - return; - } - WebWalkLog.spInfo("purchasable_buy | item={} vendor={} action={} at={}", - purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction, - compactWorldPoint(Rs2Player.getWorldLocation())); - if (Rs2Npc.interact(purchasable.vendorNpcId, purchasable.vendorAction)) { - sleepUntil(() -> Rs2Inventory.hasItem(purchasable.itemId), 4000); - } - if (!Rs2Inventory.hasItem(purchasable.itemId)) { - WebWalkLog.spWarn("purchasable_buy failed | item={} vendor={} action={} — no item acquired", - purchasable.itemId, purchasable.vendorNpcId, purchasable.vendorAction); - } - } - - private static boolean handleObject(Transport transport, TileObject tileObject, String action) { - ensureRequiredItemBeforeTransport(transport); - WorldPoint before = Rs2Player.getWorldLocation(); - Rs2GameObject.interact(tileObject, action); - // Unlike the other exception handlers, a toll-gate interaction is not complete merely - // because the menu action was issued: it may first server-walk from several tiles away and - // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so - // it cannot emit a transport handoff for a player who is still west/east of the gate. - if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { - return handleAlKharidTollGate(transport); - } - if (handleObjectExceptions(transport, tileObject)) return true; - WorldPoint tdObj = transport.getDestination(); - WorldPoint plObj = Rs2Player.getWorldLocation(); - if (tdObj == null || plObj == null) { - return false; - } - if (tdObj.getPlane() == plObj.getPlane()) { - if (transport.getType() == TransportType.AGILITY_SHORTCUT) { - Rs2Player.waitForAnimation(); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return isPlayerWithinChebyshevInclusive(tdObj, 2) - || isSettledNearAdjacentSamePlaneLanding(transport, now, tdObj, 0); - }, 10000); - } else if (transport.getType() == TransportType.MINECART) { - if (interactWithAdventureLog(transport)) { - sleepTickJitter(2); // wait extra 2 game ticks before moving - } else { - sleepUntil(() -> Rs2Player.getPoseAnimation() == 2148, 5000); - sleepUntil(() -> Rs2Player.getPoseAnimation() != 2148, 10000); - } - } else if (transport.getType() == TransportType.TELEPORTATION_PORTAL) { - sleepTickJitter(2); // wait extra 2 game ticks before moving - } else { - Rs2Player.waitForWalking(); - Rs2Dialogue.clickOption("Yes please"); //shillo village cart - if (isAdjacentSamePlaneTransport(transport)) { - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.equals(transport.getDestination()) - || !now.equals(before) - || !Rs2Player.isMoving()); - }, 2000); - WorldPoint afterOpen = Rs2Player.getWorldLocation(); - if (afterOpen != null && !afterOpen.equals(transport.getDestination())) { - boolean clicked = walkMiniMap(transport.getDestination()); - if (!clicked) { - clicked = walkFastCanvas(transport.getDestination()); - } - if (clicked) { - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return now != null && td != null && now.equals(td); - }, 3000); - } - } - } - } - return true; - } else { - WorldPoint plZ = Rs2Player.getWorldLocation(); - if (plZ == null) { - return false; - } - int z = plZ.getPlane(); - // Instrumentation: the FIRST plane-change transport of a walk consistently costs ~9.5s - // while the same kind mid-route costs ~2.2s (measured across two Falador castle runs). - // The waits below bound at 1800 + 5000 + jitter, and a failed start returns false and is - // retried, so two attempts would explain it — but that is inference. These timings say - // which of start-detection, plane-detection or retry actually burns the seconds. - long planeChangeStartedAt = System.currentTimeMillis(); - boolean started = sleepUntil(() -> { - WorldPoint p = Rs2Player.getWorldLocation(); - return p != null && (p.getPlane() != z || Rs2Player.isMoving() || Rs2Player.isAnimating()); - }, 1800); - long startWaitMs = System.currentTimeMillis() - planeChangeStartedAt; - if (!started) { - WebWalkLog.spInfo("transport_plane_change | no_start startWaitMs={} obj={} action={} — returning for retry", - startWaitMs, tileObject.getId(), transport.getAction()); - return false; - } - WorldPoint plAfterStart = Rs2Player.getWorldLocation(); - boolean planeChanged = plAfterStart != null && plAfterStart.getPlane() != z - || sleepUntil(() -> { - WorldPoint p = Rs2Player.getWorldLocation(); - return p != null && p.getPlane() != z; - }, 5000); - long planeWaitMs = System.currentTimeMillis() - planeChangeStartedAt - startWaitMs; - if (planeChanged) { - // gaussRand is an unbounded Box-Muller draw, so mean 300 / dev 120 goes negative past - // ~2.5 sigma (about one call in 160) and Thread.sleep throws IllegalArgumentException, - // killing the whole walk. Seen live: "timeout value is negative" here aborted a - // Falador castle run into ShortestPathScript auto-retry 1/3. Clamping only removes the - // impossible tail — the jitter this sleep exists to provide is untouched. - sleep(Math.max(MIN_PLANE_CHANGE_SETTLE_MS, (int) Rs2Random.gaussRand(300.0, 120.0))); - } - WebWalkLog.spInfo("transport_plane_change | changed={} startWaitMs={} planeWaitMs={} totalMs={} obj={}", - planeChanged, startWaitMs, planeWaitMs, - System.currentTimeMillis() - planeChangeStartedAt, tileObject.getId()); - return planeChanged; - } - } - - private static boolean isAdjacentSamePlaneTransport(Transport transport) { - return transport != null - && transport.getOrigin() != null - && transport.getDestination() != null - && transport.getOrigin().getPlane() == transport.getDestination().getPlane() - && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; - } - - private static boolean isAdjacentSamePlaneTransport(Rs2TransportEdge transport) { - return transport != null - && transport.getOrigin() != null - && transport.getDestination() != null - && transport.getOrigin().getPlane() == transport.getDestination().getPlane() - && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; - } - - private static int[] mapSmoothedToRaw(List smoothed, List raw) { - if (smoothed == null || raw == null || smoothed.isEmpty() || raw.isEmpty()) { - return new int[0]; - } - int[] mapping = new int[smoothed.size()]; - int rawIdx = 0; - for (int si = 0; si < smoothed.size(); si++) { - WorldPoint sp = smoothed.get(si); - while (rawIdx < raw.size() && !raw.get(rawIdx).equals(sp)) { - rawIdx++; - } - mapping[si] = Math.min(rawIdx, raw.size() - 1); - } - return mapping; - } - - private static int rawEndForSmoothedIndex(int smoothedIdx, int[] smoothedToRaw, - List rawPath, List path) { - if (smoothedIdx + 1 < path.size() && smoothedIdx + 1 < smoothedToRaw.length) { - return smoothedToRaw[smoothedIdx + 1]; - } - return rawPath.size(); - } - - private static boolean handleDoorsInRawSegment(List rawPath, int rawFrom, int rawTo, - long timeoutMs, Map attempted, - Map reachableCache) { - WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; - long startedAt = System.currentTimeMillis(); - for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { - long elapsed = System.currentTimeMillis() - startedAt; - if (elapsed >= timeoutMs) { - return false; - } - if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) - && reachableCache.containsKey(rawPath.get(ri + 1)) - && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), - playerLoc, HANDLER_RANGE)) { - continue; - } - long remainingTimeoutMs = Math.max(1L, timeoutMs - elapsed); - if (handleDoorsWithTimeout(rawPath, ri, remainingTimeoutMs, attempted)) { - return true; - } - if (isDoorInteractionSettling()) { - return false; - } - } - return false; - } + private static boolean handleDoorsInRawSegment(List rawPath, int rawFrom, int rawTo, + long timeoutMs, + Map reachableCache) { + WorldPoint playerLoc = reachableCache != null ? Rs2Player.getWorldLocation() : null; + long startedAt = System.currentTimeMillis(); + for (int ri = rawFrom; ri < rawTo && ri < rawPath.size() - 1; ri++) { + long elapsed = System.currentTimeMillis() - startedAt; + if (elapsed >= timeoutMs) { + return false; + } + if (reachableCache != null && reachableCache.containsKey(rawPath.get(ri)) + && reachableCache.containsKey(rawPath.get(ri + 1)) + && !hasDoorLikeSceneObjectOnSegment(rawPath.get(ri), rawPath.get(ri + 1), + playerLoc, HANDLER_RANGE)) { + continue; + } + long remainingTimeoutMs = Math.max(1L, timeoutMs - elapsed); + if (handleDoorsWithTimeoutBudgeted(rawPath, ri, remainingTimeoutMs, false)) { + return true; + } + if (isDoorInteractionSettling()) { + return false; + } + } + return false; + } private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { @@ -10948,7 +9876,7 @@ private static boolean doorInteractionDeferredForMovement(WorldPoint doorTile) { private static final Map failedRangedTransportEdges = new ConcurrentHashMap<>(); private static final long RANGED_TRANSPORT_RETRY_COOLDOWN_MS = 30_000L; - private static String rangedTransportEdgeKey(WorldPoint from, WorldPoint to) { + static String rangedTransportEdgeKey(WorldPoint from, WorldPoint to) { return compactWorldPoint(from) + ">" + compactWorldPoint(to); } @@ -10977,59 +9905,6 @@ private static boolean isTransportOriginNearPlayer(WorldPoint routeOrigin, && routeOrigin.distanceTo2D(playerLoc) <= Math.max(0, maxDistance); } - private static boolean finishHandledTransport(Transport transport) { - long handoffStartedAt = System.currentTimeMillis(); - routeState.lastTransportHandledAtMs = handoffStartedAt; - routeState.lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; - routeState.lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; - WorldPoint goal = currentTarget; - WorldPoint transportDest = transport != null ? transport.getDestination() : null; - boolean expectedTransport = consumeExpectedTransportDestination(transportDest); - boolean hasPrecomputedContinuation = hasPrecomputedContinuationFromTransport(transport); - if (goal != null) { - WebWalkLog.tmark("transport_handoff_enter", - 0L, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest) - + " expected=" + expectedTransport - + " precomputed=" + hasPrecomputedContinuation - + " type=" + (transport != null ? transport.getType() : "null")); - } - if ((expectedTransport || hasPrecomputedContinuation) && goal != null) { - WebWalkLog.tmark(expectedTransport ? "transport_handoff_expected_hit" : "transport_handoff_precomputed_hit", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - return true; - } - if (goal != null && transportDest != null) { - // Destination-aware handoff: prepare next path from known landing tile. - boolean queued = restartPathfinding(transportDest, goal); - WebWalkLog.tmark("transport_handoff_restart", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "queued=" + queued + " dest=" + compactWorldPoint(transportDest)); - if (!queued && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_fallback", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - } else if (goal != null && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_goal_only", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - return true; - } private static void primeExpectedTransportDestinations(List path, int startIdx) { if (path == null || path.size() < 2) { @@ -11060,862 +9935,51 @@ private static void primeExpectedTransportDestinations(List path, in } } - private static boolean consumeExpectedTransportDestination(WorldPoint destination) { - if (destination == null) { - return false; - } - synchronized (expectedTransportDestinations) { - while (!expectedTransportDestinations.isEmpty()) { - WorldPoint expected = expectedTransportDestinations.peekFirst(); - if (expected == null) { - expectedTransportDestinations.pollFirst(); - continue; - } - if (sameOrNearTransportDestination(expected, destination)) { - expectedTransportDestinations.pollFirst(); - return true; - } - break; - } - return false; - } - } - private static boolean sameOrNearTransportDestination(WorldPoint a, WorldPoint b) { - return a != null - && b != null - && a.getPlane() == b.getPlane() - && a.distanceTo2D(b) <= TRANSPORT_DEST_MATCH_CHEBYSHEV; - } - private static boolean hasPrecomputedContinuationFromTransport(Transport transport) { - if (transport == null || transport.getDestination() == null) { - return false; - } - Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); - if (!routeStatus.isReady()) { - return false; - } - List walkPath = routeStatus.getWalkablePath(); - if (walkPath == null || walkPath.size() < 2) { - return false; - } - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - int closest = getClosestTileIndex(walkPath, playerLoc); - if (closest < 0) { - return false; - } - WorldPoint destination = transport.getDestination(); - for (int i = Math.max(0, closest - 2); i < walkPath.size(); i++) { - WorldPoint point = walkPath.get(i); - if (sameOrNearTransportDestination(point, destination)) { - return i < walkPath.size() - 1; - } - } - return false; - } - static boolean shouldRecalculatePathAfterTransport(Transport transport) { - if (transport == null || transport.getDestination() == null) { - return false; - } - if (TransportType.isTeleport(transport.getType())) { - return true; - } - if (transport.getOrigin() == null) { - return false; - } - return transport.getOrigin().getPlane() != transport.getDestination().getPlane() - || transport.getOrigin().distanceTo2D(transport.getDestination()) > OFFSET; - } - private static void markAdjacentSamePlaneTransportHandled(Transport transport, TileObject tileObject) { - for (WorldPoint point : adjacentSamePlaneTransportSuppressionPoints(transport, tileObject)) { - markStationaryDoorOpened(point); - } - } - - static Set adjacentSamePlaneTransportSuppressionPoints(Transport transport, TileObject tileObject) { - if (!isAdjacentSamePlaneTransport(transport)) { - return Collections.emptySet(); - } - Set points = new LinkedHashSet<>(); - points.add(transport.getOrigin()); - points.add(transport.getDestination()); - if (tileObject != null && tileObject.getWorldLocation() != null) { - points.add(tileObject.getWorldLocation()); - } - return points; - } - static boolean isTerminalTravelTransport(TransportType transportType) { - return transportType == TransportType.SHIP - || transportType == TransportType.NPC - || transportType == TransportType.BOAT; - } /** * Options that open the destination list on NPCs whose right-click menu has no per-destination * entry. Veos answers "Can you take me somewhere?" with the Port Piscarilius / Land's End menu. */ - private static final List TERMINAL_TRAVEL_MENU_OPENERS = List.of( + static final List TERMINAL_TRAVEL_MENU_OPENERS = List.of( "Can you take me somewhere?", "Can you take me somewhere", "take me somewhere", "Travel"); - private static boolean selectTerminalTravelDialogueDestination( - Transport transport, Rs2TerminalTravelMode mode) { - if (mode == Rs2TerminalTravelMode.DIRECT) { - return true; - } - if (mode != Rs2TerminalTravelMode.DIALOGUE_DESTINATION - || transport == null - || transport.getDisplayInfo() == null - || transport.getDisplayInfo().isBlank()) { - return false; - } - if (!sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000)) { - WebWalkLog.spWarn( - "terminal travel destination dialogue did not appear name={} dest={}", - transport.getName(), transport.getDisplayInfo()); - return false; - } - if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { - return true; - } - // The destination is not in THIS menu. Several ferrymen answer a "can you take me somewhere" - // option with the destination list, so open it and look again rather than giving up — the - // walker previously stopped here with the destination menu on screen and walked away. - for (String opener : TERMINAL_TRAVEL_MENU_OPENERS) { - if (!Rs2Dialogue.hasSelectAnOption() || !Rs2Dialogue.clickOption(opener)) { - continue; - } - WebWalkLog.spInfo("terminal travel menu opened via '{}' name={} dest={}", - opener, transport.getName(), transport.getDisplayInfo()); - sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000); - if (Rs2Dialogue.clickOption(transport.getDisplayInfo())) { - return true; - } - } - WebWalkLog.spWarn( - "terminal travel destination option missing name={} dest={}", - transport.getName(), transport.getDisplayInfo()); - return false; - } - - private static TileObject findTerminalTravelObject(Transport transport) { - if (transport == null || transport.getOrigin() == null) { - return null; - } - TileObject object = Rs2GameObject.getAll( - candidate -> isTerminalTravelObjectSceneCandidate(transport, candidate), - transport.getOrigin(), 3).stream().findFirst().orElse(null); - if (object != null) { - WebWalkLog.spInfo( - "terminal travel object selected type={} name={} action={} origin={} dest={}", - transport.getType(), transport.getName(), transport.getAction(), - compactWorldPoint(transport.getOrigin()), - compactWorldPoint(transport.getDestination())); - } - return object; - } - - private static boolean isTerminalTravelObjectSceneCandidate(Transport transport, - TileObject object) { - if (object == null) { - return false; - } - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - ObjectComposition composition = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - return composition != null - && isTerminalTravelObjectCompositionCandidate( - transport, - object.getWorldLocation(), - composition.getName(), - composition.getActions()); - }).orElse(false); - } - - static boolean isTerminalTravelObjectCompositionCandidate(Transport transport, - WorldPoint objectLocation, - String objectName, - String[] objectActions) { - if (transport == null - || !isTerminalTravelTransport(transport.getType()) - || transport.getOrigin() == null - || objectLocation == null - || objectName == null - || transport.getName() == null - || transport.getAction() == null - || objectLocation.getPlane() != transport.getOrigin().getPlane() - || objectLocation.distanceTo2D(transport.getOrigin()) > 3 - || !Rs2UiHelper.stripColTags(objectName).trim().equalsIgnoreCase( - Rs2UiHelper.stripColTags(transport.getName()).trim())) { - return false; - } - return resolveTransportObjectAction( - objectActions, - Collections.singletonList(transport.getAction())).isPresent(); - } - - private static boolean awaitTerminalTravelLanding(Transport transport, - List path, - int destinationIndex) { - boolean landed = sleepUntil( - () -> hasReachedTerminalTravelLanding( - transport, path, destinationIndex, Rs2Player.getWorldLocation()), - SHIP_NPC_BOAT_LANDING_WAIT_MS); - if (!landed) { - WebWalkLog.spWarn( - "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", - SHIP_NPC_BOAT_LANDING_WAIT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - return landed; - } - - /** - * Returns interaction actions in executor preference order. Some legacy ship rows encode their - * destination label as the direct NPC menu action. The current Port Sarim NPCs instead expose - * {@code Travel}; keep the configured label first for compatible clients, then use that observed - * live fallback. Explicit dialogue and quick-travel actions must never be replaced implicitly. - */ - static List terminalNpcInteractionCandidates(TransportType transportType, - String configuredAction) { - LinkedHashSet candidates = new LinkedHashSet<>(); - if (configuredAction != null && !configuredAction.isBlank()) { - candidates.add(configuredAction); - } - if (transportType == TransportType.SHIP - && !isExplicitShipMenuAction(configuredAction)) { - candidates.add("Travel"); - } - return List.copyOf(candidates); - } - - private static boolean isExplicitShipMenuAction(String action) { - return action != null - && (action.equalsIgnoreCase("Travel") - || action.equalsIgnoreCase("Talk-to") - || action.equalsIgnoreCase("Quick-Travel") - || action.equalsIgnoreCase("Take-boat")); - } - - private static String resolveTerminalNpcInteractionAction(Rs2NpcModel npc, Transport transport) { - if (npc == null || transport == null) { - return ""; - } - for (String candidate : terminalNpcInteractionCandidates( - transport.getType(), transport.getAction())) { - // Query one candidate at a time: Rs2Npc#getAvailableAction otherwise returns NPC-menu - // order, which commonly places Talk-to before the exact configured action. - String available = Rs2Npc.getAvailableAction(npc, Collections.singletonList(candidate)); - if (!available.isEmpty()) { - return available; - } - } - return ""; - } - - static boolean markTerminalTravelAttempt(Transport transport) { - if (transport == null || transport.getOrigin() == null || transport.getDestination() == null) { - return false; - } - String key = transport.getType() - + "|" + rangedTransportEdgeKey(transport.getOrigin(), transport.getDestination()) - + "|" + transport.getObjectId() - + "|" + Objects.toString(transport.getName(), "") - + "|" + Objects.toString(transport.getAction(), ""); - return TERMINAL_TRAVEL_ATTEMPTED_EDGES.add(key); - } - - /** - * Accepts the exact catalogued landing or the immediately following path point. The latter covers - * modern ship travel that skips an obsolete deck tile and completes the next gangplank step in one - * server action. It deliberately does not scan arbitrary later route points, which could report a - * false landing when a route loops near its origin. - */ - static boolean hasReachedTerminalTravelLanding(Transport transport, - List path, - int destinationIndex, - WorldPoint playerLocation) { - if (transport == null || playerLocation == null || transport.getDestination() == null) { - return false; - } - WorldPoint origin = transport.getOrigin(); - if (origin != null - && origin.getPlane() == playerLocation.getPlane() - && origin.distanceTo2D(playerLocation) <= 1) { - return false; - } - if (isNearSamePlane(playerLocation, transport.getDestination(), - TRANSPORT_NEAR_LANDING_CHEBYSHEV)) { - return true; - } - if (path == null || destinationIndex < 0 || destinationIndex + 1 >= path.size()) { - return false; - } - WorldPoint immediateContinuation = path.get(destinationIndex + 1); - return immediateContinuation != null - && !immediateContinuation.equals(transport.getDestination()) - && isNearSamePlane(playerLocation, immediateContinuation, - TRANSPORT_NEAR_LANDING_CHEBYSHEV); - } - - private static boolean isAlKharidTollGateTransport(Transport transport) { - return transport != null - && isAlKharidTollGateObjectId(transport.getObjectId()) - && AL_KHARID_TOLL_GATE_POINTS.contains(transport.getOrigin()) - && AL_KHARID_TOLL_GATE_POINTS.contains(transport.getDestination()); - } - - private static boolean isAlKharidTollGateObjectId(int objectId) { - return AL_KHARID_TOLL_GATE_OBJECT_IDS.contains(objectId); - } - - private static boolean isPayTollAction(String action) { - return action != null && action.toLowerCase(Locale.ROOT).startsWith("pay-toll"); - } - - private static boolean isAlKharidTollGateSceneCandidate(Transport transport, TileObject object) { - if (!(object instanceof WallObject) && !(object instanceof GameObject)) { - return false; - } - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); - return comp != null - && isAlKharidTollGateCompositionCandidate( - transport, object.getWorldLocation(), comp.getName(), comp.getActions()) - && Rs2DoorGeometry.isDoorOnSegment( - object, transport.getOrigin(), transport.getDestination()); - }).orElse(false); - } - - static boolean isAlKharidTollGateCompositionCandidate(Transport transport, - WorldPoint objectLocation, - String objectName, - String[] objectActions) { - if (!isAlKharidTollGateTransport(transport) - || objectLocation == null - || !AL_KHARID_TOLL_GATE_POINTS.contains(objectLocation) - || objectName == null - || !objectName.toLowerCase(Locale.ROOT).contains("gate")) { - return false; - } - return resolveTransportObjectAction( - objectActions, getTransportActionOptions(transport.getAction())).isPresent(); - } - - static boolean hasReachedAlKharidTollDestination(Transport transport, WorldPoint playerLocation) { - return isAlKharidTollGateTransport(transport) - && playerLocation != null - && playerLocation.equals(transport.getDestination()); - } - - private static boolean handleAlKharidTollGate(Transport transport) { - // Object interaction can begin out of range. Wait for server-walking, the confirmation - // dialogue, or the crossing itself instead of sampling isMoving() immediately after click. - sleepUntil(() -> Rs2Player.isMoving() - || Rs2Dialogue.hasSelectAnOption() - || hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()), - AL_KHARID_TOLL_INTERACTION_START_WAIT_MS); - - if (Rs2Player.isMoving() - && !hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation())) { - Rs2Player.waitForWalking(); - } - - boolean confirmed = false; - if (!hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()) - && (Rs2Dialogue.hasSelectAnOption() - || sleepUntil(Rs2Dialogue::hasSelectAnOption, - AL_KHARID_TOLL_INTERACTION_START_WAIT_MS))) { - confirmed = Rs2Dialogue.clickOption("Yes, okay", "Yes"); - } - - boolean reachedDestination = hasReachedAlKharidTollDestination( - transport, Rs2Player.getWorldLocation()) - || sleepUntil(() -> hasReachedAlKharidTollDestination( - transport, Rs2Player.getWorldLocation()), - POST_HANDLE_OBJECT_LANDING_WAIT_MS); - if (!reachedDestination) { - WebWalkLog.spWarn( - "Al Kharid toll gate crossing unresolved confirmed={} dest={} at={}", - confirmed, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - return reachedDestination; - } - - private static boolean handleObjectExceptions(Transport transport, TileObject tileObject) { - for (Map.Entry entry : OPEN_TO_CLOSED_MAPPINGS.entrySet()) { - final int closedTrapdoorId = entry.getKey(); - final int openTrapdoorId = entry.getValue(); - - if (transport.getObjectId() == openTrapdoorId) { - if (tileObject.getId() == closedTrapdoorId) { - Rs2GameObject.interact(tileObject, "Open"); - sleepUntil(() -> Rs2GameObject.exists(openTrapdoorId)); - TileObject openTrapdoor = Rs2GameObject.getAll(o -> o.getId() == openTrapdoorId, tileObject.getWorldLocation(), 10).stream().findFirst().orElse(null); - if (openTrapdoor != null) { - Rs2GameObject.interact(openTrapdoor, transport.getAction()); - } - } else if (tileObject.getId() == openTrapdoorId) { - Rs2GameObject.interact(tileObject, transport.getAction()); - } - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean trapdoorLanded = sleepUntilTrue( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), - TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); - if (!trapdoorLanded) { - WebWalkLog.spWarn( - "trapdoor post-travel wait timed out ({}ms) dest={} at={}", - TRANSPORT_LANDING_WAIT_TIMEOUT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); - } - return true; - } - } - - //Al kharid broken wall will animate once and then stop and then animate again - if (tileObject.getId() == ObjectID.KHARID_POSHWALL_TOPLESS || tileObject.getId() == ObjectID.KHARID_BIGWINDOW) { - Rs2Player.waitForAnimation(); - Rs2Player.waitForAnimation(); - return true; - } - // Handle Leaves Traps in Isafdar Forest - if (tileObject.getId() == ObjectID.REGICIDE_PITFALL_SIDE) { - Rs2Player.waitForAnimation(1200); - if (Rs2Player.getWorldLocation().getY() > 6400) { - Rs2GameObject.interact(ObjectID.REGICIDE_TRAP_HAND_HOLDS); - sleepUntil(() -> Rs2Player.getWorldLocation().getY() < 6400); - } else { - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating()); - } - return true; - } - // Handle Ferox Encalve Barrier - if (tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER || tileObject.getId() == ObjectID.WILDY_HUB_ENTRY_BARRIER_M) { - if (Rs2Dialogue.isInDialogue()) { - if (Rs2Dialogue.getDialogueText().toLowerCase().contains("when returning to the enclave")) { - Rs2Dialogue.clickContinue(); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.keyPressForDialogueOption("Yes, and don't ask again."); - Rs2Dialogue.sleepUntilNotInDialogue(); - return true; - } - } - } - // Handle Cobwebs blocking path - if (tileObject.getId() == ObjectID.BIGWEB_SLASHABLE && !Rs2Equipment.isWearing(ItemID.ARANEA_BOOTS)) { - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating(1200)); - final WorldPoint webLocation = tileObject.getWorldLocation(); - final WorldPoint currentPlayerPoint = Rs2Player.getWorldLocation(); - boolean doesWebStillExist = Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isPresent(); - if (doesWebStillExist) { - sleepUntil(() -> Rs2GameObject.getAll(o -> Objects.equals(webLocation, o.getWorldLocation()) && o.getId() == ObjectID.BIGWEB_SLASHABLE).stream().findFirst().isEmpty(), - () -> { - Rs2GameObject.interact(tileObject, "slash"); - Rs2Player.waitForAnimation(); - }, 8000, 1200); - } - Rs2Walker.walkFastCanvas(transport.getDestination()); - return sleepUntil(() -> !Objects.equals(currentPlayerPoint, Rs2Player.getWorldLocation())); - } - - // Handle Brimhaven Dungeon Entrance - if (tileObject.getId() == 20877) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Rs2Dialogue.sleepUntilHasQuestion("Pay 875 coins to enter?"); - Rs2Dialogue.clickOption("Yes"); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return now != null && td != null && now.equals(td); - }); - return true; - } - // Handle Brimhaven Dungeon Stepping Stones - if (tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE1 || tileObject.getId() == ObjectID.KARAM_DUNGEON_STONE2) { - Rs2Player.waitForAnimation(600 * 7); - return true; - } - - // Handle Morte Myre Cave Agility Shortcut - if (tileObject.getId() == ObjectID.FAIRY2_ROUTE_CAVEWALLTUNNEL) { - Rs2Player.waitForAnimation((600 * 4 ) + 300); - return true; - } - - // Handle Crash Site Cavern Gate - if (tileObject.getId() == 28807 && transport.getOrigin().equals(new WorldPoint(2435,3519, 0))) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("yes"); - return true; - } - - // Handle Cave Entrance inside of Asgarnia Ice Caves - if (tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_EAST || tileObject.getId() == ObjectID.CAVEWALL_SHORTCUT_ROYAL_TITANS_WEST) { - Rs2Player.waitForAnimation(); - } - - // Handle Rev Cave Dialogue - if (tileObject.getId() == ObjectID.WILD_CAVE_ENTRANCE_LOW) { - if (Rs2Player.isMoving()) { - Rs2Player.waitForWalking(); - } - Widget dialogueSprite = Rs2Dialogue.getDialogueSprite(); - if (dialogueSprite != null && dialogueSprite.getItemId() == 1004) { - Rs2Dialogue.clickContinue(); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption("Yes, don't ask again"); - Rs2Dialogue.sleepUntilNotInDialogue(); - } - return true; - } - - if (tileObject.getId() == ObjectID.HEROROCKSLIDE) { - Rs2Player.waitForAnimation(600 * 4); - return true; - } - - if (Rs2GameObject.getObjectIdsByName("Fossil_Rowboat").contains(tileObject.getId())) { - if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; - - char option = transport.getDisplayInfo().charAt(0); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Keyboard.keyPress(option); - sleepUntil(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 10000); - return true; - } - - // Handle door/gate near wilderness agility course - if (tileObject.getId() == ObjectID.BALANCEGATE52A || tileObject.getId() == ObjectID.BALANCEGATE52B_RIGHT || tileObject.getId() == ObjectID.BALANCEGATE52B_LEFT) { - Rs2Player.waitForAnimation(600 * 4); - return true; - } - - if (tileObject.getId() == ObjectID.AERIAL_FISHING_BOAT) { - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(transport.getDisplayInfo(), true); - sleepUntil(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 10000); - return true; - } - - // Handle Magic Mushtree (Fossil Island Mycelium Transportation System) - if (MagicMushtree.isMagicMushtree(tileObject)) { - return MagicMushtree.handleTransport(transport); - } - return false; - } - - private static boolean handleWildernessObelisk(Transport transport) { - GameObject obelisk = Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()); - - if (obelisk != null) { - Rs2GameObject.interact(obelisk, transport.getAction()); - sleepUntil(() -> Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()) != null); - walkFastCanvas(transport.getOrigin()); - return sleepUntilTrue(() -> { - WorldPoint pl = Rs2Player.getWorldLocation(); - WorldPoint td = transport.getDestination(); - return pl != null && td != null && pl.getPlane() == td.getPlane() - && pl.distanceTo2D(td) < OFFSET; - }, 100, 10000); - } - return false; - } - - private static boolean handleTeleportSpell(Transport transport) { - if (Rs2Pvp.isInWilderness() && !isTeleportAllowedAtWildernessLevel( - Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()), transport.getMaxWildernessLevel())) return false; - if (!prepareTeleportSpellProviders(transport)) return false; - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - String spellName = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo().toLowerCase(); - String option = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[1].trim().toLowerCase() - : "cast"; - int identifier = hasMultipleDestination - ? 2 - : 1; - Optional homeTeleport = - TransportExecutionRegistry.homeTeleportFor(transport.getDisplayInfo()); - if (homeTeleport.isPresent()) { - return Rs2Magic.quickCast(homeTeleport.get().getDisplayName()); - } - - MagicAction magicSpell = Arrays.stream(MagicAction.values()).filter(x -> x.getName().toLowerCase().contains(spellName)).findFirst().orElse(null); - if (magicSpell != null) { - return Rs2Magic.cast(magicSpell, option, identifier); - } - return false; - } - - /** - * Equip any inventory staff/tome selected by a source-aware upstream spell requirement before - * casting. An item merely present in the inventory never acts as an infinite rune provider. - */ - private static boolean prepareTeleportSpellProviders(Transport transport) { - List requirements = transport.getItemRequirements(); - if (requirements == null || requirements.isEmpty()) { - return true; - } - Map runeQuantities = new HashMap<>(); - Rs2Magic.getRunes().forEach((rune, quantity) -> - runeQuantities.put(rune.getItemId(), quantity)); - java.util.function.IntUnaryOperator currentQuantity = itemId -> { - Runes rune = Runes.byItemId(itemId); - if (rune != null) { - return runeQuantities.getOrDefault(itemId, 0); - } - int quantity = Rs2Inventory.itemQuantity(itemId); - Rs2ItemModel equipped = Rs2Equipment.get(itemId); - return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); - }; - TransportItemRequirement.ProviderSelection providers = - TransportItemRequirement.selectProviders( - requirements, - currentQuantity, - itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), - itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) - .orElse(null); - if (providers == null) { - return false; - } - if (!equipTransportProvider(providers.getStaffItemId()) - || !equipTransportProvider(providers.getOffhandItemId())) { - return false; - } - Map verifiedRuneQuantities = new HashMap<>(); - Rs2Magic.getRunes().forEach((rune, quantity) -> - verifiedRuneQuantities.put(rune.getItemId(), quantity)); - return TransportItemRequirement.selectProviders( - requirements, - itemId -> { - Runes rune = Runes.byItemId(itemId); - if (rune != null) { - return verifiedRuneQuantities.getOrDefault(itemId, 0); - } - int quantity = Rs2Inventory.itemQuantity(itemId); - Rs2ItemModel equipped = Rs2Equipment.get(itemId); - return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); - }, - Rs2Equipment::isWearing, - Rs2Equipment::isWearing).isPresent(); - } - private static boolean equipTransportProvider(int itemId) { - if (itemId <= 0 || Rs2Equipment.isWearing(itemId)) { - return true; - } - return Rs2Inventory.hasItem(itemId) - && Rs2Inventory.wield(itemId) - && sleepUntil(() -> Rs2Equipment.isWearing(itemId), 3000); - } - private static boolean isLumbridgeHomeTeleport(Transport transport) { - return transport.getDisplayInfo() != null - && transport.getDisplayInfo().toLowerCase().startsWith("lumbridge home teleport"); - } - private static boolean handleTeleportItem(Transport transport) { - WorldPoint plWild = Rs2Player.getWorldLocation(); - if (Rs2Pvp.isInWilderness() && plWild != null - && !isTeleportAllowedAtWildernessLevel( - Rs2Pvp.getWildernessLevelFrom(plWild), transport.getMaxWildernessLevel())) { - return false; - } - boolean succesfullAction = false; - for (Set itemIds : transport.getItemIdRequirements()) { - if (succesfullAction) - break; - for (Integer itemId : itemIds) { - if (Rs2Walker.currentTarget == null) break; - // reachedDistance <= 0: do not treat as "already at destination" (legacy: raw distance < 0 never true). - int reachRd = reachedDistanceOrDefault(); - if (reachRd > 0 && isPlayerWithinChebyshevOf(transport.getDestination(), reachRd)) { - break; - } - if (succesfullAction) break; - //If an action is succesfully we break out of the loop - succesfullAction = handleWearableTeleports(transport, itemId) || handleInventoryTeleports(transport, itemId); - } - } - return succesfullAction; - } - private static boolean handleInventoryTeleports(Transport transport, int itemId) { - Rs2ItemModel rs2Item = Rs2Inventory.get(itemId); - if (rs2Item == null) return false; - // A list of generic teleports that can be used if no parsable destination action is found - List genericKeyWords = Arrays.asList( - "invoke", "empty", "consume", "open", "teleport", "rub", "break", "reminisce", "signal", "play", "commune", "squash", "blow" - ); - // Return true when the item does not use a generic keyword to teleport to its destination - boolean hasParsableDestination = transport.getDisplayInfo().contains(":"); - String destination = teleportItemLeafAction(transport.getDisplayInfo()); - boolean wildernessTransport = Rs2PathApi.isInWilderness(transport.getDestination()); - log.debug("Trying to find action for destination={}", destination); - // Check if item has destination as direct action - String itemAction = rs2Item.getAction(destination); - // Check if item has destination as sub-menu action - Map.Entry sub = rs2Item.getIndexOfSubAction(destination); - if (itemAction == null && sub != null && sub.getKey() != null) { - itemAction = destination; - } - // If there's only one destination with the item possible, a generic action will also work - if (itemAction == null && !hasParsableDestination) { - itemAction = rs2Item.getActionFromList(genericKeyWords); - } - if (itemAction != null) { - boolean interaction = Rs2Inventory.interact(rs2Item, itemAction); - if (!interaction) { - return false; - } else if (wildernessTransport) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes", "Okay"); - } else if (isQuetzalWhistleItemId(itemId)) { - return finishQuetzalWhistleTransport(transport); - } - return true; - } - // If no location-based action found, try generic actions - itemAction = rs2Item.getActionFromList(genericKeyWords); - if (itemAction == null) { - log.debug("No generic keyword found for={}, genericKeywords={}", itemAction, String.join(",", genericKeyWords)); - return false; - } - if (Rs2Inventory.interact(itemId, itemAction)) { - log.debug("Traveling with genericAction={}, to {} - ({})", itemAction, transport.getDisplayInfo(), transport.getDestination()); - if (itemAction.equalsIgnoreCase("open") && itemId == ItemID.BOOKOFSCROLLS_CHARGED) { - return handleMasterScrollBook(destination); - } else if (isQuetzalWhistleItemId(itemId)) { - return finishQuetzalWhistleTransport(transport); - } else if (isDialogueBasedTeleportItem(transport.getDisplayInfo())) { - // Multi-destination teleport items: wait for destination selection dialogue - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(destination); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; - } else if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { - // Burning amulet in inventory: confirm wilderness teleport - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("Okay, teleport to level"); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; - } else if (wildernessTransport) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes", "Okay"); - } else { - Rs2Player.waitForAnimation(); - log.info("Unsure how to handle this itemTransport={} action={}", transport, itemAction); - } - } - return false; - } - private static boolean handleWearableTeleports(Transport transport, int itemId) { - Rs2ItemModel rs2Item = Rs2Equipment.get(itemId); - if (rs2Item == null) return false; - if (transport.getDisplayInfo().contains(":")) { - String destination = teleportItemLeafAction(transport.getDisplayInfo()); - if (transport.getDisplayInfo().toLowerCase().contains("slayer ring")) { - Rs2Equipment.invokeMenu(rs2Item, "teleport"); - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(destination); - } else { - Rs2Equipment.invokeMenu(rs2Item, destination); - if (transport.getDisplayInfo().toLowerCase().contains("burning amulet")) { - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption("Okay, teleport to level"); - } - } - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return true; - } - return false; - } - /** - * Returns the executable leaf from a display hierarchy. Upstream labels may describe nested - * categories (for example {@code Max cape: POH Portals: Rimmington}); RuneLite item sub-ops are - * looked up by their leaf action, not by the intermediate display category. - */ - static String teleportItemLeafAction(String displayInfo) { - if (displayInfo == null) { - return ""; - } - String[] segments = displayInfo.split(":"); - return segments[segments.length - 1].trim().toLowerCase(Locale.ROOT); - } - static boolean isTeleportAllowedAtWildernessLevel(int currentLevel, int maximumLevel) { - return currentLevel <= maximumLevel; - } - /** - * Checks if the teleport item requires dialogue-based destination selection. - * These are items that, when rubbed/activated, show a dialogue menu to choose destination. - * - * @param displayInfo the displayInfo from the transport - * @return true if the item requires dialogue handling - */ - private static boolean isDialogueBasedTeleportItem(String displayInfo) { - if (displayInfo == null) return false; - String lowerDisplayInfo = displayInfo.toLowerCase(); - return lowerDisplayInfo.contains("slayer ring") - || lowerDisplayInfo.contains("games necklace") - || lowerDisplayInfo.contains("skills necklace") - || lowerDisplayInfo.contains("ring of dueling") - || lowerDisplayInfo.contains("ring of wealth") - || lowerDisplayInfo.contains("amulet of glory") - || lowerDisplayInfo.contains("combat bracelet") - || lowerDisplayInfo.contains("digsite pendant") - || lowerDisplayInfo.contains("necklace of passage") - || lowerDisplayInfo.contains("giantsoul amulet"); - } /** * Checks if the player's current location is within the specified area defined by the given world points. @@ -12097,7 +10161,7 @@ static String offPathRecalcDeferralReason(boolean playerMoving, */ private static boolean isMovementWalkerOwned(long nowMs, long minimapClickAtMs) { long lastOwnedActionAtMs = Math.max( - Math.max(minimapClickAtMs, routeState.doorInteractionSettleStartedAtMs), + Math.max(minimapClickAtMs, doorAttemptLedger.settleStartedAtMs()), Math.max(routeState.lastTransportHandledAtMs, Math.max(routeState.lastUnreachableRecoveryClickAtMs, routeState.interimSetAtMs))); return isRecentEvent(nowMs, lastOwnedActionAtMs, WALKER_MOVEMENT_OWNERSHIP_WINDOW_MS); @@ -12257,1257 +10321,181 @@ private static boolean interactingActorNearWalkablePath() { WorldPoint loc = actor.getWorldLocation(); if (loc == null) { return false; - } - for (WorldPoint p : path) { - if (p == null || p.getPlane() != loc.getPlane()) { - continue; - } - if (p.distanceTo2D(loc) <= 2) { - return true; - } - } - return false; - } - - private static long stallThresholdMs() { - return Rs2WalkerStallPolicy.computeThresholdMs( - STALL_BASE_MS, - STALL_COMBAT_MULTIPLIER, - STALL_ANIMATING_MULTIPLIER, - STALL_MOVING_MULTIPLIER, - STALL_INTERIM_MINIMAP_MULTIPLIER, - STALL_INTERACTING_MULTIPLIER, - Rs2Player.isInCombat(), - Rs2Player.isAnimating(), - Rs2Player.isMoving(), - routeState.interimTargetWp != null, - (Rs2Player.isMoving() || Rs2Player.isAnimating()) && interactingActorNearWalkablePath()); - } - - private static boolean isStuckTooLong() { - if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { - return false; - } - - long routeProgressAt = routeState.routeProgressAdvancedAtMs; - if (routeProgressAt > 0L && System.currentTimeMillis() - routeProgressAt < ROUTE_PROGRESS_STALL_GRACE_MS) { - return false; - } - - return routeState.lastMovedTimeMs > 0 && System.currentTimeMillis() - routeState.lastMovedTimeMs > stallThresholdMs(); - } - - /** - * @param start - */ - public void setStart(WorldPoint start) { - Set targets = Rs2PathApi.getActiveRouteTargets(); - if (targets.isEmpty()) { - return; - } - Rs2PathApi.setStartPointSet(true); - if (isClientThread()) { - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); - } else { - restartPathfinding(start, targets); - } - } - - /** - * Of these candidate tiles, the one the pathfinder can actually reach most cheaply — or null when - * none of them is reachable. - * - *

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

Worked case: approaching the Black Knights' Fortress ladder from (3024,3512), the tiles beside - * it are walkable and adjacent but walled off, while the usable approach is east through a Sturdy - * door. Proximity picks a walled tile every time; this picks the one with a route. - * - * @param start where we are pathing from - * @param candidates tiles worth standing on, in no particular order - * @return the reachable candidate, or null if the pathfinder cannot reach any of them - */ - public static WorldPoint nearestReachable(WorldPoint start, Collection candidates) { - if (start == null || candidates == null || candidates.isEmpty()) { - return null; - } - Set targets = new HashSet<>(candidates); - if (targets.contains(start)) { - return start; - } - // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. - return Rs2PathApi.plan(Rs2RouteRequest.toAny(start, targets)) - .getReachedTarget(0) - .orElse(null); - } - - /** - * Checks the distance between startpoint and endpoint using ShortestPath - * - * @param startpoint - * @param endpoint - * @return distance - */ - public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) { - return Rs2PathApi.plan(Rs2RouteRequest.to(startpoint, endpoint)).getPath().size(); - } - - /** - * Forwards to {@link Rs2LeaguesTransport#recordTransportAttempt} for Leagues locked-region chat correlation. - * Delegate records only teleport-like transports while Leagues is active (seasonal + spells/items, e.g. ectophial). - */ - public static void recordTransportAttempt(Transport transport) - { - Rs2LeaguesTransport.recordTransportAttempt(transport); - } - - /** - * Writes {@code phase="result"} for {@link Rs2LeaguesTransport#appendTransportObservation} (seasonal rows only). - */ - private static void recordTransportResult(Transport transport, boolean success) - { - if (transport == null || transport.getType() != TransportType.SEASONAL_TRANSPORT) - { - return; - } - if (!Rs2LeaguesTransport.isLeaguesActive()) - { - return; - } - Rs2LeaguesTransport.appendTransportObservation("result", transport, success, success ? "ok" : "fail"); - } - - /** Wraps an action with {@link #recordTransportAttempt} + {@link #recordTransportResult} (seasonal JSONL, Leagues snapshot for teleports). - * @see net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport - */ - private static boolean attemptObserved(Transport transport, BooleanSupplier action) - { - if (transport == null || action == null) - { - return false; - } - boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); - // Snapshot attempt for Leagues locked-region chat correlation (avoid churn outside leagues). - if (leaguesActive) - { - recordTransportAttempt(transport); - } - boolean ok = action.getAsBoolean(); - if (leaguesActive) - { - recordTransportResult(transport, ok); - } - return ok; - } - - /** - * Like {@link #attemptObserved} but does not call {@link #recordTransportAttempt} before the action. - * Seasonal handlers record attempts at their click sites so {@link Rs2LeaguesTransport#getLastTransportAttemptSnapshot} - * matches the handler that actually ran (Leagues Area vs MoA). - */ - private static boolean attemptObservedWithoutAttemptRecord(Transport transport, BooleanSupplier action) - { - if (transport == null || action == null) - { - return false; - } - boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); - boolean ok = action.getAsBoolean(); - if (leaguesActive) - { - recordTransportResult(transport, ok); - } - return ok; - } - - /** - * Tries configured seasonal transport handlers for the same {@link Transport} row. - * Attempt recording is done inside each handler (for built-ins, {@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}) - * — use {@link #attemptObservedWithoutAttemptRecord} at the call site. - */ - private static boolean handleSeasonalTransport(Transport transport) { - if (transport == null) { - return false; - } - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null) return false; - - List handlers = seasonalTransportHandlers; - for (SeasonalTransportHandler h : handlers) - { - if (h == null) - { - continue; - } - if (!h.matches(transport)) - { - continue; - } - if (h.tryUse(transport)) - { - return true; - } - } - Telemetry.incrementSeasonalHandlerMiss(); - if (log.isDebugEnabled() && SEASONAL_HANDLER_MISS_LOGGED_COUNT.get() < SEASONAL_HANDLER_MISS_LOG_CAP) - { - WorldPoint destWp = transport.getDestination(); - String hash = Integer.toHexString(displayInfo.hashCode()); - String tail = displayInfo.length() > 160 - ? displayInfo.substring(0, 160) + "|h" + hash - : displayInfo + "|h" + hash; - final String missKey; - Integer packedTileOrNull = null; - if (destWp != null) - { - packedTileOrNull = WorldPointUtil.packWorldPoint(destWp); - missKey = Integer.toHexString(packedTileOrNull) + "|" + tail; - } - else - { - missKey = "nodest|" + tail; - } - if (SEASONAL_HANDLER_MISS_LOGGED.add(missKey)) - { - // Best-effort cap: only increment while below cap; duplicates and races are fine for debug-only logs. - for (;;) - { - int prev = SEASONAL_HANDLER_MISS_LOGGED_COUNT.get(); - if (prev >= SEASONAL_HANDLER_MISS_LOG_CAP) - { - break; - } - if (SEASONAL_HANDLER_MISS_LOGGED_COUNT.compareAndSet(prev, prev + 1)) - { - break; - } - } - String sample = displayInfo.length() > 160 ? displayInfo.substring(0, 160) + "…" : displayInfo; - if (packedTileOrNull != null) - { - sample = sample + " destPacked=" + Integer.toHexString(packedTileOrNull); - } - log.debug("[Walker] seasonal transport unmatched by configured handlers (expect pathfinder-only matching rows); key={} sample={}", - missKey, sample); - } - } - return false; - } - - private static boolean handleSpiritTree(Transport transport) { - // Get Transport Information - String displayInfo = transport.getDisplayInfo(); - int objectId = transport.getObjectId(); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: displayInfo={}, objectId={}", displayInfo, objectId); - } - if (displayInfo == null || displayInfo.isEmpty()) { - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: displayInfo empty, returning false"); - } - return false; - } - - if (!Rs2Widget.isWidgetVisible(ComponentID.ADVENTURE_LOG_CONTAINER)) { - TileObject spiritTree = Rs2GameObject.findObjectById(objectId); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: findObjectById({}) returned {}", - objectId, spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); - } - if (spiritTree == null) { - // POH fix: handleSpiritTree's findObjectById uses the transport's objectId - // which is keyed from the TSV. Inside a POH the spirit tree is a different - // object id than the overworld TSV expects. Fall back to the PohTeleports - // helper which knows the full set of POH spirit-tree ids. - spiritTree = PohTeleports.getSpiritTree(); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: POH fallback getSpiritTree() returned {}", - spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); - } - } - boolean interactResult = Rs2GameObject.interact(spiritTree, "Travel"); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: interact(spiritTree, Travel) returned {}", interactResult); - } - if (!interactResult) { - return false; - } - } - - boolean result = interactWithAdventureLog(transport); - if (log.isDebugEnabled()) - { - log.debug("[Walker] handleSpiritTree: interactWithAdventureLog returned {}", result); - } - return result; - } - - private static boolean handleMinigameTeleport(Transport transport) { - final Object[] selectedOpListener = new Object[]{489, 0, 0}; - final List teleportGraphics = List.of(800, 802, 803, 804); - - @Component final int GROUPING_BUTTON_COMPONENT_ID = 46333957; // 707.5 - - @Component final int DROPDOWN_BUTTON_COMPONENT_ID = 4980760; // 76.24 - final int DROPDOWN_SELECTED_SPRITE_ID = 773; - - @Component final int MINIGAME_LIST = 4980758; // 76.22 - @Component final int SELECTED_MINIGAME = 4980747; // 76.11 - @Component final int TELEPORT_BUTTON = 4980768; // 76.32 - - // Minigame teleports cant be used if a dialogue is open. - if (Rs2Dialogue.isInDialogue()) { - var playerLocation = Rs2Player.getLocalLocation(); - walkFastLocal(playerLocation); - } - - if (Rs2Tab.getCurrentTab() != InterfaceTab.CHAT) { - Rs2Tab.switchTo(InterfaceTab.CHAT); - sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.CHAT); - } - - Widget groupingBtn = Rs2Widget.getWidget(GROUPING_BUTTON_COMPONENT_ID); - if (groupingBtn == null) return false; - - if (!Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)) { - Rs2Widget.clickWidget(groupingBtn); - sleepUntil(() -> Arrays.equals(groupingBtn.getOnOpListener(), selectedOpListener)); - } - - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - String destination = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo().trim().toLowerCase(); - - Widget selectedWidget = Rs2Widget.getWidget(SELECTED_MINIGAME); - if (selectedWidget == null) return false; - if (!selectedWidget.getText().equalsIgnoreCase(destination)) { - Widget dropdownBtn = Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID); - if (dropdownBtn == null) return false; - - if (dropdownBtn.getSpriteId() != DROPDOWN_SELECTED_SPRITE_ID) { - Rs2Widget.clickWidget(dropdownBtn); - sleepUntil(() -> Rs2Widget.findWidget(DROPDOWN_SELECTED_SPRITE_ID, List.of(Rs2Widget.getWidget(DROPDOWN_BUTTON_COMPONENT_ID))) != null); - } - - Widget minigameWidgetParent = Rs2Widget.getWidget(MINIGAME_LIST); - if (minigameWidgetParent == null) return false; - List minigameWidgetList = Arrays.stream(minigameWidgetParent.getDynamicChildren()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - Widget destinationWidget = Rs2Widget.findWidget(destination, minigameWidgetList); - if (destinationWidget == null) return false; - - NewMenuEntry destinationMenuEntry = new NewMenuEntry() - .option("Select") - .target("") - .identifier(1) - .type(MenuAction.CC_OP) - .param0(destinationWidget.getIndex()) - .param1(minigameWidgetParent.getId()) - .forceLeftClick(false); - - Microbot.doInvoke(destinationMenuEntry, new Rectangle(1, 1)); - sleepUntil(() -> Rs2Widget.getWidget(SELECTED_MINIGAME).getText().equalsIgnoreCase(destination)); - } - - Widget teleportBtn = Rs2Widget.getWidget(TELEPORT_BUTTON); - if (teleportBtn == null) return false; - Rs2Widget.clickWidget(teleportBtn); - - if (transport.getDisplayInfo().toLowerCase().contains("rat pits")) { - Rs2Dialogue.sleepUntilSelectAnOption(); - Rs2Dialogue.clickOption(transport.getDisplayInfo().split(":")[1].trim().toLowerCase()); - } - - sleepUntil(Rs2Player::isAnimating); - return sleepUntilTrue(() -> !Rs2Player.isAnimating() && teleportGraphics.stream().noneMatch(Rs2Player::hasSpotAnimation), 100, 20000); - } - - static int canoeMapMainComponentId(int stationObjectId) { - if (stationObjectId >= 60845 && stationObjectId <= 60849) { - return InterfaceID.CanoeMapDougne.MAIN_MAP; - } - if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { - return InterfaceID.CanoeMapLum.MAIN_MAP; - } - return -1; - } - - static int canoeMapDestinationsComponentId(int stationObjectId) { - if (stationObjectId >= 60845 && stationObjectId <= 60849) { - return InterfaceID.CanoeMapDougne.DESTINATIONS; - } - if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { - return InterfaceID.CanoeMapLum.DESTINATIONS; - } - return -1; - } - - private static boolean handleCanoe(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null || displayInfo.isEmpty()) return false; - - List validActions = List.of("chop-down", "shape-canoe", "float canoe", "paddle canoe"); - ObjectComposition CANOE_COMPOSITION = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - if (CANOE_COMPOSITION == null) return false; - - String currentAction = Arrays.stream(CANOE_COMPOSITION.getActions()) - .filter(Objects::nonNull) - .filter(act -> validActions.contains(act.toLowerCase())).findFirst().orElse(null); - if (currentAction == null || currentAction.isEmpty()) { - log.error("Unable to find canoe action"); - return false; - } - - switch (currentAction) { - case "Chop-down": - Rs2GameObject.interact(transport.getObjectId(), "Chop-down"); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Shape-Canoe": - @Component final int CANOE_SELECTION_PARENT = 27262976; // 416.3 - @Component final int CANOE_SHAPING_TEXT = 27262986; // 416.10 - - Rs2GameObject.interact(transport.getObjectId(), "Shape-Canoe"); - boolean isCanoeShapeTextVisible = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(CANOE_SHAPING_TEXT), 100, 10000); - if (!isCanoeShapeTextVisible) { - log.error("Canoe shape text is not visible within timeout period"); - return false; - } - - final int woodcuttingLevel = Rs2Player.getRealSkillLevel(Skill.WOODCUTTING); - String canoeOption; - if (woodcuttingLevel >= 57) { - canoeOption = "Waka canoe"; - } else if (woodcuttingLevel >= 42) { - canoeOption = "Stable dugout canoe"; - } else if (woodcuttingLevel >= 27) { - canoeOption = "Dugout canoe"; - } else if (woodcuttingLevel >= 12) { - canoeOption = "Log canoe"; - } else { - // Not high enough level to make any canoe - return false; - } - - Widget canoeSelectionParentWidget = Rs2Widget.getWidget(CANOE_SELECTION_PARENT); - if (canoeSelectionParentWidget == null) return false; - Widget canoeSelectionWidget = Rs2Widget.findWidget("Make " + canoeOption, List.of(canoeSelectionParentWidget)); - Rs2Widget.clickWidget(canoeSelectionWidget); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Float Canoe": - Rs2GameObject.interact(transport.getObjectId(), "Float Canoe"); - sleepUntil(() -> Rs2Player.isAnimating(1200)); - return sleepUntilTrue(() -> { - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(transport.getObjectId()); - - if (composition == null) return false; - return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); - }, 300, 10000); - case "Paddle Canoe": - int canoeMapMain = canoeMapMainComponentId(transport.getObjectId()); - int canoeMapDestinations = canoeMapDestinationsComponentId(transport.getObjectId()); - if (canoeMapMain < 0 || canoeMapDestinations < 0) { - log.error("Unsupported canoe station object id: {}", transport.getObjectId()); - return false; - } - if (!Rs2GameObject.interact(transport.getObjectId(), "Paddle Canoe")) { - log.error("Failed to interact with canoe station"); - return false; - } - - // Wait for the player to actually walk to the canoe station and stop moving - // before checking for the destination map widget. The interact call only - // queues the click; the player still has to walk there. - sleepUntil(Rs2Player::isMoving, 2000); - sleepUntilTrue(() -> !Rs2Player.isMoving(), 100, 30000); - - // OSRS uses separate interfaces for the River Lum and River Dougne chains. - boolean isDestinationMapVisible = sleepUntilTrue( - () -> Rs2Widget.isWidgetVisible(canoeMapMain), - 100, 10000); - if (!isDestinationMapVisible) { - log.error("Canoe destination map not visible within timeout period for station {}", - transport.getObjectId()); - return false; - } - - Widget destinationListWidget = Rs2Widget.getWidget(canoeMapDestinations); - if (destinationListWidget == null) return false; - Widget destination = Rs2Widget.findWidget("Travel to " + displayInfo, List.of(destinationListWidget), false); - if (destination == null) { - log.error("Could not find canoe destination widget for: {}", displayInfo); - return false; - } - Rs2Widget.clickWidget(destination); - - Rs2Dialogue.waitForCutScene(100, 15000); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), 100, 5000); - } - return false; - } - - private static boolean isQuetzalWhistleItemId(int itemId) { - return itemId == ItemID.HG_QUETZALWHISTLE_BASIC - || itemId == ItemID.HG_QUETZALWHISTLE_ENHANCED - || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED - || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED_INFINITE; - } - - /** - * Inventory menu action order for opening the Quetzal map from the whistle. - * Generic teleport keyword lists put {@code invoke} before {@code blow}; matching Invoke first often does not open the map. - */ - private static final List QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY = Arrays.asList( - "blow", "use", "invoke", "open", "teleport", "rub", "commune", "play"); - - private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { - assert rs2Item != null; - String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); - if (primary != null) { - return primary; - } - return rs2Item.getActionFromList(Arrays.asList( - "invoke", "empty", "consume", "reminisce", "signal", "squash")); - } - - /** - * Labels match {@code quetzals.tsv} destination rows (map icon text). - */ - static String quetzalMapLabelForDestination(WorldPoint dest) { - assert dest != null; - final int[][] coords = { - {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3222, 0}, {1548, 2995, 0}, - {1437, 3171, 0}, {1779, 3111, 0}, {1700, 3037, 0}, {1670, 2933, 0}, {1446, 3108, 0}, - {1613, 3300, 0}, {1226, 3091, 0}, {1344, 3022, 0}, {1411, 3361, 0}, - }; - final String[] labels = { - "Aldarin", "Civitas illa Fortis", "Hunter Guild", "Quetzacalli Gorge", "Sunset Coast", - "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum", - "Salvager Overlook", "Tal Teklan", "Kastori", "Auburnvale", - }; - assert coords.length == labels.length; - // Bank / script targets often sit several tiles off quetzals.tsv landing coords. - final int matchTiles = 15; - for (int i = 0; i < coords.length; i++) { - WorldPoint p = new WorldPoint(coords[i][0], coords[i][1], coords[i][2]); - if (dest.distanceTo2D(p) <= matchTiles && dest.getPlane() == p.getPlane()) { - return labels[i]; - } - } - return null; - } - - /** - * Option text on the Quetzal map — Renu uses {@link InterfaceID.QuetzalMenu}, whistle uses {@link InterfaceID.QuetzalwhistleMenu} - * (same icon labels). Prefers resolving from {@link Transport#getDestination()} so bank/custom tiles match. - */ - private static String resolveQuetzalMapOptionLabel(Transport transport) { - assert transport != null; - WorldPoint dest = transport.getDestination(); - if (dest != null) { - String byCoords = quetzalMapLabelForDestination(dest); - if (byCoords != null && !byCoords.isEmpty()) { - return byCoords; - } - } - String di = transport.getDisplayInfo(); - if (di != null && di.contains(":")) { - String[] parts = di.split(":", 2); - if (parts.length >= 2) { - String loc = parts[1].trim(); - if (!loc.isEmpty()) { - return loc; - } - } - } - return dest != null ? quetzalMapLabelForDestination(dest) : null; - } - - /** True when any Quetzal or whistle-map layer is visible (CONTENTS alone can stay hidden while MAP/ICONS show). */ - private static boolean isQuetzalMapInterfaceVisible() { - return Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.UNIVERSE) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.MAP) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.ICONS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.CONTENTS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.UNIVERSE) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.MAP) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.ICONS) - || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.CONTENTS); - } - - private static boolean finishQuetzalWhistleTransport(Transport transport) { - assert transport != null; - WorldPoint dest = transport.getDestination(); - assert dest != null; - WorldPoint pl = Rs2Player.getWorldLocation(); - if (pl != null && pl.getPlane() == dest.getPlane() && pl.distanceTo2D(dest) < OFFSET) { - log.debug("Quetzal whistle: already within {} tiles of {}, skipping map", OFFSET, dest); - return true; - } - String mapLabel = resolveQuetzalMapOptionLabel(transport); - if (mapLabel == null || mapLabel.isEmpty()) { - log.warn("Quetzal whistle: could not resolve map label (displayInfo={}, destination={})", - transport.getDisplayInfo(), dest); - return false; - } - Rs2Player.waitForAnimation(1800); - sleepUntil(() -> isQuetzalMapInterfaceVisible() || !Rs2Player.isAnimating(), 1400); - sleep(Rs2Random.between(120, 260)); - return clickQuetzalMapDestination(mapLabel, dest); - } - - /** - * Finds destination row/icon; map can open before icon layer is built — search full subtree from several roots, - * not only {@link Widget#getDynamicChildren()} of {@link InterfaceID.QuetzalMenu#ICONS}. - */ - private static Widget findQuetzalMapDestinationWidget(String mapOptionLabel) { - assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); - int[] roots = { - InterfaceID.QuetzalMenu.ICONS, - InterfaceID.QuetzalMenu.MAP, - InterfaceID.QuetzalMenu.SCROLL, - InterfaceID.QuetzalMenu.CONTENTS, - InterfaceID.QuetzalMenu.UNIVERSE, - InterfaceID.QuetzalwhistleMenu.ICONS, - InterfaceID.QuetzalwhistleMenu.MAP, - InterfaceID.QuetzalwhistleMenu.SCROLL, - InterfaceID.QuetzalwhistleMenu.CONTENTS, - InterfaceID.QuetzalwhistleMenu.UNIVERSE, - }; - for (int rootId : roots) { - // Widget#getDynamicChildren / isHidden must not run off the client thread — use marshalled helpers. - if (Rs2Widget.isHidden(rootId)) { - continue; - } - Widget root = Rs2Widget.getWidget(rootId); - if (root == null) { - continue; - } - Widget hit = Rs2Widget.findWidget(mapOptionLabel, List.of(root), false); - if (hit != null) { - return hit; - } - } - return null; - } - - /** - * Opens no NPC — caller must already have opened the Quetzal map (whistle or Renu). - */ - private static boolean clickQuetzalMapDestination(String mapOptionLabel, WorldPoint expectedDestination) { - assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); - assert expectedDestination != null; - long quetzalStartAt = System.currentTimeMillis(); - - WorldPoint here = Rs2Player.getWorldLocation(); - if (here != null && here.getPlane() == expectedDestination.getPlane() - && here.distanceTo2D(expectedDestination) < OFFSET) { - log.debug("Quetzal map: already within {} tiles of {}, skipping map click", OFFSET, expectedDestination); - return true; - } - - boolean mapVisible = sleepUntilTrue(() -> isQuetzalMapInterfaceVisible(), 100, QUETZAL_MAP_VISIBLE_WAIT_MS); - if (!mapVisible) { - log.error("Quetzal map UI not visible within timeout (label={}, checked UNIVERSE/MAP/ICONS/CONTENTS)", - mapOptionLabel); - return false; - } - WebWalkLog.tmark("quetzal_ui_opened", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - - // ICONS subtree can attach shortly after the shell — brief pause before walking widget tree from walker thread. - sleep(Rs2Random.between(80, 160)); - - AtomicReference destRef = new AtomicReference<>(); - boolean iconReady = sleepUntilTrue(() -> { - Widget w = findQuetzalMapDestinationWidget(mapOptionLabel); - destRef.set(w); - return w != null; - }, 120, QUETZAL_ICON_READY_WAIT_MS); - Widget actionWidget = destRef.get(); - if (!iconReady || actionWidget == null) { - log.error("Could not find Quetzal map icon for: {} (waited for widget tree after map visible)", mapOptionLabel); - return false; - } - WebWalkLog.tmark("quetzal_option_found", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - - Rs2Widget.clickWidget(actionWidget); - log.info("Quetzal map: traveling to {} -> {}", mapOptionLabel, expectedDestination); - WebWalkLog.tmark("quetzal_click_sent", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), - "label=" + mapOptionLabel); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(expectedDestination, OFFSET), 100, 8000); - } - - private static boolean handleQuetzal(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - if (displayInfo == null || displayInfo.isEmpty()) return false; - - WorldPoint destCheck = transport.getDestination(); - WorldPoint plCheck = Rs2Player.getWorldLocation(); - if (destCheck != null && plCheck != null && plCheck.getPlane() == destCheck.getPlane() - && plCheck.distanceTo2D(destCheck) < OFFSET) { - log.debug("Quetzal Renu: already within {} tiles of {}, skip travel UI", OFFSET, destCheck); - return true; - } - - Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); - - if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { - Rs2Player.waitForWalking(); - WorldPoint dest = transport.getDestination(); - String mapLabel = resolveQuetzalMapOptionLabel(transport); - if (mapLabel == null || mapLabel.isEmpty() || dest == null) { - return false; - } - return clickQuetzalMapDestination(mapLabel, dest); - } - return false; - } - - private static boolean handleMasterScrollBook(String destination) { - boolean isMasterScrollBookOpen = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(InterfaceID.Bookofscrolls.CONTENTS), 100, 10000); - if (!isMasterScrollBookOpen) { - log.error("Master Scroll Book did not open within timeout period"); - return false; - } - - Widget bookOfScrollsWidget = Rs2Widget.getWidget(InterfaceID.Bookofscrolls.CONTENTS); - List bookOfScrollsChildren = Arrays.stream(bookOfScrollsWidget.getStaticChildren()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - Widget destinationWidget = Rs2Widget.findWidget(destination, bookOfScrollsChildren, false); - if (destinationWidget == null) return false; - boolean interaction = Rs2Widget.clickWidget(destinationWidget); - if (interaction && destination.equalsIgnoreCase("Revenant cave")) { - Rs2Dialogue.sleepUntilInDialogue(); - return Rs2Dialogue.clickOption("Yes, teleport me now"); - } - return interaction; - } - - private static boolean handleMagicCarpet(Transport transport) { - final int flyingPoseAnimation = 6936; - var rugMerchant = Rs2Npc.getNpc(transport.getObjectId()); - if (rugMerchant == null) return false; - - Rs2Npc.interact(rugMerchant, transport.getAction()); - Rs2Dialogue.sleepUntilInDialogue(); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> Rs2Player.getPoseAnimation() == flyingPoseAnimation, 10000); - return sleepUntilTrue(() -> Rs2Player.getPoseAnimation() != flyingPoseAnimation, 600,60000); - } - - private static boolean handleCharterShip(Transport transport) { - String npcName = transport.getName(); - - Rs2NpcModel npc = Rs2Npc.getNpc(npcName); - log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); - if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { - Rs2Player.waitForWalking(); - if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4), 5000)) { - return false; - } - - Widget destinationWidget = findCharterDestinationWidget(transport.getDisplayInfo()); - if (!invokeCharterDestinationWidget(destinationWidget, transport.getDisplayInfo())) { - return false; - } - confirmCharterTravelIfPrompted(); - return true; - } - return false; - } - - private static Widget findCharterDestinationWidget(String destinationText) { - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - Widget root = Microbot.getClient().getWidget(885, 4); - if (root == null || root.isHidden()) { - return null; - } - - Widget textMatch = findCharterDestinationTextWidget(root, destinationText); - if (textMatch == null) { - return null; - } - - Widget clickable = findClickableCharterWidget(textMatch, root); - return clickable != null ? clickable : textMatch; - }).orElse(null); - } - - private static Widget findCharterDestinationTextWidget(Widget widget, String destinationText) { - if (widget == null || widget.isHidden()) { - return null; - } - if (charterWidgetMatchesDestination(widget, destinationText)) { - return widget; - } - - Widget[] staticChildren = widget.getStaticChildren(); - Widget found = findCharterDestinationTextWidget(staticChildren, destinationText); - if (found != null) { - return found; - } - - Widget[] dynamicChildren = widget.getDynamicChildren(); - found = findCharterDestinationTextWidget(dynamicChildren, destinationText); - if (found != null) { - return found; - } - - return findCharterDestinationTextWidget(widget.getNestedChildren(), destinationText); - } - - private static Widget findCharterDestinationTextWidget(Widget[] widgets, String destinationText) { - if (widgets == null) { - return null; - } - for (Widget widget : widgets) { - Widget found = findCharterDestinationTextWidget(widget, destinationText); - if (found != null) { - return found; - } - } - return null; - } - - private static boolean charterWidgetMatchesDestination(Widget widget, String destinationText) { - String needle = normalizeCharterWidgetText(destinationText); - if (needle.isEmpty()) { - return false; - } - if (normalizeCharterWidgetText(widget.getText()).contains(needle) - || normalizeCharterWidgetText(widget.getName()).contains(needle)) { - return true; - } - String[] actions = widget.getActions(); - if (actions == null) { - return false; - } - return Arrays.stream(actions) - .filter(Objects::nonNull) - .map(Rs2Walker::normalizeCharterWidgetText) - .anyMatch(action -> action.contains(needle)); - } - - private static String normalizeCharterWidgetText(String text) { - if (text == null || text.isEmpty()) { - return ""; - } - return Rs2UiHelper.stripTagsToSpace(text) - .trim() - .toLowerCase(Locale.ROOT) - .replaceAll("\\s+", " "); - } - - private static Widget findClickableCharterWidget(Widget widget, Widget root) { - Widget current = widget; - while (current != null) { - if (hasWidgetActions(current)) { - return current; + } + for (WorldPoint p : path) { + if (p == null || p.getPlane() != loc.getPlane()) { + continue; } - if (current == root) { - return null; + if (p.distanceTo2D(loc) <= 2) { + return true; } - current = current.getParent(); } - return null; + return false; } - private static boolean hasWidgetActions(Widget widget) { - String[] actions = widget.getActions(); - return actions != null && Arrays.stream(actions).anyMatch(action -> action != null && !action.isEmpty()); + private static long stallThresholdMs() { + return Rs2WalkerStallPolicy.computeThresholdMs( + STALL_BASE_MS, + STALL_COMBAT_MULTIPLIER, + STALL_ANIMATING_MULTIPLIER, + STALL_MOVING_MULTIPLIER, + STALL_INTERIM_MINIMAP_MULTIPLIER, + STALL_INTERACTING_MULTIPLIER, + Rs2Player.isInCombat(), + Rs2Player.isAnimating(), + Rs2Player.isMoving(), + routeState.interimTargetWp != null, + (Rs2Player.isMoving() || Rs2Player.isAnimating()) && interactingActorNearWalkablePath()); } - private static boolean invokeCharterDestinationWidget(Widget widget, String destinationText) { - if (widget == null) { + private static boolean isStuckTooLong() { + if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { return false; } - String option = getFirstWidgetAction(widget); - if (option == null || option.isBlank()) { - option = destinationText; + long routeProgressAt = routeState.routeProgressAdvancedAtMs; + if (routeProgressAt > 0L && System.currentTimeMillis() - routeProgressAt < ROUTE_PROGRESS_STALL_GRACE_MS) { + return false; } - NewMenuEntry destinationMenuEntry = new NewMenuEntry() - .option(option) - .target("") - .identifier(1) - .type(MenuAction.CC_OP) - .param0(widget.getIndex()) - .param1(widget.getId()) - .forceLeftClick(false); - - Rectangle bounds = widget.getBounds(); - Microbot.doInvoke(destinationMenuEntry, bounds != null ? bounds : Rs2UiHelper.getDefaultRectangle()); - return true; + return routeState.lastMovedTimeMs > 0 && System.currentTimeMillis() - routeState.lastMovedTimeMs > stallThresholdMs(); } - private static String getFirstWidgetAction(Widget widget) { - String[] actions = widget.getActions(); - if (actions == null) { - return null; + /** + * @param start + */ + public void setStart(WorldPoint start) { + Set targets = Rs2PathApi.getActiveRouteTargets(); + if (targets.isEmpty()) { + return; } - return Arrays.stream(actions) - .filter(action -> action != null && !action.isEmpty()) - .findFirst() - .orElse(null); - } - - private static void confirmCharterTravelIfPrompted() { - if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2000)) { - Rs2Dialogue.clickOption("Yes", true); + Rs2PathApi.setStartPointSet(true); + if (isClientThread()) { + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); + } else { + restartPathfinding(start, targets); } } + /** - * interact with interfaces like spirit tree etc... + * Of these candidate tiles, the one the pathfinder can actually reach most cheaply — or null when + * none of them is reachable. * - * @param transport + *

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

Worked case: approaching the Black Knights' Fortress ladder from (3024,3512), the tiles beside + * it are walkable and adjacent but walled off, while the usable approach is east through a Sturdy + * door. Proximity picks a walled tile every time; this picks the one with a route. + * + * @param start where we are pathing from + * @param candidates tiles worth standing on, in no particular order + * @return the reachable candidate, or null if the pathfinder cannot reach any of them */ - /** The Lovakengj minecart destination list: TEXT entries under 947:9, one per station. */ - private static final int MINECART_MENU_GROUP = 947; - private static final int MINECART_MENU_LIST_CHILD = 9; - - private static boolean isMinecartMenuVisible() { - return !Rs2Widget.isHidden(MINECART_MENU_GROUP, MINECART_MENU_LIST_CHILD); - } - - private static boolean interactWithAdventureLog(Transport transport) { - if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; - - // Two menus arrive here, and they are different interfaces: spirit trees and their kin open - // the adventure log (187), but the Lovakengj minecart opens its own list (947, "Minecart - // rides: 20 coins"). Waiting on 187 alone made every minecart trip time out for 10s and - // return false without ever seeing its menu — the user-visible "it never selects the - // destination". Verified live at Hosidius South: 947:9 holds "1: Arceuus".."C: Shayzien - // West" as plain TEXT entries, and clicking the row by its verbatim displayInfo rides. - boolean menuVisible = sleepUntilTrue( - () -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER) || isMinecartMenuVisible(), - Rs2Player::isMoving, 100, 10000); - - if (!menuVisible) { - log.warn("[Walker] destination menu (187/947) did not open for {}", transport.getDisplayInfo()); - return false; - } - if (isMinecartMenuVisible()) { - return selectMinecartDestination(transport); - } - - String displayInfo = transport.getDisplayInfo(); - // The menu prefixes every option with its shortcut key — digits for the first nine entries - // and LETTERS after that (the Lovakengj minecart runs 1-9 then A: Port Piscarilius through - // C: Shayzien West, read off the live interface). The old strip handled only digit prefixes, - // so letter-keyed destinations searched for "A: Port Piscarilius" verbatim and could never - // match a widget that stores the name apart from its key. - String destinationString = displayInfo.replaceAll("^[0-9A-Za-z]:\\s*", ""); - - // Null-safe on purpose: the old List.of(getWidget(187, 3)) THREW on a null child rather than - // returning false, and the null branch below used to return with no log at all — this class - // of failure reached the user as "it just doesn't select". - Widget optionsRoot = Rs2Widget.getWidget(187, 3); - Widget destinationWidget = optionsRoot == null ? null - : Rs2Widget.findWidget(destinationString, List.of(optionsRoot)); - if (destinationWidget != null) { - Rs2Widget.clickWidget(destinationWidget); - log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); - } - - // Text lookup failed. This menu is BUILT for keyboard selection — child 187:1 is literally - // named "keylisteners" in the cache, and every option's shortcut key is the displayInfo - // prefix we just stripped. Pressing it is also what a human at this menu actually does. - char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); - boolean hasShortcut = displayInfo.length() > 1 && displayInfo.charAt(1) == ':' - && Character.isLetterOrDigit(shortcutKey); - if (hasShortcut) { - log.warn("[Walker] destination '{}' not found by text in menu 187:3 (rootNull={}); pressing shortcut '{}'", - destinationString, optionsRoot == null, shortcutKey); - Rs2Keyboard.keyPress(shortcutKey); - log.info("Traveling to {} - ({})", displayInfo, transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); - } - - log.warn("[Walker] destination '{}' not found in menu 187:3 and displayInfo '{}' carries no shortcut key", - destinationString, displayInfo); - return false; + public static WorldPoint nearestReachable(WorldPoint start, Collection candidates) { + if (start == null || candidates == null || candidates.isEmpty()) { + return null; + } + Set targets = new HashSet<>(candidates); + if (targets.contains(start)) { + return start; + } + // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. + return Rs2PathApi.plan(Rs2RouteRequest.toAny(start, targets)) + .getReachedTarget(0) + .orElse(null); } /** - * Selects a station in the minecart list (947:9). The tsv displayInfo is the row's verbatim text - * ("7: Lovakengj"), so a text click is the primary path — verified live to ride. The rows are - * also keyboard-built (the prefix is the shortcut), so a failed click falls back to the key. + * Checks the distance between startpoint and endpoint using ShortestPath + * + * @param startpoint + * @param endpoint + * @return distance */ - private static boolean selectMinecartDestination(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - boolean selected = Rs2Widget.clickWidget(displayInfo, - Optional.of(MINECART_MENU_GROUP), MINECART_MENU_LIST_CHILD, true); - if (!selected && displayInfo.length() > 1 && displayInfo.charAt(1) == ':' - && Character.isLetterOrDigit(displayInfo.charAt(0))) { - char shortcutKey = Character.toLowerCase(displayInfo.charAt(0)); - log.warn("[Walker] minecart row '{}' not clickable; pressing shortcut '{}'", displayInfo, shortcutKey); - Rs2Keyboard.keyPress(shortcutKey); - selected = true; - } - if (!selected) { - log.warn("[Walker] minecart destination '{}' not found in menu 947:9", displayInfo); - return false; - } - log.info("Traveling to {} - ({}) via minecart menu", displayInfo, transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 10000); + public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) { + return Rs2PathApi.plan(Rs2RouteRequest.to(startpoint, endpoint)).getPath().size(); } - private static boolean handleGlider(Transport transport) { - int TA_QUIR_PRIW = 9043972; - int SINDARPOS = 9043975; - int LEMANTO_ANDRA = 9043978; - int KAR_HEWO = 9043981; - int GANDIUS = 9043984; - int OOKOOKOLLY_UNDRI = 9043993; - int LEMANTOLLY_UNDRI = 9043989; - // Get Transport Information - String displayInfo = transport.getDisplayInfo(); - String npcName = transport.getName(); - String action = transport.getAction(); - final int GLIDER_PARENT_WIDGET = 138; - final int GLIDER_CHILD_WIDGET = 0; - // Check if the widget is already visible - boolean isGliderMenuVisible = Rs2Widget.getWidget(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET) != null; - if (!isGliderMenuVisible) { - // Find the glider NPC - var gnome = Rs2Npc.getNpc(npcName); // Use the NPC name to find the NPC - if (gnome == null) { - return false; - } - // Interact with the gnome glider NPC - if (Rs2Npc.interact(gnome, action)) { - sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); - } - } - // Wait for the widget to become visible - boolean widgetVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET), Rs2Player::isMoving, 100, 10000); - if (!widgetVisible) { - log.error("Widget did not become visible within the timeout."); - return false; - } - if (displayInfo.isEmpty()) return false; - switch (displayInfo) { - case "Kar-Hewo": - return Rs2Widget.clickWidget(KAR_HEWO); - case "Ta Quir Priw": - return Rs2Widget.clickWidget(TA_QUIR_PRIW); - case "Sindarpos": - return Rs2Widget.clickWidget(SINDARPOS); - case "Lemanto Andra": - return Rs2Widget.clickWidget(LEMANTO_ANDRA); - case "Gandius": - return Rs2Widget.clickWidget(GANDIUS); - case "Ookookolly Undri": - return Rs2Widget.clickWidget(OOKOOKOLLY_UNDRI); - case "Lemantolly Undri": - return Rs2Widget.clickWidget(LEMANTOLLY_UNDRI); - default: - log.error("{} not found on the interface.", displayInfo); - return false; + + + /** + * Inventory menu action order for opening the Quetzal map from the whistle. + * Generic teleport keyword lists put {@code invoke} before {@code blow}; matching Invoke first often does not open the map. + */ + private static final List QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY = Arrays.asList( + "blow", "use", "invoke", "open", "teleport", "rub", "commune", "play"); + + private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { + assert rs2Item != null; + String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); + if (primary != null) { + return primary; } + return rs2Item.getActionFromList(Arrays.asList( + "invoke", "empty", "consume", "reminisce", "signal", "squash")); } - // Constants for widget IDs - private static final int SLOT_ONE = 26083331; - private static final int SLOT_TWO = 26083332; - private static final int SLOT_THREE = 26083333; - private static final int SLOT_ONE_CW_ROTATION = 26083347; - private static final int SLOT_ONE_ACW_ROTATION = 26083348; - private static final int SLOT_TWO_CW_ROTATION = 26083349; - private static final int SLOT_TWO_ACW_ROTATION = 26083350; - private static final int SLOT_THREE_CW_ROTATION = 26083351; - private static final int SLOT_THREE_ACW_ROTATION = 26083352; - private static int fairyRingGraphicId = 569; - private static boolean handleFairyRing(Transport transport) { - Rs2ItemModel startingWeapon = null; - TileObject fairyRingObject = PohTeleports.isInHouse() ? PohTeleports.getFairyRings() : Rs2GameObject.getAll(o -> Objects.equals(o.getWorldLocation(), transport.getOrigin())).stream().findFirst().orElse(null); - if (fairyRingObject == null) return false; - if (!PohTeleports.isInHouse() && !Rs2GameObject.canWalkTo(fairyRingObject, 25)) return false; - boolean hasLumbridgeElite = Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; - if (!hasLumbridgeElite) { - if (Rs2Equipment.isWearing(EquipmentInventorySlot.WEAPON)) { - startingWeapon = Rs2Equipment.get(EquipmentInventorySlot.WEAPON); - } - if (!Rs2Equipment.isWearing("Dramen staff") && !Rs2Equipment.isWearing("Lunar staff")) { - if (Rs2Inventory.contains("Dramen staff")) { - Rs2Inventory.equip("Dramen staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Dramen staff")); - } else if (Rs2Inventory.contains("Lunar staff")) { - Rs2Inventory.equip("Lunar staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Lunar staff")); - } else { - return false; - } - } - } - String lastDestinationAction = "last-destination (" + transport.getDisplayInfo() + ")"; - String treeLastDestinationAction = "Ring-last-destination (" + transport.getDisplayInfo() + ")"; - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(fairyRingObject); - log.info("Interacting with Fairy Ring @ {}", fairyRingObject.getWorldLocation()); - // we can use the last-destination to handle fairy rings - if (Rs2GameObject.hasAction(composition, lastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, lastDestinationAction); - } else if (Rs2GameObject.hasAction(composition, treeLastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, treeLastDestinationAction); - } else { - // We have to configure fairy rings through the interface - if (Rs2GameObject.hasAction(composition, "Configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Configure"); - } else if (Rs2GameObject.hasAction(composition, "Ring-configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Ring-configure"); - } - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON), 10000); - if (Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON)) { - log.warn("Fairy ring interface did not open (interrupted by combat?). Retrying next iteration."); - return false; - } - Widget slotOne = Rs2Widget.getWidget(SLOT_ONE); - Widget slotTwo = Rs2Widget.getWidget(SLOT_TWO); - Widget slotThree = Rs2Widget.getWidget(SLOT_THREE); - if (slotOne == null || slotTwo == null || slotThree == null) { - log.warn("Fairy ring slot widget(s) are null; interface may have closed unexpectedly."); - return false; - } - rotateSlotToDesiredRotation(SLOT_ONE, slotOne.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(0)), SLOT_ONE_ACW_ROTATION, SLOT_ONE_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_TWO, slotTwo.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(1)), SLOT_TWO_ACW_ROTATION, SLOT_TWO_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_THREE, slotThree.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(2)), SLOT_THREE_ACW_ROTATION, SLOT_THREE_CW_ROTATION); - Rs2Widget.clickWidget(ComponentID.FAIRY_RING_TELEPORT_BUTTON); - } - sleepUntil(() -> Rs2Player.getGraphicId() == fairyRingGraphicId, 5000); - sleepUntil(() -> Objects.equals(Rs2Player.getWorldLocation(), transport.getDestination()) && Rs2Player.getGraphicId() != fairyRingGraphicId, 10000); - if (startingWeapon != null) { - Rs2ItemModel finalStartingWeapon = startingWeapon; - Rs2Inventory.equip(finalStartingWeapon.getId()); - sleepUntil(() -> Rs2Equipment.isWearing(finalStartingWeapon.getId())); - } - return true; - } + + + /** - * Rotates a fairy ring slot to the desired rotation value. - * Calculates the most efficient rotation direction (clockwise or anticlockwise) - * and performs the necessary number of rotations to reach the target. + * interact with interfaces like spirit tree etc... * - * @param slotId The widget ID of the slot to rotate - * @param currentRotation The current rotation value of the slot - * @param desiredRotation The target rotation value to achieve - * @param slotAcwRotationId The widget ID for anticlockwise rotation button - * @param slotCwRotationId The widget ID for clockwise rotation button + * @param transport */ - private static void rotateSlotToDesiredRotation(int slotId, int currentRotation, int desiredRotation, int slotAcwRotationId, int slotCwRotationId) { - int anticlockwiseTurns = (desiredRotation - currentRotation + 2048) % 2048; - int clockwiseTurns = (currentRotation - desiredRotation + 2048) % 2048; + /** The Lovakengj minecart destination list: TEXT entries under 947:9, one per station. */ + static final int MINECART_MENU_GROUP = 947; + static final int MINECART_MENU_LIST_CHILD = 9; - int turns = Math.min(clockwiseTurns, anticlockwiseTurns) / 512; - boolean rotateCW = clockwiseTurns <= anticlockwiseTurns; - int rotationWidget = rotateCW ? slotCwRotationId : slotAcwRotationId; - for (int i = 0; i < turns; i++) { - final int previousRotation = currentRotation; - Rs2Widget.clickWidget(rotationWidget); - sleepUntil(() -> { - Widget slotWidget = Rs2Widget.getWidget(slotId); - return slotWidget != null && slotWidget.getRotationY() != previousRotation; - }, 2000); - Widget slotWidget = Rs2Widget.getWidget(slotId); - if (slotWidget != null) { - currentRotation = slotWidget.getRotationY(); - } else { - break; - } - } - sleepUntil(() -> { - Widget slotWidget = Rs2Widget.getWidget(slotId); - return slotWidget != null && slotWidget.getRotationY() == desiredRotation; - }, 3000); - } + // Constants for widget IDs + static final int SLOT_ONE = 26083331; + static final int SLOT_TWO = 26083332; + static final int SLOT_THREE = 26083333; + + static final int SLOT_ONE_CW_ROTATION = 26083347; + static final int SLOT_ONE_ACW_ROTATION = 26083348; + static final int SLOT_TWO_CW_ROTATION = 26083349; + static final int SLOT_TWO_ACW_ROTATION = 26083350; + static final int SLOT_THREE_CW_ROTATION = 26083351; + static final int SLOT_THREE_ACW_ROTATION = 26083352; + static int fairyRingGraphicId = 569; + + - /** - * Maps fairy ring letters to their corresponding rotation values. - * Each letter corresponds to a specific rotation degree needed for fairy ring teleportation. - * - * @param letter The fairy ring letter (A-Z) to get rotation for - * @return The rotation value (0, 512, 1024, or 1536) for the letter, or -1 if invalid - */ - private static int getDesiredRotation(char letter) { - switch (letter) { - case 'A': - case 'I': - case 'P': - return 0; - case 'B': - case 'J': - case 'Q': - return 512; - case 'C': - case 'K': - case 'R': - return 1024; - case 'D': - case 'L': - case 'S': - return 1536; - default: - return -1; - } - } /** * Checks if the specified item ID corresponds to a teleportation item. @@ -14117,4 +11105,110 @@ public static boolean closeWorldMap() { } return sleepUntil(() -> !Rs2Widget.isWidgetVisible(InterfaceID.Worldmap.CLOSE), 3000); } + + static void logRouteClear(String reason) { + routeState.lastRouteClearReason = reason == null ? "" : reason; + routeState.lastRouteClearAtMs = System.currentTimeMillis(); + if (reason == null || reason.isBlank()) { + WebWalkLog.routeClearMissingReason(Thread.currentThread().getName()); + } else { + WebWalkLog.routeClear(reason); + } + } + + static boolean walkReachableMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { + int currentDistance = euclideanSq(playerLoc, target); + return Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet().stream() + .filter(tile -> tile != null + && tile.getPlane() == playerLoc.getPlane() + && !tile.equals(playerLoc) + && euclideanSq(playerLoc, tile) <= maxEuclidean * maxEuclidean + && euclideanSq(tile, target) < currentDistance) + .sorted(Comparator + .comparingInt((WorldPoint tile) -> euclideanSq(tile, target)) + .thenComparing(Comparator.comparingInt((WorldPoint tile) -> euclideanSq(playerLoc, tile)).reversed())) + .filter(Rs2Walker::walkMiniMap) + .findFirst() + .map(tile -> { + log.info("[Walker] Minimap click target {} was outside clip; used reachable fallback {}", target, tile); + return true; + }) + .orElse(false); + } + + /** + * Pure settle decision after a handled transport. Settling ends as soon as the player is confirmed + * ARRIVED — standing at/next to the transport's planned destination, neither moving nor animating — + * after a one-tick floor for post-action state flux; {@link #TRANSPORT_POST_INTERACT_SETTLE_MS} is + * only the ceiling for when arrival never confirms (unknown destination, drawn-out travel). The old + * check compared against where the player stood when the transport was MARKED handled, which after + * landing is always true while standing still — so the settle could only ever end by timeout, a fixed + * ~900ms freeze after every single transport. + */ + static boolean transportSettlePending(long ageMs, WorldPoint now, WorldPoint plannedDestination, + boolean moving, boolean animating) { + if (ageMs < 0L || ageMs > TRANSPORT_POST_INTERACT_SETTLE_MS) { + return false; + } + if (ageMs < POST_INTERACT_SETTLE_MIN_MS) { + return true; + } + if (now == null || plannedDestination == null) { + return ageMs <= TRANSPORT_POST_INTERACT_SETTLE_MS / 2; + } + boolean arrivedIdle = now.getPlane() == plannedDestination.getPlane() + && now.distanceTo2D(plannedDestination) <= 1 + && !moving && !animating; + return !arrivedIdle; + } + + static boolean isClientThreadReadTimeout(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof TimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } + + static HashMap nearbyTilesIgnoringCollision( + WorldPoint origin, int radius) { + HashMap result = new HashMap<>(); + if (origin == null || radius < 0) { + return result; + } + int boundedRadius = Math.min(radius, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + for (int dx = -boundedRadius; dx <= boundedRadius; dx++) { + for (int dy = -boundedRadius; dy <= boundedRadius; dy++) { + int distance = Math.max(Math.abs(dx), Math.abs(dy)); + if (distance <= boundedRadius) { + result.put(new WorldPoint( + origin.getX() + dx, + origin.getY() + dy, + origin.getPlane()), distance); + } + } + } + return result; + } + + /** + * Updates world-map marker and restarts pathfinding for {@code target}. Does not assign + * {@link #currentTarget}; callers set it when appropriate. + */ + static void applyWalkerDestination(WorldPoint target) { + Rs2WalkerLifecycleRuntime.applyWalkerDestination(target); + } + + static String normalizeCharterWidgetText(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + return Rs2UiHelper.stripTagsToSpace(text) + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", " "); + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java new file mode 100644 index 00000000000..0b309db2616 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerTransports.java @@ -0,0 +1,3090 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.*; +import net.runelite.api.Point; +import net.runelite.api.annotations.Component; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.*; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.devtools.MovementFlag; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; +import net.runelite.client.plugins.microbot.shortestpath.*; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; +import net.runelite.client.plugins.microbot.util.coords.Rs2LocalPoint; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldArea; +import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; +import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.Runes; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandlers; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.slf4j.event.Level; +import net.runelite.client.plugins.microbot.util.poh.PohTeleports; +import net.runelite.client.plugins.microbot.util.poh.PohTransport; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +import net.runelite.client.plugins.microbot.util.leaguetransport.LeaguesRegion; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.walker.door.DoorAttemptLedger; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier; +import net.runelite.client.plugins.microbot.util.walker.door.DoorProbeContext; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry; +import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; +import net.runelite.client.plugins.microbot.util.walker.obstacle.MineableResolver; +import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; +import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; +import net.runelite.client.plugins.microbot.util.walker.recovery.FrontierDecision; +import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate; +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2WalkerAwaits; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits; +import net.runelite.client.plugins.microbot.util.walker.puzzles.DraynorBasementSolver; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; +import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; +import javax.inject.Named; +import java.awt.*; +import java.util.*; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static net.runelite.client.plugins.microbot.util.Global.*; +import static net.runelite.client.plugins.microbot.util.walker.Rs2Walker.*; + +/** + * The transport-execution component extracted from {@code Rs2Walker} (Phase E1, 2026-08-13): the + * per-type transport handlers, the terminal-travel machinery and their private helpers — the + * dispatcher {@code handleSelectedTransport} and its exclusive call-graph closure, moved verbatim. + * Shared walker state and helpers remain in {@code Rs2Walker} (same package) and are consumed via + * static imports; the walker calls back in through the package-private dispatcher. + */ +@lombok.extern.slf4j.Slf4j +final class Rs2WalkerTransports { + + private Rs2WalkerTransports() { + } + + /** + * Same-plane Chebyshev distance from player to {@code dest} strictly less than {@code maxChebyshevExclusive}. + * Requires matching {@link WorldPoint#getPlane()} before using {@link WorldPoint#distanceTo2D} — that method only + * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. + */ + private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) < maxChebyshevExclusive; + } + + /** + * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). + */ + private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; + } + + + + + + + /** + * Executes the exact transport retained by the active route through its registered Microbot executor. + * Candidate discovery must happen through immutable route steps, never by rescanning the mutable + * transport catalog. The local transport payload is isolated here because POH execution still carries + * subtype behavior that is not part of the planner-independent edge value. + */ + static boolean handleSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection) { + if (selection == null || !selection.isExecutable()) { + if (selection != null) { + WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", + selection.getEdge().getType(), + compactWorldPoint(selection.getEdge().getOrigin()), + compactWorldPoint(selection.getEdge().getDestination())); + } + return false; + } + Transport selectedTransport = selection.getLocalExecutionTransport(); + Rs2TerminalTravelMode terminalTravelMode = selection.getEdge().getTerminalTravelMode(); + if (path == null || selectedTransport == null + || indexOfStartPoint < 0 || indexOfStartPoint >= path.size()) { + return false; + } + if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 + && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + return false; + } + if (log.isDebugEnabled()) { + log.debug("[Walker] handleTransports at {}: exact planned candidate — {} executor={}", + path.get(indexOfStartPoint), selectedTransport.getDisplayInfo(), selection.getExecutor()); + } + // When the player is inside a POH instance, the player's raw world-location plane is + // the instance-template plane and has no relationship to the POH-transport origin plane. + // Skip the plane guard in that case so POH transports can actually be considered. + boolean inPohInstance = Microbot.getClient().getTopLevelWorldView().getScene().isInstance() + && net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile() != null; + + // Pre-compute path point index map for O(1) lookups instead of repeated O(n) scans + Map pathFirstIndex = new HashMap<>(path.size()); + for (int idx = 0; idx < path.size(); idx++) { + pathFirstIndex.putIfAbsent(path.get(idx), idx); + } + + for (Transport transport : Collections.singletonList(selectedTransport)) { + Collection worldPointCollections; + //in some cases the getOrigin is null, for teleports that start the player location + if (transport.getOrigin() == null) { + worldPointCollections = Collections.singleton(null); + } else if (inPohInstance && transport.getType() == TransportType.POH) { + // POH fix: when the player is inside a POH instance, the transport's exit-portal + // origin is an overworld tile that doesn't map into the player's instance chunks, + // so toLocalInstance() returns an empty collection and the inner loop never runs. + // Pass the origin through directly so the per-i dispatch below can execute. + worldPointCollections = Collections.singleton(transport.getOrigin()); + } else { + worldPointCollections = WorldPoint.toLocalInstance(Microbot.getClient().getTopLevelWorldView(), transport.getOrigin()); + } + log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", + transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); + originLoop: + for (WorldPoint origin : worldPointCollections) { + WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); + if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null + && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { + continue; + } + + // Hoist path-constant checks out of the inner loop: destination must exist in path + if (!pathFirstIndex.containsKey(transport.getDestination())) { + log.debug("[Walker] skip {}: destination {} not in path", transport.getDisplayInfo(), transport.getDestination()); + continue; + } + // QUETZAL is not {@link TransportType#isTeleport} — without this, stall/off-path recalc can re-open the map and + // click the same landing repeatedly while already there (no movement → infinite stall loop). + if (transport.getType() == TransportType.QUETZAL) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { + log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", + transport.getDisplayInfo(), OFFSET, transport.getDestination()); + continue; + } + } + if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { + log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); + continue; + } + } + + // Pre-compute origin/destination indices once per transport (not per inner iteration) + int precomputedIndexOfOrigin = -1; + int precomputedIndexOfDest = -1; + if (!TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + Integer originIdx = pathFirstIndex.get(transport.getOrigin()); + Integer destIdx = pathFirstIndex.get(transport.getDestination()); + precomputedIndexOfOrigin = originIdx != null ? originIdx : -1; + precomputedIndexOfDest = destIdx != null ? destIdx : -1; + if (log.isDebugEnabled()) { + log.debug("[Walker] filter4 {}: indexOfOrigin={}, indexOfDestination={}, pathSize={}, originInPath={}, destInPath={}", + transport.getDisplayInfo(), precomputedIndexOfOrigin, precomputedIndexOfDest, path.size(), + precomputedIndexOfOrigin != -1, precomputedIndexOfDest != -1); + } + if (precomputedIndexOfDest == -1) continue; + if (precomputedIndexOfOrigin == -1) continue; + if (precomputedIndexOfDest < precomputedIndexOfOrigin) continue; + } + + for (int i = indexOfStartPoint; i < path.size(); i++) { + WorldPoint plPathLoop = Rs2Player.getWorldLocation(); + if (plPathLoop == null) { + // Cannot verify plane / dispatch — do not burn remaining path indices this tick. + break; + } + if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { + log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); + break; // plane won't change across iterations, so break instead of continue + } + + if (i == indexOfStartPoint) { + log.debug("[Walker] reached pre-dispatch for {}: i={}, path[i]={}, origin={}, equalsOrigin={}", + transport.getDisplayInfo(), i, path.get(i), origin, path.get(i).equals(origin)); + } + + if (path.get(i).equals(origin)) { + if (selection.getExecutor() == Rs2TransportExecutor.BARROWS_DIG) { + WorldPoint digOrigin = transport.getOrigin(); + WorldPoint playerAtMound = Rs2Player.getWorldLocation(); + if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { + // Digging is tile-sensitive. Let the ordinary path click finish the + // approach instead of firing the spade from an adjacent mound tile. + return false; + } + boolean dug = attemptObserved(transport, + () -> Rs2Inventory.interact(ItemID.SPADE, "Dig")); + if (!dug) { + return false; + } + boolean enteredCrypt = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf( + transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (enteredCrypt) { + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "Barrows dig post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + return false; + } + + if (isTerminalTravelTransport(transport.getType())) { + if (terminalTravelMode == Rs2TerminalTravelMode.UNSUPPORTED) { + WebWalkLog.spWarn( + "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", + transport.getType(), compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + break originLoop; + } + + Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); + if (npc != null && Rs2Npc.canWalkTo(npc, 20)) { + String npcAction = resolveTerminalNpcInteractionAction( + npc, transport); + if (npcAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal NPC has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; + } + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; + } + if (!npcAction.equalsIgnoreCase(transport.getAction())) { + WebWalkLog.spInfo( + "terminal NPC action fallback name={} configured={} selected={} dest={}", + transport.getName(), transport.getAction(), npcAction, + transport.getDisplayInfo()); + } + + // Wrap with observation so Leagues blocked-region chat can attribute this attempt. + if (attemptObserved(transport, () -> Rs2Npc.interact(npc, npcAction))) { + Rs2Player.waitForWalking(); + sleepUntil(Rs2Dialogue::isInDialogue, 600 * 2); + + if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption("Can you take me somewhere?"); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")) { + sleepTickJitter(2); + Rs2Dialogue.clickContinue(); + } + // Right-clicking the destination is always preferred and needs no + // dialogue — that is what DIRECT means. But the mode is decided + // statically from a name whitelist, so an NPC whose row names a + // destination it no longer offers (Veos: the row says + // "Port Piscarilius", the game now asks in conversation) resolved + // to DIRECT, skipped destination selection entirely, and left the + // walker staring at the destination menu. + // + // resolveTerminalNpcInteractionAction already told us which action + // the NPC actually offered. If it had to fall back to a generic one + // then the destination was NOT chosen by the click and has to be + // chosen in the dialogue, whatever the static mode says. + Rs2TerminalTravelMode effectiveTravelMode = terminalTravelMode; + if (!npcAction.equalsIgnoreCase(transport.getAction()) + && transport.getDisplayInfo() != null + && !transport.getDisplayInfo().isBlank()) { + effectiveTravelMode = Rs2TerminalTravelMode.DIALOGUE_DESTINATION; + } + if (!selectTerminalTravelDialogueDestination( + transport, effectiveTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } + } + } else { + TileObject terminalObject = findTerminalTravelObject(transport); + if (terminalObject != null) { + String objectAction = resolveTransportObjectAction( + terminalObject, + Collections.singletonList(transport.getAction())) + .orElse(""); + if (objectAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal object has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; + } + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; + } + prepareTransportObjectForInteraction(terminalObject); + final TileObject selectedTerminalObject = terminalObject; + if (attemptObserved(transport, () -> Rs2GameObject.interact( + selectedTerminalObject, objectAction))) { + if (!selectTerminalTravelDialogueDestination( + transport, terminalTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } + } + } else { + WorldPoint originTile = path.get(i); + boolean clicked = Rs2Walker.walkFastCanvas(originTile); + if (!clicked) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc != null) { + clicked = walkMiniMapToward(originTile, playerLoc, 13); + } + } + if (!clicked) { + clicked = Rs2Walker.walkMiniMap(originTile); + } + if (!clicked) { + log.debug("[Walker] terminal travel fallback click failed for {}", originTile); + } + sleep(1200, 1600); + } + } + + // Terminal travel is terminal for this transport scan. The exact edge can be + // clicked at most once in one top-level walk invocation; callers can start + // a fresh walk after a surfaced failure, but this invocation never spams the + // target for later path indices or another local-instance copy of the origin. + break originLoop; + } + + if (transport.getType() == TransportType.CHARTER_SHIP) { + if (attemptObserved(transport, () -> handleCharterShip(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + boolean charterLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!charterLanded) { + WebWalkLog.spWarn( + "charter ship post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + sleepTickJitter(4); // wait 4 extra ticks before walking + return finishHandledTransport(transport); + } + } + } + + log.debug("[Walker] Handling {} transport: {} (i={}, path[i]={}, origin={})", + transport.getType(), transport.getDisplayInfo(), i, path.get(i), origin); + if (transport.getType() == TransportType.POH) { + boolean pohResult = attemptObserved(transport, () -> handlePohTransport(transport)); + log.debug("[Walker] handlePohTransport({}) returned {}", transport.getDisplayInfo(), pohResult); + if (pohResult) { + // Shares ship/NPC/boat 10s landing budget — intentional single timeout constant. + boolean pohNearDest = sleepUntil( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!pohNearDest) { + WebWalkLog.spWarn( + "POH post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (pohNearDest) { + return finishHandledTransport(transport); + } + } + } + + if (transport.getType() == TransportType.CANOE) { + if (attemptObserved(transport, () -> handleCanoe(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.HOT_AIR_BALLOON) { + if (attemptObserved(transport, () -> Rs2HotAirBalloon.handle(selection.getEdge()))) { + boolean balloonLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (balloonLanded) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "hot-air balloon post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + // This is a specialized map interaction. Do not fall through to the generic + // object handler and click the same basket again during this walker tick. + return false; + } + + if (transport.getType() == TransportType.SPIRIT_TREE) { + if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { + log.debug("[Walker] skip spirit tree transport — setting is off"); + continue; + } + if (attemptObserved(transport, () -> handleSpiritTree(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + boolean spiritLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!spiritLanded) { + WebWalkLog.spWarn( + "spirit tree post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (spiritLanded) { + return finishHandledTransport(transport); + } + } + } + + if (transport.getType() == TransportType.QUETZAL) { + if (attemptObserved(transport, () -> handleQuetzal(transport))) { + boolean landedNearDest = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!landedNearDest) { + WebWalkLog.spWarn( + "quetzal post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.MAGIC_CARPET) { + if (attemptObserved(transport, () -> handleMagicCarpet(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.WILDERNESS_OBELISK) { + if (attemptObserved(transport, () -> handleWildernessObelisk(transport))) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.GNOME_GLIDER) { + if (attemptObserved(transport, () -> handleGlider(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), + TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + sleepTickJitter(3); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.FAIRY_RING) { + WorldPoint plFairy = Rs2Player.getWorldLocation(); + WorldPoint tdFairy = transport.getDestination(); + boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); + if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_MINIGAME) { + if (attemptObserved(transport, () -> handleMinigameTeleport(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_ITEM) { + if (attemptObserved(transport, () -> handleTeleportItem(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.TELEPORTATION_SPELL) { + if (attemptObserved(transport, () -> handleTeleportSpell(transport))) { + if (isLumbridgeHomeTeleport(transport)) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 600, 35000); + } else { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + } + Rs2Tab.switchTo(InterfaceTab.INVENTORY); + return finishHandledTransport(transport); + } + } + + if (transport.getType() == TransportType.SEASONAL_TRANSPORT) { + if (attemptObservedWithoutAttemptRecord(transport, () -> handleSeasonalTransport(transport))) { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + return finishHandledTransport(transport); + } + } + + if (transport.getObjectId() <= 0) break; + + final int transportObjectId = transport.getObjectId(); + final String transportAction = transport.getAction(); + final List transportActions = getTransportActionOptions(transportAction); + // Climb-down transports have a closed-variant (trapdoor/manhole/grate/hatch) + // that shares the same tile but a different object ID. Infer the closed + // variant from ObjectComposition (any nearby object with an "Open" action + // and a matching name) rather than a hardcoded ID pair, so new variants + // work without a code change. + final boolean allowClosedVariant = "Climb-down".equalsIgnoreCase(transportAction) + || "Climb down".equalsIgnoreCase(transportAction); + + final boolean allowAlKharidTollGateVariant = isAlKharidTollGateObjectId(transportObjectId); + // The FIRST transport of a walk costs ~12.7s in the segment handler while the same + // transport mid-route costs ~1.8s, and the plane-change waits account for only + // ~1.5s of it (measured over three Falador castle runs). This scan runs once per + // CANDIDATE transport at the tile, and a staircase tile carries several rows, so + // the suspicion is N scans rather than one. Time it and say how many candidates + // were queued, so the next run distinguishes "one slow scan" from "many scans". + long objectScanStartedAt = System.currentTimeMillis(); + final Integer legacyClosedId = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); + // Most catalog transports can use their stable object id. The Al Kharid gate cannot: + // its historical catalog ids collide with unrelated live objects in newer injected-client + // revisions. Select that edge by its transformed live composition and route geometry instead. + // This deliberately has no id fallback: clicking an unrelated object is worse than failing + // closed and replanning. + List matched; + if (allowAlKharidTollGateVariant) { + matched = Rs2GameObject.getAll( + o -> isAlKharidTollGateSceneCandidate(transport, o), + transport.getOrigin(), 3); + } else { + // Id-only first: these are plain field reads, no composition resolution. + matched = Rs2GameObject.getAll(o -> { + int id = o.getId(); + if (id == transportObjectId) return true; + return legacyClosedId != null && id == legacyClosedId; + }, transport.getOrigin(), 10); + } + if (matched.isEmpty() && allowClosedVariant) { + // Only now pay for compositions, and only on the transport's own tile: a closed + // variant (trapdoor/manhole/grate/hatch) sits where the transport is, never ten + // tiles away. Previously this ran for EVERY object within 10 tiles whenever the + // action was Climb-down, one client-thread hop each — measured at 5.5-10.9 + // SECONDS for a single scan inside Falador castle, and the reason descending + // stairs was slow while ascending was not. + matched = Rs2GameObject.getAll(o -> { + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); + if (comp == null || comp.getActions() == null) return false; + String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); + boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") + || nm.contains("grate") || nm.contains("hatch"); + if (!nameMatches) return false; + return Arrays.stream(comp.getActions()).filter(Objects::nonNull) + .anyMatch(a -> a.equalsIgnoreCase("Open")); + }, transport.getOrigin(), 2); + } + List objects = matched.stream() + .sorted(Comparator + .comparingInt((TileObject o) -> resolveTransportObjectAction(o, transportActions).isPresent() ? 0 : 1) + .thenComparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) + .collect(Collectors.toList()); + + long objectScanMs = System.currentTimeMillis() - objectScanStartedAt; + if (objectScanMs >= TRANSPORT_OBJECT_SCAN_SLOW_MS) { + WebWalkLog.spInfo("transport_object_scan | slow scanMs={} objectId={} candidatesAtTile={} matches={} origin={}", + objectScanMs, transportObjectId, 1, objects.size(), + compactWorldPoint(transport.getOrigin())); + } + TileObject object = objects.stream().findFirst().orElse(null); + if (object instanceof GroundObject) { + object = objects.stream() + .filter(o -> !Objects.equals(o.getWorldLocation(), Rs2Player.getWorldLocation())) + .min(Comparator.comparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getOrigin())) + .thenComparing(o -> ((TileObject) o).getWorldLocation().distanceTo(transport.getDestination()))).orElse(null); + } + + if (object != null) { + // Skip reachability check for GroundObjects and Magic Mushtrees + if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { + if (!Rs2Tile.isTileReachable(transport.getOrigin())) { + break; + } + } + + // Closed variant detection: if the found object doesn't advertise the + // transport action but does advertise "Open", open it first and re-find + // the now-open object before invoking handleObject. + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + if (comp != null && comp.getActions() != null) { + String[] actions = comp.getActions(); + boolean hasTransportAction = resolveTransportObjectAction(actions, transportActions).isPresent(); + boolean hasOpen = Arrays.stream(actions).filter(Objects::nonNull) + .anyMatch(a -> a.equalsIgnoreCase("Open")); + if (!hasTransportAction && hasOpen) { + log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", + transport.getOrigin(), object.getId(), comp.getName(), transportAction); + final int closedId = object.getId(); + Rs2GameObject.interact(object, "Open"); + Rs2Player.waitForAnimation(2000); + TileObject reopened = Rs2GameObject.getAll(o -> { + if (o.getId() == closedId) return false; + ObjectComposition c = Rs2GameObject.convertToObjectComposition(o); + if (c == null || c.getActions() == null) return false; + return resolveTransportObjectAction(c.getActions(), transportActions).isPresent(); + }, transport.getOrigin(), 3).stream() + .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) + .orElse(null); + if (reopened != null) object = reopened; + } + } + + String interactionAction = resolveTransportObjectAction(object, transportActions) + .orElse(transportAction); + if (!Objects.equals(interactionAction, transportAction)) { + log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", + interactionAction, transportAction, object.getWorldLocation(), object.getId()); + } + prepareTransportObjectForInteraction(object); + if (!handleObject(transport, object, interactionAction)) { + return false; + } + sleepUntil(() -> !Rs2Player.isAnimating()); + WorldPoint destWait = transport.getDestination(); + int maxInclusive = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; + if (destWait == null) { + return false; + } + boolean landedAfterObject = waitForPostHandleObjectLanding(transport, destWait, maxInclusive); + if (!landedAfterObject) { + WorldPoint afterInteraction = Rs2Player.getWorldLocation(); + // Adjacent same-plane transports demand landing on the EXACT destination + // tile (maxInclusive == 0), and agility shortcuts routinely deposit the + // player a tile off it — so a crossing can physically succeed while this + // check still fails. Suppression previously ran only on the success path, + // which left the inverse transport immediately eligible: the walker + // crossed, took the same shortcut straight back, and stranded itself. If + // we are no longer on the origin we did cross, so suppress both tiles + // regardless of the landing verdict. The landing result itself is + // unchanged — this still returns false and replans. + if (isAdjacentSamePlaneTransport(transport) + && afterInteraction != null + && !afterInteraction.equals(transport.getOrigin())) { + markAdjacentSamePlaneTransportHandled(transport, object); + } + WebWalkLog.spWarn( + "post-handleObject landing unresolved (timeout={}ms) dest={} at={}", + POST_HANDLE_OBJECT_LANDING_WAIT_MS, + compactWorldPoint(destWait), + compactWorldPoint(afterInteraction)); + } + if (landedAfterObject) { + markAdjacentSamePlaneTransportHandled(transport, object); + return finishHandledTransport(transport); + } + return false; + } + } + } + } + return false; + } + + private static boolean waitForPostHandleObjectLanding(Transport transport, + WorldPoint destWait, + int maxInclusive) { + long waitStartedAt = System.currentTimeMillis(); + AtomicBoolean settledAwayFromAdjacentDestination = new AtomicBoolean(false); + AtomicBoolean settledNearAdjacentDestination = new AtomicBoolean(false); + boolean completed = sleepUntil(() -> { + if (isPlayerWithinChebyshevInclusive(destWait, maxInclusive)) { + return true; + } + if (!isAdjacentSamePlaneTransport(transport) + || System.currentTimeMillis() - waitStartedAt < POST_HANDLE_OBJECT_FAILED_SETTLE_MS) { + return false; + } + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null || destWait == null || playerLoc.getPlane() != destWait.getPlane() + || Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + if (isSettledNearAdjacentSamePlaneLanding(transport, playerLoc, destWait, maxInclusive)) { + settledNearAdjacentDestination.set(true); + return true; + } + WorldPoint origin = transport == null ? null : transport.getOrigin(); + boolean settledAwayFromOrigin = origin != null && playerLoc.distanceTo2D(origin) > 1; + if (playerLoc.distanceTo2D(destWait) > Math.max(1, maxInclusive) + && settledAwayFromOrigin) { + settledAwayFromAdjacentDestination.set(true); + return true; + } + return false; + }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); + + if (settledNearAdjacentDestination.get()) { + WebWalkLog.spInfo("post-handleObject adjacent landing accepted | dest={} at={}", + compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); + return true; + } + if (settledAwayFromAdjacentDestination.get()) { + WebWalkLog.spInfo("post-handleObject adjacent landing failed | dest={} at={}", + compactWorldPoint(destWait), compactWorldPoint(Rs2Player.getWorldLocation())); + return false; + } + return completed; + } + + static boolean isSettledNearAdjacentSamePlaneLanding(Transport transport, + WorldPoint playerLoc, + WorldPoint destWait, + int maxInclusive) { + if (!isAdjacentSamePlaneTransport(transport) + || playerLoc == null + || destWait == null + || playerLoc.getPlane() != destWait.getPlane()) { + return false; + } + WorldPoint origin = transport.getOrigin(); + if (origin == null || playerLoc.equals(origin)) { + return false; + } + int destinationDistance = playerLoc.distanceTo2D(destWait); + if (destinationDistance <= Math.max(1, maxInclusive) + && playerLoc.distanceTo2D(origin) > 0) { + return true; + } + if (transport.getType() != TransportType.AGILITY_SHORTCUT) { + return false; + } + + // Some adjacent shortcut catalogues describe a multi-object animation as one-tile + // hops. The Falador stepping stones, for example, can carry 3154 -> 3149 while the + // selected edge says 3154 -> 3153. Accept only a tightly bounded forward, collinear + // overshoot; sideways movement, reverse movement, and arbitrary teleports still fail. + int edgeX = destWait.getX() - origin.getX(); + int edgeY = destWait.getY() - origin.getY(); + int movedX = playerLoc.getX() - origin.getX(); + int movedY = playerLoc.getY() - origin.getY(); + int forwardProgress = movedX * edgeX + movedY * edgeY; + int lateralOffset = Math.abs(movedX * edgeY - movedY * edgeX); + return forwardProgress > 0 + && forwardProgress <= 6 + && lateralOffset <= 1; + } + + /** + * Handles the transportation process specifically for instances of PohTransport. + * Any Transport param that reaches this is assumed to be a PohTransport. + * + * @param transport the transport object to be checked and processed + * @return true if the transport is an instance of PohTransport and its transport method executes successfully, false otherwise + */ + private static boolean handlePohTransport(Transport transport) { + if(!(transport instanceof PohTransport)) { + throw new IllegalStateException("handlePohTransport should not be called for non-PohTransports"); + } + return ((PohTransport)transport).execute(); + } + + private static List getTransportActionOptions(String action) { + if (action == null || action.isBlank()) { + return Collections.emptyList(); + } + + List actions = new ArrayList<>(); + actions.add(action); + if ("Bottom-floor".equalsIgnoreCase(action)) { + actions.add("Climb-down"); + actions.add("Climb down"); + } else if ("Top-floor".equalsIgnoreCase(action)) { + actions.add("Climb-up"); + actions.add("Climb up"); + } + return actions; + } + + private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null || comp.getActions() == null) { + return Optional.empty(); + } + return resolveTransportObjectAction(comp.getActions(), actionOptions); + }).orElse(Optional.empty()); + } + + private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { + if (objectActions == null || actionOptions == null || actionOptions.isEmpty()) { + return Optional.empty(); + } + + for (String desired : actionOptions) { + for (String actual : objectActions) { + if (actual != null && desired.equalsIgnoreCase(Rs2UiHelper.stripColTags(actual))) { + return Optional.of(actual); + } + } + } + return Optional.empty(); + } + + private static void prepareTransportObjectForInteraction(TileObject tileObject) { + if (tileObject == null || tileObject.getLocalLocation() == null) { + return; + } + if (!Rs2Camera.isTileOnScreen(tileObject)) { + Rs2Camera.turnTo(tileObject); + sleepUntil(() -> Rs2Camera.isTileOnScreen(tileObject), 1200); + } + } + + private static boolean handleObject(Transport transport, TileObject tileObject) { + return handleObject(transport, tileObject, transport.getAction()); + } + + /** + * A transport may be gated on an item that its own vendor sells on the spot (the Shantay pass + * pattern: the gate wants a ticket, Shantay sells tickets two tiles away). The catalog rows in + * {@code purchasable_items.tsv} say which item, which vendor, and how close the vendor must be + * to the transport origin; the transports.tsv duplicate-row OR (item row + currency-twin row) + * already made the planner route through such transports for players holding only the coins. + * This pre-step completes the currency variant: buy the item before interacting. Free rows + * (e.g. a gate's exit direction) carry neither item nor currency requirements and never match. + * + *

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

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

GAME objects: the NAME must prove doorhood, or the ACTION must be traversal-proof. Bare + * Open/Enter/Push on a non-door name is scenery: the Gift of Peace chest (Stronghold, + * 2026-08-13) was Open-clicked as a route door en route, costing 7-9s of failed traversal per + * encounter — and any Open-actioned coffin, cupboard or sarcophagus on a route segment would do + * the same. Large double gates ARE GameObjects, which is why the rule is name-or-verb rather + * than a flat name filter. + */ + public static boolean isRouteDoorObject(boolean wallObject, String name, String walkAction) { + if (wallObject) { + return isDoorLikeGameObjectName(name) + || (walkAction != null && doorActionPriorityIndex(walkAction) < Integer.MAX_VALUE); + } + return isDoorLikeGameObjectName(name) || isTraversalProofAction(walkAction); + } + /** Whether a (real, non-impostor) composition exposes one of {@code doorActions}. */ public static boolean isDoorComposition(ObjectComposition comp, List doorActions) { if (comp == null || comp.getImpostorIds() != null || isNullOrPlaceholderObjectName(comp.getName()) || comp.getActions() == null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java index 6e2df08e534..d8c9d945998 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorDetection.java @@ -42,7 +42,7 @@ public static boolean isDoorLikeSceneObject(TileObject object) { return false; } String action = Rs2DoorClassifier.pickWalkDoorAction(comp); - return Rs2DoorClassifier.isDoorLikeGameObjectName(comp.getName()) - || (action != null && Rs2DoorClassifier.doorActionPriorityIndex(action) < Integer.MAX_VALUE); + return Rs2DoorClassifier.isRouteDoorObject(object instanceof net.runelite.api.WallObject, + comp.getName(), action); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index 52488462ead..8796b768a6b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java @@ -123,7 +123,13 @@ public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, DoorAttempt return false; } ObjectComposition comp = resolveDoorComposition(ctx, object); - return Rs2DoorClassifier.isDoorComposition(comp, doorActions); + if (!Rs2DoorClassifier.isDoorComposition(comp, doorActions)) { + return false; + } + // The decide table's classification rule (D3 requirement #3): an Open-actioned GameObject + // with a non-door name is scenery, not a route door — see Rs2DoorClassifier.isRouteDoorObject. + return Rs2DoorClassifier.isRouteDoorObject(object instanceof WallObject, comp.getName(), + Rs2DoorClassifier.getDoorAction(comp, doorActions)); } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java index aa0fd18511c..3ae9ab46adc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/state/WalkerRouteState.java @@ -119,26 +119,13 @@ public void clearRecentTransportContext() { /** Cooldown so partial-segment in-transit path recalculation does not spam. */ public volatile long lastPartialTransRecalcMs = 0L; - // ---- door interaction: settle windows, focused-door raw-scan state, attempt tracking and - // cooldowns shared by the door cascade, the recovery block and the movement-ownership check. ---- - - /** Path index of the door the raw scene scan is currently focused on; null when none. */ - public volatile Integer rawScanFocusedDoorIdx = null; - /** Wall-clock ms the focused door was selected. */ - public volatile long rawScanFocusedDoorSetAtMs = 0L; - /** Interaction attempts spent on the focused door so far. */ - public volatile int rawScanFocusedDoorAttempts = 0; - /** Door settle window ceiling; 0 when no settle is pending. */ - public volatile long doorInteractionSettleUntilMs = 0L; - /** When the current door settle window started, and the door's far-side tile — the early-exit signal. */ - public volatile long doorInteractionSettleStartedAtMs = 0L; - public volatile WorldPoint doorSettleFarSideWp = null; + // ---- door interaction (D3 slice 4: settle window, raw-scan focus, pass budget and the global + // cooldown migrated to DoorAttemptLedger; the diagnostics timestamps below remain). ---- + /** Wall-clock ms a door-edge pass was last skipped (per-edge cooldown diagnostics). */ public volatile long lastDoorEdgePassSkipAtMs = 0L; /** Cooldown for the expensive path-adjacent door scan on unreachable tiles. */ public volatile long lastDoorPathAdjAttemptAtMs = 0L; - /** Global door-interaction throttle: no door interaction may fire before this wall-clock ms. */ - public volatile long nextDoorInteractionAllowedAtMs = 0L; /** * When the walker first held off a door interaction because an option menu was open; 0 when no * such hold-off is active. Bounds the wait so an unanswered conversation cannot stall the walk. diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index 91bae166134..dce17027686 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 @@ -51,62 +51,62 @@ public class Rs2WalkerUnitTest { @Test public void teleportItemLeafActionSupportsNestedUpstreamLabels() { assertEquals("rimmington", - Rs2Walker.teleportItemLeafAction("Max cape: POH Portals: Rimmington")); + Rs2WalkerTransports.teleportItemLeafAction("Max cape: POH Portals: Rimmington")); assertEquals("fishing guild", - Rs2Walker.teleportItemLeafAction("Max cape: Fishing Teleports: Fishing Guild")); + Rs2WalkerTransports.teleportItemLeafAction("Max cape: Fishing Teleports: Fishing Guild")); assertEquals("teleport", - Rs2Walker.teleportItemLeafAction("Quest point cape: Teleport")); - assertEquals("chronicle", Rs2Walker.teleportItemLeafAction("Chronicle")); - assertEquals("", Rs2Walker.teleportItemLeafAction(null)); + Rs2WalkerTransports.teleportItemLeafAction("Quest point cape: Teleport")); + assertEquals("chronicle", Rs2WalkerTransports.teleportItemLeafAction("Chronicle")); + assertEquals("", Rs2WalkerTransports.teleportItemLeafAction(null)); } @Test public void teleportWildernessLimitIsInclusiveWithoutOffByOne() { - assertTrue(Rs2Walker.isTeleportAllowedAtWildernessLevel(20, 20)); - assertFalse(Rs2Walker.isTeleportAllowedAtWildernessLevel(21, 20)); + assertTrue(Rs2WalkerTransports.isTeleportAllowedAtWildernessLevel(20, 20)); + assertFalse(Rs2WalkerTransports.isTeleportAllowedAtWildernessLevel(21, 20)); } @Test public void quetzalDestinationLabelsUseCurrentLandingAndMapText() { assertEquals("Quetzacalli Gorge", - Rs2Walker.quetzalMapLabelForDestination(new WorldPoint(1510, 3222, 0))); + Rs2WalkerTransports.quetzalMapLabelForDestination(new WorldPoint(1510, 3222, 0))); assertEquals("Cam Torum", - Rs2Walker.quetzalMapLabelForDestination(new WorldPoint(1446, 3108, 0))); + Rs2WalkerTransports.quetzalMapLabelForDestination(new WorldPoint(1446, 3108, 0))); } @Test public void terminalTravelTransport_onlyMatchesShipNpcAndBoat() { - assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.SHIP)); - assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.NPC)); - assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.BOAT)); + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.SHIP)); + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.NPC)); + assertTrue(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.BOAT)); - assertFalse(Rs2Walker.isTerminalTravelTransport(TransportType.CHARTER_SHIP)); - assertFalse(Rs2Walker.isTerminalTravelTransport(TransportType.TRANSPORT)); - assertFalse(Rs2Walker.isTerminalTravelTransport(null)); + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.CHARTER_SHIP)); + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(TransportType.TRANSPORT)); + assertFalse(Rs2WalkerTransports.isTerminalTravelTransport(null)); } @Test public void terminalNpcInteractionCandidates_onlyFallbackForLegacyShipLabels() { assertEquals(Arrays.asList("Musa Point", "Travel"), - Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Musa Point")); + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Musa Point")); assertEquals(Collections.singletonList("Travel"), - Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Travel")); + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Travel")); assertEquals(Collections.singletonList("Talk-to"), - Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Talk-to")); + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.SHIP, "Talk-to")); assertEquals(Collections.singletonList("Follow"), - Rs2Walker.terminalNpcInteractionCandidates(TransportType.NPC, "Follow")); - assertTrue(Rs2Walker.terminalNpcInteractionCandidates(TransportType.NPC, null).isEmpty()); + Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.NPC, "Follow")); + assertTrue(Rs2WalkerTransports.terminalNpcInteractionCandidates(TransportType.NPC, null).isEmpty()); } @Test public void terminalTravelAttempt_isOncePerExactEdgeUntilWalkStateReset() { Transport ship = portSarimToMusaShip(); - assertTrue(Rs2Walker.markTerminalTravelAttempt(ship)); - assertFalse(Rs2Walker.markTerminalTravelAttempt(ship)); + assertTrue(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); + assertFalse(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); Rs2Walker.clearWalkerDedupeForTesting(); - assertTrue(Rs2Walker.markTerminalTravelAttempt(ship)); + assertTrue(Rs2WalkerTransports.markTerminalTravelAttempt(ship)); } @Test @@ -118,12 +118,12 @@ public void terminalTravelLanding_acceptsExactOrImmediateContinuationOnly() { ship.getDestination(), modernGroundLanding); - assertTrue(Rs2Walker.hasReachedTerminalTravelLanding( + assertTrue(Rs2WalkerTransports.hasReachedTerminalTravelLanding( ship, modernPath, 1, ship.getDestination())); - assertTrue(Rs2Walker.hasReachedTerminalTravelLanding( + assertTrue(Rs2WalkerTransports.hasReachedTerminalTravelLanding( ship, modernPath, 1, modernGroundLanding)); assertFalse("standing at the origin is not a completed trip", - Rs2Walker.hasReachedTerminalTravelLanding(ship, modernPath, 1, ship.getOrigin())); + Rs2WalkerTransports.hasReachedTerminalTravelLanding(ship, modernPath, 1, ship.getOrigin())); List loopingPath = Arrays.asList( ship.getOrigin(), @@ -131,8 +131,8 @@ public void terminalTravelLanding_acceptsExactOrImmediateContinuationOnly() { new WorldPoint(2957, 3143, 1), modernGroundLanding); assertFalse("an arbitrary later path point must not prove terminal arrival", - Rs2Walker.hasReachedTerminalTravelLanding(ship, loopingPath, 1, modernGroundLanding)); - assertFalse(Rs2Walker.hasReachedTerminalTravelLanding( + Rs2WalkerTransports.hasReachedTerminalTravelLanding(ship, loopingPath, 1, modernGroundLanding)); + assertFalse(Rs2WalkerTransports.hasReachedTerminalTravelLanding( ship, modernPath, 1, new WorldPoint(3200, 3200, 0))); } @@ -162,28 +162,28 @@ public void terminalTravelObjectCandidate_matchesConfiguredSemanticTargetNearOri 41311, 8); - assertTrue(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + assertTrue(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ferry, ferry.getOrigin(), "Ferry", new String[]{"Board"})); assertTrue("nearby multi-tile object anchors remain eligible", - Rs2Walker.isTerminalTravelObjectCompositionCandidate( + Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ferry, new WorldPoint(3273, 3144, 0), "Ferry", new String[]{"Board"})); - assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ferry, ferry.getOrigin(), "Boat", new String[]{"Board"})); - assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ferry, ferry.getOrigin(), "Ferry", new String[]{"Travel"})); - assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ferry, new WorldPoint(3275, 3144, 0), "Ferry", new String[]{"Board"})); Transport ordinaryObject = new Transport( ferry.getOrigin(), ferry.getDestination(), "", TransportType.TRANSPORT, true, "Board", "Ferry", 41311, 8); - assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + assertFalse(Rs2WalkerTransports.isTerminalTravelObjectCompositionCandidate( ordinaryObject, ferry.getOrigin(), "Ferry", new String[]{"Board"})); } @@ -200,13 +200,13 @@ public void alKharidTollLanding_requiresExactSelectedDestination() { net.runelite.api.ObjectID.CITY_GATE_2786, 2); - assertTrue(Rs2Walker.hasReachedAlKharidTollDestination( + assertTrue(Rs2WalkerTransports.hasReachedAlKharidTollDestination( eastbound, eastbound.getDestination())); assertFalse("the adjacent origin must never count as a crossing", - Rs2Walker.hasReachedAlKharidTollDestination(eastbound, eastbound.getOrigin())); - assertFalse(Rs2Walker.hasReachedAlKharidTollDestination( + Rs2WalkerTransports.hasReachedAlKharidTollDestination(eastbound, eastbound.getOrigin())); + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination( eastbound, new WorldPoint(3268, 3228, 0))); - assertFalse(Rs2Walker.hasReachedAlKharidTollDestination(eastbound, null)); + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination(eastbound, null)); } @Test @@ -221,7 +221,7 @@ public void alKharidTollLanding_rejectsUnrelatedTransport() { "Door", 136); - assertFalse(Rs2Walker.hasReachedAlKharidTollDestination( + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination( door, door.getDestination())); } @@ -245,30 +245,30 @@ public void alKharidTollSegment_matchesOnlyCrossGateEdges() { public void alKharidTollObjectCandidate_requiresGateActionAndSelectedEdgeLocation() { Transport payToll = alKharidGateTransport("Pay-toll(10gp)"); - assertTrue(Rs2Walker.isAlKharidTollGateCompositionCandidate( + assertTrue(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( payToll, new WorldPoint(3268, 3227, 0), "Gate", new String[]{"Open", "Pay-toll(10gp)"})); assertFalse("a stale id collision must not make an unrelated object eligible", - Rs2Walker.isAlKharidTollGateCompositionCandidate( + Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( payToll, new WorldPoint(3268, 3227, 0), "Lever", new String[]{"Pay-toll(10gp)"})); - assertFalse(Rs2Walker.isAlKharidTollGateCompositionCandidate( + assertFalse(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( payToll, new WorldPoint(3268, 3227, 0), "Gate", new String[]{"Open"})); - assertFalse(Rs2Walker.isAlKharidTollGateCompositionCandidate( + assertFalse(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( payToll, new WorldPoint(3269, 3227, 0), "Gate", new String[]{"Pay-toll(10gp)"})); Transport open = alKharidGateTransport("Open"); - assertTrue(Rs2Walker.isAlKharidTollGateCompositionCandidate( + assertTrue(Rs2WalkerTransports.isAlKharidTollGateCompositionCandidate( open, new WorldPoint(3267, 3228, 0), "City gate", @@ -290,15 +290,15 @@ private static Transport alKharidGateTransport(String action) { @Test public void canoeStationsSelectTheirOwnMapInterfaceAndUnknownIdsFailClosed() { - assertEquals(InterfaceID.CanoeMapLum.MAIN_MAP, Rs2Walker.canoeMapMainComponentId(12163)); + assertEquals(InterfaceID.CanoeMapLum.MAIN_MAP, Rs2WalkerTransports.canoeMapMainComponentId(12163)); assertEquals(InterfaceID.CanoeMapLum.DESTINATIONS, - Rs2Walker.canoeMapDestinationsComponentId(39638)); + Rs2WalkerTransports.canoeMapDestinationsComponentId(39638)); assertEquals(InterfaceID.CanoeMapDougne.MAIN_MAP, - Rs2Walker.canoeMapMainComponentId(60845)); + Rs2WalkerTransports.canoeMapMainComponentId(60845)); assertEquals(InterfaceID.CanoeMapDougne.DESTINATIONS, - Rs2Walker.canoeMapDestinationsComponentId(60849)); - assertEquals(-1, Rs2Walker.canoeMapMainComponentId(99999)); - assertEquals(-1, Rs2Walker.canoeMapDestinationsComponentId(99999)); + Rs2WalkerTransports.canoeMapDestinationsComponentId(60849)); + assertEquals(-1, Rs2WalkerTransports.canoeMapMainComponentId(99999)); + assertEquals(-1, Rs2WalkerTransports.canoeMapDestinationsComponentId(99999)); } @Test @@ -383,7 +383,7 @@ public void adjacentTransportSuppression_onlyAdjacentSamePlaneTransports() { assertEquals(new HashSet<>(Arrays.asList( new WorldPoint(3123, 3360, 0), new WorldPoint(3123, 3361, 0))), - Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(door, null)); + Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(door, null)); } /** @@ -410,7 +410,7 @@ public void adjacentTransportSuppression_coversAgilityShortcuts() { new HashSet<>(Arrays.asList( new WorldPoint(3151, 3363, 0), new WorldPoint(3150, 3363, 0))), - Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(shortcut, null)); + Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(shortcut, null)); } @Test @@ -425,7 +425,7 @@ public void adjacentTransportSuppression_ignoresNonAdjacentTransports() { "Ladder", 133); - assertTrue(Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); + assertTrue(Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); } @Test @@ -438,7 +438,7 @@ public void shouldRecalculatePathAfterTransport_includesOriginlessTeleport() { 20, Collections.emptyMap()); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockTeleport)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockTeleport)); } @Test @@ -513,7 +513,7 @@ public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport( "Door", 136); - assertFalse(Rs2Walker.shouldRecalculatePathAfterTransport(door)); + assertFalse(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(door)); } @Test @@ -528,7 +528,7 @@ public void isSettledNearAdjacentSamePlaneLanding_acceptsNearDestinationOffOrigi "Door", 136); - assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertTrue(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3154, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -547,7 +547,7 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsOriginTile() { "Door", 136); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3152, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -566,7 +566,7 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsTilesTooFarFromDestinat "Door", 136); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( door, new WorldPoint(3155, 3363, 0), new WorldPoint(3153, 3363, 0), @@ -585,7 +585,7 @@ public void isSettledNearAdjacentSamePlaneLanding_acceptsBoundedForwardAgilityOv "Stepping stone", 16533); - assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertTrue(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3149, 3363, 0), steppingStone.getDestination(), @@ -604,17 +604,17 @@ public void isSettledNearAdjacentSamePlaneLanding_rejectsReverseOrUnboundedAgili "Stepping stone", 16533); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3155, 3363, 0), steppingStone.getDestination(), 0)); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3147, 3363, 0), steppingStone.getDestination(), 0)); - assertFalse(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertFalse(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3149, 3365, 0), steppingStone.getDestination(), @@ -633,7 +633,7 @@ public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() "Gangplank", 2082); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(ship)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(ship)); } @Test @@ -648,7 +648,7 @@ public void shouldRecalculatePathAfterTransport_includesSamePlaneCoordinateBandT "Ladder", 11806); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockSewerLadder)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockSewerLadder)); } @Test @@ -2102,49 +2102,6 @@ public void shouldBlacklistDoorAfterWrongTraversal_planeChangeTrustedEvenWhileMo true)); } - @Test - public void markDoorEdgeAttemptThisPass_allowsFirstAttemptOnly() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] segment = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; - - WorldPoint playerPos = new WorldPoint(2465, 3494, 0); - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); - assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); - } - - @Test - public void markDoorEdgeAttemptThisPass_treatsReverseEdgeAsDuplicate() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] forward = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; - WorldPoint[] reverse = new WorldPoint[] { - new WorldPoint(2465, 3493, 0), - new WorldPoint(2465, 3494, 0) - }; - - WorldPoint playerPos = new WorldPoint(2465, 3494, 0); - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, forward, playerPos)); - assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, reverse, playerPos)); - } - - @Test - public void markDoorEdgeAttemptThisPass_allowsRetryAfterPlayerProgress() { - java.util.Map attempted = new java.util.HashMap<>(); - WorldPoint[] segment = new WorldPoint[] { - new WorldPoint(2465, 3494, 0), - new WorldPoint(2465, 3493, 0) - }; - - assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2465, 3494, 0))); - assertTrue("retry should be allowed after moving away from same-edge attempt tile", - Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2462, 3491, 0))); - } - // --------------------------------------------------------------------------- // #19 — Quest-lock dialogue heuristic // --------------------------------------------------------------------------- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java index f7fdabf5e59..d430cd3d13e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java @@ -276,6 +276,83 @@ public void walkScopedBlocksDrainOnceAndInOrder() ledger.drainWalkScopedBlocks().isEmpty()); } + // ---- the pass budget (formerly processWalk's doorEdgesAttemptedThisTail map) ---- + + @Test + public void anEdgeIsClaimableOncePerPassFromTheSameStand() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + @Test + public void theReverseEdgeIsTheSameClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(toWp, fromWp, stand)); + } + + @Test + public void movingReArmsTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2465, 3494, 0))); + assertTrue("retry should be allowed after moving away from same-edge attempt tile", + ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2462, 3491, 0))); + } + + @Test + public void aNewPassAndAReleaseEachReArmTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand); + ledger.releaseEdgeThisPass(fromWp, toWp); + assertTrue("a released claim (no interaction happened) must be attemptable this pass", + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + ledger.beginTailPass(); + assertTrue("a new pass owes a fresh budget", ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + // ---- the settle window, global cooldown and raw-scan focus (walk-runtime facets) ---- + + @Test + public void theSettleWindowStoresAndEndsEarly() + { + WorldPoint farSide = new WorldPoint(1876, 5239, 0); + ledger.markSettling(farSide, T0, 900); + assertEquals(T0, ledger.settleStartedAtMs()); + assertEquals(T0 + 900, ledger.settleUntilMs()); + assertEquals(farSide, ledger.settleFarSide()); + ledger.endSettleEarly(); + assertEquals("early end clears the ceiling, not the start (heartbeat still reads it)", + 0L, ledger.settleUntilMs()); + assertNull(ledger.settleFarSide()); + assertEquals(T0, ledger.settleStartedAtMs()); + } + + @Test + public void theRawScanFocusIsABoundedCommitment() + { + ledger.setRawScanFocus(7, T0); + assertEquals(Integer.valueOf(7), ledger.rawScanFocusDoorIdx()); + assertEquals(T0, ledger.rawScanFocusSetAtMs()); + ledger.recordRawScanFocusAttempt(); + ledger.recordRawScanFocusAttempt(); + assertEquals(2, ledger.rawScanFocusAttempts()); + ledger.clearRawScanFocus(); + assertNull(ledger.rawScanFocusDoorIdx()); + assertEquals(0, ledger.rawScanFocusAttempts()); + } + @Test public void withdrawingTheClaimLeavesTheCooldownStanding() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java index 6c85efd9c44..7fe2cb9861e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java @@ -134,4 +134,44 @@ public void getDoorActionReturnsHighestPriorityConfiguredMatch() { assertNull(Rs2DoorClassifier.getDoorAction(compWithActions("Examine", "Look-at"), doorActions)); assertNull(Rs2DoorClassifier.getDoorAction(null, doorActions)); } + + // ---- route-door classification (D3 requirement #3 — the Gift of Peace lesson) ---- + // + // An Open-actioned GameObject with a non-door name is scenery. The Stronghold's goal chest was + // Open-clicked as a route door en route (2026-08-13, 7-9s of failed traversal per encounter); + // the rule is name-or-traversal-verb because large double gates ARE GameObjects, so a flat name + // filter (the old segment-probe contains("door")) missed real doors while the flat action rule + // (the old segment-door site) admitted chests. + + @Test + public void anOpenActionedChestIsNotARouteDoor() { + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Gift of Peace", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Sarcophagus", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Cupboard", "Open")); + } + + @Test + public void aGameObjectGateIsARouteDoorByName() { + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Gate of War", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Temple door", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Curtain", "Open")); + } + + @Test + public void aTraversalVerbProvesDoorhoodWhateverTheName() { + // Field entrances, tollgates and the like carry inherently-traversal verbs. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Wheat", "Walk-through")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Ornate railing", "Pay-toll")); + assertFalse("Enter is scenery-shared, not traversal-proof", + Rs2DoorClassifier.isRouteDoorObject(false, "Cave entrance", "Enter")); + } + + @Test + public void aWallThatOpensIsADoorWhateverItsName() { + // Unchanged wall semantics: quest walls with odd names still open. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Oozing barrier", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Strange wall", "Push")); + assertFalse("an actionless, namelessly-non-door wall is still nothing", + Rs2DoorClassifier.isRouteDoorObject(true, "Wall", null)); + } } diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index cc564bb2e52..9f26b0ff49a 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 @@ -737,19 +737,10 @@ net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapDrawWidget net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#adjacentSamePlaneTransportSuppressionPoints(Transport, TileObject): Set -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#closeWorldMap(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#findClickableCharterWidget(Widget, Widget): Widget -> net.runelite.api.widgets.Widget#getParent(): Widget -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getFirstWidgetAction(Widget): String -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint, WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint, WorldPoint): WorldPoint -> net.runelite.api.CollisionData#getFlags(): int[][] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint, WorldPoint): WorldPoint -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] @@ -761,44 +752,19 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCanoe(Transport): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleFairyRing(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMasterScrollBook(String): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObject(Transport, TileObject, String): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleStrongholdOfSecurityAnswer(TileObject, String): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasWidgetActions(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getIndex(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isClientThread(): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int @@ -810,35 +776,12 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$191(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$194(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$196(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$218(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$185(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$187(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$160(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$160(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$161(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$161(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$124(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$127(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$128(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$129(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$131(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$131(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$132(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$168(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$169(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$40(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$sceneDoorAdjacentToEdge$23(WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#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$resolveProbeGameObject$35(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$36(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$sceneDoorAdjacentToEdge$19(WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint @@ -858,8 +801,8 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveProbeGameObjec net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint, String): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Player#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean, List): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -889,6 +832,63 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTranspo net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTransportsAndStateLocked(WorldPoint, int, boolean): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#adjacentSamePlaneTransportSuppressionPoints(Transport, TileObject): Set -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#charterWidgetMatchesDestination(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findCharterDestinationTextWidget(Widget, String): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#findClickableCharterWidget(Widget, Widget): Widget -> net.runelite.api.widgets.Widget#getParent(): Widget +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#getFirstWidgetAction(Widget): String -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleCanoe(Transport): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleFairyRing(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMasterScrollBook(String): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleMinigameTeleport(Transport): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObject(Transport, TileObject, String): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#hasWidgetActions(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#invokeCharterDestinationWidget(Widget, String): boolean -> net.runelite.api.widgets.Widget#getIndex(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$107(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$110(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleCanoe$112(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleFairyRing$134(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleMinigameTeleport$101(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleMinigameTeleport$103(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$72(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$78(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$78(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$79(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleObjectExceptions$79(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$42(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$44(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$44(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$46(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$47(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$48(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$49(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$51(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$51(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleSelectedTransport$52(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleWildernessObelisk$86(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2WalkerTransports#lambda$handleWildernessObelisk$87(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#doorCompositionSpecifiesOnlyCloseOrShut(ObjectComposition): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#getDoorAction(ObjectComposition, List): String -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] @@ -901,6 +901,7 @@ net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorInte net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorOnSegment(TileObject, WorldPoint, WorldPoint): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isCatalogTransportObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#isDoorCandidateOnSegment(DoorProbeContext, DoorAttemptLedger, TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, DoorAttemptLedger, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$5(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.Client#getTopLevelWorldView(): WorldView