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..5fe79d4dc49 --- /dev/null +++ b/docs/entity-guides/death.md @@ -0,0 +1,445 @@ +# 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 inspect before committing — walking there is free, only the reclaim costs +if (Rs2Death.walkToDeathsOffice() && Rs2Death.enterDeathsOffice() && Rs2Death.openDeathsOffice()) { + 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 +} +``` + +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. Death's Office publishes nothing equivalent, and the API deliberately does not +estimate one — see rule 11. + +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 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. 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: + +| 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. + +**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. + +What to use instead: + +- `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. + +**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 + +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/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 new file mode 100644 index 00000000000..812de8e9cbf --- /dev/null +++ b/docs/walker-fix-plan-2026-08-10.md @@ -0,0 +1,622 @@ +# 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. + +> **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 +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/gradle.properties b/gradle.properties index 1c9ca12800c..07790e38c48 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,11 +28,11 @@ org.gradle.parallel=true org.gradle.caching=false project.build.group=net.runelite -project.build.version=1.12.34.1 -runelite.injected-client.version=1.12.34.1 +project.build.version=1.12.35 +runelite.injected-client.version=1.12.35 glslang.path= -microbot.version=2.6.18 +microbot.version=2.6.19 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username= diff --git a/runelite-api/src/main/java/net/runelite/api/ItemID.java b/runelite-api/src/main/java/net/runelite/api/ItemID.java index 8c4d0f2cdb3..834b9eb2d94 100644 --- a/runelite-api/src/main/java/net/runelite/api/ItemID.java +++ b/runelite-api/src/main/java/net/runelite/api/ItemID.java @@ -16615,5 +16615,6 @@ public final class ItemID public static final int DULL_SUNSTONE_CORE = 34056; public static final int FINAL_LETTER = 34057; public static final int WYRMSCRAIG = 34058; + public static final int FAIRY_TALE_QUEST_LAMP = 34059; /* This file is automatically generated. Do not edit. */ } \ No newline at end of file diff --git a/runelite-api/src/main/java/net/runelite/api/NpcID.java b/runelite-api/src/main/java/net/runelite/api/NpcID.java index a8e83ad23af..9f0cf41482c 100644 --- a/runelite-api/src/main/java/net/runelite/api/NpcID.java +++ b/runelite-api/src/main/java/net/runelite/api/NpcID.java @@ -13579,5 +13579,11 @@ public final class NpcID public static final int FISHING_SPOT_16335 = 16335; public static final int FISHING_SPOT_16336 = 16336; public static final int FISHING_SPOT_16337 = 16337; + public static final int FISHING_SPOT_16338 = 16338; + public static final int FISHING_SPOT_16339 = 16339; + public static final int FISHING_SPOT_16340 = 16340; + public static final int FISHING_SPOT_16341 = 16341; + public static final int FISHING_SPOT_16342 = 16342; + public static final int FISHING_SPOT_16343 = 16343; /* This file is automatically generated. Do not edit. */ } diff --git a/runelite-api/src/main/java/net/runelite/api/NullItemID.java b/runelite-api/src/main/java/net/runelite/api/NullItemID.java index c68439f7f7a..b714f4d06f9 100644 --- a/runelite-api/src/main/java/net/runelite/api/NullItemID.java +++ b/runelite-api/src/main/java/net/runelite/api/NullItemID.java @@ -17129,5 +17129,6 @@ public final class NullItemID public static final int NULL_34041 = 34041; public static final int NULL_34043 = 34043; public static final int NULL_34047 = 34047; + public static final int NULL_34060 = 34060; /* This file is automatically generated. Do not edit. */ } \ No newline at end of file diff --git a/runelite-api/src/main/java/net/runelite/api/ObjectID.java b/runelite-api/src/main/java/net/runelite/api/ObjectID.java index c93d19455e3..79210586a14 100644 --- a/runelite-api/src/main/java/net/runelite/api/ObjectID.java +++ b/runelite-api/src/main/java/net/runelite/api/ObjectID.java @@ -30094,5 +30094,6 @@ public final class ObjectID public static final int SUNSTONE_ROCKS_62394 = 62394; public static final int BUOY_62395 = 62395; public static final int BUOY_62396 = 62396; + public static final int BROKEN_WALL_62400 = 62400; /* This file is automatically generated. Do not edit. */ } diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/DBTableID.java b/runelite-api/src/main/java/net/runelite/api/gameval/DBTableID.java index bc1438875d3..753cc2bc6b0 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/DBTableID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/DBTableID.java @@ -7020,6 +7020,7 @@ public static final class Row public static final int HISCORES_BOSSES_YAMA = 5130; public static final int HISCORES_BOSSES_DOOM_OF_MOKHAIOTL = 5484; public static final int HISCORES_BOSSES_MAD_ANGEL = 7208; + public static final int HISCORES_BOSSES_MAGGOT_KING = 7209; public static final int HISCORES_BOSSES_GRYPHON_BOSS = 9447; public static final int HISCORES_BOSSES_COWBOSS = 9655; } diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/InterfaceID.java b/runelite-api/src/main/java/net/runelite/api/gameval/InterfaceID.java index be023d01dbb..72773266080 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/InterfaceID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/InterfaceID.java @@ -27722,47 +27722,51 @@ public static final class OmnishopMain { public static final int INFINITY = 0x0333_0000; public static final int UNIVERSE = 0x0333_0001; - public static final int DROPDOWN_CONTAINER = 0x0333_0002; - public static final int FRAME = 0x0333_0003; - public static final int CONTENT = 0x0333_0004; - public static final int R_COL = 0x0333_0005; - public static final int CONTROL_LAYER = 0x0333_0006; - public static final int VIEW_TOGGLE_LAYER = 0x0333_0007; - public static final int HELP_BUTTON_LAYER = 0x0333_0008; - public static final int FILTER_LAYER = 0x0333_0009; - public static final int POINTS_LAYER_1 = 0x0333_000a; - public static final int LIST_LAYER = 0x0333_000b; - public static final int LIST_LAYER_RECT0 = 0x0333_000c; - public static final int POINTS_LAYER_RECT0 = 0x0333_000d; - public static final int R_COL_BACK = 0x0333_000e; - public static final int R_COL_CONTENT = 0x0333_000f; - public static final int R_COL_BORDER = 0x0333_0010; - public static final int INFO_LAYER = 0x0333_0011; - public static final int POINTS_LAYER = 0x0333_0012; - public static final int BUTTONS_INFO = 0x0333_0013; - public static final int BUTTON_INFO_HOLDER = 0x0333_0014; - public static final int NOTE_BUTTON_LAYER = 0x0333_0015; - public static final int BUTTON_1 = 0x0333_0016; - public static final int BUTTON_2 = 0x0333_0017; - public static final int BUTTON_3 = 0x0333_0018; - public static final int BUTTON_4 = 0x0333_0019; - public static final int INFO_LAYER_RECT0 = 0x0333_001a; - public static final int POINTS_BORDER = 0x0333_001b; - public static final int POINTS_TITLE = 0x0333_001c; - public static final int POINTS_VALUE = 0x0333_001d; - public static final int INFO_BORDER = 0x0333_001e; - public static final int INFO = 0x0333_001f; - public static final int INFO_SCROLLER = 0x0333_0020; - public static final int LIST_BORDER = 0x0333_0021; - public static final int LIST = 0x0333_0022; - public static final int LIST_SCROLLER = 0x0333_0023; - public static final int DROPDOWN = 0x0333_0024; - public static final int DROPDOWN_CONTENT = 0x0333_0025; - public static final int DROPDOWN_SCROLLER = 0x0333_0026; - public static final int POINTS_LAYER_1_RECT0 = 0x0333_0027; - public static final int POINTS_BORDER_1 = 0x0333_0028; - public static final int POINTS_TITLE_1 = 0x0333_0029; - public static final int POINTS_VALUE_1 = 0x0333_002a; + public static final int TRIGGERS = 0x0333_0002; + public static final int TRIGGER_EXAMINE = 0x0333_0003; + public static final int TRIGGER_BUY = 0x0333_0004; + public static final int TRIGGER_REQUEST_INFO = 0x0333_0005; + public static final int DROPDOWN_CONTAINER = 0x0333_0006; + public static final int FRAME = 0x0333_0007; + public static final int CONTENT = 0x0333_0008; + public static final int R_COL = 0x0333_0009; + public static final int CONTROL_LAYER = 0x0333_000a; + public static final int VIEW_TOGGLE_LAYER = 0x0333_000b; + public static final int HELP_BUTTON_LAYER = 0x0333_000c; + public static final int FILTER_LAYER = 0x0333_000d; + public static final int POINTS_LAYER_1 = 0x0333_000e; + public static final int LIST_LAYER = 0x0333_000f; + public static final int LIST_LAYER_RECT0 = 0x0333_0010; + public static final int POINTS_LAYER_RECT0 = 0x0333_0011; + public static final int R_COL_BACK = 0x0333_0012; + public static final int R_COL_CONTENT = 0x0333_0013; + public static final int R_COL_BORDER = 0x0333_0014; + public static final int INFO_LAYER = 0x0333_0015; + public static final int POINTS_LAYER = 0x0333_0016; + public static final int BUTTONS_INFO = 0x0333_0017; + public static final int BUTTON_INFO_HOLDER = 0x0333_0018; + public static final int NOTE_BUTTON_LAYER = 0x0333_0019; + public static final int BUTTON_1 = 0x0333_001a; + public static final int BUTTON_2 = 0x0333_001b; + public static final int BUTTON_3 = 0x0333_001c; + public static final int BUTTON_4 = 0x0333_001d; + public static final int INFO_LAYER_RECT0 = 0x0333_001e; + public static final int POINTS_BORDER = 0x0333_001f; + public static final int POINTS_TITLE = 0x0333_0020; + public static final int POINTS_VALUE = 0x0333_0021; + public static final int INFO_BORDER = 0x0333_0022; + public static final int INFO = 0x0333_0023; + public static final int INFO_SCROLLER = 0x0333_0024; + public static final int LIST_BORDER = 0x0333_0025; + public static final int LIST = 0x0333_0026; + public static final int LIST_SCROLLER = 0x0333_0027; + public static final int DROPDOWN = 0x0333_0028; + public static final int DROPDOWN_CONTENT = 0x0333_0029; + public static final int DROPDOWN_SCROLLER = 0x0333_002a; + public static final int POINTS_LAYER_1_RECT0 = 0x0333_002b; + public static final int POINTS_BORDER_1 = 0x0333_002c; + public static final int POINTS_TITLE_1 = 0x0333_002d; + public static final int POINTS_VALUE_1 = 0x0333_002e; } public static final class WorldswitcherFilter diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/ItemID.java b/runelite-api/src/main/java/net/runelite/api/gameval/ItemID.java index 1f5af9dae0c..42e89bf4d73 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/ItemID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/ItemID.java @@ -89146,6 +89146,11 @@ public final class ItemID */ public static final int SAILING_SKILLGUIDE_PORTS_WYRMSCRAIG = 34058; + /** + * Fairy tale quest lamp + */ + public static final int DEADMAN_QUEST_LAMP_TIER_11 = 34059; + public static final class Cert { public static final int TWPART1 = 7; @@ -93851,6 +93856,7 @@ public static final class Cert public static final int GOAT_PIT_FUR = 34018; public static final int HALLOWFELL = 34028; public static final int MAD_ANGEL_SWORD = 34036; + public static final int BH_EMBLEM_5 = 34060; } public static final class Placeholder diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/NpcID.java b/runelite-api/src/main/java/net/runelite/api/gameval/NpcID.java index 60d4786866a..03c9f37747e 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/NpcID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/NpcID.java @@ -70718,5 +70718,35 @@ public final class NpcID * Fishing spot */ public static final int _0_40_134_LAVAFISH = 16337; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_SALTFISH = 16338; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_MEMBERFISH = 16339; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_RAREFISH = 16340; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_KARAMBWANFISH = 16341; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_PISCARILIUSFISH = 16342; + + /** + * Fishing spot + */ + public static final int FISHING_BOAT_MONKFISH = 16343; /* This file is automatically generated. Do not edit. */ } diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/ObjectID1.java b/runelite-api/src/main/java/net/runelite/api/gameval/ObjectID1.java index eb8a62f6052..f2124d81a54 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/ObjectID1.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/ObjectID1.java @@ -83180,5 +83180,10 @@ public class ObjectID1 public static final int SAILING_GANGPLANK_WYRMSCRAIG = 62397; public static final int SAILING_GANGPLANK_WYRMSCRAIG_CAVE = 62398; public static final int HAVEN_TOWER_BASEMENT_WALL_STONE_CREVICE = 62399; + + /** + * Broken wall + */ + public static final int DEADMAN_POH_GAP_WHERE_WINDOW_IS_NOT = 62400; /* This file is automatically generated. Do not edit. */ } diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/SpriteID.java b/runelite-api/src/main/java/net/runelite/api/gameval/SpriteID.java index 629d3fce512..41c06e571e9 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/SpriteID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/SpriteID.java @@ -862,6 +862,7 @@ public final class SpriteID public static final int LEAFYTREE_TILED_AUTUMN01 = 8557; public static final int LEAFYTREE_AUTUMN02 = 8558; public static final int LEAFYTREE_TILED_AUTUMN02 = 8559; + public static final int MINIMENU_ICONS = 8560; public static final class _2XStandardSpellsOn { diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/VarClientID.java b/runelite-api/src/main/java/net/runelite/api/gameval/VarClientID.java index 88305358d4c..cd9e4eebc09 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/VarClientID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/VarClientID.java @@ -1511,5 +1511,6 @@ public final class VarClientID public static final int MUSIC_CLIENT_SYNC_TIMER_TIME_PER_INTERVAL = 1504; public static final int CASTLE_DRAKAN_WORLD_MAP_X = 1505; public static final int CASTLE_DRAKAN_WORLD_MAP_Y = 1506; + public static final int SETTINGS_RENDERER_OPTION = 1507; /* This file is automatically generated. Do not edit. */ } diff --git a/runelite-api/src/main/java/net/runelite/api/gameval/VarbitID.java b/runelite-api/src/main/java/net/runelite/api/gameval/VarbitID.java index df4fdb878e2..2d09d762b7a 100644 --- a/runelite-api/src/main/java/net/runelite/api/gameval/VarbitID.java +++ b/runelite-api/src/main/java/net/runelite/api/gameval/VarbitID.java @@ -5945,6 +5945,7 @@ public final class VarbitID public static final int LEAGUE_TYPE = 10032; public static final int LEAGUE_TASK_FILTER_TIER = 10033; public static final int LEAGUE_TASK_FILTER_COMPLETED = 10034; + public static final int MOUSEOVER_TEXT_DISABLED = 10035; public static final int LEAGUE_MAIN_PROFILE_INTRO = 10036; public static final int LEAGUE_TUTORIAL_COMPLETED = 10037; public static final int LEAGUE_GRUBBY_CHEST_COUNTER = 10038; @@ -7085,6 +7086,7 @@ public final class VarbitID public static final int TALENT_EXTRA_RETRO_EQUIP_ECHO_WEP = 11618; public static final int TALENT_EXTRA_RETRO_EQUIP_MOONS_ITEM = 11619; public static final int ENDLESS_HARVEST_LAST_OP = 11620; + public static final int SETTINGS_RT7_WARNING_SHOWN = 11621; public static final int TALENT_FREE_RESET_2 = 11622; public static final int GRIMSTONE_UNCERTER_STATUS = 11623; public static final int SETTINGS_SAILING_WIND_ON_ORB_DISABLED = 11624; @@ -7600,7 +7602,6 @@ public final class VarbitID public static final int STAT_BOOSTS_HUD_TOOLTIPS_HIDDEN = 12374; public static final int STAT_BOOSTS_HUD_NUM_DISPLAYS = 12375; public static final int STAT_BOOSTS_HUD_DISPLAY_RELATIVE = 12376; - public static final int MOUSEOVER_TEXT_ENABLED = 12377; public static final int OPTION_HIDE_ROOFTOPS = 12378; public static final int AGILITY_HELPER_DISABLED = 12379; public static final int AGILITY_HELPER_HIGHLIGHT_OBSTACLES_ENABLED = 12380; @@ -10372,6 +10373,7 @@ public final class VarbitID public static final int SLAYER_CHOOSE_TASK_2 = 15801; public static final int SLAYER_CHOOSE_TASK_3 = 15802; public static final int SLAYER_CHOOSE_TASK_1_BOSS_ID = 15803; + public static final int DEADMAN_QUEST_LAMP_TIER_11 = 15804; public static final int XMAS24_MATCH_AMIK = 15892; public static final int XMAS24_MATCH_HAIRDRESSER = 15902; public static final int XMAS24_MATCH_SARAH = 15903; @@ -10536,7 +10538,6 @@ public final class VarbitID public static final int COLLECTION_CLUES_SCROLL_CASES_COMPLETED = 16609; public static final int GIANT_BONE_BURY_WARNING_DISABLE = 16616; public static final int SETTINGS_HD_NEW_RENDERER_TOGGLE = 16617; - public static final int SETTINGS_HD_WARNING_SHOWN = 16618; public static final int SETTINGS_SD_BETA_ENABLED = 16619; public static final int SAILING_WARNING_TELEPORTOFFBOAT = 16620; public static final int SETTINGS_WORLD_MAP_HOTKEY_DISABLED = 16621; 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/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/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/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index a86cc2b66e8..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 @@ -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()); @@ -836,7 +846,26 @@ private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay) final CollisionMap map = pathfinderConfig.getMap(); map.beginSearch(); // pin the freshly captured snapshot for this validation final int from = LiveRouteValidator.nearestIndex(path, me); - final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map); + // A door transport joins two adjacent same-plane tiles, so to the validator its step looks + // like walking — and while the door is SHUT the edge honestly reads blocked. That is its + // normal state, not an obstruction: the walker's executor opens it on contact. Recalculating + // here yanked the route out from under the walker while it stood at the door handling it. + final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map, + (a, b) -> { + // The walker's door subsystem has claimed this edge — catalog or not. Quest doors + // (fightarena_door1) are in no catalog, yet the recalc mid-interaction is just as + // wrong there. + if (Rs2Walker.isActiveDoorEdge(a, b)) { + return true; + } + for (Transport t : pathfinderConfig.getTransportsPacked() + .getOrDefault(WorldPointUtil.packWorldPoint(a), java.util.Collections.emptySet())) { + if (b.equals(t.getDestination())) { + return true; + } + } + return false; + }); if (blocked >= 0) { lastLiveRecalcMs = now; log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating", 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/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/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/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..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 @@ -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( @@ -473,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) @@ -486,8 +475,13 @@ 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(); + lastKeyLeaguesMs = System.currentTimeMillis() - keyStart; final int refreshCacheKeyHash = computeTransportRefreshCacheKeyHash(target, leaguesCtx); + long keyTime = System.currentTimeMillis() - keyStart; TransportRefreshSnapshot snap = transportRefreshSnapshots.get(refreshCacheKeyHash); if (snap != null && client != null) { @@ -738,6 +732,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() @@ -756,10 +751,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) { @@ -774,17 +772,22 @@ 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()); + 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) @@ -856,63 +859,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,43 +890,33 @@ 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(); + /** + * 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) { @@ -2139,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; @@ -2159,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/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/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; } 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..32478a32299 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java @@ -0,0 +1,65 @@ +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 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. + *

+ * 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 +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; + // 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.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 new file mode 100644 index 00000000000..53cb8728122 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -0,0 +1,953 @@ +package net.runelite.client.plugins.microbot.util.death; + +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; +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.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; +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.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; + +/** + * 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);
+ * 
+ * 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();          // or inspect first and call closeInterfaces() to decline
+ *     Rs2Death.closeInterfaces();
+ * }
+ * 
+ * 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. + *

+ * {@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: + * 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 + * 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"; + + /** 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"; + + /** 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; + + /** 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) { + 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(); + + 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)); + } + + /** + * 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; 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; + 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. + * + * 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. + 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 + + // 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); + } + } + + /** + * 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(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; + } + + /** + * 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; + + // 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--) { + 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, 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; + + @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()) { + // 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; + } + + // 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 only to inspect what Death is holding can decline and + * walk away cleanly without reclaiming. + */ + 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/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; } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 3a8a22ef202..5e9d34033ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -503,6 +503,119 @@ public static boolean isTileReachable(WorldPoint targetPoint) { return runClientReadBoolean(() -> isTileReachableInternal(targetPoint)); } + /** + * Whether a single step from {@code from} to {@code to} is currently permitted by the CLIENT's + * collision data — the live flags the server drives, so a door that has just opened clears its + * blocking flag here on the same tick. + *

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

+ * Answers {@code false} for anything it cannot decide — off-scene, an instance (raw coordinates + * make the scene conversion unreliable), or a plane other than the one loaded. Callers use it to + * release early, so an unknown must never read as "open". + * + * @return true only when the step is known to be unobstructed + */ + public static boolean isEdgePassable(WorldPoint from, WorldPoint to) { + return runClientReadBoolean(() -> isEdgePassableInternal(from, to)); + } + + /** + * Why the last {@link #isEdgePassable} call answered as it did. A bare {@code false} is ambiguous + * between "the door is shut" and "this could not be decided", and callers that release a wait on + * {@code true} behave very differently depending on which it was. + */ + private static volatile String lastEdgeDecision = "-"; + + /** @see #lastEdgeDecision */ + public static String lastEdgeDecision() { + return lastEdgeDecision; + } + + private static boolean isEdgePassableInternal(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + lastEdgeDecision = "bad-args"; + return false; + } + + final int dx = to.getX() - from.getX(); + final int dy = to.getY() - from.getY(); + if (dx == 0 && dy == 0) { + lastEdgeDecision = "same-tile"; + return true; + } + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { + lastEdgeDecision = "not-adjacent"; + return false; + } + + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); + if (wv == null) { + lastEdgeDecision = "no-worldview"; + return false; + } + if (wv.getPlane() != from.getPlane()) { + lastEdgeDecision = "plane-not-loaded"; + return false; + } + // Instance scenes repeat template chunks, so world -> scene by base offset is wrong there. + if (wv.getScene() != null && wv.getScene().isInstance()) { + lastEdgeDecision = "instance"; + return false; + } + + final int[][] flags = getFlagsInternal(); + if (flags == null) { + lastEdgeDecision = "no-flags"; + return false; + } + + final int fx = from.getX() - Microbot.getClient().getBaseX(); + final int fy = from.getY() - Microbot.getClient().getBaseY(); + final int tx = fx + dx; + final int ty = fy + dy; + if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) { + lastEdgeDecision = "off-scene"; + return false; + } + + boolean allowed = isStepAllowed(flags, fx, fy, dx, dy); + lastEdgeDecision = allowed ? "open" : "blocked"; + return allowed; + } + + /** + * The collision rule alone, with no client reads: is a single {@code (dx, dy)} step out of + * {@code (fx, fy)} unobstructed by these flags? Split out so the cardinal/diagonal rules are + * covered by a decision table rather than only by a live client. + */ + static boolean isStepAllowed(int[][] flags, int fx, int fy, int dx, int dy) { + if (dx == 0 && dy == 0) return true; + final int tx = fx + dx; + final int ty = fy + dy; + + if ((flags[tx][ty] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) != 0) return false; + + if (dx == 0 || dy == 0) { + return (flags[fx][fy] & cardinalBlockFlag(dx, dy)) == 0; + } + // Diagonal: both cardinal components must be clear, and so must the two tiles cut through — + // the same rule the reachability search uses for corners. + return (flags[fx][fy] & cardinalBlockFlag(dx, 0)) == 0 + && (flags[fx][fy] & cardinalBlockFlag(0, dy)) == 0 + && (flags[tx][fy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(0, dy))) == 0 + && (flags[fx][ty] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(dx, 0))) == 0; + } + + private static int cardinalBlockFlag(int dx, int dy) { + if (dx > 0) return CollisionDataFlag.BLOCK_MOVEMENT_EAST; + if (dx < 0) return CollisionDataFlag.BLOCK_MOVEMENT_WEST; + if (dy > 0) return CollisionDataFlag.BLOCK_MOVEMENT_NORTH; + return CollisionDataFlag.BLOCK_MOVEMENT_SOUTH; + } + private static boolean isTileReachableInternal(WorldPoint targetPoint) { if (targetPoint == null) return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/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 5a8efd7896d..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 @@ -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; @@ -65,7 +66,11 @@ import net.runelite.client.plugins.microbot.util.walker.obstacle.MineableResolver; import net.runelite.client.plugins.microbot.util.walker.obstacle.ObstacleResolution; import net.runelite.client.plugins.microbot.util.walker.obstacle.PlannedEdge; +import net.runelite.client.plugins.microbot.util.walker.recovery.FrontierDecision; import net.runelite.client.plugins.microbot.util.walker.recovery.RouteRecovery; +import net.runelite.client.plugins.microbot.util.walker.segment.SegmentGate; +import net.runelite.client.plugins.microbot.util.walker.recovery.TailDecision; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; import net.runelite.client.plugins.microbot.util.walker.state.WalkerRouteState; import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; import net.runelite.client.plugins.microbot.util.walker.door.Rs2WalkerAwaits; @@ -112,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; /** @@ -173,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; @@ -190,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; @@ -207,7 +214,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; - // UNREACHABLE_RECOVERY_FORWARD_SCAN_TILES moved into recovery/RouteRecovery (P1) + /** + * 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. *

@@ -230,7 +279,7 @@ public static WorldPoint getCurrentTarget() { 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; /** @@ -246,11 +295,11 @@ public static WorldPoint getCurrentTarget() { private static final int PATHFINDER_NULL_WAIT_MS = 6_000; private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; - private static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; + static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; private static final int PATH_VARIANCE_TOLERANCE_CHEBYSHEV = 6; private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6; private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15; - private static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L; + static final 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) /** @@ -261,23 +310,23 @@ public static WorldPoint getCurrentTarget() { * when the transport was marked handled (always true while standing at the destination) and the door * settle had no early exit at all. */ - private static final long POST_INTERACT_SETTLE_MIN_MS = 300L; + static final long POST_INTERACT_SETTLE_MIN_MS = 300L; // misc route-timer state migrated to WalkerRouteState (see routeState) /** * Consolidated route state (P1 walker decomposition, enabling step). Fields are migrated here in * cohesive clusters; first cluster: transport handoff. See {@link WalkerRouteState}. */ - private static final WalkerRouteState routeState = new WalkerRouteState(); + static final WalkerRouteState routeState = new WalkerRouteState(); // idle-nudge state migrated to WalkerRouteState (see routeState) // route-progress state migrated to WalkerRouteState (see routeState) - private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); + static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); - private static final Set AL_KHARID_TOLL_GATE_OBJECT_IDS = Set.of( + static final Set AL_KHARID_TOLL_GATE_OBJECT_IDS = Set.of( net.runelite.api.ObjectID.CITY_GATE_2786, net.runelite.api.ObjectID.CITY_GATE_2787, net.runelite.api.ObjectID.CITY_GATE_2788, net.runelite.api.ObjectID.CITY_GATE_2789); - private static final Set AL_KHARID_TOLL_GATE_POINTS = Set.of( + static final Set AL_KHARID_TOLL_GATE_POINTS = Set.of( new WorldPoint(3267, 3227, 0), new WorldPoint(3267, 3228, 0), new WorldPoint(3268, 3227, 0), @@ -296,22 +345,22 @@ public static WorldPoint getCurrentTarget() { static final int OFFSET = 10; /** Post-travel poll/timeout for Spirit Tree, Quetzal, glider, fairy ring, and other same-plane landing waits. */ - private static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; - private static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; + static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; + static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; /** Ship / charter / glider — landing predicate uses {@link #isPlayerWithinChebyshevOf} with this exclusive bound. */ - private static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; + static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; /** Max wait after ship/NPC/boat dialogue until near destination (must match {@link #sleepUntil} timeout + warn text). */ - private static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; + static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ - private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; - private static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; - 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 @@ -338,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 "?"; } @@ -352,29 +401,66 @@ 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(); + lastExemptRunLocation = null; + reachableBfsCalls.set(0); + reachableBfsMillis.set(0L); + // Seed rather than zero: a fresh walk has not moved yet, and an unknown tile-change time + // credits the pose flag, which would hand a spinning player the benefit of the doubt for the + // whole first stall window. + routeState.lastTileChangeAtMs = System.currentTimeMillis(); // The interim target belongs to the PREVIOUS route's click; letting it survive into a fresh walk // makes the new walk yield to (and report progress against) a stale objective — repeatedly seen as // interim= camping at Clock Tower when the script restarts walks every ~40s. clearInterimTarget("walk-start"); + // Same staleness, door flavour: the latest door claim belongs to the PREVIOUS walk, and its + // 6s window comfortably spans a script's walk-to-walk gap. A fresh walk re-nudged the old + // door — observed as a first_door_edge_nudge pointing BACKWARD at walk start, ~2s of standing + // still (or worse, a step the wrong way) before the new route's first click. Per-edge + // cooldowns survive on purpose: hammering one door across two walks is still hammering. + doorAttemptLedger.clearLatestAttempt(); resetRouteProgress(); 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; + } + + /** Same package (e.g. unit tests) only — not part of the script API. */ + static DoorAttemptLedger doorAttemptLedgerForTesting() { + return doorAttemptLedger; } 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) { @@ -420,20 +506,43 @@ private enum WalkerPhase { STEADY } + /** + * One consistent view of the world per loop pass (B2). Captured at the top of the pass and + * RE-CAPTURED after any branch that blocks (a click-and-sleep, a handler wait) — a pass-start + * position is a lie after a second of sleeping, which is the same staleness class the + * reachable-recapture above the recovery scan exists for. Consumers between blocking points + * share the snapshot instead of re-reading the client, so they cannot disagree about where the + * player is — the disagreement that produced the Stronghold gate bounce. + */ private static final class WalkLoopSnapshot { private final WorldPoint playerLoc; - private final HashMap closestReachableTiles; - - private WalkLoopSnapshot(WorldPoint playerLoc) { + private final boolean moving; + private final boolean animating; + private final boolean interacting; + // Lazy: capture() is cheap enough to run once per SEGMENT iteration; the reachability BFS + // only runs if a consumer actually asks for the closest index (once per snapshot). + private HashMap closestReachableTiles; + + private WalkLoopSnapshot(WorldPoint playerLoc, boolean moving, boolean animating, boolean interacting) { this.playerLoc = playerLoc; - this.closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + this.moving = moving; + this.animating = animating; + this.interacting = interacting; } private static WalkLoopSnapshot capture() { - return new WalkLoopSnapshot(Rs2Player.getWorldLocation()); + return new WalkLoopSnapshot(Rs2Player.getWorldLocation(), + Rs2Player.isMoving(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + } + + private boolean idle() { + return !moving && !animating && !interacting; } private int closestTileIndex(List path) { + if (closestReachableTiles == null) { + closestReachableTiles = getClosestIndexReachableTiles(playerLoc); + } return WalkerPathGeometry.getClosestTileIndex(path, playerLoc, closestReachableTiles); } } @@ -548,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; } @@ -558,51 +667,12 @@ private static ObstaclePolicy obstaclePolicyForCurrentPhase() { : STEADY_OBSTACLE_POLICY; } - static boolean shouldSkipStartupPreclickSegmentHandlers(boolean startupBeforeFirstClick, - int segmentIdx, - int routeStartIdx, - boolean recentDoorAttemptNearSegment, - boolean doorSettling, - boolean recoveryInFlight) { - if (!startupBeforeFirstClick || routeStartIdx < 0 || segmentIdx < routeStartIdx) { - return false; - } - if (recentDoorAttemptNearSegment || doorSettling || recoveryInFlight) { - return false; - } - return true; - } - static boolean shouldRunActiveRouteIdleNudge(boolean idleNudgeDue, boolean immediateRouteTransportPending) { return idleNudgeDue && !immediateRouteTransportPending; } - /** - * Same-plane Chebyshev distance from player to {@code dest} strictly less than {@code maxChebyshevExclusive}. - * Requires matching {@link WorldPoint#getPlane()} before using {@link WorldPoint#distanceTo2D} — that method only - * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. - */ - private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) < maxChebyshevExclusive; - } - /** - * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). - */ - private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { - if (dest == null) { - return false; - } - WorldPoint pl = Rs2Player.getWorldLocation(); - return pl != null && pl.getPlane() == dest.getPlane() - && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; - } /** * Caps configured finish distance when the route already ends very close to the marked goal. @@ -628,6 +698,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 @@ -725,17 +823,6 @@ 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) { - 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 @@ -748,30 +835,6 @@ 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. */ - static boolean isRouteProgressExit(String exitReason) { - if (exitReason == null) { - return false; - } - if (exitReason.startsWith("door-handled")) { - return true; - } - switch (exitReason) { - case "raw-path-scene-object-handled": - case "post-click-raw-path-scene-object-handled": - case "current-tile-transport-handled": - case "post-click-current-tile-transport-handled": - case "transport-handled": - case "rockfall-handled": - case "path-blocker-handled": - case "interim-in-flight": - case "recovery-move-in-flight": - case "route-fold-continuation-click": - return true; - default: - return false; - } - } - /** @return true only when a canvas click was actually issued, so the caller can size its minimap hold-off. */ private static boolean maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List path) { if (goal == null || path == null || path.isEmpty()) { @@ -883,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). */ @@ -982,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. @@ -1006,7 +1060,7 @@ static void clearWalkerDedupeForTesting() resetRouteProgress(); } - private static volatile List seasonalTransportHandlers = + static volatile List seasonalTransportHandlers = SeasonalTransportHandlers.defaultHandlerList(); /** @@ -1037,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(); @@ -1086,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(); @@ -1172,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 ); @@ -1394,10 +1449,17 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long */ private static WalkerState walkWithStateInternal(WorldPoint target, int distance) { Objects.requireNonNull(target, "walk target"); + currentWalkDistance = Math.max(0, distance); if (isClientThread()) { log.warn("Please do not call the walker from the main thread"); return WalkerState.EXIT; } + // BEFORE any planning. The first version withdrew these inside markWalkSessionStart, which + // runs after setTarget has already kicked the pathfinder off — measured live at the Tithe + // door: the retry's plan ran against the previous walk's blocks (SEARCH_EXHAUSTED against a + // sealed goal), collapsed to a 1-tile path, and the retry burned itself on it while the + // unlearn arrived two lines later. + withdrawWalkScopedDoorBlocks(); WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -1405,11 +1467,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()) { @@ -1529,8 +1611,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; } @@ -1551,8 +1634,125 @@ 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); + } + + /** How long the route progress index may hold still before the route is declared stagnant. */ + private static final long ROUTE_STAGNATION_BUDGET_MS = 60_000L; + /** Stagnation replans per walk before the goal is called unreachable. */ + private static final int MAX_ROUTE_STAGNATION_REPLANS = 2; + + /** + * The enforced oscillation bound (TailDecision.decideRouteStagnation). Unlike the two observe-only + * budgets above, this one acts: the wall-clock budget is sized for whole journeys and the + * exempt-run counter resets on any movement, so a walk ping-ponging between two tiles — the Tithe + * Farm door/recovery oscillation ran 4+ minutes until a human cancelled it — trips neither. + * Returns null to continue the loop (spending a replan restarts the clock), or the honest + * terminal state. + */ + private static WalkerState handleRouteStagnation(WorldPoint target, int distance, List path) { + long now = System.currentTimeMillis(); + TailDecision.StagnationAction action = TailDecision.decideRouteStagnation( + routeState.routeProgressAdvancedAtMs, now, ROUTE_STAGNATION_BUDGET_MS, + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS); + if (action == TailDecision.StagnationAction.NONE) { + return null; + } + if (action == TailDecision.StagnationAction.REPLAN) { + routeState.stagnationReplansSpent++; + // Restart the clock by hand: a replan that returns the identical route never trips the + // route-changed re-stamp, and each replan is owed a full budget of its own. + routeState.routeProgressAdvancedAtMs = now; + WebWalkLog.spInfo("route_stagnation_replan | spent={}/{} idx={} at={} goal={}", + routeState.stagnationReplansSpent, MAX_ROUTE_STAGNATION_REPLANS, + routeState.routeProgressIdx, compactWorldPoint(Rs2Player.getWorldLocation()), + compactWorldPoint(target)); + recalculatePath(); + return null; + } + WorldPoint endpoint = path == null || path.isEmpty() ? null : path.get(path.size() - 1); + WebWalkLog.spInfo("route_stagnation_exhausted | idx={} replans={} at={} goal={} — route index " + + "never advanced; movement without progress is not progress", + routeState.routeProgressIdx, routeState.stagnationReplansSpent, + compactWorldPoint(Rs2Player.getWorldLocation()), compactWorldPoint(target)); + Telemetry.recordUnreachable("route-stagnation-exhausted", Rs2Player.getWorldLocation(), + target, endpoint, path == null ? 0 : path.size(), distance, + Rs2PathApi.getActiveRouteStatus().getMetrics().orElse(null)); + setTarget(null, "rs2walker:processWalk:route-stagnation-exhausted"); + return WalkerState.UNREACHABLE; + } + + /** Player tile at the last tail-exempt iteration; a change means the run was making progress. */ + private static volatile WorldPoint lastExemptRunLocation = null; + + /** + * Counts consecutive tail-exempt iterations THAT DID NOT MOVE THE PLAYER. + * + *

Counting every exempt iteration was wrong, and a real farm-run log proved it: a completely + * healthy Catherby-to-Ardougne walk yielded {@code interim-in-flight} 28 times in a row while + * steadily covering ground, because that is simply what travelling between minimap clicks looks + * like. A bound on yields is a bound on walking; the state actually worth reporting is yielding + * while STATIONARY, which no number of tail refunds can ever surface through the iteration cap. + */ + private static int trackExemptRun(int run, WorldPoint target, WalkExit exit, String detail) { + WorldPoint at = Rs2Player.getWorldLocation(); + int next = (at != null && !at.equals(lastExemptRunLocation)) ? 1 : run + 1; + lastExemptRunLocation = at; + if (TailDecision.isExemptRunTooLong(next, MAX_CONSECUTIVE_EXEMPT_ITERATIONS)) { + reportExemptRunTooLong(target, exit.wireName(detail), next); + } + return next; + } + + /** + * Reports a loop that keeps yielding without advancing. Every one of these iterations refunds + * its own tail charge, so no number of them can trip the iteration cap. + */ + private static void reportExemptRunTooLong(WorldPoint target, String exitWireName, int run) { + if (run % MAX_CONSECUTIVE_EXEMPT_ITERATIONS != 1) { + return; + } + log.warn("[Walker] {} consecutive tail-exempt iterations (exit={}) target={} at={} —" + + " the loop is yielding without advancing and cannot exhaust the tail cap", + run, exitWireName, target, Rs2Player.getWorldLocation()); + } + private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { long now = System.currentTimeMillis(); + reportWalkBudgetIfExhausted(target, now, processWalkTail); if (now - lastHeartbeatAtMs < WALKER_HEARTBEAT_INTERVAL_MS) { return; } @@ -1561,15 +1761,16 @@ private static void walkerHeartbeat(WorldPoint target, int processWalkTail) { // DEBUG, not INFO: this fires every second for the whole of every walk, and it exists to // diagnose stalls, not to narrate healthy ones. Behind the verbose toggle it costs nothing // until someone is actually chasing a silent stretch in the log. - WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={}", + WebWalkLog.spDebug("walker_heartbeat | tail={} at={} goal={} moving={} animating={} interim={} interimAgeMs={} sinceMovedMs={} sinceDoorSettleMs={} bfs={}/{}ms", processWalkTail, compactWorldPoint(playerLoc), compactWorldPoint(target), Rs2Player.isMoving(), Rs2Player.isAnimating(), compactWorldPoint(routeState.interimTargetWp), routeState.interimSetAtMs > 0L ? now - routeState.interimSetAtMs : -1L, routeState.lastMovedTimeMs > 0L ? now - routeState.lastMovedTimeMs : -1L, - routeState.doorInteractionSettleStartedAtMs > 0L - ? now - routeState.doorInteractionSettleStartedAtMs : -1L); + doorAttemptLedger.settleStartedAtMs() > 0L + ? now - doorAttemptLedger.settleStartedAtMs() : -1L, + reachableBfsCalls.get(), reachableBfsMillis.get()); } /** @@ -1594,6 +1795,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 @@ -1633,6 +1859,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; @@ -1735,7 +1962,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; @@ -1791,25 +2018,18 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } - int earlyRouteStartIdx = stabilizeRouteProgressIndex(path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); + int earlyRouteStartIdx = stabilizeRouteProgressWithRawWatermark(rawPath, path, walkLoop.closestTileIndex(path), target, walkLoop.playerLoc); boolean immediateRouteTransportPending = hasImmediatePlannedTransportStep(path, earlyRouteStartIdx, walkLoop.playerLoc); // Do not clear walk target while a sticky minimap interim is active — breaks // isWalkCancelled and forces EXIT while the flag is still carrying the player. // Partial paths end at an intermediate waypoint (dst still far from {@code target}); // clearing here would drop currentTarget before the partial-path retry/recalc branch. - if (!partialPath && isNear(dst) && routeState.interimTargetWp == null) { + if (!partialPath && isNear(dst, walkLoop.playerLoc) && routeState.interimTargetWp == null) { setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); } boolean shouldIssueActiveRouteIdleNudge = shouldIssueActiveRouteIdleNudge(); - long nowTickGraceMs = System.currentTimeMillis(); - if (lastAttemptedMinimapClickOk && lastAttemptedMinimapClickAtMs > 0L - && !shouldIssueActiveRouteIdleNudge - && nowTickGraceMs - lastAttemptedMinimapClickAtMs < MINIMAP_CLICK_STALL_GRACE_MS) { - routeState.lastMovedTimeMs = nowTickGraceMs; - } - checkIfStuck(); if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; @@ -1824,9 +2044,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, @@ -1839,7 +2059,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; @@ -1858,7 +2078,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 @@ -1868,10 +2088,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(); } } @@ -1879,10 +2102,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"); @@ -1911,9 +2132,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // walker can run minutes in the wrong corridor without ever replanning. Off-path, do nothing // here: the player stops, the "moving" deferral ends, and OFFPATH_RECALC replans properly. if (clearedInterimTarget - && isNearPath() - && !Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + && isNearPath(walkLoop.playerLoc) + && !walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { @@ -1962,31 +2183,31 @@ && tryIssueRouteContinuationClick(rawPath, path, target, distance)) { boolean doorOrTransportResult = false; boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); - String exitReason = "end-of-path"; - Map doorEdgesAttemptedThisTail = new HashMap<>(); + WalkExit exit = WalkExit.END_OF_PATH; + String offPathDeferDetail = ""; + doorAttemptLedger.beginTailPass(); ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); - WorldPoint activeInterimPlayer = Rs2Player.getWorldLocation(); + // Re-capture: the widget dialogs above sleep for seconds when they fire. + walkLoop = WalkLoopSnapshot.capture(); long activeInterimNowMs = System.currentTimeMillis(); - if (!Rs2Player.isInteracting() - && !Rs2Player.isAnimating() + if (!walkLoop.interacting + && !walkLoop.animating && !isDoorInteractionSettling() && !isTransportInteractionSettling() && (target == null - || activeInterimPlayer == null - || activeInterimPlayer.distanceTo(target) > immediateFinishTh) - && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowMs)) { - exitReason = "interim-in-flight"; - WebWalkLog.earlyExit(exitReason, - activeInterimPlayer, + || walkLoop.playerLoc == null + || walkLoop.playerLoc.distanceTo(target) > immediateFinishTh) + && shouldYieldForActiveRouteInterim(walkLoop.playerLoc, path, activeInterimNowMs)) { + exit = WalkExit.INTERIM_IN_FLIGHT_ROUTE; + WebWalkLog.earlyExit(exit.wireName(offPathDeferDetail), + walkLoop.playerLoc, target, path.get(path.size() - 1), indexOfStartPoint, path.size()); walkerDiag("tail exempt exitReason=%s tailBefore=%d early=true interim=%s", - exitReason, - processWalkTail, - routeState.interimTargetWp); + exit.wireName(offPathDeferDetail), processWalkTail, routeState.interimTargetWp); processWalkTail--; continue; } @@ -2005,7 +2226,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, @@ -2029,13 +2250,12 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM : (startupPolicy.allowBroadRawHandlers() ? "gated-outer" : "policy-startup"); boolean rawSceneHandled = allowRawSceneScan && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target, true); - tmarkPostTransport("post_transport_raw_scene_scan_why", target, - "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); + tmarkPostTransport("post_transport_raw_scene_scan_why", target, "why=" + lastRawScanEarlyReturn + " handled=" + rawSceneHandled); tmarkPostTransport("post_transport_raw_scene_scan", target, "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); if (rawSceneHandled) { doorOrTransportResult = true; - exitReason = "raw-path-scene-object-handled"; + exit = WalkExit.RAW_PATH_SCENE_OBJECT_HANDLED; } long currentTileTransportStartAt = System.currentTimeMillis(); @@ -2046,7 +2266,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) { @@ -2056,7 +2276,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; @@ -2089,21 +2311,20 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM boolean recentTransportWindow = routeState.lastTransportHandledAtMs > 0 && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; - WorldPoint playerForPathCheck = Rs2Player.getWorldLocation(); + // One world per segment iteration: the previous iteration's handlers may have blocked. + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerForPathCheck = walkLoop.playerLoc; if (isTransportInteractionSettling()) { - tmarkPostTransport("post_transport_settling_yield", target, - "at=" + compactWorldPoint(playerForPathCheck)); - exitReason = "transport-settling-yield"; + tmarkPostTransport("post_transport_settling_yield", target, "at=" + compactWorldPoint(playerForPathCheck)); + exit = WalkExit.TRANSPORT_SETTLING_YIELD; break; } - boolean nearPath = isNearPath(); + boolean nearPath = isNearPath(walkLoop.playerLoc); boolean nearPathByVariance = !nearPath && isNearPathByVariance(path, playerForPathCheck); if (recentTransportWindow && !nearPath) { WebWalkLog.tmark("post_transport_nearpath_gate", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "nearPath=false variance=" + nearPathByVariance); + target, playerForPathCheck, "nearPath=false variance=" + nearPathByVariance); } if (!nearPath && !recentTransportWindow && !nearPathByVariance) { // Avoid mid-walk recalculation while recent clicks, route progress, or busy state @@ -2116,24 +2337,22 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM && System.currentTimeMillis() - routeState.lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { WebWalkLog.tmark("post_transport_offpath_moving_yield", System.currentTimeMillis() - routeState.lastTransportHandledAtMs, - target, - playerForPathCheck, - "defer=" + deferReason); + target, playerForPathCheck, "defer=" + deferReason); } - exitReason = "off-path-deferred:" + deferReason; + exit = WalkExit.OFF_PATH_DEFERRED; + offPathDeferDetail = deferReason; break; } - Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); + Telemetry.recordOffPathRecalc(walkLoop.playerLoc, path.size()); // Distinguish the drift signature in logs: off-path while still moving with no // walker action in flight = something external is steering the player. - WebWalkLog.recalc(Rs2Player.isMoving() - ? "off_path_unowned_movement" : "no_longer_near_path"); + WebWalkLog.recalc(walkLoop.moving ? "off_path_unowned_movement" : "no_longer_near_path"); if (config.cancelInstead()) { setTarget(null, "rs2walker:processWalk:off-path-cancel-instead"); } else { recalculatePathForRecovery(); } - exitReason = "not-near-path"; + exit = WalkExit.NOT_NEAR_PATH; break; } if (!nearPath && recentTransportWindow) { @@ -2146,9 +2365,9 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM // Gate scene-object handlers to segments near the player. Doors/rockfalls/transports // can only be interacted with when the object is in the loaded scene (near the player), // and these calls do scene-object scans that add up across 100+ segment paths. - WorldPoint playerNearSeg = Rs2Player.getWorldLocation(); + WorldPoint playerNearSeg = walkLoop.playerLoc; if (playerNearSeg == null) { - exitReason = "player-location-null"; + exit = WalkExit.PLAYER_LOCATION_NULL; break; } int segDistance = currentWorldPoint.distanceTo2D(playerNearSeg); @@ -2159,31 +2378,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; @@ -2209,20 +2416,21 @@ && shouldSkipStartupPreclickSegmentHandlers( // // With an earlier segment skipped, doors fall back to the stationary requirement, // which is the behaviour from before ranged door dispatch existed. - boolean nearestSegmentDoor = !segmentHandlersRanThisPass && !segmentSkippedThisPass; + boolean nearestSegmentDoor = SegmentGate.mayDispatchDoorAtRange( + segmentHandlersRanThisPass, segmentSkippedThisPass); segmentHandlersRanThisPass = true; boolean doorMovementGateOk = !Rs2Player.isMoving() || (nearestSegmentDoor && doorInteractionWhileApproachingEnabled()); if (!startupImmediateTransportOnly && doorMovementGateOk && !isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { doorOrTransportResult = handleDoorsInRawSegment(rawPath, rawI, rawEnd, - obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.segmentDoorTimeoutMs(), reachableTilesCache); } if (doorOrTransportResult) { tmarkPostTransport("post_transport_segment_handler", target, "stage=door handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "door-handled"; + exit = WalkExit.DOOR_HANDLED; break; } @@ -2234,10 +2442,10 @@ && shouldSkipStartupPreclickSegmentHandlers( && !Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe() && allowPathAdjacentProbe) { if (tryHandleBlockingPathObjectsWithTimeout(rawPath, rawI, 5, 10, - obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { + obstaclePolicy.pathAdjacentProbeTimeoutMs())) { tmarkPostTransport("post_transport_segment_handler", target, "stage=path_adj handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); - exitReason = "path-blocker-handled"; + exit = WalkExit.PATH_BLOCKER_HANDLED; break; } } @@ -2255,7 +2463,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; } @@ -2272,7 +2480,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, @@ -2281,44 +2489,45 @@ && shouldSkipStartupPreclickSegmentHandlers( } 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. 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(); - 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); - } - } + walkLoop = WalkLoopSnapshot.capture(); + WorldPoint playerLoc = walkLoop.playerLoc; + if (playerLoc != null && !playerLoc.equals(reachableTilesCacheOrigin)) { + reachableTilesCache = Rs2Tile.getReachableTilesFromTile(playerLoc, HANDLER_RANGE * 3); + reachableTilesCacheOrigin = playerLoc; + tileReachable = reachableTilesCache.containsKey(currentWorldPoint); + WebWalkLog.spDebug("reachable_recapture | from={} tile={} reachableNow={}", compactWorldPoint(playerLoc), compactWorldPoint(currentWorldPoint), tileReachable); } } if (!tileReachable && !inInstance) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); + WorldPoint playerLoc = walkLoop.playerLoc; if (playerLoc != null) { int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); if (unreachableDist <= HANDLER_RANGE + 2) { + int recoveryScanStart = forwardRecoveryScanStart(rawPath, smoothedToRaw, indexOfStartPoint, playerLoc); boolean candidateOnCurrentRouteFrontier = RouteRecovery.isLocalRecoveryCandidateOnForwardRoute( rawPath, smoothedToRaw, - indexOfStartPoint, + recoveryScanStart, i, LOCAL_RECOVERY_RAW_ROUTE_LOOKAHEAD_STEPS); if (!candidateOnCurrentRouteFrontier) { - log.info("[Walker] spatially-near future route branch ignored for local recovery: " - + "tile={} idx={}/{} routeStart={} player={}", - currentWorldPoint, i, path.size(), indexOfStartPoint, playerLoc); + log.info("[Walker] spatially-near future route branch ignored for local recovery: tile={} idx={}/{} routeStart={} player={}", currentWorldPoint, i, path.size(), recoveryScanStart, playerLoc); if (tryIssueRouteContinuationClick(rawPath, path, target, distance)) { - exitReason = "route-fold-continuation-click"; - } else { - exitReason = "route-fold-continuation-pending"; + exit = WalkExit.ROUTE_FOLD_CONTINUATION_CLICK; + break; } - break; + // Fold stall fix: ending the pass at a behind/branch tile left 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 @@ -2329,23 +2538,22 @@ && shouldSkipStartupPreclickSegmentHandlers( // 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, recoveryScanStart, i, currentPlayerPlane, reachableTilesCache); + if (rewoundIdx != FrontierDecision.NO_EARLIER_BLOCKED_INDEX) { + log.info("[Walker] frontier rewind: earliest blocked route tile idx={} tile={} (miss was idx={})", + rewoundIdx, path.get(rewoundIdx), i); + i = rewoundIdx; + currentWorldPoint = path.get(rewoundIdx); } - int edgeIdx = Math.max(indexOfStartPoint, i - 1); - int rawEdgeStart = (edgeIdx < smoothedToRaw.length) ? smoothedToRaw[edgeIdx] : 0; - int rawEdgeEnd = (i < smoothedToRaw.length) ? smoothedToRaw[i] + 1 : rawPath.size(); - WorldPoint edgeFrom = rawEdgeStart >= 0 && rawEdgeStart < rawPath.size() ? rawPath.get(rawEdgeStart) : null; - WorldPoint edgeTo = rawEdgeEnd - 1 >= 0 && rawEdgeEnd - 1 < rawPath.size() ? rawPath.get(rawEdgeEnd - 1) : null; + FrontierDecision.FrontierEdge frontier = + FrontierDecision.frontierEdge(rawPath, smoothedToRaw, recoveryScanStart, i); + int edgeIdx = frontier.edgeIndex(); + int rawEdgeStart = frontier.rawStart(); + int rawEdgeEnd = frontier.rawEndExclusive(); + WorldPoint edgeFrom = frontier.from(); + WorldPoint edgeTo = frontier.to(); // Unified obstacle dispatch for the blocked frontier (P2). One call resolves both // a rockfall to mine here and a reachable transport/agility-shortcut origin to step @@ -2359,7 +2567,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) { @@ -2369,72 +2577,59 @@ && shouldSkipStartupPreclickSegmentHandlers( } if (hasRecentDoorAttemptOnEdge(edgeFrom, edgeTo)) { - boolean resolvedAfterWait = waitForDoorEdgeResolution(edgeFrom, edgeTo, + boolean edgeResolved = waitForDoorEdgeResolution(edgeFrom, edgeTo, obstaclePolicy.edgeResolutionWaitTimeoutMs()); - if (resolvedAfterWait && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target)) { - exitReason = "door-edge-resolved-fast-click"; - } else { - exitReason = resolvedAfterWait ? "door-edge-resolved-after-wait" : "door-edge-waiting-retry"; - } + boolean 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)) { - exitReason = "door-edge-resolved-fast-click"; - } else { - exitReason = "door-edge-resolved-after-nearby-wait"; - } - break; - } - if (!resolvedAfterNearbyWait) { - exitReason = "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. - exitReason = "door-settling-yield"; + FrontierDecision.FrontierYield frontierYield = + FrontierDecision.yieldBeforeDoorActions( + isDoorInteractionSettling(), + isDoorEdgePassSkipCoolingDown(), + recentDoorAttemptAgeNearIndex(rawPath, rawEdgeStart), + DOOR_TRAVERSAL_RECOVERY_BLOCK_MS, + Rs2Player.isMoving(), + shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())); + if (frontierYield.yields()) { + exit = frontierYield.exit(); break; } - if (pendingDoorTraversal) { - // Keep one-shot behavior after door open: let traversal finish - // before issuing fallback path-adj/recovery actions. - exitReason = "door-traversal-pending-yield"; - break; - } - if (shouldYieldForActiveRecoveryInterim(playerLoc, path, System.currentTimeMillis())) { - exitReason = "interim-in-flight"; - break; - } - if (tryRecentDoorAttemptEdgeNudge(playerLoc, target)) { - exitReason = "recent-door-edge-nudge"; + if (tryRecentDoorAttemptEdgeNudge(playerLoc, target, rawPath)) { + exit = WalkExit.RECENT_DOOR_EDGE_NUDGE; break; } if (handlePendingDoorNearRawPath(rawPath, obstaclePolicy.unreachableDoorTimeoutMs(), - doorEdgesAttemptedThisTail, playerLoc, 2, 14)) { - exitReason = "door-handled-local-reachability-raw-scan"; + playerLoc, 2, 14)) { + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY_RAW_SCAN; break; } if (handleDoorsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), null)) { - exitReason = "door-handled-local-reachability"; + exit = WalkExit.DOOR_HANDLED_LOCAL_REACHABILITY; break; } if (isRecoveryMovementInFlight()) { - exitReason = "recovery-move-in-flight"; + exit = WalkExit.RECOVERY_MOVE_IN_FLIGHT; break; } boolean unresolvedDoorNearRawPath = hasUnresolvedDoorLikeObjectNearRawPath(rawPath, @@ -2442,30 +2637,47 @@ && shouldSkipStartupPreclickSegmentHandlers( UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + // No !gateDoorInteraction re-check: reaching here means the yield above + // returned NONE, which already proved the door-settling window closed. + if (unresolvedDoorNearRawPath && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, - obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail, + obstaclePolicy.unreachableDoorTimeoutMs(), playerLoc, UNREACHABLE_DOOR_RECOVERY_BACKTRACK_EDGES, UNREACHABLE_DOOR_RECOVERY_LOOKAHEAD_EDGES, HANDLER_RANGE)) { - exitReason = "door-handled-nearby-route-door"; + exit = WalkExit.DOOR_HANDLED_NEARBY_ROUTE_DOOR; break; } // Fallback: only interact with objects on/adjacent to blocked path edges // within ~15 tiles. Prevents clicking already-open / unrelated doors. final long nowMs = System.currentTimeMillis(); - if (!gateDoorInteraction - && unresolvedDoorNearRawPath + if (unresolvedDoorNearRawPath && obstaclePolicy.allowNearbyFallback() && nowMs - routeState.lastDoorPathAdjAttemptAtMs > 1200) { routeState.lastDoorPathAdjAttemptAtMs = nowMs; if (tryResolvePathAdjacentBlocker(playerLoc, rawPath, rawEdgeStart, 3, 10)) { - exitReason = "door-handled-path-adj-scan"; + exit = WalkExit.DOOR_HANDLED_PATH_ADJ_SCAN; break; } } + // A shortcut / transport on the blocked frontier is TAKEN here rather than + // routed around: the minimap fallback below would pick the tile on the FAR + // side and send the server the long way around the gap. Recovery acts on the + // edge blocking us right now, so it is the nearest obstacle by construction + // and may dispatch from range. + // Ordered BEFORE door suppression, which breaks out of recovery and so never + // let the transport have its turn. Measured near Draynor: a catalog transport + // at (3064,3282) was refused as walled, declined by the door handlers, then + // suppressed as a "nearby route door" that was this very transport — four + // seconds before the raw scan dispatched it. Suppression still guards the + // generic recovery click below; it just no longer outranks this. + if ((PohTeleports.isInHouse() || !inInstance) + && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { + exit = WalkExit.TRANSPORT_HANDLED_LOCAL_REACHABILITY; + break; + } + if (unresolvedDoorNearRawPath) { // An unresolved door sits on/near the blocked edge but every door handler above // declined (settling / recent-attempt cooldowns). Do NOT fall through to the @@ -2494,27 +2706,12 @@ && handleUnresolvedDoorNearRawPath(rawPath, rawEdgeStart, routeState.lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_suppressed_approach | to={} idx={} tile={}", compactWorldPoint(doorApproach), rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-suppressed-approach-click"; + exit = WalkExit.DOOR_SUPPRESSED_APPROACH_CLICK; break; } WebWalkLog.spInfo("door_recovery_suppressed | reason=nearby-route-door idx={} tile={}", rawEdgeStart, compactWorldPoint(currentWorldPoint)); - exitReason = "door-recovery-suppressed"; - break; - } - - // An agility shortcut / transport sitting on the blocked frontier is TAKEN - // here rather than routed around. The minimap-click fallback below picks the - // furthest path tile within Euclidean minimap reach, which for a stepping-stone - // (or any gap/wall shortcut) is the tile on the FAR side -- clicking it makes the - // server walk the long way around the gap it should have crossed. Taking the - // transport first mirrors the segment-handler transport scan (which can be - // skipped in the post-transport window) and the door/rockfall handling above. - // Recovery acts on the edge blocking us RIGHT NOW, so it is the nearest - // obstacle by construction and may dispatch from range. - if ((PohTeleports.isInHouse() || !inInstance) - && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { - exitReason = "transport-handled-local-reachability"; + exit = WalkExit.DOOR_RECOVERY_SUPPRESSED; break; } @@ -2543,7 +2740,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; @@ -2555,7 +2752,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) { @@ -2571,13 +2768,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( @@ -2586,21 +2779,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 @@ -2620,7 +2807,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) { @@ -2628,11 +2815,10 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt WebWalkLog.spInfo("recovery_target_walled | to={} player={} replanning", compactWorldPoint(recoverTarget), compactWorldPoint(playerLoc)); recalculatePathForRecovery(); - exitReason = "recovery-target-walled-replan"; - break; } - if (clickAction == RouteRecovery.RecoveryClickAction.WAIT_WALLED) { - exitReason = "recovery-target-walled-waiting"; + WalkExit recoveryClickExit = FrontierDecision.exitForRecoveryClick(clickAction); + if (recoveryClickExit != null) { + exit = recoveryClickExit; break; } WorldPoint clickedRecoveryTarget = null; @@ -2648,8 +2834,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; @@ -2683,10 +2870,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; } } @@ -2699,7 +2886,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); @@ -2717,7 +2904,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. @@ -2731,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; @@ -2741,7 +2928,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; @@ -2759,7 +2946,7 @@ && walkFastCanvas(recoverTarget)) { boolean closeEnoughForNextClick = posAfterWait != null && interimFinal.distanceTo2D(posAfterWait) <= INTERIM_CLOSE_TILES; if (!closeEnoughForNextClick && Rs2Player.isMoving()) { - exitReason = "interim-in-flight"; + exit = WalkExit.INTERIM_IN_FLIGHT_CLICK; walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", interimFinal, posAfterWait == null ? interimDist : interimFinal.distanceTo2D(posAfterWait), @@ -2882,10 +3069,10 @@ && walkFastCanvas(recoverTarget)) { } } if (!inInstance && handlePendingDoorBeforeRouteClick(rawPath, path, i, targetIdx, - smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail, + smoothedToRaw, obstaclePolicy.segmentDoorTimeoutMs(), playerLoc)) { doorOrTransportResult = true; - exitReason = "door-handled-before-minimap-click"; + exit = WalkExit.DOOR_HANDLED_BEFORE_MINIMAP_CLICK; break; } clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, MINIMAP_REACH_EUCLIDEAN - 1); @@ -2977,12 +3164,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; } } @@ -2998,7 +3185,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; @@ -3019,7 +3206,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 @@ -3042,15 +3229,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); @@ -3059,7 +3246,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; } @@ -3086,7 +3273,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); } @@ -3103,9 +3290,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); @@ -3117,38 +3304,26 @@ && walkFastCanvas(recoverTarget)) { if (walkCancelledDiag(target, "processWalk:partial-path-branch", processWalkTail)) { return WalkerState.EXIT; } - // Route progress since the last retry means the walk is working — refill the budget. - // It otherwise only ever increments, so "3 retries" meant three outer-loop iterations - // for the whole journey rather than three consecutive failures to advance. - // - // Standing somewhere new is required as well as the progress timestamp: - // routeState.routeProgressAdvancedAtMs is also bumped whenever the route is merely REPLACED, and - // each retry calls recalculatePath(), so the timestamp alone would let a retry refill - // the budget it just spent. When the target is genuinely unreachable the player stops - // moving, so requiring movement is what still lets the budget drain and terminate. WorldPoint retryLoc = Rs2Player.getWorldLocation(); boolean movedSinceLastRetry = lastPartialRetryAtLoc == null || (retryLoc != null && !retryLoc.equals(lastPartialRetryAtLoc)); - if (partialRetriesWorking > 0 - && movedSinceLastRetry - && routeState.routeProgressAdvancedAtMs > lastPartialRetryAtMs) { - walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", - routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); + if (TailDecision.shouldRefillPartialRetryBudget(partialRetriesWorking, movedSinceLastRetry, + routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs)) { + walkerDiag("partial retry budget refilled progressAt=%d lastRetryAt=%d spent=%d at=%s", routeState.routeProgressAdvancedAtMs, lastPartialRetryAtMs, partialRetriesWorking, retryLoc); partialRetriesWorking = 0; } - // A handled door/transport/blocker ended the iteration because work was done, not - // because the walker is stuck. Still re-route, but do not charge the budget for it. - if (isRouteProgressExit(exitReason)) { - walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", - exitReason, processWalkTail, partialRetriesWorking); + TailDecision.TailAction partialAction = TailDecision.decide(false, true, exit, + partialRetriesWorking, TailDecision.MAX_PARTIAL_RETRIES); + if (partialAction == TailDecision.TailAction.PARTIAL_PROGRESS_REPLAN) { + walkerDiag("partial retry exempt exitReason=%s tail=%d spent=%d", exit.wireName(offPathDeferDetail), processWalkTail, partialRetriesWorking); recalculatePath(); continue; } - if (partialRetriesWorking < 3) { + if (partialAction == TailDecision.TailAction.PARTIAL_RETRY_REPLAN) { lastPartialRetryAtMs = System.currentTimeMillis(); lastPartialRetryAtLoc = retryLoc; Telemetry.recordPartialRetry(partialRetriesWorking + 1, finalDist); - WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, 3); + WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, TailDecision.MAX_PARTIAL_RETRIES); recalculatePath(); partialRetriesWorking++; continue; @@ -3165,10 +3340,14 @@ && walkFastCanvas(recoverTarget)) { setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { - if (isOffPathRecalcDeferredExit(exitReason)) { + WalkerState stagnated = handleRouteStagnation(target, distance, path); + if (stagnated != null) { + return stagnated; + } + if (exit == WalkExit.OFF_PATH_DEFERRED) { // Wait briefly for the player to re-enter the path or for the progress signal // that deferred the recalc to expire. Prevents a tight loop around isNearPath(). - String deferReason = offPathDeferredReasonFromExit(exitReason); + String deferReason = offPathDeferDetail; long offPathWaitMs = offPathRecalcDeferredWaitMs(deferReason, System.currentTimeMillis(), routeState.lastMovedTimeMs, @@ -3207,19 +3386,16 @@ && walkFastCanvas(recoverTarget)) { } // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. - if ("interim-in-flight".equals(exitReason) - || "recovery-move-in-flight".equals(exitReason) - || "route-move-in-flight".equals(exitReason) - || "route-fold-continuation-click".equals(exitReason) - || isOffPathRecalcDeferredExit(exitReason)) { - walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); + if (exit.isTailExempt()) { + consecutiveExemptIterations = trackExemptRun(consecutiveExemptIterations, target, exit, offPathDeferDetail); + walkerDiag("tail exempt exitReason=%s tailBefore=%d", exit.wireName(offPathDeferDetail), processWalkTail); processWalkTail--; + } else { + consecutiveExemptIterations = 0; } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", - processWalkTail + 1, - exitReason, - Rs2Player.getWorldLocation().distanceTo(target), - partialPath); + processWalkTail + 1, exit.wireName(offPathDeferDetail), + Rs2Player.getWorldLocation().distanceTo(target), partialPath); continue; } } catch (Exception ex) { @@ -3324,7 +3500,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, @@ -3552,10 +3728,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; @@ -3565,18 +3758,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)); } @@ -3685,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 — @@ -3749,48 +3916,204 @@ private static WorldPoint findFurthestRawPathPointMatchingGated(List && playerLoc.distanceTo2D(selected) <= CLOSEST_INDEX_REACHABLE_STEP_BUDGET - 2) { WebWalkLog.spInfo("route_click_walled | to={} player={} anchorIdx={} — refused, falling back", compactWorldPoint(selected), compactWorldPoint(playerLoc), rawAnchorIndex); + learnWalledRouteEdge(rawPath, playerLoc, reachable); return null; } return selected; } /** - * Selects the next minimap click target from the raw route, gated on collision reachability. + * 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. *

- * Preference order: - *

    - *
  1. Furthest-forward raw point that is collision-reachable from the player. A point on the - * far side of a wall is Euclidean-close but not reachable within the sampled area, so it is - * excluded — this is what stops the walker clicking through castle walls / into buildings.
  2. - *
  3. Furthest-forward raw point that is off the loaded scene. Collision cannot be verified for - * unloaded tiles, but a minimap click toward a distant route point is still correct, so long - * outdoor routes keep flowing.
  4. - *
- * Returns {@code null} when neither exists; the caller then falls back to wall-distance nudging - * plus {@link #findReachableRejoinRawPathPoint} rejoin handling. - */ - private static WorldPoint selectRouteClickTarget(List rawPath, WorldPoint playerLoc, - int maxEuclidean, int rawAnchorIndex) { - if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { - routeState.lastRouteClickTier = "norawpath"; - return null; + * 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; } - // Anti-ban: vary HOW FAR ALONG the route we click. Selection otherwise always returns the - // furthest candidate inside a fixed radius, so every click covers the same tile span — a - // deterministic signature. Varying the reach is the safe axis: it only changes how far - // forward we pick, never sideways, so the target stays on the planned route (#20). Lateral - // tile offsets are the wrong axis and were removed for exactly that reason (#15); lateral - // randomness belongs inside the tile (click-point jitter), not in tile selection. - int jitteredReach = routeClickReach(maxEuclidean); - WorldPoint selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, rawAnchorIndex); - if (selected == null && jitteredReach < maxEuclidean) { - // A shortened reach must never be the reason selection fails — that would drop the click - // onto the caller's off-route wall-nudge clamp. Retry at full reach before giving up. - selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + // 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; } - if (selected == null && rawAnchorIndex >= 0) { - // The smoothed->raw anchor can point past the player's vicinity (stale mapping, sparse - // smoothing, or a replanned route). The anchored forward scan then breaks immediately on + // The 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; + } + // 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. + 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(); + } + } + + /** + * 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. + *

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

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

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

+ * Preference order: + *

    + *
  1. Furthest-forward raw point that is collision-reachable from the player. A point on the + * far side of a wall is Euclidean-close but not reachable within the sampled area, so it is + * excluded — this is what stops the walker clicking through castle walls / into buildings.
  2. + *
  3. Furthest-forward raw point that is off the loaded scene. Collision cannot be verified for + * unloaded tiles, but a minimap click toward a distant route point is still correct, so long + * outdoor routes keep flowing.
  4. + *
+ * Returns {@code null} when neither exists; the caller then falls back to wall-distance nudging + * plus {@link #findReachableRejoinRawPathPoint} rejoin handling. + */ + private static WorldPoint selectRouteClickTarget(List rawPath, WorldPoint playerLoc, + int maxEuclidean, int rawAnchorIndex) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { + routeState.lastRouteClickTier = "norawpath"; + return null; + } + // Anti-ban: vary HOW FAR ALONG the route we click. Selection otherwise always returns the + // furthest candidate inside a fixed radius, so every click covers the same tile span — a + // deterministic signature. Varying the reach is the safe axis: it only changes how far + // forward we pick, never sideways, so the target stays on the planned route (#20). Lateral + // tile offsets are the wrong axis and were removed for exactly that reason (#15); lateral + // randomness belongs inside the tile (click-point jitter), not in tile selection. + int jitteredReach = routeClickReach(maxEuclidean); + WorldPoint selected = selectRouteClickTargetAnchored(rawPath, playerLoc, jitteredReach, rawAnchorIndex); + if (selected == null && jitteredReach < maxEuclidean) { + // A shortened reach must never be the reason selection fails — that would drop the click + // onto the caller's off-route wall-nudge clamp. Retry at full reach before giving up. + selected = selectRouteClickTargetAnchored(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); + } + if (selected == null && rawAnchorIndex >= 0) { + // The smoothed->raw anchor can point past the player's vicinity (stale mapping, sparse + // smoothing, or a replanned route). The anchored forward scan then breaks immediately on // the Euclidean bound and yields nothing for EVERY predicate — which is exactly the // sel=none case that dropped route clicks onto the off-route wall-nudge clamp. Retry // anchored at the player's own closest raw tile before giving up. @@ -3911,7 +4234,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, @@ -3926,6 +4249,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(); @@ -3993,8 +4333,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, @@ -4038,15 +4382,38 @@ private static boolean tryIssueRouteMovementClick(List rawPath, maxEuclidean - 1, Rs2Walker::isKnownWalkableOrUnloaded); } + // The primary selector reaches here only after refusing every route point (e.g. the + // walled net saw a shut door between), and this fallback vets candidates by + // WALKABILITY, not reachability. Clicking a walkable-but-unreachable tile moves the + // player nowhere while still arming an interim — at the Stronghold's chained gates the + // idle nudge did exactly that every ~2s beyond the shut second gate, and the dead + // interim's in-flight yields starved the pass that would have opened it. + if (clickTarget != null && !Rs2Tile.isTileReachable(clickTarget)) { + WebWalkLog.spDebug("route_click_fallback_unreachable | to={} player={}", + compactWorldPoint(clickTarget), compactWorldPoint(playerLoc)); + return false; + } } boolean clicked = false; WorldPoint clickedTarget = null; if (clickTarget != null && !clickTarget.equals(playerLoc)) { clickTarget = RouteRecovery.clampToEuclideanRadius(playerLoc, clickTarget, maxEuclidean - 1); - clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, - maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); - clicked = clickedTarget != null; + // The finish needs scene precision, not minimap reach. A minimap tile is a few pixels + // wide, so a click at the goal from 1-2 tiles out routinely quantizes onto a neighbour — + // measured as the last-tile dance (1784,3559 -> 1786,3559 -> 1784,3560 around a + // 1785,3560 goal). Inside the final band, click the exact tile on screen instead. + if (target != null && playerLoc.distanceTo2D(target) <= INTERIM_CLOSE_TILES + && clickTarget.getPlane() == target.getPlane() + && clickTarget.distanceTo2D(target) <= 1 + && walkFastCanvas(clickTarget)) { + clickedTarget = clickTarget; + clicked = true; + } else { + clickedTarget = clickMiniMapOrFallback(rawPath, clickTarget, playerLoc, + maxEuclidean - 1, rawPath == null || rawPath.isEmpty(), rawAnchorIndex); + clicked = clickedTarget != null; + } } // EVERY movement click logs at info. The interim-continuation label used to log at debug only, // which made its clicks invisible: the walker appeared to "randomly click far from the path" @@ -5031,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) { @@ -5067,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; } } @@ -5076,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() @@ -5084,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) { @@ -5125,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; } } @@ -5135,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, @@ -5164,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; } } @@ -5228,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; @@ -5281,6 +5644,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; @@ -5403,14 +5768,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; } } @@ -5455,6 +5825,82 @@ 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 { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + 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 + * 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, true); + } finally { + long tookMs = System.currentTimeMillis() - startedAt; + if (rawScanWallSnapshot != null || rawScanGameObjectSnapshot != null) { + rawScanDoorVerifyMs += tookMs; + } + doorLegVerifyMs += tookMs; + } + } /** * The door segment probe, timed. "doorProbe" in the slow-scan line is a RESIDUAL — the whole @@ -5465,12 +5911,14 @@ private static DoorProbeContext doorProbeContext() { 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; } } @@ -5577,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) { @@ -5606,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) { @@ -5689,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); @@ -5738,14 +6182,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; + // 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; 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; @@ -5817,14 +6263,14 @@ 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) // 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; @@ -5844,8 +6290,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; } @@ -5882,6 +6328,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; } @@ -5901,7 +6360,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); } } @@ -5983,12 +6442,26 @@ 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 { Telemetry.recordDoorReject("orient-mismatch"); } } else { + if (!Rs2DoorClassifier.isRouteDoorObject(false, name, action)) { + Telemetry.recordDoorReject("gameobject-not-a-door"); + continue; + } + if (isGoalTileObjectNotObstacle(object, probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_skip_goal_object | mode=segment-door probe={} from={} — the goal tile's own object is the destination, not an obstacle; finishing within distance", + compactWorldPoint(probe), compactWorldPoint(fromWp)); + return false; + } if (Rs2DoorGeometry.isDoorOnSegment(object, fromWp, toWp)) { log.debug("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); found = true; @@ -6004,7 +6477,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; @@ -6019,7 +6492,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()); @@ -6031,14 +6504,14 @@ private static boolean handleDoors(List path, int index, boolean all return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (!traversed && isQuestLockedDoorDialogue()) { String dialogue = Rs2Dialogue.getDialogueText(); log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", probe, name, action, dialogue); - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); Rs2Dialogue.clickContinue(); Rs2PathApi.refreshPlanningConfiguration(); recalculatePath(); @@ -6049,7 +6522,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), @@ -6059,18 +6532,21 @@ 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); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); } 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; } } return false; } + clearDoorCrossFailures(fromWp, toWp); markStationaryDoorOpened(probe); markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); } @@ -6080,7 +6556,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; } @@ -6091,7 +6567,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)) { @@ -6116,10 +6593,20 @@ 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; } - } 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)); + 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; @@ -6137,7 +6624,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; @@ -6152,7 +6639,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()); @@ -6164,16 +6651,17 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } markDoorInteractionSettling(toWp); - waitForDoorInteractionProgress(fromWp, toWp); + waitForDoorInteractionProgress(fromWp, toWp, probe, doorActions, action, object); WorldPoint posAfter = Rs2Player.getWorldLocation(); boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); if (traversed) { + clearDoorCrossFailures(fromWp, toWp); markStationaryDoorOpened(probe); markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); return true; } if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp, Rs2Player.isMoving())) { - sessionBlacklistedDoors.add(probe); + doorAttemptLedger.blacklistDoor(probe); log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", probe, fromWp, toWp, posBefore, posAfter); } @@ -6181,19 +6669,21 @@ 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(); 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); + registerDoorCrossFailure(fromWp, toWp, + isConclusiveRefusedOpenSample(posAfter, fromWp), "refused-open"); } 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; } @@ -6201,8 +6691,82 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return false; } + /** + * THE door we clicked is open — not "some door near here is open". + *

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

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

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

+ * Two live runs have now ended without a single {@code releasedBy=door-opened}, and neither could + * say why: the poll count proves the check ran, but not what it read. This names every object the + * strict match considers and the action currently on it, which separates the remaining candidates + * — nothing matched the tile at all, versus something matched and still offers the opening action. + */ + 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(); + } + } + + /** + * @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) { + List doorActions, String action, boolean strictTile) { if (probe == null || action == null) { return false; } @@ -6212,7 +6776,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() @@ -6221,7 +6786,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; } @@ -6235,7 +6800,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; @@ -6245,8 +6813,20 @@ private static boolean doorObjectStillHasAction(TileObject object, WorldPoint pr return currentAction != null && currentAction.equalsIgnoreCase(action); } - private static void markStationaryDoorOpened(WorldPoint doorTile) { - Rs2DoorHandler.markStationaryDoorOpened(recentlyOpenedStationaryDoors, doorTile); + static void markStationaryDoorOpened(WorldPoint doorTile) { + doorAttemptLedger.markStationaryDoorOpened(doorTile, System.currentTimeMillis()); + } + + /** + * Whether the player already stands on the far side of this wall door's face relative to the + * segment's approach tile — in which case the crossing has happened and clicking the door again + * can only undo it (a moves-you gate carries the player straight back). Shell wrapper over + * {@link Rs2DoorGeometry#playerBeyondWallFace}; see there for the Stronghold bounce this exists + * to prevent. + */ + 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) { @@ -6254,12 +6834,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) { @@ -6318,7 +6894,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); } @@ -6380,6 +6956,34 @@ 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) { + 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; } @@ -6387,7 +6991,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) { @@ -6397,15 +7004,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; @@ -6421,22 +7040,58 @@ 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; } + /** 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) { + 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) { - WorldPoint from = routeState.lastDoorAttemptFrom; - WorldPoint to = routeState.lastDoorAttemptTo; - long attemptedAt = routeState.lastDoorAttemptAtMs; - if (playerLoc == null || from == null || to == null || attemptedAt <= 0L) { + return tryRecentDoorAttemptEdgeNudge(playerLoc, target, null); + } + + private static boolean tryRecentDoorAttemptEdgeNudge(WorldPoint playerLoc, WorldPoint target, + List routePath) { + DoorAttemptLedger.Attempt claim = + doorAttemptLedger.latestAttempt(POST_DOOR_NUDGE_RECENT_ATTEMPT_MS, System.currentTimeMillis()); + if (playerLoc == null || claim == null) { return false; } - 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) { @@ -6445,7 +7100,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)); @@ -6453,6 +7108,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; @@ -6470,7 +7172,24 @@ 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. + * Shared with the ranged door await, which uses the same reading as its "passed the door" release. + */ + static boolean hasCrossedDoorAxis(WorldPoint fromWp, WorldPoint toWp, WorldPoint after) { + return Rs2DoorGeometry.crossedDoorAxis(fromWp, toWp, after); } private static int interimPreclickTiles() { @@ -6733,8 +7452,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) { + DoorAttemptLedger.Attempt lastClaim = doorAttemptLedger.latestAttempt(); + boolean sameEdge = fromWp != null && toWp != null && lastClaim != null + && lastClaim.isSameDirectedEdge(fromWp, toWp); + return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(System.currentTimeMillis(), + doorAttemptLedger.globalCooldownUntilMs(), sameEdge, + DOOR_INTERACTION_GLOBAL_COOLDOWN_MS, DOOR_INTERACTION_CROSS_EDGE_COOLDOWN_MS) || shouldDeferDoorInteractionForDialogue(); } @@ -6773,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; @@ -6803,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; @@ -6837,27 +7543,85 @@ 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) { - 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()); + } + + /** + * 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; + } + DoorAttemptLedger.Strike strike = doorAttemptLedger.registerCrossFailure( + fromWp, toWp, + conclusiveSample, + System.currentTimeMillis(), + DOOR_CROSS_FAILURE_DECAY_MS, + DOOR_CROSS_FAILURE_STRIKE_LIMIT); + if (strike != DoorAttemptLedger.Strike.STRIKE_OUT) { + return; + } + String reason = "door-strike-out (" + mode + ")"; + if (Rs2PathApi.learnBlockedEdge(fromWp, toWp, reason)) { + doorAttemptLedger.recordWalkScopedBlock(fromWp, toWp); + } + if (Rs2PathApi.learnBlockedEdge(toWp, fromWp, reason)) { + doorAttemptLedger.recordWalkScopedBlock(toWp, fromWp); + } + WebWalkLog.spInfo("door_strike_out | from={} to={} mode={} — {} concluded attempts never crossed; " + + "blocking edge for this walk and replanning", + compactWorldPoint(fromWp), compactWorldPoint(toWp), mode, DOOR_CROSS_FAILURE_STRIKE_LIMIT); + recalculatePath(); + } + + /** + * Withdraws every strike-out block the previous walk earned. Called at walk session start: the + * new walk may run under changed conditions (seeds acquired, key obtained), so each refused door + * gets a fresh chance — and a walk retried without the condition just re-earns the strike-out in + * a few attempts, loudly, instead of inheriting a stale block silently. + */ + private static void withdrawWalkScopedDoorBlocks() { + 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) { + doorAttemptLedger.clearCrossFailures(fromWp, toWp); + } + + /** + * A refused-open only counts when the attempt genuinely concluded AT the door: player stationary + * on (or beside) the near-side tile. A ranged click whose wait expired mid-approach samples a + * player still tiles away and proves nothing about the door. + */ + 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; @@ -6879,28 +7643,14 @@ private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoin System.currentTimeMillis()); } - private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { - return Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( - recentlyOpenedStationaryDoors, - STATIONARY_DOOR_SUPPRESS_MS, - fromWp, - toWp); + static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint 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. */ @@ -7105,14 +7855,72 @@ && isAdjacentSamePlaneTransport(t) */ private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { - long startedAt = System.currentTimeMillis(); - AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); + waitForDoorInteractionProgress(fromWp, toWp, null, null, null, null); + } + + /** + * Door-identified variant: lets the await release the moment the door is OPEN rather than when we + * have finished walking through it. An unlocked door opens within a game tick, so the traversal + * that used to be waited out is time the server is already spending walking us — time in which the + * next door on the route could be clicked. Falls back to the positional conditions when the door + * cannot be identified or the config switch is off. + */ + 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 = + (probe == null || action == null || !doorInteractionWhileApproachingEnabled()) + ? null + : () -> doorObservedOpen(probe, fromWp, toWp, doorActions, action); + java.util.function.Supplier observation = + (probe == null || action == null) ? null + : () -> describeDoorObservation(probe, fromWp, toWp, doorActions, action); + // Ranged budgets can hold for seconds, so a hold must release when the plan it belongs to + // stops existing — the walk cancelled or re-targeted, OR the route replanned under the same + // target. The second case is what live collision does when it sees the awaited edge blocked: + // it recalculates and routes around, and holding the old plan's door after that is pure + // waste (measured: the replan fired a second before a 6.9s ranged timeout expired). + // A new Pathfinder instance IS the replan signal; the reference is captured at click time. + WorldPoint walkTarget = currentTarget; + Object plannerAtClick = Rs2PathApi.getPathfinder(); + java.util.function.BooleanSupplier cancelled = () -> { + // 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; + }; + // 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); + 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; } } @@ -7320,6 +8128,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 @@ -7328,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, @@ -7340,11 +8154,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 @@ -7353,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); } @@ -7364,24 +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(); - if (!markDoorEdgeAttemptThisPass(attemptedDoorEdgesThisPass, segment, playerBeforeAttempt)) { + resetDoorLegStages(); + if (passBudgeted && claimableSegment + && !doorAttemptLedger.tryClaimEdgeThisPass(segment[0], segment[1], playerBeforeAttempt)) { routeState.lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); return false; @@ -7390,13 +8209,13 @@ 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; } 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; @@ -7444,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 @@ -7486,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++; @@ -7512,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++; @@ -7578,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; @@ -7615,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; @@ -7710,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; @@ -7749,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; @@ -7814,7 +8609,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; @@ -8153,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; @@ -8205,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; } } @@ -8220,10 +9014,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() { @@ -8232,11 +9031,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.")) { @@ -8335,13 +9159,29 @@ 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. + * + *

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); @@ -8349,10 +9189,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. @@ -8363,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()) { @@ -8415,6 +9227,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; } @@ -8521,6 +9336,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() { @@ -8539,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() @@ -8615,18 +9455,19 @@ 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); } - /** - * 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) @@ -8749,996 +9590,91 @@ 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(); - } - if (!selectTerminalTravelDialogueDestination( - transport, terminalTravelMode)) { - 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); - } - } + 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.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); - } - } + 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; + } - 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); - } - } + 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.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); - } - } + 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.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); - } - } + 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; + } - 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 handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { - return handleTransportsInRawSegment(rawPath, rawFrom, rawTo, false); - } + private static boolean handleTransportsInRawSegment(List rawPath, int rawFrom, int rawTo) { + return handleTransportsInRawSegment(rawPath, rawFrom, rawTo, false); + } /** * Dispatches a planned transport on this raw segment. @@ -9940,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); } @@ -9969,60 +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.lastTransportHandledAtLocation = Rs2Player.getWorldLocation(); - routeState.lastTransportOriginLocation = transport != null ? transport.getOrigin() : null; - routeState.lastTransportDestinationLocation = transport != null ? transport.getDestination() : null; - WorldPoint goal = currentTarget; - WorldPoint transportDest = transport != null ? transport.getDestination() : null; - boolean expectedTransport = consumeExpectedTransportDestination(transportDest); - boolean hasPrecomputedContinuation = hasPrecomputedContinuationFromTransport(transport); - if (goal != null) { - WebWalkLog.tmark("transport_handoff_enter", - 0L, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest) - + " expected=" + expectedTransport - + " precomputed=" + hasPrecomputedContinuation - + " type=" + (transport != null ? transport.getType() : "null")); - } - if ((expectedTransport || hasPrecomputedContinuation) && goal != null) { - WebWalkLog.tmark(expectedTransport ? "transport_handoff_expected_hit" : "transport_handoff_precomputed_hit", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - return true; - } - if (goal != null && transportDest != null) { - // Destination-aware handoff: prepare next path from known landing tile. - boolean queued = restartPathfinding(transportDest, goal); - WebWalkLog.tmark("transport_handoff_restart", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "queued=" + queued + " dest=" + compactWorldPoint(transportDest)); - if (!queued && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_fallback", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - } else if (goal != null && shouldRecalculatePathAfterTransport(transport)) { - recalculatePath(); - WebWalkLog.tmark("transport_handoff_recalc_goal_only", - System.currentTimeMillis() - handoffStartedAt, - goal, - Rs2Player.getWorldLocation(), - "dest=" + compactWorldPoint(transportDest)); - } - return true; - } private static void primeExpectedTransportDestinations(List path, int startIdx) { if (path == null || path.size() < 2) { @@ -10053,838 +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. + */ + 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())) { - WebWalkLog.spWarn( - "terminal travel destination option missing name={} dest={}", - transport.getName(), transport.getDisplayInfo()); - return false; - } - return true; - } - 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. @@ -10951,18 +10046,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)) { @@ -10974,7 +10082,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; @@ -11053,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); @@ -11096,17 +10204,6 @@ static int offPathRecalcDeferredWaitMs(String reason, Math.min(OFF_PATH_RECALC_DEFER_WAIT_MAX_MS, remainingMs)); } - private static boolean isOffPathRecalcDeferredExit(String exitReason) { - return exitReason != null && exitReason.startsWith("off-path-deferred:"); - } - - private 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; } @@ -11158,7 +10255,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(); @@ -11167,6 +10268,7 @@ private static void checkIfStuck() { routeState.stuckCount++; } } else { + routeState.lastTileChangeAtMs = System.currentTimeMillis(); routeState.stuckCount = 0; routeState.lastMovedTimeMs = System.currentTimeMillis(); } @@ -11176,18 +10278,32 @@ 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 + * 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(); @@ -11206,1184 +10322,180 @@ private static boolean interactingActorNearWalkablePath() { 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); + return routeState.lastMovedTimeMs > 0 && System.currentTimeMillis() - routeState.lastMovedTimeMs > stallThresholdMs(); + } - Rectangle bounds = widget.getBounds(); - Microbot.doInvoke(destinationMenuEntry, bounds != null ? bounds : Rs2UiHelper.getDefaultRectangle()); - return true; + /** + * @param start + */ + public void setStart(WorldPoint start) { + Set targets = Rs2PathApi.getActiveRouteTargets(); + if (targets.isEmpty()) { + return; + } + Rs2PathApi.setStartPointSet(true); + if (isClientThread()) { + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); + } else { + restartPathfinding(start, targets); + } } - private static String getFirstWidgetAction(Widget widget) { - String[] actions = widget.getActions(); - if (actions == null) { + /** + * Of these candidate tiles, the one the pathfinder can actually reach most cheaply — or null when + * none of them is reachable. + * + *

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

Worked case: approaching the Black Knights' Fortress ladder from (3024,3512), the tiles beside + * it are walkable and adjacent but walled off, while the usable approach is east through a Sturdy + * door. Proximity picks a walled tile every time; this picks the one with a route. + * + * @param start where we are pathing from + * @param candidates tiles worth standing on, in no particular order + * @return the reachable candidate, or null if the pathfinder cannot reach any of them + */ + public static WorldPoint nearestReachable(WorldPoint start, Collection candidates) { + if (start == null || candidates == null || candidates.isEmpty()) { return null; } - return Arrays.stream(actions) - .filter(action -> action != null && !action.isEmpty()) - .findFirst() + Set targets = new HashSet<>(candidates); + if (targets.contains(start)) { + return start; + } + // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. + return Rs2PathApi.plan(Rs2RouteRequest.toAny(start, targets)) + .getReachedTarget(0) .orElse(null); } - private static void confirmCharterTravelIfPrompted() { - if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2000)) { - Rs2Dialogue.clickOption("Yes", true); - } - } /** - * interact with interfaces like spirit tree etc... + * Checks the distance between startpoint and endpoint using ShortestPath * - * @param transport + * @param startpoint + * @param endpoint + * @return distance */ - private static boolean interactWithAdventureLog(Transport transport) { - if (transport.getDisplayInfo() == null || transport.getDisplayInfo().isEmpty()) return false; - - // Wait for the widget to become visible - boolean isAdventureLogVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(ComponentID.ADVENTURE_LOG_CONTAINER), Rs2Player::isMoving, 100, 10000); + public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) { + return Rs2PathApi.plan(Rs2RouteRequest.to(startpoint, endpoint)).getPath().size(); + } - if (!isAdventureLogVisible) { - log.error("Widget did not become visible within the timeout."); - return false; - } - String destinationString = transport.getDisplayInfo().replaceAll("^\\d+:\\s*", ""); - Widget destinationWidget = Rs2Widget.findWidget(destinationString, List.of(Rs2Widget.getWidget(187, 3))); - if (destinationWidget == null) return false; - Rs2Widget.clickWidget(destinationWidget); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); - } - private static boolean handleGlider(Transport transport) { - int TA_QUIR_PRIW = 9043972; - int SINDARPOS = 9043975; - int LEMANTO_ANDRA = 9043978; - int KAR_HEWO = 9043981; - int GANDIUS = 9043984; - int OOKOOKOLLY_UNDRI = 9043993; - int LEMANTOLLY_UNDRI = 9043989; - // Get Transport Information - String displayInfo = transport.getDisplayInfo(); - String npcName = transport.getName(); - String action = transport.getAction(); - final int GLIDER_PARENT_WIDGET = 138; - final int GLIDER_CHILD_WIDGET = 0; - // Check if the widget is already visible - boolean isGliderMenuVisible = Rs2Widget.getWidget(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET) != null; - if (!isGliderMenuVisible) { - // Find the glider NPC - var gnome = Rs2Npc.getNpc(npcName); // Use the NPC name to find the NPC - if (gnome == null) { - return false; - } - // Interact with the gnome glider NPC - if (Rs2Npc.interact(gnome, action)) { - sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); - } - } - // Wait for the widget to become visible - boolean widgetVisible = sleepUntilTrue(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET), Rs2Player::isMoving, 100, 10000); - if (!widgetVisible) { - log.error("Widget did not become visible within the timeout."); - return false; - } - if (displayInfo.isEmpty()) 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"); - switch (displayInfo) { - case "Kar-Hewo": - return Rs2Widget.clickWidget(KAR_HEWO); - case "Ta Quir Priw": - return Rs2Widget.clickWidget(TA_QUIR_PRIW); - case "Sindarpos": - return Rs2Widget.clickWidget(SINDARPOS); - case "Lemanto Andra": - return Rs2Widget.clickWidget(LEMANTO_ANDRA); - case "Gandius": - return Rs2Widget.clickWidget(GANDIUS); - case "Ookookolly Undri": - return Rs2Widget.clickWidget(OOKOOKOLLY_UNDRI); - case "Lemantolly Undri": - return Rs2Widget.clickWidget(LEMANTOLLY_UNDRI); - default: - log.error("{} not found on the interface.", displayInfo); - return false; + private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { + assert rs2Item != null; + String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); + if (primary != null) { + return primary; } + return rs2Item.getActionFromList(Arrays.asList( + "invoke", "empty", "consume", "reminisce", "signal", "squash")); } - // Constants for widget IDs - private static final int SLOT_ONE = 26083331; - private static final int SLOT_TWO = 26083332; - private static final int SLOT_THREE = 26083333; - private static final int SLOT_ONE_CW_ROTATION = 26083347; - private static final int SLOT_ONE_ACW_ROTATION = 26083348; - private static final int SLOT_TWO_CW_ROTATION = 26083349; - private static final int SLOT_TWO_ACW_ROTATION = 26083350; - private static final int SLOT_THREE_CW_ROTATION = 26083351; - private static final int SLOT_THREE_ACW_ROTATION = 26083352; - private static int fairyRingGraphicId = 569; - private static boolean handleFairyRing(Transport transport) { - Rs2ItemModel startingWeapon = null; - TileObject fairyRingObject = PohTeleports.isInHouse() ? PohTeleports.getFairyRings() : Rs2GameObject.getAll(o -> Objects.equals(o.getWorldLocation(), transport.getOrigin())).stream().findFirst().orElse(null); - if (fairyRingObject == null) return false; - if (!PohTeleports.isInHouse() && !Rs2GameObject.canWalkTo(fairyRingObject, 25)) return false; - boolean hasLumbridgeElite = Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; - if (!hasLumbridgeElite) { - if (Rs2Equipment.isWearing(EquipmentInventorySlot.WEAPON)) { - startingWeapon = Rs2Equipment.get(EquipmentInventorySlot.WEAPON); - } - if (!Rs2Equipment.isWearing("Dramen staff") && !Rs2Equipment.isWearing("Lunar staff")) { - if (Rs2Inventory.contains("Dramen staff")) { - Rs2Inventory.equip("Dramen staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Dramen staff")); - } else if (Rs2Inventory.contains("Lunar staff")) { - Rs2Inventory.equip("Lunar staff"); - sleepUntil(() -> Rs2Equipment.isWearing("Lunar staff")); - } else { - return false; - } - } - } - String lastDestinationAction = "last-destination (" + transport.getDisplayInfo() + ")"; - String treeLastDestinationAction = "Ring-last-destination (" + transport.getDisplayInfo() + ")"; - ObjectComposition composition = Rs2GameObject.convertToObjectComposition(fairyRingObject); - log.info("Interacting with Fairy Ring @ {}", fairyRingObject.getWorldLocation()); - // we can use the last-destination to handle fairy rings - if (Rs2GameObject.hasAction(composition, lastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, lastDestinationAction); - } else if (Rs2GameObject.hasAction(composition, treeLastDestinationAction, true)) { - Rs2GameObject.interact(fairyRingObject, treeLastDestinationAction); - } else { - // We have to configure fairy rings through the interface - if (Rs2GameObject.hasAction(composition, "Configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Configure"); - } else if (Rs2GameObject.hasAction(composition, "Ring-configure", true)) { - Rs2GameObject.interact(fairyRingObject, "Ring-configure"); - } - sleepUntil(() -> !Rs2Player.isMoving() && !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON), 10000); - if (Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON)) { - log.warn("Fairy ring interface did not open (interrupted by combat?). Retrying next iteration."); - return false; - } - Widget slotOne = Rs2Widget.getWidget(SLOT_ONE); - Widget slotTwo = Rs2Widget.getWidget(SLOT_TWO); - Widget slotThree = Rs2Widget.getWidget(SLOT_THREE); - if (slotOne == null || slotTwo == null || slotThree == null) { - log.warn("Fairy ring slot widget(s) are null; interface may have closed unexpectedly."); - return false; - } - rotateSlotToDesiredRotation(SLOT_ONE, slotOne.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(0)), SLOT_ONE_ACW_ROTATION, SLOT_ONE_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_TWO, slotTwo.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(1)), SLOT_TWO_ACW_ROTATION, SLOT_TWO_CW_ROTATION); - rotateSlotToDesiredRotation(SLOT_THREE, slotThree.getRotationY(), getDesiredRotation(transport.getDisplayInfo().charAt(2)), SLOT_THREE_ACW_ROTATION, SLOT_THREE_CW_ROTATION); - Rs2Widget.clickWidget(ComponentID.FAIRY_RING_TELEPORT_BUTTON); - } - sleepUntil(() -> Rs2Player.getGraphicId() == fairyRingGraphicId, 5000); - sleepUntil(() -> Objects.equals(Rs2Player.getWorldLocation(), transport.getDestination()) && Rs2Player.getGraphicId() != fairyRingGraphicId, 10000); - if (startingWeapon != null) { - Rs2ItemModel finalStartingWeapon = startingWeapon; - Rs2Inventory.equip(finalStartingWeapon.getId()); - sleepUntil(() -> Rs2Equipment.isWearing(finalStartingWeapon.getId())); - } - return true; - } + + + /** - * Rotates a fairy ring slot to the desired rotation value. - * Calculates the most efficient rotation direction (clockwise or anticlockwise) - * and performs the necessary number of rotations to reach the target. + * 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. @@ -12993,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 new file mode 100644 index 00000000000..bacaa442020 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedger.java @@ -0,0 +1,366 @@ +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(); + } + + // ---- walk-runtime facets (D3 slice 4): pass claims, settle window, cooldown, raw-scan focus ---- + + private final Map edgeAttemptPosByKeyThisPass = new ConcurrentHashMap<>(); + private volatile long settleStartedAtMs; + private volatile long settleUntilMs; + private volatile WorldPoint settleFarSideWp; + private volatile long globalCooldownUntilMs; + private volatile Integer rawScanFocusDoorIdx; + private volatile long rawScanFocusSetAtMs; + private volatile int rawScanFocusAttempts; + + /** A new tail pass gets a fresh per-edge attempt budget (formerly doorEdgesAttemptedThisTail). */ + public void beginTailPass() { + edgeAttemptPosByKeyThisPass.clear(); + } + + /** + * One-shot budget per edge per pass, re-armed once the player has genuinely MOVED since the + * previous attempt (within one tile of the recorded position = still the same stand, refuse). + * A null recorded position never binds — preserving the old map's null-value semantics. + */ + public boolean tryClaimEdgeThisPass(WorldPoint fromWp, WorldPoint toWp, WorldPoint playerBeforeAttempt) { + if (fromWp == null || toWp == null) { + return true; + } + String edgeKey = Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp); + WorldPoint previous = edgeAttemptPosByKeyThisPass.get(edgeKey); + if (previous != null && playerBeforeAttempt != null + && previous.getPlane() == playerBeforeAttempt.getPlane() + && previous.distanceTo2D(playerBeforeAttempt) <= 1) { + return false; + } + if (playerBeforeAttempt != null) { + edgeAttemptPosByKeyThisPass.put(edgeKey, playerBeforeAttempt); + } else { + edgeAttemptPosByKeyThisPass.remove(edgeKey); + } + return true; + } + + /** Hands the budget back when no interaction happened — a later resolver may try the edge this pass. */ + public void releaseEdgeThisPass(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + edgeAttemptPosByKeyThisPass.remove(Rs2DoorHandler.doorAttemptKey(null, fromWp, toWp)); + } + } + + /** Starts the door settle window, remembering the far-side tile so it can end when the edge opens. */ + public void markSettling(WorldPoint farSideWp, long nowMs, long settleMs) { + settleStartedAtMs = nowMs; + settleUntilMs = nowMs + settleMs; + settleFarSideWp = farSideWp; + } + + public long settleStartedAtMs() { + return settleStartedAtMs; + } + + public long settleUntilMs() { + return settleUntilMs; + } + + public WorldPoint settleFarSide() { + return settleFarSideWp; + } + + /** The far side proved reachable: the edge is open, there is nothing left to settle. */ + public void endSettleEarly() { + settleUntilMs = 0L; + settleFarSideWp = null; + } + + public void markGlobalCooldownUntil(long untilMs) { + globalCooldownUntilMs = untilMs; + } + + public long globalCooldownUntilMs() { + return globalCooldownUntilMs; + } + + /** The raw scene scan commits to one door index for a bounded number of attempts. */ + public void setRawScanFocus(int index, long nowMs) { + rawScanFocusDoorIdx = index; + rawScanFocusSetAtMs = nowMs; + rawScanFocusAttempts = 0; + } + + public Integer rawScanFocusDoorIdx() { + return rawScanFocusDoorIdx; + } + + public long rawScanFocusSetAtMs() { + return rawScanFocusSetAtMs; + } + + public int rawScanFocusAttempts() { + return rawScanFocusAttempts; + } + + public void recordRawScanFocusAttempt() { + rawScanFocusAttempts++; + } + + public void clearRawScanFocus() { + rawScanFocusDoorIdx = null; + rawScanFocusSetAtMs = 0L; + rawScanFocusAttempts = 0; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java index 8545312f3b1..4e10b73edb6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifier.java @@ -17,7 +17,7 @@ public final class Rs2DoorClassifier { private static final String[] DOOR_LIKE_NAME_FRAGMENTS = { - "door", "gate", "barrier", "stile", "portcullis", "archway", "cattlegate", "fence" + "door", "gate", "barrier", "stile", "portcullis", "archway", "cattlegate", "fence", "curtain" }; /** {@code fence} must be whole-word — substring matches {@code defence} ("fence" inside) otherwise. */ @@ -29,9 +29,44 @@ public final class Rs2DoorClassifier { "push", "climb-over", "climb-through", "squeeze-through", "cross", "force", "exit" ); + /** + * Actions that carry the player ACROSS the obstacle rather than opening an edge in it. + * + *

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

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

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

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

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

Corner walls (orientation 16..128) answer false: their face does not divide the plane + * along a single axis. + * + * @param orientationA the wall's {@code getOrientationA()}: 1=west, 2=north, 4=east, 8=south + */ + public static boolean playerBeyondWallFace(int orientationA, WorldPoint wallTile, + WorldPoint from, WorldPoint at) { + if (wallTile == null || from == null || at == null + || wallTile.getPlane() != from.getPlane() || at.getPlane() != from.getPlane()) { + return false; + } + switch (orientationA) { + case 1: // west face: boundary between x = wallTile.x-1 and x = wallTile.x + return sidesDiffer(from.getX(), at.getX(), wallTile.getX()); + case 4: // east face: boundary between x = wallTile.x and x = wallTile.x+1 + return sidesDiffer(from.getX(), at.getX(), wallTile.getX() + 1); + case 2: // north face: boundary between y = wallTile.y and y = wallTile.y+1 + return sidesDiffer(from.getY(), at.getY(), wallTile.getY() + 1); + case 8: // south face: boundary between y = wallTile.y-1 and y = wallTile.y + return sidesDiffer(from.getY(), at.getY(), wallTile.getY()); + default: + return false; + } + } + + /** Opposite sides of the boundary that lies just before {@code boundary} ({@code >=} vs {@code <}). */ + private static boolean sidesDiffer(int a, int b, int boundary) { + return (a >= boundary) != (b >= boundary); + } + /** As above, with the object's location supplied (see {@link #wallDoorTouchesSegment}). */ public static boolean isDoorOnSegment(TileObject object, WorldPoint objectLocation, WorldPoint fromWp, WorldPoint toWp) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java index 4357448c64d..8b13f757ac0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -16,49 +16,27 @@ public static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, Worl return compactWorldPoint(doorTile) + "|" + compactWorldPoint(fromWp) + "->" + compactWorldPoint(toWp); } - public static boolean shouldThrottleDoorAttempt(Map recentDoorAttemptByEdge, - long cooldownMs, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - String key = doorAttemptKey(doorTile, fromWp, toWp); - long now = System.currentTimeMillis(); - recentDoorAttemptByEdge.entrySet().removeIf(entry -> now - entry.getValue() > cooldownMs); - Long last = recentDoorAttemptByEdge.get(key); - return last != null && now - last < cooldownMs; - } - - public static void markDoorAttempt(Map recentDoorAttemptByEdge, - WorldPoint doorTile, - WorldPoint fromWp, - WorldPoint toWp) { - recentDoorAttemptByEdge.put(doorAttemptKey(doorTile, fromWp, toWp), System.currentTimeMillis()); - } - - public static void markStationaryDoorOpened(Map recentlyOpenedStationaryDoors, WorldPoint doorTile) { - if (doorTile != null) { - recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); - } + public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { + return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; } - public static boolean recentlyOpenedStationaryDoorOnSegment(Map recentlyOpenedStationaryDoors, - long suppressMs, - WorldPoint fromWp, - WorldPoint toWp) { - if (fromWp == null || toWp == null) { - return false; + /** + * Edge-scoped variant. The full window is anti-hammer for ONE door — re-clicking the same edge + * before the world has caught up. A DIFFERENT door immediately after a successful open is not + * hammering, it is chaining, and holding it for the full window serialised every pair of nearby + * doors. A different edge owes only the cross-edge floor (one game tick): enough that two clicks + * cannot land inside the same tick, no more. + * + * @param fullCooldownMs the window {@code nextAllowedAtMs} was stamped with + * @param crossEdgeCooldownMs the floor a different edge still owes + */ + public static boolean shouldThrottleGlobalDoorInteraction(long nowMs, long nextAllowedAtMs, + boolean sameEdgeAsLastAttempt, + long fullCooldownMs, long crossEdgeCooldownMs) { + if (sameEdgeAsLastAttempt) { + return nowMs < nextAllowedAtMs; } - final int segmentDoorSuppressDist = 2; - long now = System.currentTimeMillis(); - recentlyOpenedStationaryDoors.entrySet().removeIf(entry -> now - entry.getValue() > suppressMs); - return recentlyOpenedStationaryDoors.keySet().stream() - .anyMatch(door -> door != null - && door.getPlane() == fromWp.getPlane() - && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); - } - - public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { - return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; + return nowMs < nextAllowedAtMs - (fullCooldownMs - crossEdgeCooldownMs); } public static long markGlobalDoorInteractionCooldown(long cooldownMs) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index e66aab862dc..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 @@ -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()) @@ -92,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) { @@ -104,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; } @@ -116,7 +123,13 @@ public static boolean isDoorCandidateOnSegment(DoorProbeContext ctx, Set toWp} segment, using scan snapshots when present. */ - public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set blacklist, - Map recentlyOpened, long stationaryDoorSuppressMs, + public static TileObject findDoorNearSegment(DoorProbeContext ctx, DoorAttemptLedger ledger, + long stationaryDoorSuppressMs, WorldPoint fromWp, WorldPoint toWp, List doorActions) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null || fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return null; } - if (Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment(recentlyOpened, stationaryDoorSuppressMs, fromWp, toWp)) { + if (ledger.recentlyOpenedDoorOnSegment(fromWp, toWp, stationaryDoorSuppressMs, System.currentTimeMillis())) { return null; } @@ -173,7 +186,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, locations.get(o), + .filter(o -> isDoorCandidateOnSegment(ctx, ledger, o, locations.get(o), playerLoc, fromWp, toWp, doorActions, searchDistance)) .min(Comparator.comparingInt(o -> locations.get(o).distanceTo2D(playerLoc))) .orElse(null); @@ -182,7 +195,7 @@ public static TileObject findDoorNearSegment(DoorProbeContext ctx, Set isDoorCandidateOnSegment(ctx, blacklist, o, o.getWorldLocation(), + return Rs2GameObject.getAll(o -> isDoorCandidateOnSegment(ctx, ledger, o, o.getWorldLocation(), playerLoc, fromWp, toWp, doorActions, searchDistance), playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java index 17feaa108c8..0eb376b4bb2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -18,6 +18,39 @@ public final class Rs2WalkerAwaits { private static final long DOOR_IDLE_ACCEPT_MIN_MS = 1_200L; /** Above this combined wait, say which condition released the door await. */ private static final long DOOR_AWAIT_SLOW_LOG_MS = 900L; + /** + * An unlocked door opens within one game tick of the click landing, so there is nothing to observe + * before then and polling earlier only spends client-thread time. Checked on an interval rather + * than every poll because the observation is a scene scan (~60ms measured), not a field read. + */ + private static final long DOOR_OPEN_POLL_START_MS = 250L; + private static final long DOOR_OPEN_POLL_INTERVAL_MS = 250L; + /** + * A click issued from further than the legacy dispatch band is a RANGED click: the server has to + * walk us to the door before anything can open, so the wait is an approach, not a traversal. + */ + static final int RANGED_CLICK_MIN_TILES = 3; + /** Walking pace is one tile per 0.6s; running arrives sooner and releases early via the edge read. */ + private static final long APPROACH_MS_PER_TILE = 600L; + /** Hard ceiling on any door await. The stall release keeps long budgets from ever stranding us. */ + private static final long DOOR_TRAVERSAL_MAX_BUDGET_MS = 8_000L; + + /** + * How long the traversal phase may hold, given how far from the door the click was issued. + *

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

+ * Two rules, both about cost rather than correctness. An unlocked door opens within one game tick + * of the click landing, so an observation before {@link #DOOR_OPEN_POLL_START_MS} can only ever + * report "still shut" and is pure waste. And the observation is a scene scan (~60ms measured), not + * a field read, so at the poll rate of the surrounding wait it would otherwise run several times a + * second for the whole budget — the cost that made door handling expensive in the first place. + */ + static boolean shouldPollDoorOpen(long sinceTraversalStartMs, long sinceLastPollMs) { + return sinceTraversalStartMs >= DOOR_OPEN_POLL_START_MS + && sinceLastPollMs >= DOOR_OPEN_POLL_INTERVAL_MS; + } + @SuppressWarnings("unused") static boolean shouldAcceptIdleDoorAwait(boolean moving, boolean animating, long elapsedMs, boolean edgeResolved) { if (moving || animating) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/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..2eed9d8315e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecision.java @@ -0,0 +1,498 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +import java.util.List; +import java.util.Map; + +/** + * The blocked-frontier cascade's pure decisions: WHERE the route is actually blocked, and which raw + * edge that frontier corresponds to. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The edge starts one smoothed index BEFORE the frontier — the blocked edge is the step INTO + * the unreachable tile, not the step out of it — clamped so it can never precede the route + * position the pass started from. + */ + public static FrontierEdge frontierEdge(List rawPath, + int[] smoothedToRaw, + int fromIndex, + int frontierIndex) + { + int rawSize = rawPath == null ? 0 : rawPath.size(); + int edgeIndex = Math.max(fromIndex, frontierIndex - 1); + int rawStart = smoothedToRaw != null && edgeIndex < smoothedToRaw.length + ? smoothedToRaw[edgeIndex] + : 0; + int rawEndExclusive = smoothedToRaw != null && frontierIndex < smoothedToRaw.length + ? smoothedToRaw[frontierIndex] + 1 + : rawSize; + WorldPoint from = rawStart >= 0 && rawStart < rawSize ? rawPath.get(rawStart) : null; + WorldPoint to = rawEndExclusive - 1 >= 0 && rawEndExclusive - 1 < rawSize + ? rawPath.get(rawEndExclusive - 1) + : null; + return new FrontierEdge(edgeIndex, rawStart, rawEndExclusive, from, to); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java new file mode 100644 index 00000000000..50645b7c211 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/TailDecision.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; + +/** + * What the walk loop does at the end of one iteration: finish, replan, give up, or go round again. + * + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Wire names are load-bearing

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

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

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

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

Distinct from {@link #lastMovedTimeMs}, which several places refresh to buy grace and which + * therefore cannot answer "is the player really covering ground". This one only ever moves when + * the observed tile differs from the previous sample, which is what makes it a usable check on + * the pose-based movement flag. + */ + public volatile long lastTileChangeAtMs = 0L; /** Rising-edge detection for animation progress without tile delta in the stuck check. */ public volatile boolean prevAnimatingForStuckCheck = false; /** Wall-clock ms of the last walled-recovery replan (cooldown selects replan vs wait). */ @@ -88,30 +119,13 @@ public final class WalkerRouteState { /** 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; - /** Origin/destination/time of the last door interaction attempt (wrong-traversal detection reads these). */ - public volatile WorldPoint lastDoorAttemptFrom = null; - public volatile WorldPoint lastDoorAttemptTo = null; - public volatile long lastDoorAttemptAtMs = 0L; - /** Global door-interaction throttle: no door interaction may fire before this wall-clock ms. */ - public volatile long nextDoorInteractionAllowedAtMs = 0L; /** * When the walker first held off a door interaction because an option menu was open; 0 when no * such hold-off is active. Bounds the wait so an unanswered conversation cannot stall the walk. diff --git a/runelite-client/src/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/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/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/main/scripts/BankMainBuild.hash b/runelite-client/src/main/scripts/BankMainBuild.hash index 5234cf89cc3..ab433d8e361 100644 --- a/runelite-client/src/main/scripts/BankMainBuild.hash +++ b/runelite-client/src/main/scripts/BankMainBuild.hash @@ -1 +1 @@ -0DC667D052C87AB3A8C728C4304FD41B21DB5F1AF3747FEC3B03FA81B25A6BD3 \ No newline at end of file +529C412482C269504EAEA9FD65214207A1A2097004449304998BDEACF909387B \ No newline at end of file diff --git a/runelite-client/src/main/scripts/BankMainBuild.rs2asm b/runelite-client/src/main/scripts/BankMainBuild.rs2asm index 4d6c72fc281..e733148e33f 100644 --- a/runelite-client/src/main/scripts/BankMainBuild.rs2asm +++ b/runelite-client/src/main/scripts/BankMainBuild.rs2asm @@ -456,12 +456,12 @@ LABEL390: get_varbit 4150 iconst 9 if_icmpgt LABEL397 - jump LABEL884 + jump LABEL879 LABEL397: get_varbit 4150 iconst 15 if_icmpne LABEL401 - jump LABEL884 + jump LABEL879 LABEL401: iload 25 iconst 1410 @@ -896,26 +896,25 @@ LABEL805: iload 37 iconst 1 if_icmpeq LABEL809 - jump LABEL848 + jump LABEL843 LABEL809: oload 2 string_length iconst 0 if_icmpgt LABEL814 - jump LABEL833 + jump LABEL832 LABEL814: - sconst "Showing items: " - sconst "" + sconst "Showing items: " oload 2 sconst "" - join_string 4 + join_string 3 iload 6 if_settext get_varc_int 5 iconst 11 - if_icmpeq LABEL825 - jump LABEL832 -LABEL825: + if_icmpeq LABEL824 + jump LABEL831 +LABEL824: sconst "Show items whose names contain the following text: (" iload 32 tostring @@ -927,44 +926,40 @@ LABEL825: pop_int ; pop number of matches iconst 10616875 if_settext +LABEL831: + jump LABEL842 LABEL832: - jump LABEL847 -LABEL833: - sconst "Showing items: " - sconst "" - sconst "*" - sconst "" - join_string 4 + sconst "Showing items: *" iload 6 if_settext get_varc_int 5 iconst 11 - if_icmpeq LABEL844 - jump LABEL847 -LABEL844: + if_icmpeq LABEL839 + jump LABEL842 +LABEL839: sconst "Show items whose names contain the following text:" sconst "setSearchBankInputText" ; load event name runelite_callback ; invoke callback iconst 10616875 if_settext -LABEL847: - jump LABEL860 -LABEL848: +LABEL842: + jump LABEL855 +LABEL843: get_varc_int 1380 iconst -1 - if_icmpne LABEL852 - jump LABEL857 -LABEL852: + if_icmpne LABEL847 + jump LABEL852 +LABEL847: get_varc_int 1380 invoke 9552 iload 6 if_settext - jump LABEL860 -LABEL857: + jump LABEL855 +LABEL852: sconst "The Bank of Gielinor" iload 6 if_settext -LABEL860: +LABEL855: iload 0 iload 1 iload 2 @@ -1011,18 +1006,18 @@ singletabbuildmode: get_varbit 4179 ; add ; istore 36 ; store start to the first item in the "All items" tab -LABEL884: +LABEL879: ; if (~bankmain_searching = 1) { iload 37 iconst 1 - if_icmpeq LABEL888 - jump LABEL892 -LABEL888: + if_icmpeq LABEL883 + jump LABEL887 +LABEL883: iconst 1 iconst 1 iconst 1 invoke 299 -LABEL892: +LABEL887: ; after ~meslayer close iconst -1 istore 38 @@ -1036,12 +1031,12 @@ LABEL892: istore 40 iconst 0 istore 41 -LABEL904: +LABEL899: iload 25 iconst 1410 - if_icmplt LABEL908 - jump LABEL984 -LABEL908: + if_icmplt LABEL903 + jump LABEL979 +LABEL903: ; item index = (loop index + offset) % bank size iload 25 ; loop index iload 36 ; offset @@ -1056,9 +1051,9 @@ LOAD_ITEM_INDEX: iload 35 ; use item index instead of loop index cc_find iconst 1 - if_icmpeq LABEL914 - jump LABEL979 -LABEL914: + if_icmpeq LABEL909 + jump LABEL974 +LABEL909: iconst 95 jump LOAD_ITEM_INDEX2 iload 25 @@ -1068,34 +1063,34 @@ LOAD_ITEM_INDEX2: istore 30 iload 30 iconst -1 - if_icmpne LABEL922 - jump LABEL926 -LABEL922: + if_icmpne LABEL917 + jump LABEL921 +LABEL917: iload 34 iconst 1 add istore 34 -LABEL926: +LABEL921: iload 42 ; overriding single tab building mode? iconst 1 ; if_icmpeq filtertest ; iload 25 iload 38 - if_icmpge LABEL930 - jump LABEL977 -LABEL930: + if_icmpge LABEL925 + jump LABEL972 +LABEL925: iload 25 iload 39 - if_icmplt LABEL934 - jump LABEL977 + if_icmplt LABEL929 + jump LABEL972 filtertest: iload 30 ; obj iload 37 ; bankmain_searching oload 2 ; bankmain_filterstring invoke 279 ; ~bankmain_filteritem iconst 1 ; - if_icmpne LABEL977 ; -LABEL934: + if_icmpne LABEL972 ; +LABEL929: iconst 0 cc_sethide iload 30 @@ -1124,15 +1119,15 @@ LOAD_ITEM_INDEX3: cc_setposition iload 40 iload 27 - if_icmplt LABEL961 - jump LABEL966 -LABEL961: + if_icmplt LABEL956 + jump LABEL961 +LABEL956: iload 40 iconst 1 add istore 40 - jump LABEL976 -LABEL966: + jump LABEL971 +LABEL961: iconst 0 iload 41 iconst 1 @@ -1143,43 +1138,43 @@ LABEL966: iconst 36 multiply istore 33 -LABEL976: - jump LABEL979 -LABEL977: +LABEL971: + jump LABEL974 +LABEL972: iconst 1 cc_sethide -LABEL979: +LABEL974: iload 25 iconst 1 add istore 25 - jump LABEL904 -LABEL984: + jump LABEL899 +LABEL979: iload 33 iconst 32 add istore 33 get_varc_int 1380 iconst -1 - if_icmpne LABEL992 - jump LABEL997 -LABEL992: + if_icmpne LABEL987 + jump LABEL992 +LABEL987: get_varc_int 1380 invoke 9552 iload 6 if_settext - jump LABEL1025 -LABEL997: + jump LABEL1020 +LABEL992: get_varbit 4150 iconst 15 - if_icmpeq LABEL1001 - jump LABEL1005 -LABEL1001: + if_icmpeq LABEL996 + jump LABEL1000 +LABEL996: sconst "Potion storage" iload 6 if_settext - jump LABEL1025 -LABEL1005: + jump LABEL1020 +LABEL1000: iload 42 ; overriding single tab building mode? iconst 1 ; if_icmpne tabtitle ; set normal "Tab" title @@ -1190,9 +1185,9 @@ LABEL1005: tabtitle: ; get_varbit 4170 iconst 2 - if_icmpeq LABEL1009 - jump LABEL1019 -LABEL1009: + if_icmpeq LABEL1004 + jump LABEL1014 +LABEL1004: sconst "Tab " iconst 105 iconst 115 @@ -1202,8 +1197,8 @@ LABEL1009: join_string 2 iload 6 if_settext - jump LABEL1025 -LABEL1019: + jump LABEL1020 +LABEL1014: sconst "Tab " get_varbit 4150 tostring @@ -1211,7 +1206,7 @@ LABEL1019: iload 6 if_settext FinishBuilding: -LABEL1025: +LABEL1020: iload 0 iload 1 iload 2 diff --git a/runelite-client/src/main/scripts/ChatBuilder.hash b/runelite-client/src/main/scripts/ChatBuilder.hash index 7d363aec698..ea71db21560 100644 --- a/runelite-client/src/main/scripts/ChatBuilder.hash +++ b/runelite-client/src/main/scripts/ChatBuilder.hash @@ -1 +1 @@ -D1173E761A81C65F358FC67899ACE5720266BD90A09F8609D96256E948D696BD \ No newline at end of file +58CD909CABCDDA7ECEA2E1602D9DF9316092E2B6B12438509200256E023D70C1 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/ChatBuilder.rs2asm b/runelite-client/src/main/scripts/ChatBuilder.rs2asm index 99cdea70439..e9260e2d9b3 100644 --- a/runelite-client/src/main/scripts/ChatBuilder.rs2asm +++ b/runelite-client/src/main/scripts/ChatBuilder.rs2asm @@ -298,24 +298,21 @@ LABEL239: get_varc_int 41 iconst 2 if_icmpeq LABEL290 - jump LABEL346 + jump LABEL343 LABEL290: chat_getmessagefilter string_length iconst 0 if_icmpgt LABEL295 - jump LABEL346 + jump LABEL343 LABEL295: oload 4 - sconst "Public chat filtering:" - sconst "" - sconst " " - sconst "" + sconst "Public chat filtering: " chat_getmessagefilter escape lowercase sconst "" - join_string 7 + join_string 4 iload 8 iload 9 iconst 10616890 @@ -357,7 +354,7 @@ LABEL295: iload 8 enum istore 9 -LABEL346: +LABEL343: iload 0 istore 10 iconst 0 @@ -393,33 +390,33 @@ LABEL346: iconst 0 activeclansettings_find_affined iconst 1 - if_icmpeq LABEL383 - jump LABEL403 -LABEL383: + if_icmpeq LABEL380 + jump LABEL400 +LABEL380: iconst 0 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL388 - jump LABEL403 -LABEL388: + if_icmpeq LABEL385 + jump LABEL400 +LABEL385: oload 0 activeclanchannel_getuserslot istore 17 iload 17 iconst -1 - if_icmpne LABEL395 - jump LABEL398 -LABEL395: + if_icmpne LABEL392 + jump LABEL395 +LABEL392: iload 17 activeclanchannel_getuserrank istore 18 -LABEL398: +LABEL395: activeclanchannel_getrankkick iconst 2956 invoke 4456 istore 20 istore 19 -LABEL403: +LABEL400: sconst "" ostore 24 iconst 0 @@ -428,17 +425,17 @@ LABEL403: ostore 25 iconst -1 istore 22 -LABEL411: +LABEL408: iload 10 iconst -1 - if_icmpne LABEL415 - jump LABEL2353 -LABEL415: + if_icmpne LABEL412 + jump LABEL2320 +LABEL412: iload 9 iconst -1 - if_icmpne LABEL419 - jump LABEL2353 -LABEL419: + if_icmpne LABEL416 + jump LABEL2320 +LABEL416: iload 10 chat_gethistoryex_byuid istore 21 @@ -456,7 +453,7 @@ LABEL419: invoke 193 iconst 1 if_icmpeq CHAT_FILTER - jump LABEL2349 + jump LABEL2316 CHAT_FILTER: oload 21 ; Load the message iconst 1 ; Gets changed to 0 if message is blocked @@ -468,9 +465,9 @@ CHAT_FILTER: pop_int ; Pop the messageType iconst 1 ; 2nd half of conditional ostore 21 ; Override the message with our filtered message - if_icmpeq LABEL437 ; Check if we are building this message - jump LABEL2349 ; continue to next message, skipping this -LABEL437: + if_icmpeq LABEL434 ; Check if we are building this message + jump LABEL2316 ; continue to next message, skipping this +LABEL434: iload 11 oload 19 oload 24 @@ -484,9 +481,9 @@ LABEL437: iload 15 invoke 90 iconst 1 - if_icmpeq LABEL452 - jump LABEL2349 -LABEL452: + if_icmpeq LABEL449 + jump LABEL2316 +LABEL449: iconst 0 ; splitpmbox iload 10 ; message uid oload 20 ; message channel @@ -503,36 +500,36 @@ LABEL452: ostore 20 ; message channel iload 11 switch - 2: LABEL455 - 1: LABEL455 - 90: LABEL479 - 91: LABEL479 - 3: LABEL503 - 7: LABEL503 - 101: LABEL528 - 5: LABEL549 - 6: LABEL585 - 103: LABEL610 - 104: LABEL610 - 110: LABEL610 - 109: LABEL631 - 9: LABEL652 - 111: LABEL681 - 112: LABEL706 - 41: LABEL731 - 44: LABEL950 - 43: LABEL1115 - 46: LABEL1343 - 14: LABEL1398 - 118: LABEL1428 - 107: LABEL1453 - 113: LABEL1492 - 114: LABEL1513 - 116: LABEL1567 - 0: LABEL1621 - 117: LABEL1658 - jump LABEL1679 -LABEL455: + 2: LABEL452 + 1: LABEL452 + 90: LABEL476 + 91: LABEL476 + 3: LABEL500 + 7: LABEL500 + 101: LABEL525 + 5: LABEL546 + 6: LABEL582 + 103: LABEL607 + 104: LABEL607 + 110: LABEL607 + 109: LABEL628 + 9: LABEL649 + 111: LABEL677 + 112: LABEL702 + 41: LABEL727 + 44: LABEL940 + 43: LABEL1100 + 46: LABEL1324 + 14: LABEL1378 + 118: LABEL1407 + 107: LABEL1432 + 113: LABEL1471 + 114: LABEL1492 + 116: LABEL1546 + 0: LABEL1600 + 117: LABEL1637 + jump LABEL1658 +LABEL452: oload 24 oload 19 sconst ":" @@ -556,8 +553,8 @@ LABEL455: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL479: + jump LABEL1675 +LABEL476: oload 24 oload 19 sconst ":" @@ -581,8 +578,8 @@ LABEL479: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL503: + jump LABEL1675 +LABEL500: oload 24 sconst "From " oload 19 @@ -609,8 +606,8 @@ LABEL503: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL528: + jump LABEL1675 +LABEL525: oload 24 oload 9 oload 21 @@ -631,8 +628,8 @@ LABEL528: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL549: + jump LABEL1675 +LABEL546: oload 24 oload 5 oload 21 @@ -655,9 +652,9 @@ LABEL549: istore 7 get_varbit 1627 iconst 0 - if_icmpeq LABEL573 - jump LABEL584 -LABEL573: + if_icmpeq LABEL570 + jump LABEL581 +LABEL570: iload 12 iconst 500 add @@ -669,9 +666,9 @@ LABEL573: sconst "i" iconst 10616832 if_setontimer -LABEL584: - jump LABEL1696 -LABEL585: +LABEL581: + jump LABEL1675 +LABEL582: oload 24 sconst "To " oload 19 @@ -698,8 +695,8 @@ LABEL585: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL610: + jump LABEL1675 +LABEL607: oload 24 oload 10 oload 21 @@ -720,8 +717,8 @@ LABEL610: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL631: + jump LABEL1675 +LABEL628: oload 24 sconst "" oload 21 @@ -742,17 +739,16 @@ LABEL631: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL652: + jump LABEL1675 +LABEL649: oload 24 sconst "[" oload 3 oload 20 - sconst "" - sconst "] " + sconst "] " oload 19 sconst ":" - join_string 7 + join_string 6 sconst "null" invoke 4742 oload 7 @@ -772,8 +768,8 @@ LABEL652: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL681: + jump LABEL1675 +LABEL677: oload 21 invoke 632 ostore 21 @@ -798,8 +794,8 @@ LABEL681: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL706: + jump LABEL1675 +LABEL702: oload 21 invoke 632 ostore 21 @@ -824,26 +820,26 @@ LABEL706: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL731: + jump LABEL1675 +LABEL727: iconst 1 activeclansettings_find_affined iconst 1 - if_icmpeq LABEL736 - jump LABEL783 -LABEL736: + if_icmpeq LABEL732 + jump LABEL778 +LABEL732: iconst 1 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL741 - jump LABEL783 -LABEL741: + if_icmpeq LABEL737 + jump LABEL778 +LABEL737: oload 21 invoke 5501 iconst 1 - if_icmpeq LABEL746 - jump LABEL783 -LABEL746: + if_icmpeq LABEL742 + jump LABEL778 +LABEL742: oload 21 invoke 632 ostore 21 @@ -852,9 +848,8 @@ LABEL746: sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -880,29 +875,29 @@ LABEL746: iload 4 invoke 4483 istore 7 - jump LABEL949 -LABEL783: + jump LABEL939 +LABEL778: iconst 0 activeclansettings_find_affined iconst 1 - if_icmpeq LABEL788 - jump LABEL917 -LABEL788: + if_icmpeq LABEL783 + jump LABEL908 +LABEL783: iconst 0 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL793 - jump LABEL917 -LABEL793: + if_icmpeq LABEL788 + jump LABEL908 +LABEL788: oload 19 removetags activeclansettings_getaffinedslot istore 17 iload 17 iconst -1 - if_icmpne LABEL801 - jump LABEL880 -LABEL801: + if_icmpne LABEL796 + jump LABEL872 +LABEL796: iload 17 activeclansettings_getaffinedrank invoke 4302 @@ -910,16 +905,15 @@ LABEL801: ostore 23 iload 16 iconst -1 - if_icmpne LABEL810 - jump LABEL843 -LABEL810: + if_icmpne LABEL805 + jump LABEL837 +LABEL805: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iload 16 @@ -945,15 +939,14 @@ LABEL810: iload 4 invoke 4483 istore 7 - jump LABEL879 -LABEL843: + jump LABEL871 +LABEL837: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -961,11 +954,10 @@ LABEL843: iconst 0 oload 2 oload 23 - sconst "" - sconst " " + sconst " " oload 19 sconst ":" - join_string 6 + join_string 5 oload 8 oload 21 sconst "" @@ -983,9 +975,9 @@ LABEL843: iload 4 invoke 4483 istore 7 -LABEL879: - jump LABEL916 -LABEL880: +LABEL871: + jump LABEL907 +LABEL872: iconst -1 invoke 4302 istore 16 @@ -994,9 +986,8 @@ LABEL880: sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iload 16 @@ -1022,16 +1013,15 @@ LABEL880: iload 4 invoke 4483 istore 7 -LABEL916: - jump LABEL949 -LABEL917: +LABEL907: + jump LABEL939 +LABEL908: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -1057,28 +1047,28 @@ LABEL917: iload 4 invoke 4483 istore 7 -LABEL949: - jump LABEL1696 -LABEL950: +LABEL939: + jump LABEL1675 +LABEL940: activeclansettings_find_listened iconst 1 - if_icmpeq LABEL954 - jump LABEL1082 -LABEL954: + if_icmpeq LABEL944 + jump LABEL1068 +LABEL944: activeclanchannel_find_listened iconst 1 - if_icmpeq LABEL958 - jump LABEL1082 -LABEL958: + if_icmpeq LABEL948 + jump LABEL1068 +LABEL948: oload 19 removetags activeclansettings_getaffinedslot istore 17 iload 17 iconst -1 - if_icmpne LABEL966 - jump LABEL1045 -LABEL966: + if_icmpne LABEL956 + jump LABEL1032 +LABEL956: iload 17 activeclansettings_getaffinedrank invoke 4302 @@ -1086,16 +1076,15 @@ LABEL966: ostore 23 iload 16 iconst -1 - if_icmpne LABEL975 - jump LABEL1008 -LABEL975: + if_icmpne LABEL965 + jump LABEL997 +LABEL965: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iload 16 @@ -1121,15 +1110,14 @@ LABEL975: iload 4 invoke 4483 istore 7 - jump LABEL1044 -LABEL1008: + jump LABEL1031 +LABEL997: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -1137,11 +1125,10 @@ LABEL1008: iconst 0 oload 2 oload 23 - sconst "" - sconst " " + sconst " " oload 19 sconst ":" - join_string 6 + join_string 5 oload 11 oload 21 sconst "" @@ -1159,9 +1146,9 @@ LABEL1008: iload 4 invoke 4483 istore 7 -LABEL1044: - jump LABEL1081 -LABEL1045: +LABEL1031: + jump LABEL1067 +LABEL1032: iconst -1 invoke 4302 istore 16 @@ -1170,9 +1157,8 @@ LABEL1045: sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iload 16 @@ -1198,16 +1184,15 @@ LABEL1045: iload 4 invoke 4483 istore 7 -LABEL1081: - jump LABEL1114 -LABEL1082: +LABEL1067: + jump LABEL1099 +LABEL1068: oload 24 sconst "[" oload 2 oload 20 - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -1233,15 +1218,15 @@ LABEL1082: iload 4 invoke 4483 istore 7 -LABEL1114: - jump LABEL1696 -LABEL1115: +LABEL1099: + jump LABEL1675 +LABEL1100: oload 21 invoke 5309 iconst 1 - if_icmpeq LABEL1120 - jump LABEL1201 -LABEL1120: + if_icmpeq LABEL1105 + jump LABEL1185 +LABEL1105: oload 21 invoke 632 ostore 21 @@ -1249,22 +1234,22 @@ LABEL1120: oload 21 string_length iconst 0 - if_icmpgt LABEL1129 - jump LABEL1140 -LABEL1129: + if_icmpgt LABEL1114 + jump LABEL1125 +LABEL1114: oload 21 sconst "|" iconst 0 string_indexof_string iconst -1 - if_icmpne LABEL1136 - jump LABEL1140 -LABEL1136: + if_icmpne LABEL1121 + jump LABEL1125 +LABEL1121: oload 21 invoke 632 ostore 21 ostore 25 -LABEL1140: +LABEL1125: oload 21 sconst "" sconst "" @@ -1275,16 +1260,15 @@ LABEL1140: iconst 1 activeclansettings_find_affined iconst 1 - if_icmpeq LABEL1152 - jump LABEL1179 -LABEL1152: + if_icmpeq LABEL1137 + jump LABEL1163 +LABEL1137: oload 24 sconst "[" oload 2 activeclansettings_getclanname - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 oload 14 @@ -1304,8 +1288,8 @@ LABEL1152: iload 4 invoke 203 istore 7 - jump LABEL1200 -LABEL1179: + jump LABEL1184 +LABEL1163: oload 24 sconst "" sconst "null" @@ -1327,15 +1311,15 @@ LABEL1179: iload 4 invoke 203 istore 7 -LABEL1200: - jump LABEL1342 -LABEL1201: +LABEL1184: + jump LABEL1323 +LABEL1185: oload 21 invoke 6264 iconst 1 - if_icmpeq LABEL1206 - jump LABEL1258 -LABEL1206: + if_icmpeq LABEL1190 + jump LABEL1240 +LABEL1190: oload 21 invoke 632 ostore 21 @@ -1343,16 +1327,14 @@ LABEL1206: iconst 2 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL1215 - jump LABEL1239 -LABEL1215: + if_icmpeq LABEL1199 + jump LABEL1221 +LABEL1199: oload 24 sconst "[" oload 2 - sconst "PvP Arena" - sconst "" - sconst "]" - join_string 5 + sconst "PvP Arena]" + join_string 3 sconst "null" invoke 4742 oload 21 @@ -1369,8 +1351,8 @@ LABEL1215: iload 4 invoke 203 istore 7 - jump LABEL1257 -LABEL1239: + jump LABEL1239 +LABEL1221: oload 24 sconst "" sconst "null" @@ -1389,9 +1371,9 @@ LABEL1239: iload 4 invoke 203 istore 7 -LABEL1257: - jump LABEL1342 -LABEL1258: +LABEL1239: + jump LABEL1323 +LABEL1240: oload 21 sconst "" sconst "" @@ -1402,35 +1384,34 @@ LABEL1258: oload 21 string_length iconst 0 - if_icmpgt LABEL1270 - jump LABEL1281 -LABEL1270: + if_icmpgt LABEL1252 + jump LABEL1263 +LABEL1252: oload 21 sconst "|" iconst 0 string_indexof_string iconst -1 - if_icmpne LABEL1277 - jump LABEL1281 -LABEL1277: + if_icmpne LABEL1259 + jump LABEL1263 +LABEL1259: oload 21 invoke 632 ostore 21 ostore 25 -LABEL1281: +LABEL1263: iconst 0 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL1286 - jump LABEL1317 -LABEL1286: + if_icmpeq LABEL1268 + jump LABEL1298 +LABEL1268: oload 24 sconst "[" oload 2 activeclanchannel_getclanname - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -1454,8 +1435,8 @@ LABEL1286: iload 4 invoke 4483 istore 7 - jump LABEL1342 -LABEL1317: + jump LABEL1323 +LABEL1298: oload 24 sconst "" sconst "null" @@ -1481,21 +1462,20 @@ LABEL1317: iload 4 invoke 4483 istore 7 -LABEL1342: - jump LABEL1696 -LABEL1343: +LABEL1323: + jump LABEL1675 +LABEL1324: activeclanchannel_find_listened iconst 1 - if_icmpeq LABEL1347 - jump LABEL1375 -LABEL1347: + if_icmpeq LABEL1328 + jump LABEL1355 +LABEL1328: oload 24 sconst "[" oload 2 activeclanchannel_getclanname - sconst "" - sconst "]" - join_string 5 + sconst "]" + join_string 4 sconst "null" invoke 4742 iconst -1 @@ -1516,8 +1496,8 @@ LABEL1347: iload 4 invoke 4483 istore 7 - jump LABEL1397 -LABEL1375: + jump LABEL1377 +LABEL1355: oload 24 sconst "" sconst "null" @@ -1540,9 +1520,9 @@ LABEL1375: iload 4 invoke 4483 istore 7 -LABEL1397: - jump LABEL1696 -LABEL1398: +LABEL1377: + jump LABEL1675 +LABEL1378: oload 21 invoke 2066 istore 13 @@ -1550,9 +1530,8 @@ LABEL1398: ostore 21 oload 24 oload 1 - sconst "Broadcast:" - sconst "" - join_string 3 + sconst "Broadcast:" + join_string 2 sconst "null" invoke 4742 oload 18 @@ -1572,8 +1551,8 @@ LABEL1398: iload 4 invoke 203 istore 7 - jump LABEL1696 -LABEL1428: + jump LABEL1675 +LABEL1407: oload 21 invoke 632 ostore 21 @@ -1598,15 +1577,15 @@ LABEL1428: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL1453: + jump LABEL1675 +LABEL1432: clientclock iload 12 sub iconst 500 - if_icmpgt LABEL1459 - jump LABEL1474 -LABEL1459: + if_icmpgt LABEL1438 + jump LABEL1453 +LABEL1438: sconst "" iload 8 iload 9 @@ -1621,8 +1600,8 @@ LABEL1459: iload 4 invoke 199 istore 7 - jump LABEL1491 -LABEL1474: + jump LABEL1470 +LABEL1453: oload 24 oload 21 sconst "null" @@ -1640,9 +1619,9 @@ LABEL1474: iload 4 invoke 199 istore 7 -LABEL1491: - jump LABEL1696 -LABEL1492: +LABEL1470: + jump LABEL1675 +LABEL1471: oload 24 oload 16 oload 21 @@ -1663,8 +1642,8 @@ LABEL1492: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL1513: + jump LABEL1675 +LABEL1492: oload 21 invoke 632 ostore 21 @@ -1672,9 +1651,9 @@ LABEL1513: oload 19 string_length iconst 0 - if_icmpgt LABEL1522 - jump LABEL1546 -LABEL1522: + if_icmpgt LABEL1501 + jump LABEL1525 +LABEL1501: oload 24 oload 19 sconst ":" @@ -1698,8 +1677,8 @@ LABEL1522: iload 4 invoke 203 istore 7 - jump LABEL1566 -LABEL1546: + jump LABEL1545 +LABEL1525: oload 24 oload 17 oload 21 @@ -1720,9 +1699,9 @@ LABEL1546: iload 4 invoke 199 istore 7 -LABEL1566: - jump LABEL1696 -LABEL1567: +LABEL1545: + jump LABEL1675 +LABEL1546: oload 21 invoke 632 ostore 21 @@ -1730,9 +1709,9 @@ LABEL1567: oload 19 string_length iconst 0 - if_icmpgt LABEL1576 - jump LABEL1600 -LABEL1576: + if_icmpgt LABEL1555 + jump LABEL1579 +LABEL1555: oload 24 oload 19 sconst ":" @@ -1756,8 +1735,8 @@ LABEL1576: iload 4 invoke 203 istore 7 - jump LABEL1620 -LABEL1600: + jump LABEL1599 +LABEL1579: oload 24 oload 4 oload 21 @@ -1778,32 +1757,32 @@ LABEL1600: iload 4 invoke 199 istore 7 -LABEL1620: - jump LABEL1696 -LABEL1621: +LABEL1599: + jump LABEL1675 +LABEL1600: oload 21 string_length iconst 0 - if_icmpgt LABEL1626 - jump LABEL1638 -LABEL1626: + if_icmpgt LABEL1605 + jump LABEL1617 +LABEL1605: oload 21 sconst "|" iconst 0 string_indexof_string iconst -1 - if_icmpne LABEL1633 - jump LABEL1638 -LABEL1633: + if_icmpne LABEL1612 + jump LABEL1617 +LABEL1612: oload 21 invoke 632 ostore 21 ostore 25 - jump LABEL1640 -LABEL1638: + jump LABEL1619 +LABEL1617: sconst "" ostore 25 -LABEL1640: +LABEL1619: oload 24 oload 21 sconst "null" @@ -1821,8 +1800,8 @@ LABEL1640: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL1658: + jump LABEL1675 +LABEL1637: oload 24 oload 15 oload 21 @@ -1843,8 +1822,8 @@ LABEL1658: iload 4 invoke 199 istore 7 - jump LABEL1696 -LABEL1679: + jump LABEL1675 +LABEL1658: oload 24 oload 21 sconst "null" @@ -1862,35 +1841,35 @@ LABEL1679: iload 4 invoke 199 istore 7 -LABEL1696: +LABEL1675: iload 9 if_clearops iload 11 switch - 1: LABEL1701 - 2: LABEL1701 - 3: LABEL1701 - 6: LABEL1701 - 7: LABEL1701 - 9: LABEL1701 - 90: LABEL1701 - 91: LABEL1701 - 106: LABEL1701 - 41: LABEL1701 - 44: LABEL1701 - 0: LABEL1818 - 101: LABEL1926 - 103: LABEL1978 - 104: LABEL1978 - 110: LABEL1978 - 14: LABEL2021 - 118: LABEL2082 - 109: LABEL2137 - 111: LABEL2180 - 112: LABEL2223 - 43: LABEL2266 - jump LABEL2323 -LABEL1701: + 1: LABEL1680 + 2: LABEL1680 + 3: LABEL1680 + 6: LABEL1680 + 7: LABEL1680 + 9: LABEL1680 + 90: LABEL1680 + 91: LABEL1680 + 106: LABEL1680 + 41: LABEL1680 + 44: LABEL1680 + 0: LABEL1797 + 101: LABEL1902 + 103: LABEL1954 + 104: LABEL1954 + 110: LABEL1954 + 14: LABEL1997 + 118: LABEL2055 + 109: LABEL2107 + 111: LABEL2150 + 112: LABEL2193 + 43: LABEL2236 + jump LABEL2290 +LABEL1680: sconst "" oload 19 sconst "" @@ -1914,39 +1893,39 @@ LABEL1701: if_setonmouseleave iload 11 iconst 41 - if_icmpne LABEL1726 - jump LABEL1735 -LABEL1726: + if_icmpne LABEL1705 + jump LABEL1714 +LABEL1705: oload 19 invoke 2759 iconst 1 - if_icmpeq LABEL1731 - jump LABEL1735 -LABEL1731: + if_icmpeq LABEL1710 + jump LABEL1714 +LABEL1710: iconst 10 sconst "Crown Info" iload 9 if_setop -LABEL1735: +LABEL1714: oload 0 oload 19 removetags compare iconst 0 - if_icmpne LABEL1742 - jump LABEL1817 -LABEL1742: + if_icmpne LABEL1721 + jump LABEL1796 +LABEL1721: iload 15 iconst 1 - if_icmpeq LABEL1746 - jump LABEL1751 -LABEL1746: + if_icmpeq LABEL1725 + jump LABEL1730 +LABEL1725: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL1759 -LABEL1751: + jump LABEL1738 +LABEL1730: iconst 6 sconst "Add friend" iload 9 @@ -1955,92 +1934,92 @@ LABEL1751: sconst "Add ignore" iload 9 if_setop -LABEL1759: +LABEL1738: iconst 8 sconst "Report" iload 9 if_setop iload 11 iconst 9 - if_icmpeq LABEL1767 - jump LABEL1780 -LABEL1767: + if_icmpeq LABEL1746 + jump LABEL1759 +LABEL1746: clan_getchatcount iconst 0 - if_icmpgt LABEL1771 - jump LABEL1779 -LABEL1771: + if_icmpgt LABEL1750 + jump LABEL1758 +LABEL1750: clan_getchatrank clan_getchatminkick - if_icmpge LABEL1775 - jump LABEL1779 -LABEL1775: + if_icmpge LABEL1754 + jump LABEL1758 +LABEL1754: iconst 9 sconst "Kick" iload 9 if_setop -LABEL1779: - jump LABEL1817 -LABEL1780: +LABEL1758: + jump LABEL1796 +LABEL1759: iload 11 iconst 41 - if_icmpeq LABEL1784 - jump LABEL1817 -LABEL1784: + if_icmpeq LABEL1763 + jump LABEL1796 +LABEL1763: iload 18 iload 19 - if_icmpge LABEL1788 - jump LABEL1817 -LABEL1788: + if_icmpge LABEL1767 + jump LABEL1796 +LABEL1767: iconst 0 activeclanchannel_find_affined iconst 1 - if_icmpeq LABEL1793 - jump LABEL1817 -LABEL1793: + if_icmpeq LABEL1772 + jump LABEL1796 +LABEL1772: oload 19 removetags activeclanchannel_getuserslot istore 17 iload 17 iconst -1 - if_icmpeq LABEL1805 + if_icmpeq LABEL1784 iload 17 activeclanchannel_getuserrank iconst -1 - if_icmple LABEL1805 - jump LABEL1817 -LABEL1805: + if_icmple LABEL1784 + jump LABEL1796 +LABEL1784: iconst 9 sconst "Kick" iload 9 if_setop iload 18 iload 20 - if_icmpge LABEL1813 - jump LABEL1817 -LABEL1813: + if_icmpge LABEL1792 + jump LABEL1796 +LABEL1792: iconst 10 sconst "Ban" iload 9 if_setop -LABEL1817: - jump LABEL2335 -LABEL1818: +LABEL1796: + jump LABEL2302 +LABEL1797: oload 19 string_length iconst 0 - if_icmpgt LABEL1823 - jump LABEL1856 -LABEL1823: + if_icmpgt LABEL1802 + jump LABEL1835 +LABEL1802: oload 0 oload 19 removetags compare iconst 0 - if_icmpne LABEL1830 - jump LABEL1856 -LABEL1830: + if_icmpne LABEL1809 + jump LABEL1835 +LABEL1809: iconst 8 sconst "Report" iload 9 @@ -2066,14 +2045,14 @@ LABEL1830: sconst "" iload 9 if_setonmouseleave - jump LABEL1925 -LABEL1856: + jump LABEL1901 +LABEL1835: oload 25 string_length iconst 0 - if_icmpne LABEL1861 - jump LABEL1913 -LABEL1861: + if_icmpne LABEL1840 + jump LABEL1889 +LABEL1840: oload 25 sconst "CA_ID:" iconst 0 @@ -2081,9 +2060,9 @@ LABEL1861: istore 22 iload 22 iconst -1 - if_icmpne LABEL1870 - jump LABEL1912 -LABEL1870: + if_icmpne LABEL1849 + jump LABEL1888 +LABEL1849: oload 25 iload 22 sconst "CA_ID:" @@ -2104,10 +2083,7 @@ LABEL1870: sconst "Open" iload 9 if_setop - sconst "" - sconst "Task" - sconst "" - join_string 3 + sconst "Task" iload 9 if_setopbase iconst 7821 @@ -2126,9 +2102,9 @@ LABEL1870: sconst "" iload 9 if_setonmouseleave -LABEL1912: - jump LABEL1925 -LABEL1913: +LABEL1888: + jump LABEL1901 +LABEL1889: iconst -1 sconst "" iload 9 @@ -2141,9 +2117,9 @@ LABEL1913: sconst "" iload 9 if_setonmouseleave -LABEL1925: - jump LABEL2335 -LABEL1926: +LABEL1901: + jump LABEL2302 +LABEL1902: sconst "" oload 19 sconst "" @@ -2167,31 +2143,31 @@ LABEL1926: if_setonmouseleave invoke 5548 iconst 1 - if_icmpeq LABEL1951 - jump LABEL1956 -LABEL1951: + if_icmpeq LABEL1927 + jump LABEL1932 +LABEL1927: iconst 1 sconst "Accept invitation" iload 9 if_setop - jump LABEL1960 -LABEL1956: + jump LABEL1936 +LABEL1932: iconst 1 sconst "Accept trade" iload 9 if_setop -LABEL1960: +LABEL1936: iload 15 iconst 1 - if_icmpeq LABEL1964 - jump LABEL1969 -LABEL1964: + if_icmpeq LABEL1940 + jump LABEL1945 +LABEL1940: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL1977 -LABEL1969: + jump LABEL1953 +LABEL1945: iconst 6 sconst "Add friend" iload 9 @@ -2200,9 +2176,9 @@ LABEL1969: sconst "Add ignore" iload 9 if_setop -LABEL1977: - jump LABEL2335 -LABEL1978: +LABEL1953: + jump LABEL2302 +LABEL1954: sconst "" oload 19 sconst "" @@ -2230,15 +2206,15 @@ LABEL1978: if_setop iload 15 iconst 1 - if_icmpeq LABEL2007 - jump LABEL2012 -LABEL2007: + if_icmpeq LABEL1983 + jump LABEL1988 +LABEL1983: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL2020 -LABEL2012: + jump LABEL1996 +LABEL1988: iconst 6 sconst "Add friend" iload 9 @@ -2247,20 +2223,20 @@ LABEL2012: sconst "Add ignore" iload 9 if_setop -LABEL2020: - jump LABEL2335 -LABEL2021: +LABEL1996: + jump LABEL2302 +LABEL1997: oload 22 string_length iconst 0 - if_icmpgt LABEL2026 - jump LABEL2055 -LABEL2026: + if_icmpgt LABEL2002 + jump LABEL2031 +LABEL2002: iload 13 iconst -1 - if_icmpne LABEL2030 - jump LABEL2055 -LABEL2030: + if_icmpne LABEL2006 + jump LABEL2031 +LABEL2006: iconst 6 sconst "Open" iload 9 @@ -2285,8 +2261,8 @@ LABEL2030: sconst "iii" iload 9 if_setonmouseleave - jump LABEL2063 -LABEL2055: + jump LABEL2039 +LABEL2031: iconst -1 sconst "" iload 9 @@ -2295,15 +2271,12 @@ LABEL2055: sconst "" iload 9 if_setonmouseleave -LABEL2063: +LABEL2039: iconst 9 sconst "Clear history" iload 9 if_setop - sconst "" - sconst "Notification" - sconst "" - join_string 3 + sconst "Notification" iload 9 if_setopbase iconst 2064 @@ -2314,17 +2287,17 @@ LABEL2063: sconst "iisi" iload 9 if_setonop - jump LABEL2335 -LABEL2082: + jump LABEL2302 +LABEL2055: iconst 105 iconst 73 iconst 5918 iload 13 enum iconst -1 - if_icmpne LABEL2090 - jump LABEL2111 -LABEL2090: + if_icmpne LABEL2063 + jump LABEL2084 +LABEL2063: iconst 6 sconst "Open" iload 9 @@ -2345,8 +2318,8 @@ LABEL2090: sconst "iii" iload 9 if_setonmouseleave - jump LABEL2119 -LABEL2111: + jump LABEL2092 +LABEL2084: iconst -1 sconst "" iload 9 @@ -2355,15 +2328,12 @@ LABEL2111: sconst "" iload 9 if_setonmouseleave -LABEL2119: +LABEL2092: iconst 9 sconst "Clear all" iload 9 if_setop - sconst "" - sconst "Skill guide" - sconst "" - join_string 3 + sconst "Skill guide" iload 9 if_setopbase iconst 7780 @@ -2373,8 +2343,8 @@ LABEL2119: sconst "iii" iload 9 if_setonop - jump LABEL2335 -LABEL2137: + jump LABEL2302 +LABEL2107: sconst "" oload 19 sconst "" @@ -2402,15 +2372,15 @@ LABEL2137: if_setonmouseleave iload 15 iconst 1 - if_icmpeq LABEL2166 - jump LABEL2171 -LABEL2166: + if_icmpeq LABEL2136 + jump LABEL2141 +LABEL2136: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL2179 -LABEL2171: + jump LABEL2149 +LABEL2141: iconst 6 sconst "Add friend" iload 9 @@ -2419,9 +2389,9 @@ LABEL2171: sconst "Add ignore" iload 9 if_setop -LABEL2179: - jump LABEL2335 -LABEL2180: +LABEL2149: + jump LABEL2302 +LABEL2150: sconst "" oload 19 sconst "" @@ -2449,15 +2419,15 @@ LABEL2180: if_setonmouseleave iload 15 iconst 1 - if_icmpeq LABEL2209 - jump LABEL2214 -LABEL2209: + if_icmpeq LABEL2179 + jump LABEL2184 +LABEL2179: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL2222 -LABEL2214: + jump LABEL2192 +LABEL2184: iconst 6 sconst "Add friend" iload 9 @@ -2466,9 +2436,9 @@ LABEL2214: sconst "Add ignore" iload 9 if_setop -LABEL2222: - jump LABEL2335 -LABEL2223: +LABEL2192: + jump LABEL2302 +LABEL2193: sconst "" oload 19 sconst "" @@ -2496,15 +2466,15 @@ LABEL2223: if_setonmouseleave iload 15 iconst 1 - if_icmpeq LABEL2252 - jump LABEL2257 -LABEL2252: + if_icmpeq LABEL2222 + jump LABEL2227 +LABEL2222: iconst 6 sconst "Message" iload 9 if_setop - jump LABEL2265 -LABEL2257: + jump LABEL2235 +LABEL2227: iconst 6 sconst "Add friend" iload 9 @@ -2513,15 +2483,15 @@ LABEL2257: sconst "Add ignore" iload 9 if_setop -LABEL2265: - jump LABEL2335 -LABEL2266: +LABEL2235: + jump LABEL2302 +LABEL2236: oload 25 string_length iconst 0 - if_icmpne LABEL2271 - jump LABEL2322 -LABEL2271: + if_icmpne LABEL2241 + jump LABEL2289 +LABEL2241: oload 25 sconst "CA_ID:" iconst 0 @@ -2529,9 +2499,9 @@ LABEL2271: istore 22 iload 22 iconst -1 - if_icmpne LABEL2280 - jump LABEL2322 -LABEL2280: + if_icmpne LABEL2250 + jump LABEL2289 +LABEL2250: oload 25 iload 22 sconst "CA_ID:" @@ -2552,10 +2522,7 @@ LABEL2280: sconst "Open" iload 9 if_setop - sconst "" - sconst "Task" - sconst "" - join_string 3 + sconst "Task" iload 9 if_setopbase iconst 7821 @@ -2574,9 +2541,9 @@ LABEL2280: sconst "" iload 9 if_setonmouseleave -LABEL2322: - jump LABEL2335 -LABEL2323: +LABEL2289: + jump LABEL2302 +LABEL2290: iconst -1 sconst "" iload 9 @@ -2589,7 +2556,7 @@ LABEL2323: sconst "" iload 9 if_setonmouseleave -LABEL2335: +LABEL2302: iload 6 iload 7 sub @@ -2604,20 +2571,20 @@ LABEL2335: iload 8 enum istore 9 -LABEL2349: +LABEL2316: iload 10 chat_getprevuid istore 10 - jump LABEL411 -LABEL2353: + jump LABEL408 +LABEL2320: iload 8 istore 23 -LABEL2355: +LABEL2322: iload 9 iconst -1 - if_icmpne LABEL2359 - jump LABEL2442 -LABEL2359: + if_icmpne LABEL2326 + jump LABEL2409 +LABEL2326: iload 9 if_clearops iconst -1 @@ -2644,14 +2611,14 @@ LABEL2359: multiply cc_find iconst 1 - if_icmpeq LABEL2387 - jump LABEL2391 -LABEL2387: + if_icmpeq LABEL2354 + jump LABEL2358 +LABEL2354: sconst "" cc_settext iconst 1 cc_sethide -LABEL2391: +LABEL2358: iconst 10616890 iload 8 iconst 4 @@ -2660,14 +2627,14 @@ LABEL2391: add cc_find iconst 1 - if_icmpeq LABEL2401 - jump LABEL2405 -LABEL2401: + if_icmpeq LABEL2368 + jump LABEL2372 +LABEL2368: sconst "" cc_settext iconst 1 cc_sethide -LABEL2405: +LABEL2372: iconst 10616890 iload 8 iconst 4 @@ -2676,14 +2643,14 @@ LABEL2405: add cc_find iconst 1 - if_icmpeq LABEL2415 - jump LABEL2419 -LABEL2415: + if_icmpeq LABEL2382 + jump LABEL2386 +LABEL2382: sconst "" cc_settext iconst 1 cc_sethide -LABEL2419: +LABEL2386: iconst 10616890 iload 8 iconst 4 @@ -2692,12 +2659,12 @@ LABEL2419: add cc_find iconst 1 - if_icmpeq LABEL2429 - jump LABEL2431 -LABEL2429: + if_icmpeq LABEL2396 + jump LABEL2398 +LABEL2396: iconst 1 cc_sethide -LABEL2431: +LABEL2398: iload 8 iconst 1 add @@ -2708,8 +2675,8 @@ LABEL2431: iload 8 enum istore 9 - jump LABEL2355 -LABEL2442: + jump LABEL2322 +LABEL2409: iload 6 iconst 2 sub @@ -2723,20 +2690,20 @@ LABEL2442: istore 24 iload 6 iload 24 - if_icmpgt LABEL2457 - jump LABEL2459 -LABEL2457: + if_icmpgt LABEL2424 + jump LABEL2426 +LABEL2424: iload 6 istore 24 -LABEL2459: +LABEL2426: iload 23 istore 8 -LABEL2461: +LABEL2428: iload 8 iconst 0 - if_icmpgt LABEL2465 - jump LABEL2548 -LABEL2465: + if_icmpgt LABEL2432 + jump LABEL2515 +LABEL2432: iload 8 iconst 1 sub @@ -2767,15 +2734,15 @@ LABEL2465: multiply cc_find iconst 1 - if_icmpeq LABEL2497 - jump LABEL2502 -LABEL2497: + if_icmpeq LABEL2464 + jump LABEL2469 +LABEL2464: cc_getx iload 6 iconst 0 iconst 0 cc_setposition -LABEL2502: +LABEL2469: iconst 10616890 iload 8 iconst 4 @@ -2784,15 +2751,15 @@ LABEL2502: add cc_find iconst 1 - if_icmpeq LABEL2512 - jump LABEL2517 -LABEL2512: + if_icmpeq LABEL2479 + jump LABEL2484 +LABEL2479: cc_getx iload 6 iconst 0 iconst 0 cc_setposition -LABEL2517: +LABEL2484: iconst 10616890 iload 8 iconst 4 @@ -2801,15 +2768,15 @@ LABEL2517: add cc_find iconst 1 - if_icmpeq LABEL2527 - jump LABEL2532 -LABEL2527: + if_icmpeq LABEL2494 + jump LABEL2499 +LABEL2494: cc_getx iload 6 iconst 0 iconst 0 cc_setposition -LABEL2532: +LABEL2499: iconst 10616890 iload 8 iconst 4 @@ -2818,17 +2785,17 @@ LABEL2532: add cc_find iconst 1 - if_icmpeq LABEL2542 - jump LABEL2547 -LABEL2542: + if_icmpeq LABEL2509 + jump LABEL2514 +LABEL2509: cc_getx iload 6 iconst 0 iconst 0 cc_setposition -LABEL2547: - jump LABEL2461 -LABEL2548: +LABEL2514: + jump LABEL2428 +LABEL2515: iconst 0 iload 24 iconst 10616890 diff --git a/runelite-client/src/main/scripts/ChatSend.hash b/runelite-client/src/main/scripts/ChatSend.hash index e36a2d91bec..511f7764487 100644 --- a/runelite-client/src/main/scripts/ChatSend.hash +++ b/runelite-client/src/main/scripts/ChatSend.hash @@ -1 +1 @@ -7E4DE66F2654C0436E1904D959C3479AA36E8C732527ADFF9E6DCCBFDE1207A6 \ No newline at end of file +E79EF98BA67390169CFB92CCB22E56F121E9C7FFAEACFF29762BFA52A9DA1507 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/ChatSend.rs2asm b/runelite-client/src/main/scripts/ChatSend.rs2asm index 685257bbd71..ecf7019096c 100644 --- a/runelite-client/src/main/scripts/ChatSend.rs2asm +++ b/runelite-client/src/main/scripts/ChatSend.rs2asm @@ -5,67 +5,65 @@ get_varbit 4394 iconst 1 if_icmpeq LABEL4 - jump LABEL24 + jump LABEL22 LABEL4: iload 0 iconst 1 if_icmpeq LABEL8 - jump LABEL16 + jump LABEL15 LABEL8: chat_playername - sconst ": " - sconst "" + sconst ": " oload 0 sconst "" - join_string 5 + join_string 4 mes - jump LABEL23 -LABEL16: + jump LABEL21 +LABEL15: chat_playername - sconst ": " - sconst "" + sconst ": " oload 0 sconst "" - join_string 5 + join_string 4 mes -LABEL23: +LABEL21: return -LABEL24: +LABEL22: invoke 5262 iconst 0 - if_icmpeq LABEL28 - jump LABEL34 -LABEL28: + if_icmpeq LABEL26 + jump LABEL32 +LABEL26: iload 3 iconst 4 - if_icmpeq LABEL32 - jump LABEL34 -LABEL32: + if_icmpeq LABEL30 + jump LABEL32 +LABEL30: get_varc_int 945 istore 3 -LABEL34: +LABEL32: iload 3 iconst -1 - if_icmpne LABEL38 - jump LABEL75 -LABEL38: + if_icmpne LABEL36 + jump LABEL71 +LABEL36: iload 3 iconst 4 - if_icmple LABEL42 - jump LABEL75 -LABEL42: + if_icmple LABEL40 + jump LABEL71 +LABEL40: iload 3 get_varc_int 945 - if_icmpne LABEL46 - jump LABEL75 -LABEL46: + if_icmpne LABEL44 + jump LABEL71 +LABEL44: iload 3 set_varc_int 945 iload 3 iconst 0 - if_icmpne LABEL52 - jump LABEL66 -LABEL52: + if_icmpne LABEL50 + jump LABEL62 +LABEL50: sconst "Your chatbox mode is now set to " iconst 105 iconst 115 @@ -74,13 +72,11 @@ LABEL52: enum sconst " chat. To reset your mode, type " sconst "" - sconst "/@p" - sconst "" - sconst "." - join_string 7 + sconst "/@p." + join_string 5 mes - jump LABEL75 -LABEL66: + jump LABEL71 +LABEL62: sconst "Your chatbox mode has been reset to " iconst 105 iconst 115 @@ -90,41 +86,41 @@ LABEL66: sconst " chat." join_string 3 mes -LABEL75: +LABEL71: iload 2 iconst 1 - if_icmpeq LABEL79 - jump LABEL99 -LABEL79: + if_icmpeq LABEL75 + jump LABEL95 +LABEL75: get_varc_int 945 switch - 1: LABEL82 - 2: LABEL85 - 3: LABEL90 - 4: LABEL95 - jump LABEL99 -LABEL82: + 1: LABEL78 + 2: LABEL81 + 3: LABEL86 + 4: LABEL91 + jump LABEL95 +LABEL78: iconst 2 istore 0 - jump LABEL99 -LABEL85: + jump LABEL95 +LABEL81: iconst 3 iconst 0 istore 1 istore 0 - jump LABEL99 -LABEL90: + jump LABEL95 +LABEL86: iconst 4 iconst 0 istore 1 istore 0 - jump LABEL99 -LABEL95: + jump LABEL95 +LABEL91: iconst 3 iconst 1 istore 1 istore 0 -LABEL99: +LABEL95: oload 0 ; load input iload 0 ; load chat type iload 1 ; load clan target @@ -141,26 +137,26 @@ CONTINUE: ostore 1 iload 0 switch - 3: LABEL104 - 4: LABEL104 - jump LABEL154 -LABEL104: + 3: LABEL100 + 4: LABEL100 + jump LABEL150 +LABEL100: oload 0 invoke 5501 iconst 1 - if_icmpeq LABEL109 - jump LABEL113 -LABEL109: + if_icmpeq LABEL105 + jump LABEL109 +LABEL105: oload 0 invoke 632 ostore 0 ostore 1 -LABEL113: +LABEL109: iload 1 iconst 1 - if_icmpeq LABEL117 - jump LABEL143 -LABEL117: + if_icmpeq LABEL113 + jump LABEL139 +LABEL113: oload 0 iconst 0 iconst 1 @@ -180,40 +176,40 @@ LABEL117: oload 0 string_length iconst 80 - if_icmpgt LABEL138 - jump LABEL143 -LABEL138: + if_icmpgt LABEL134 + jump LABEL139 +LABEL134: oload 0 iconst 0 iconst 80 substring ostore 0 -LABEL143: +LABEL139: oload 0 string_length iconst 0 - if_icmple LABEL148 - jump LABEL149 -LABEL148: + if_icmple LABEL144 + jump LABEL145 +LABEL144: return -LABEL149: +LABEL145: oload 0 iload 0 iload 1 chat_sendclan - jump LABEL185 -LABEL154: + jump LABEL181 +LABEL150: iload 0 iconst 2 - if_icmpeq LABEL158 - jump LABEL176 -LABEL158: + if_icmpeq LABEL154 + jump LABEL172 +LABEL154: oload 0 string_length iconst 0 - if_icmpgt LABEL163 - jump LABEL176 -LABEL163: + if_icmpgt LABEL159 + jump LABEL172 +LABEL159: oload 0 iconst 0 iconst 1 @@ -221,26 +217,26 @@ LABEL163: sconst "/" compare iconst 0 - if_icmpne LABEL172 - jump LABEL176 -LABEL172: + if_icmpne LABEL168 + jump LABEL172 +LABEL168: sconst "/" oload 0 append ostore 0 -LABEL176: +LABEL172: oload 0 string_length iconst 0 - if_icmple LABEL181 - jump LABEL182 -LABEL181: + if_icmple LABEL177 + jump LABEL178 +LABEL177: return -LABEL182: +LABEL178: oload 0 iload 0 chat_sendpublic -LABEL185: +LABEL181: clientclock set_varc_int 61 return diff --git a/runelite-client/src/main/scripts/ChatSplitBuilder.hash b/runelite-client/src/main/scripts/ChatSplitBuilder.hash index 2689161466d..a95c2cb89e2 100644 --- a/runelite-client/src/main/scripts/ChatSplitBuilder.hash +++ b/runelite-client/src/main/scripts/ChatSplitBuilder.hash @@ -1 +1 @@ -6DAD41E416F31EA41FDF389B3A4AE4CFDF0634623BABDA6B2477E1ABEDE7E60C \ No newline at end of file +A92472A08CFFC09992D8C2F0F03E0BDD04FCF442BF412F2641138C52DF645184 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/ChatSplitBuilder.rs2asm b/runelite-client/src/main/scripts/ChatSplitBuilder.rs2asm index 967a123f9ab..b5978035090 100644 --- a/runelite-client/src/main/scripts/ChatSplitBuilder.rs2asm +++ b/runelite-client/src/main/scripts/ChatSplitBuilder.rs2asm @@ -228,20 +228,20 @@ LABEL177: get_varc_int 55 get_varc_int 202 if_icmpge LABEL210 - jump LABEL342 + jump LABEL339 LABEL210: get_varc_int 55 clientclock iconst 3000 sub if_icmpgt LABEL216 - jump LABEL342 + jump LABEL339 LABEL216: iconst 14 chat_gethistorylength iconst 0 if_icmpgt LABEL221 - jump LABEL342 + jump LABEL339 LABEL221: iconst 14 iconst 0 @@ -257,7 +257,7 @@ LABEL221: iload 12 iconst -1 if_icmpne LABEL236 - jump LABEL342 + jump LABEL339 LABEL236: oload 0 invoke 2066 @@ -270,7 +270,7 @@ LABEL236: reboottimer iconst 0 if_icmple LABEL248 - jump LABEL342 + jump LABEL339 LABEL248: iload 7 oload 2 @@ -346,10 +346,7 @@ LABEL314: sconst "Clear history" iload 10 if_setop - sconst "" - sconst "Notification" - sconst "" - join_string 3 + sconst "Notification" iload 10 if_setopbase iconst 2064 @@ -370,7 +367,7 @@ LABEL314: iload 9 enum istore 10 -LABEL342: +LABEL339: iload 0 istore 12 iconst 0 @@ -379,39 +376,39 @@ LABEL342: istore 19 get_varp 287 iconst 1 - if_icmpeq LABEL352 - jump LABEL594 -LABEL352: + if_icmpeq LABEL349 + jump LABEL589 +LABEL349: get_varc_int 41 iconst 1337 - if_icmpne LABEL359 + if_icmpne LABEL356 get_varbit 4089 iconst 0 - if_icmpeq LABEL359 - jump LABEL594 -LABEL359: + if_icmpeq LABEL356 + jump LABEL589 +LABEL356: invoke 7831 iconst 1 - if_icmpeq LABEL363 - jump LABEL594 -LABEL363: + if_icmpeq LABEL360 + jump LABEL589 +LABEL360: iload 12 iconst -1 - if_icmpne LABEL367 - jump LABEL594 -LABEL367: + if_icmpne LABEL364 + jump LABEL589 +LABEL364: iload 10 iconst -1 - if_icmpne LABEL371 - jump LABEL594 -LABEL371: + if_icmpne LABEL368 + jump LABEL589 +LABEL368: iload 7 iload 4 sub iconst 57 - if_icmplt LABEL377 - jump LABEL594 -LABEL377: + if_icmplt LABEL374 + jump LABEL589 +LABEL374: iload 12 chat_gethistoryex_byuid istore 15 @@ -429,7 +426,7 @@ LABEL377: invoke 91 iconst 1 if_icmpeq CHAT_FILTER ; Jump to our new label instead - jump LABEL590 + jump LABEL585 CHAT_FILTER: oload 0 ; Load the message iconst 1 ; Gets changed to 0 if message is blocked @@ -441,9 +438,9 @@ CHAT_FILTER: pop_int ; Pop the messageType iconst 1 ; 2nd half of conditional ostore 0 ; Override the message with our filtered message - if_icmpeq LABEL395 ; Check if we are building this message - jump LABEL590 -LABEL395: + if_icmpeq LABEL392 ; Check if we are building this message + jump LABEL585 +LABEL392: iconst 1 ; splitpmbox iload 12 ; message uid sconst "" ; message channel @@ -460,12 +457,12 @@ LABEL395: pop_object ; message channel iload 18 switch - 3: LABEL398 - 7: LABEL398 - 6: LABEL427 - 5: LABEL456 - jump LABEL494 -LABEL398: + 3: LABEL395 + 7: LABEL395 + 6: LABEL423 + 5: LABEL451 + jump LABEL489 +LABEL395: iload 7 oload 2 oload 5 @@ -473,9 +470,8 @@ LABEL398: runelite_callback sconst "From " oload 1 - sconst ":" - sconst "" - join_string 5 + sconst ":" + join_string 4 oload 5 invoke 4742 oload 5 @@ -496,8 +492,8 @@ LABEL398: invoke 203 add istore 7 - jump LABEL513 -LABEL427: + jump LABEL508 +LABEL423: iload 7 oload 2 oload 5 @@ -505,9 +501,8 @@ LABEL427: runelite_callback sconst "To " oload 1 - sconst ":" - sconst "" - join_string 5 + sconst ":" + join_string 4 oload 5 invoke 4742 oload 5 @@ -528,8 +523,8 @@ LABEL427: invoke 203 add istore 7 - jump LABEL513 -LABEL456: + jump LABEL508 +LABEL451: iload 7 oload 2 oload 5 @@ -554,9 +549,9 @@ LABEL456: istore 7 iload 19 iconst 0 - if_icmpeq LABEL482 - jump LABEL493 -LABEL482: + if_icmpeq LABEL477 + jump LABEL488 +LABEL477: iload 13 iconst 500 add @@ -568,9 +563,9 @@ LABEL482: sconst "i" iconst 10616832 if_setontimer -LABEL493: - jump LABEL513 -LABEL494: +LABEL488: + jump LABEL508 +LABEL489: iload 7 oload 2 oload 0 @@ -590,31 +585,31 @@ LABEL494: invoke 199 add istore 7 -LABEL513: +LABEL508: iload 10 if_clearops iload 18 iconst 3 - if_icmpeq LABEL525 + if_icmpeq LABEL520 iload 18 iconst 6 - if_icmpeq LABEL525 + if_icmpeq LABEL520 iload 18 iconst 7 - if_icmpeq LABEL525 - jump LABEL568 -LABEL525: + if_icmpeq LABEL520 + jump LABEL563 +LABEL520: iload 14 iconst 1 - if_icmpeq LABEL529 - jump LABEL534 -LABEL529: + if_icmpeq LABEL524 + jump LABEL529 +LABEL524: iconst 7 sconst "Message" iload 10 if_setop - jump LABEL542 -LABEL534: + jump LABEL537 +LABEL529: iconst 7 sconst "Add friend" iload 10 @@ -623,7 +618,7 @@ LABEL534: sconst "Add ignore" iload 10 if_setop -LABEL542: +LABEL537: iconst 9 sconst "Report" iload 10 @@ -631,14 +626,14 @@ LABEL542: oload 1 invoke 2759 iconst 1 - if_icmpeq LABEL551 - jump LABEL555 -LABEL551: + if_icmpeq LABEL546 + jump LABEL550 +LABEL546: iconst 10 sconst "Crown Info" iload 10 if_setop -LABEL555: +LABEL550: sconst "" oload 1 sconst "" @@ -651,13 +646,13 @@ LABEL555: sconst "is" iload 10 if_setonop - jump LABEL572 -LABEL568: + jump LABEL567 +LABEL563: iconst -1 sconst "" iload 10 if_setonop -LABEL572: +LABEL567: iconst -1 sconst "" iload 10 @@ -676,17 +671,17 @@ LABEL572: iload 9 enum istore 10 -LABEL590: +LABEL585: iload 12 chat_getprevuid istore 12 - jump LABEL363 -LABEL594: + jump LABEL360 +LABEL589: iload 10 iconst -1 - if_icmpne LABEL598 - jump LABEL681 -LABEL598: + if_icmpne LABEL593 + jump LABEL676 +LABEL593: iload 10 if_clearops iconst -1 @@ -713,14 +708,14 @@ LABEL598: multiply cc_find iconst 1 - if_icmpeq LABEL626 - jump LABEL630 -LABEL626: + if_icmpeq LABEL621 + jump LABEL625 +LABEL621: sconst "" cc_settext iconst 1 cc_sethide -LABEL630: +LABEL625: iconst 10682368 iload 9 iconst 4 @@ -729,14 +724,14 @@ LABEL630: add cc_find iconst 1 - if_icmpeq LABEL640 - jump LABEL644 -LABEL640: + if_icmpeq LABEL635 + jump LABEL639 +LABEL635: sconst "" cc_settext iconst 1 cc_sethide -LABEL644: +LABEL639: iconst 10682368 iload 9 iconst 4 @@ -745,14 +740,14 @@ LABEL644: add cc_find iconst 1 - if_icmpeq LABEL654 - jump LABEL658 -LABEL654: + if_icmpeq LABEL649 + jump LABEL653 +LABEL649: sconst "" cc_settext iconst 1 cc_sethide -LABEL658: +LABEL653: iconst 10682368 iload 9 iconst 4 @@ -761,12 +756,12 @@ LABEL658: add cc_find iconst 1 - if_icmpeq LABEL668 - jump LABEL670 -LABEL668: + if_icmpeq LABEL663 + jump LABEL665 +LABEL663: iconst 1 cc_sethide -LABEL670: +LABEL665: iload 9 iconst 1 add @@ -777,6 +772,6 @@ LABEL670: iload 9 enum istore 10 - jump LABEL594 -LABEL681: + jump LABEL589 +LABEL676: return diff --git a/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.hash b/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.hash index 28a4a31a723..83c6f06b478 100644 --- a/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.hash +++ b/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.hash @@ -1 +1 @@ -731D34761408EE074A436E08A1BCA605B7302243A844B2B6F3471C646C233AF1 \ No newline at end of file +5C2C8897332020947CDFBE802C958888FC54988726320D11AFA2705B3F088608 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.rs2asm b/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.rs2asm index aa20b5d4651..fbbb4ee4b89 100644 --- a/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.rs2asm +++ b/runelite-client/src/main/scripts/ChatboxInputWidgetBuilder.rs2asm @@ -44,7 +44,7 @@ LABEL23: get_varbit 8119 iconst 1 if_icmpeq LABEL40 - jump LABEL167 + jump LABEL165 LABEL40: invoke 3160 iconst 1 @@ -129,36 +129,34 @@ LABEL95: invoke 1353 iconst 1 if_icmpeq LABEL108 - jump LABEL120 + jump LABEL119 LABEL108: iload 2 iconst 79 if_icmplt LABEL112 - jump LABEL119 + jump LABEL118 LABEL112: oload 2 oload 0 - sconst "*" - sconst "" - join_string 3 + sconst "*" + join_string 2 append ostore 2 +LABEL118: + jump LABEL129 LABEL119: - jump LABEL131 -LABEL120: iload 2 iconst 80 - if_icmplt LABEL124 - jump LABEL131 -LABEL124: + if_icmplt LABEL123 + jump LABEL129 +LABEL123: oload 2 oload 0 - sconst "*" - sconst "" - join_string 3 + sconst "*" + join_string 2 append ostore 2 -LABEL131: +LABEL129: oload 2 iconst 2147483647 iconst 495 @@ -167,22 +165,22 @@ LABEL131: iload 3 iconst 10616889 if_getwidth - if_icmpgt LABEL141 - jump LABEL147 -LABEL141: + if_icmpgt LABEL139 + jump LABEL145 +LABEL139: iconst 2 iconst 2 iconst 0 iconst 10616889 if_settextalign - jump LABEL152 -LABEL147: + jump LABEL150 +LABEL145: iconst 0 iconst 2 iconst 0 iconst 10616889 if_settextalign -LABEL152: +LABEL150: iconst 10616889 if_clearops iconst -1 @@ -197,30 +195,30 @@ LABEL152: sconst "" iconst 10616889 if_setonop - jump LABEL226 -LABEL167: + jump LABEL221 +LABEL165: invoke 3160 iconst 1 - if_icmpeq LABEL171 - jump LABEL176 -LABEL171: + if_icmpeq LABEL169 + jump LABEL174 +LABEL169: invoke 7776 sconst " You must set a name before you can chat." join_string 2 ostore 2 - jump LABEL193 -LABEL176: + jump LABEL191 +LABEL174: invoke 5849 iconst 1 - if_icmpeq LABEL180 - jump LABEL185 -LABEL180: + if_icmpeq LABEL178 + jump LABEL183 +LABEL178: sconst "" sconst " You must set a name before you can chat." join_string 2 ostore 2 - jump LABEL193 -LABEL185: + jump LABEL191 +LABEL183: iconst 105 iconst 115 iconst 1894 @@ -229,7 +227,7 @@ LABEL185: sconst " You must set a name before you can chat." join_string 2 ostore 2 -LABEL193: +LABEL191: iconst 1 iconst 2 iconst 0 @@ -239,10 +237,7 @@ LABEL193: sconst "Configure" iconst 10616889 if_setop - sconst "" - sconst "Display name" - sconst "" - join_string 3 + sconst "Display name" iconst 10616889 if_setopbase iconst 45 @@ -263,7 +258,7 @@ LABEL193: sconst "ii" iconst 10616889 if_setonop -LABEL226: +LABEL221: oload 2 iconst 10616889 if_settext diff --git a/runelite-client/src/main/scripts/FriendUpdate.hash b/runelite-client/src/main/scripts/FriendUpdate.hash index eda02c7113f..3521b5a33b6 100644 --- a/runelite-client/src/main/scripts/FriendUpdate.hash +++ b/runelite-client/src/main/scripts/FriendUpdate.hash @@ -1 +1 @@ -B2E07C05C8E50C64230E97A899D3929C67D12D4A7B67AF113A566150F91CC79D \ No newline at end of file +8A39110C4232C6EE422B75300B9B1111FBB9D50E1C2F701BDCE7EF5C4AC2F132 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/FriendUpdate.rs2asm b/runelite-client/src/main/scripts/FriendUpdate.rs2asm index 04ecca4f7ee..f2b693a2dba 100644 --- a/runelite-client/src/main/scripts/FriendUpdate.rs2asm +++ b/runelite-client/src/main/scripts/FriendUpdate.rs2asm @@ -95,68 +95,56 @@ iload 17 iconst -2 if_icmple LABEL88 - jump LABEL109 + jump LABEL103 LABEL88: get_varbit 8119 iconst 1 if_icmpeq LABEL92 - jump LABEL99 + jump LABEL96 LABEL92: - sconst "Loading friends list" - sconst "
" - sconst "Please wait..." - join_string 3 + sconst "Loading friends list
Please wait..." iload 7 if_settext - jump LABEL105 -LABEL99: - sconst "You must set a name" - sconst "
" - sconst "before using this." - join_string 3 + jump LABEL99 +LABEL96: + sconst "You must set a name
before using this." iload 7 if_settext -LABEL105: +LABEL99: iconst 1 iload 0 if_sethide - jump LABEL509 -LABEL109: + jump LABEL494 +LABEL103: iload 17 iconst -1 - if_icmpeq LABEL113 - jump LABEL134 -LABEL113: + if_icmpeq LABEL107 + jump LABEL122 +LABEL107: get_varbit 8119 iconst 1 - if_icmpeq LABEL117 - jump LABEL124 -LABEL117: - sconst "Loading friends list" - sconst "
" - sconst "Please wait..." - join_string 3 + if_icmpeq LABEL111 + jump LABEL115 +LABEL111: + sconst "Loading friends list
Please wait..." iload 7 if_settext - jump LABEL130 -LABEL124: - sconst "You must set a name" - sconst "
" - sconst "before using this." - join_string 3 + jump LABEL118 +LABEL115: + sconst "You must set a name
before using this." iload 7 if_settext -LABEL130: +LABEL118: iconst 1 iload 0 if_sethide - jump LABEL509 -LABEL134: + jump LABEL494 +LABEL122: iload 17 iconst 0 - if_icmpeq LABEL138 - jump LABEL150 -LABEL138: + if_icmpeq LABEL126 + jump LABEL138 +LABEL126: sconst "You may add friends by using the button below, or by " sconst "right-clicking" sconst "long pressing" @@ -168,21 +156,21 @@ LABEL138: iconst 1 iload 0 if_sethide - jump LABEL509 -LABEL150: + jump LABEL494 +LABEL138: invoke 1972 istore 14 iload 14 iconst 1 - if_icmpeq LABEL156 - jump LABEL161 -LABEL156: + if_icmpeq LABEL144 + jump LABEL149 +LABEL144: iconst 8 iconst 5 iload 13 scale istore 13 -LABEL161: +LABEL149: sconst "" iload 7 if_settext @@ -192,43 +180,43 @@ LABEL161: 3628 get_varc_int 183 switch - 1: LABEL171 - 2: LABEL174 - 3: LABEL179 - 8: LABEL184 - 9: LABEL189 - 4: LABEL194 - 5: LABEL214 - jump LABEL233 -LABEL171: + 1: LABEL159 + 2: LABEL162 + 3: LABEL167 + 8: LABEL172 + 9: LABEL177 + 4: LABEL182 + 5: LABEL202 + jump LABEL221 +LABEL159: iconst 0 3629 - jump LABEL233 -LABEL174: + jump LABEL221 +LABEL162: iconst 1 3633 iconst 1 3630 - jump LABEL233 -LABEL179: + jump LABEL221 +LABEL167: iconst 1 3633 iconst 0 3630 - jump LABEL233 -LABEL184: + jump LABEL221 +LABEL172: iconst 1 3633 iconst 1 3632 - jump LABEL233 -LABEL189: + jump LABEL221 +LABEL177: iconst 1 3633 iconst 0 3632 - jump LABEL233 -LABEL194: + jump LABEL221 +LABEL182: iconst 1 3633 iconst 1 @@ -237,26 +225,26 @@ LABEL194: 3631 get_varc_int 205 switch - 3: LABEL205 - 8: LABEL208 - 9: LABEL211 + 3: LABEL193 + 8: LABEL196 + 9: LABEL199 iconst 1 3630 - jump LABEL213 -LABEL205: + jump LABEL201 +LABEL193: iconst 0 3630 - jump LABEL213 -LABEL208: + jump LABEL201 +LABEL196: iconst 1 3632 - jump LABEL213 -LABEL211: + jump LABEL201 +LABEL199: iconst 0 3632 -LABEL213: - jump LABEL233 -LABEL214: +LABEL201: + jump LABEL221 +LABEL202: iconst 1 3633 iconst 1 @@ -265,31 +253,31 @@ LABEL214: 3631 get_varc_int 205 switch - 3: LABEL225 - 8: LABEL228 - 9: LABEL231 + 3: LABEL213 + 8: LABEL216 + 9: LABEL219 iconst 1 3630 - jump LABEL233 -LABEL225: + jump LABEL221 +LABEL213: iconst 0 3630 - jump LABEL233 -LABEL228: + jump LABEL221 +LABEL216: iconst 1 3632 - jump LABEL233 -LABEL231: + jump LABEL221 +LABEL219: iconst 0 3632 -LABEL233: +LABEL221: 3639 -LABEL234: +LABEL222: iload 9 iload 17 - if_icmplt LABEL238 - jump LABEL501 -LABEL238: + if_icmplt LABEL226 + jump LABEL486 +LABEL226: iload 9 friend_getname ostore 1 @@ -337,9 +325,9 @@ LABEL238: istore 11 iload 11 iconst 0 - if_icmpne LABEL285 - jump LABEL307 -LABEL285: + if_icmpne LABEL273 + jump LABEL295 +LABEL273: iconst 1 sconst "Message" cc_setop @@ -348,32 +336,32 @@ LABEL285: cc_setop iload 15 iconst 1 - if_icmpeq LABEL295 - jump LABEL303 -LABEL295: + if_icmpeq LABEL283 + jump LABEL291 +LABEL283: iload 16 iload 11 - if_icmpne LABEL299 - jump LABEL303 -LABEL299: + if_icmpne LABEL287 + jump LABEL291 +LABEL287: iconst 4 sconst "Switch world" cc_setop - jump LABEL306 -LABEL303: + jump LABEL294 +LABEL291: iconst 4 sconst "" cc_setop -LABEL306: - jump LABEL313 -LABEL307: +LABEL294: + jump LABEL301 +LABEL295: iconst 1 sconst "" cc_setop iconst 2 sconst "Message" cc_setop -LABEL313: +LABEL301: iconst 3 sconst "Delete" cc_setop @@ -416,14 +404,14 @@ LABEL313: oload 1 string_length iconst 0 - if_icmpgt LABEL355 - jump LABEL406 -LABEL355: + if_icmpgt LABEL343 + jump LABEL393 +LABEL343: iload 14 iconst 1 - if_icmpeq LABEL359 - jump LABEL374 -LABEL359: + if_icmpeq LABEL347 + jump LABEL362 +LABEL347: iconst 10 sconst "Reveal previous name" cc_setop @@ -438,12 +426,11 @@ LABEL359: iload 11 sconst "isiiissi" cc_setonop - jump LABEL403 -LABEL374: - sconst "Previous name:" - sconst "
" + jump LABEL390 +LABEL362: + sconst "Previous name:
" oload 1 - join_string 3 + join_string 2 ostore 1 iconst 526 iconst -2147483645 @@ -469,11 +456,11 @@ LABEL374: iload 11 sconst "isiiissi" cc_setonop -LABEL403: +LABEL390: iconst 0 cc_sethide 1 - jump LABEL423 -LABEL406: + jump LABEL410 +LABEL393: iconst 40 iload 8 sconst "i" @@ -491,7 +478,7 @@ LABEL406: iload 11 sconst "isiiissi" cc_setonop -LABEL423: +LABEL410: iload 5 iconst 4 iload 10 @@ -521,20 +508,20 @@ LABEL423: cc_settextshadow iload 11 iconst 0 - if_icmpeq LABEL454 - jump LABEL459 -LABEL454: + if_icmpeq LABEL441 + jump LABEL446 +LABEL441: sconst "Offline" cc_settext iconst 16711680 cc_setcolour - jump LABEL492 -LABEL459: + jump LABEL477 +LABEL446: iload 11 map_world - if_icmpeq LABEL463 - jump LABEL471 -LABEL463: + if_icmpeq LABEL450 + jump LABEL458 +LABEL450: sconst "World " iload 11 tostring @@ -542,33 +529,31 @@ LABEL463: cc_settext iconst 901389 cc_setcolour - jump LABEL492 -LABEL471: + jump LABEL477 +LABEL458: iload 11 iconst 5000 - if_icmpgt LABEL475 - jump LABEL484 -LABEL475: - sconst "" - sconst "Classic " + if_icmpgt LABEL462 + jump LABEL470 +LABEL462: + sconst "Classic " iload 11 iconst 5000 sub tostring - join_string 3 + join_string 2 cc_settext - jump LABEL490 -LABEL484: - sconst "" - sconst "World " + jump LABEL475 +LABEL470: + sconst "World " iload 11 tostring - join_string 3 + join_string 2 cc_settext -LABEL490: +LABEL475: iconst 16776960 cc_setcolour -LABEL492: +LABEL477: iload 9 iconst 1 add @@ -577,24 +562,24 @@ LABEL492: add istore 12 istore 9 - jump LABEL234 -LABEL501: + jump LABEL222 +LABEL486: iload 17 iconst 1 - if_icmpge LABEL505 - jump LABEL509 -LABEL505: + if_icmpge LABEL490 + jump LABEL494 +LABEL490: iload 12 iconst 5 add istore 12 -LABEL509: +LABEL494: iload 12 iload 5 if_getheight - if_icmpgt LABEL514 - jump LABEL523 -LABEL514: + if_icmpgt LABEL499 + jump LABEL508 +LABEL499: iconst 0 iload 12 iload 5 @@ -603,8 +588,8 @@ LABEL514: iload 5 get_varc_int 9 invoke 72 - jump LABEL531 -LABEL523: + jump LABEL516 +LABEL508: iconst 0 iconst 0 iload 5 @@ -613,5 +598,5 @@ LABEL523: iload 5 iconst 0 invoke 72 -LABEL531: +LABEL516: return diff --git a/runelite-client/src/main/scripts/GeExamineInfoText.hash b/runelite-client/src/main/scripts/GeExamineInfoText.hash index 904daaf9aa5..5dba52ab07c 100644 --- a/runelite-client/src/main/scripts/GeExamineInfoText.hash +++ b/runelite-client/src/main/scripts/GeExamineInfoText.hash @@ -1 +1 @@ -2D501130CBECE642CD81D5FFC6931C6B9D260A7C4C2118A1DEF858FCDB676E68 \ No newline at end of file +F3FC61F873090BBACF1525EE36EE7DA16F76CE12CA51F5852F776CF8D6072D03 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/GeExamineInfoText.rs2asm b/runelite-client/src/main/scripts/GeExamineInfoText.rs2asm index 2f91edd8e52..04dd728efa9 100644 --- a/runelite-client/src/main/scripts/GeExamineInfoText.rs2asm +++ b/runelite-client/src/main/scripts/GeExamineInfoText.rs2asm @@ -22,7 +22,7 @@ string_length iconst 0 if_icmpgt LABEL15 - jump LABEL149 + jump LABEL148 LABEL15: iload 0 if_getwidth @@ -34,7 +34,7 @@ LABEL15: LABEL22: oload 0 ostore 2 - jump LABEL44 + jump LABEL43 LABEL25: oload 0 iload 2 @@ -42,22 +42,21 @@ LABEL25: paraheight iconst 2 if_icmple LABEL32 - jump LABEL39 + jump LABEL38 LABEL32: oload 0 - sconst "
" - sconst "
" + sconst "

" oload 1 - join_string 4 + join_string 3 ostore 2 - jump LABEL44 -LABEL39: + jump LABEL43 +LABEL38: oload 0 sconst "
" oload 1 join_string 3 ostore 2 -LABEL44: +LABEL43: oload 0 ; examine oload 1 ; Convenience fee @@ -116,14 +115,14 @@ LABEL44: if_setposition invoke 6811 iconst 1 - if_icmpeq LABEL94 - jump LABEL98 -LABEL94: + if_icmpeq LABEL93 + jump LABEL97 +LABEL93: iconst 1 iload 1 if_sethide - jump LABEL148 -LABEL98: + jump LABEL147 +LABEL97: iconst 0 iload 1 if_sethide @@ -174,9 +173,9 @@ LABEL98: sconst "Info" iload 1 if_setop +LABEL147: + jump LABEL169 LABEL148: - jump LABEL170 -LABEL149: oload 0 ostore 2 @@ -208,7 +207,7 @@ LABEL149: if_setonop iload 1 if_clearops -LABEL170: +LABEL169: oload 2 iload 0 if_settext diff --git a/runelite-client/src/main/scripts/IgnoreUpdate.hash b/runelite-client/src/main/scripts/IgnoreUpdate.hash index 93bc626380d..f71a41aedb9 100644 --- a/runelite-client/src/main/scripts/IgnoreUpdate.hash +++ b/runelite-client/src/main/scripts/IgnoreUpdate.hash @@ -1 +1 @@ -C4CC4E6FBAD1910AD132D4E484DCBFA269CAED7C5C32D1834660E3D2DFFA2DF7 \ No newline at end of file +10C829E6E070C469449768A8582F238D7C7E9CF9F334E27632E51F777A5EA401 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/IgnoreUpdate.rs2asm b/runelite-client/src/main/scripts/IgnoreUpdate.rs2asm index d57b341d4c1..d0aae59b05a 100644 --- a/runelite-client/src/main/scripts/IgnoreUpdate.rs2asm +++ b/runelite-client/src/main/scripts/IgnoreUpdate.rs2asm @@ -53,38 +53,32 @@ iload 12 iconst 0 if_icmplt LABEL46 - jump LABEL67 + jump LABEL61 LABEL46: get_varbit 8119 iconst 1 if_icmpeq LABEL50 - jump LABEL57 + jump LABEL54 LABEL50: - sconst "Loading ignore list" - sconst "
" - sconst "Please wait..." - join_string 3 + sconst "Loading ignore list
Please wait..." iload 5 if_settext - jump LABEL63 -LABEL57: - sconst "You must set a name" - sconst "
" - sconst "before using this." - join_string 3 + jump LABEL57 +LABEL54: + sconst "You must set a name
before using this." iload 5 if_settext -LABEL63: +LABEL57: iconst 1 iload 0 if_sethide - jump LABEL281 -LABEL67: + jump LABEL274 +LABEL61: iload 12 iconst 0 - if_icmpeq LABEL71 - jump LABEL83 -LABEL71: + if_icmpeq LABEL65 + jump LABEL77 +LABEL65: sconst "You may ignore users by using the button below, or by " sconst "right-clicking" sconst "long pressing" @@ -96,21 +90,21 @@ LABEL71: iconst 1 iload 0 if_sethide - jump LABEL281 -LABEL83: + jump LABEL274 +LABEL77: invoke 1972 istore 11 iload 11 iconst 1 - if_icmpeq LABEL89 - jump LABEL94 -LABEL89: + if_icmpeq LABEL83 + jump LABEL88 +LABEL83: iconst 8 iconst 5 iload 10 scale istore 10 -LABEL94: +LABEL88: sconst "" iload 5 if_settext @@ -120,29 +114,29 @@ LABEL94: 3640 get_varc_int 184 switch - 1: LABEL104 - 2: LABEL107 - 3: LABEL110 - jump LABEL112 -LABEL104: + 1: LABEL98 + 2: LABEL101 + 3: LABEL104 + jump LABEL106 +LABEL98: iconst 0 3641 - jump LABEL112 -LABEL107: + jump LABEL106 +LABEL101: iconst 1 3642 - jump LABEL112 -LABEL110: + jump LABEL106 +LABEL104: iconst 0 3642 -LABEL112: +LABEL106: 3643 -LABEL113: +LABEL107: iload 7 iload 12 - if_icmplt LABEL117 - jump LABEL273 -LABEL117: + if_icmplt LABEL111 + jump LABEL266 +LABEL111: iload 7 ignore_getname ostore 1 @@ -227,14 +221,14 @@ LABEL117: oload 1 string_length iconst 0 - if_icmpgt LABEL199 - jump LABEL248 -LABEL199: + if_icmpgt LABEL193 + jump LABEL241 +LABEL193: iload 11 iconst 1 - if_icmpeq LABEL203 - jump LABEL217 -LABEL203: + if_icmpeq LABEL197 + jump LABEL211 +LABEL197: iconst 10 sconst "Reveal previous name" cc_setop @@ -248,12 +242,11 @@ LABEL203: oload 0 sconst "isiiiss" cc_setonop - jump LABEL245 -LABEL217: - sconst "Previous name:" - sconst "
" + jump LABEL238 +LABEL211: + sconst "Previous name:
" oload 1 - join_string 3 + join_string 2 ostore 1 iconst 526 iconst -2147483645 @@ -278,11 +271,11 @@ LABEL217: sconst "null" sconst "isiiiss" cc_setonop -LABEL245: +LABEL238: iconst 0 cc_sethide 1 - jump LABEL264 -LABEL248: + jump LABEL257 +LABEL241: iconst 40 iload 6 sconst "i" @@ -299,7 +292,7 @@ LABEL248: sconst "null" sconst "isiiiss" cc_setonop -LABEL264: +LABEL257: iload 7 iconst 1 add @@ -308,24 +301,24 @@ LABEL264: add istore 9 istore 7 - jump LABEL113 -LABEL273: + jump LABEL107 +LABEL266: iload 12 iconst 1 - if_icmpge LABEL277 - jump LABEL281 -LABEL277: + if_icmpge LABEL270 + jump LABEL274 +LABEL270: iload 9 iconst 5 add istore 9 -LABEL281: +LABEL274: iload 9 iload 3 if_getheight - if_icmpgt LABEL286 - jump LABEL296 -LABEL286: + if_icmpgt LABEL279 + jump LABEL289 +LABEL279: iconst 0 iload 9 iload 3 @@ -335,8 +328,8 @@ LABEL286: iload 3 if_getscrolly invoke 72 - jump LABEL304 -LABEL296: + jump LABEL297 +LABEL289: iconst 0 iconst 0 iload 3 @@ -345,5 +338,5 @@ LABEL296: iload 3 iconst 0 invoke 72 -LABEL304: +LABEL297: return diff --git a/runelite-client/src/main/scripts/SkillTabBuilder.hash b/runelite-client/src/main/scripts/SkillTabBuilder.hash index 33e0faad01d..74c7c7f1815 100644 --- a/runelite-client/src/main/scripts/SkillTabBuilder.hash +++ b/runelite-client/src/main/scripts/SkillTabBuilder.hash @@ -1 +1 @@ -D8934D27F70A288BCEA8103E6DE30399565A0DBFF6B331AC12D60BA6228A861C \ No newline at end of file +75423D395E8A04512D55836D83B8E98B3FFAAF2114520C0935D0AE626626B614 \ No newline at end of file diff --git a/runelite-client/src/main/scripts/SkillTabBuilder.rs2asm b/runelite-client/src/main/scripts/SkillTabBuilder.rs2asm index 5b4574cf007..9b9b97c2ff8 100644 --- a/runelite-client/src/main/scripts/SkillTabBuilder.rs2asm +++ b/runelite-client/src/main/scripts/SkillTabBuilder.rs2asm @@ -132,7 +132,7 @@ LABEL104: invoke 1138 iconst 0 if_icmpne LABEL116 - jump LABEL326 + jump LABEL316 LABEL116: iload 0 invoke 1936 @@ -140,7 +140,7 @@ LABEL116: iload 7 iconst -1 if_icmpne LABEL123 - jump LABEL157 + jump LABEL156 LABEL123: iload 7 iconst 10 @@ -149,16 +149,15 @@ LABEL123: iload 7 iload 5 if_icmpgt LABEL131 - jump LABEL157 + jump LABEL156 LABEL131: oload 2 sconst "|" sconst "" sconst "" invoke 6850 - sconst "XP to regain:" - sconst "" - join_string 4 + sconst "XP to regain:" + join_string 3 append ostore 2 oload 3 @@ -177,42 +176,41 @@ LABEL131: ostore 3 iconst 1 istore 8 -LABEL157: +LABEL156: iload 8 iconst 0 - if_icmpeq LABEL161 - jump LABEL326 -LABEL161: + if_icmpeq LABEL160 + jump LABEL316 +LABEL160: get_varp 1588 iconst 0 - if_icmpgt LABEL165 - jump LABEL326 -LABEL165: + if_icmpgt LABEL164 + jump LABEL316 +LABEL164: iload 0 switch - 0: LABEL168 - 2: LABEL168 - 6: LABEL168 - 4: LABEL221 - 1: LABEL274 - jump LABEL326 -LABEL168: + 0: LABEL167 + 2: LABEL167 + 6: LABEL167 + 4: LABEL217 + 1: LABEL267 + jump LABEL316 +LABEL167: iconst 20 invoke 2031 istore 10 iload 10 iconst 0 - if_icmpgt LABEL175 - jump LABEL198 -LABEL175: + if_icmpgt LABEL174 + jump LABEL196 +LABEL174: oload 2 sconst "|" sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -227,8 +225,8 @@ LABEL175: join_string 4 append ostore 3 - jump LABEL220 -LABEL198: + jump LABEL216 +LABEL196: iconst 1 istore 9 oload 2 @@ -236,9 +234,8 @@ LABEL198: sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -246,30 +243,28 @@ LABEL198: sconst "" sconst "" invoke 6850 - sconst "NONE" - sconst "" - join_string 4 + sconst "NONE" + join_string 3 append ostore 3 -LABEL220: - jump LABEL326 -LABEL221: +LABEL216: + jump LABEL316 +LABEL217: iconst 30 invoke 2031 istore 10 iload 10 iconst 0 - if_icmpgt LABEL228 - jump LABEL251 -LABEL228: + if_icmpgt LABEL224 + jump LABEL246 +LABEL224: oload 2 sconst "|" sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -284,8 +279,8 @@ LABEL228: join_string 4 append ostore 3 - jump LABEL273 -LABEL251: + jump LABEL266 +LABEL246: iconst 1 istore 9 oload 2 @@ -293,9 +288,8 @@ LABEL251: sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -303,30 +297,28 @@ LABEL251: sconst "" sconst "" invoke 6850 - sconst "NONE" - sconst "" - join_string 4 + sconst "NONE" + join_string 3 append ostore 3 -LABEL273: - jump LABEL326 -LABEL274: +LABEL266: + jump LABEL316 +LABEL267: iconst 40 invoke 2031 istore 10 iload 10 iconst 0 - if_icmpgt LABEL281 - jump LABEL304 -LABEL281: + if_icmpgt LABEL274 + jump LABEL296 +LABEL274: oload 2 sconst "|" sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -341,8 +333,8 @@ LABEL281: join_string 4 append ostore 3 - jump LABEL326 -LABEL304: + jump LABEL316 +LABEL296: iconst 1 istore 9 oload 2 @@ -350,9 +342,8 @@ LABEL304: sconst "" sconst "" invoke 6850 - sconst "XP permitted:" - sconst "" - join_string 4 + sconst "XP permitted:" + join_string 3 append ostore 2 oload 3 @@ -360,33 +351,32 @@ LABEL304: sconst "" sconst "" invoke 6850 - sconst "NONE" - sconst "" - join_string 4 + sconst "NONE" + join_string 3 append ostore 3 -LABEL326: +LABEL316: iload 1 iconst 6 cc_find 1 iconst 1 - if_icmpeq LABEL332 - jump LABEL342 -LABEL332: + if_icmpeq LABEL322 + jump LABEL332 +LABEL322: iload 9 iconst 1 - if_icmpeq LABEL336 - jump LABEL339 -LABEL336: + if_icmpeq LABEL326 + jump LABEL329 +LABEL326: iconst 0 cc_sethide 1 - jump LABEL341 -LABEL339: + jump LABEL331 +LABEL329: iconst 1 cc_sethide 1 -LABEL341: - jump LABEL370 -LABEL342: +LABEL331: + jump LABEL360 +LABEL332: iload 1 iconst 5 iconst 6 @@ -408,48 +398,44 @@ LABEL342: cc_setgraphicshadow 1 iload 9 iconst 1 - if_icmpeq LABEL365 - jump LABEL368 -LABEL365: + if_icmpeq LABEL355 + jump LABEL358 +LABEL355: iconst 0 cc_sethide 1 - jump LABEL370 -LABEL368: + jump LABEL360 +LABEL358: iconst 1 cc_sethide 1 -LABEL370: +LABEL360: iload 3 iconst 1 - if_icmpeq LABEL374 - jump LABEL393 -LABEL374: + if_icmpeq LABEL364 + jump LABEL379 +LABEL364: map_members iconst 0 - if_icmpeq LABEL378 - jump LABEL393 -LABEL378: + if_icmpeq LABEL368 + jump LABEL379 +LABEL368: get_varc_int 103 iconst 0 - if_icmpeq LABEL382 - jump LABEL393 -LABEL382: + if_icmpeq LABEL372 + jump LABEL379 +LABEL372: sconst "" oload 0 - sconst ":" - sconst "" - join_string 4 - ostore 2 - sconst "" - sconst "Members Only" - sconst "" + sconst ":" join_string 3 + ostore 2 + sconst "Members Only" ostore 3 -LABEL393: +LABEL379: invoke 1972 iconst 1 - if_icmpeq LABEL397 - jump LABEL424 -LABEL397: + if_icmpeq LABEL383 + jump LABEL410 +LABEL383: iconst 2367 iconst -2147483644 iconst -2147483645 @@ -463,14 +449,14 @@ LABEL397: if_setonop get_varc_int 218 iload 1 - if_icmpeq LABEL412 - jump LABEL423 -LABEL412: + if_icmpeq LABEL398 + jump LABEL409 +LABEL398: get_varc_int 217 iconst -1 - if_icmpeq LABEL416 - jump LABEL423 -LABEL416: + if_icmpeq LABEL402 + jump LABEL409 +LABEL402: iload 1 iconst -1 iload 2 @@ -478,9 +464,9 @@ LABEL416: oload 3 iconst 495 invoke 2344 -LABEL423: - jump LABEL439 -LABEL424: +LABEL409: + jump LABEL425 +LABEL410: iconst 992 iconst -2147483645 iconst -1 @@ -496,5 +482,5 @@ LABEL424: if_setonmouserepeat iconst 0 set_varc_int 2 -LABEL439: +LABEL425: return 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()); 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/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()); + } +} 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..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 @@ -608,11 +608,42 @@ 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)); + } + + // ---- Varrock museum interior (the Kudos dead-end) ---------------------------------------------- + + /** + * 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 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) ------------------------------------ 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<>())); - } -} 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()); + } } 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/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 64051e5ae1f..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 @@ -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; @@ -50,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 @@ -117,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(), @@ -130,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))); } @@ -161,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"})); } @@ -199,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 @@ -220,7 +221,7 @@ public void alKharidTollLanding_rejectsUnrelatedTransport() { "Door", 136); - assertFalse(Rs2Walker.hasReachedAlKharidTollDestination( + assertFalse(Rs2WalkerTransports.hasReachedAlKharidTollDestination( door, door.getDestination())); } @@ -244,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", @@ -289,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 @@ -357,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 @@ -382,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)); } /** @@ -409,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 @@ -424,7 +425,7 @@ public void adjacentTransportSuppression_ignoresNonAdjacentTransports() { "Ladder", 133); - assertTrue(Rs2Walker.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); + assertTrue(Rs2WalkerTransports.adjacentSamePlaneTransportSuppressionPoints(ladder, null).isEmpty()); } @Test @@ -437,7 +438,7 @@ public void shouldRecalculatePathAfterTransport_includesOriginlessTeleport() { 20, Collections.emptyMap()); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockTeleport)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockTeleport)); } @Test @@ -512,7 +513,7 @@ public void shouldRecalculatePathAfterTransport_skipsAdjacentSamePlaneTransport( "Door", 136); - assertFalse(Rs2Walker.shouldRecalculatePathAfterTransport(door)); + assertFalse(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(door)); } @Test @@ -527,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), @@ -546,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), @@ -565,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), @@ -584,7 +585,7 @@ public void isSettledNearAdjacentSamePlaneLanding_acceptsBoundedForwardAgilityOv "Stepping stone", 16533); - assertTrue(Rs2Walker.isSettledNearAdjacentSamePlaneLanding( + assertTrue(Rs2WalkerTransports.isSettledNearAdjacentSamePlaneLanding( steppingStone, new WorldPoint(3149, 3363, 0), steppingStone.getDestination(), @@ -603,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(), @@ -632,7 +633,7 @@ public void shouldRecalculatePathAfterTransport_includesLongDistanceTransport() "Gangplank", 2082); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(ship)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(ship)); } @Test @@ -647,7 +648,7 @@ public void shouldRecalculatePathAfterTransport_includesSamePlaneCoordinateBandT "Ladder", 11806); - assertTrue(Rs2Walker.shouldRecalculatePathAfterTransport(varrockSewerLadder)); + assertTrue(Rs2WalkerTransports.shouldRecalculatePathAfterTransport(varrockSewerLadder)); } @Test @@ -983,58 +984,41 @@ public void rockfallGateStaysClosedAwayFromTheMine() { Rs2ObstacleHandler.isMotherlodeRockfallCandidate(varrock, null, 0)); } - /** - * A handled door/transport/blocker must NOT be charged against the partial-retry budget. - * - *

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

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

The base stays 12s on purpose: the longest legitimate motionless stretch measured across + * four live farm runs is ~7.1s, waiting out a transport handoff. Cutting the base is how you get + * a walker that interrupts its own ships. */ @Test - public void nonProgressExits_stillConsumeThePartialRetryBudget() { - for (String stuck : new String[]{ - "end-of-path", - "not-near-path", - "player-location-null", - "click-failed-off-minimap", - "door-edge-waiting-retry", - "door-edge-nearby-waiting-retry", - "door-recovery-suppressed", - "local-reachability-miss-no-click", - null}) { - assertFalse("'" + stuck + "' is not route progress and must still spend a retry", - Rs2Walker.isRouteProgressExit(stuck)); - } + public void stallBudgetStaysWithinItsMeasuredEnvelope() { + long plain = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, false, false); + long withInterim = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, false, false, false, true, false); + long worst = Rs2WalkerStallPolicy.computeThresholdMs(Rs2Walker.STALL_BASE_MS, + Rs2Walker.STALL_COMBAT_MULTIPLIER, Rs2Walker.STALL_ANIMATING_MULTIPLIER, + Rs2Walker.STALL_MOVING_MULTIPLIER, Rs2Walker.STALL_INTERIM_MINIMAP_MULTIPLIER, + Rs2Walker.STALL_INTERACTING_MULTIPLIER, true, true, true, true, true); + + assertEquals("plain stall budget", 12_000L, plain); + assertEquals("the common case: a sticky interim is live for most of a walk", 15_000L, withInterim); + assertEquals("worst case, everything applying at once", 24_000L, worst); + assertTrue("must stay clear of the ~7.1s transport handoff measured live", plain >= 10_000L); } /** @@ -1645,6 +1629,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( @@ -1910,34 +1941,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() { @@ -2098,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 // --------------------------------------------------------------------------- @@ -2181,6 +2142,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) // --------------------------------------------------------------------------- @@ -2188,20 +2230,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))); } // --------------------------------------------------------------------------- @@ -2313,4 +2355,294 @@ public void walkUntil_failedConditionFallsBackToNormalWalkerResult() { public void walkUntil_rejectsNullCondition() { Rs2Walker.walkUntil(new WorldPoint(3200, 3200, 0), 2, null); } + + // ---- arrival beside an unwalkable target (false-success near interactables) --------------------- + + /** + * "Within distance of an object" was reported as ARRIVED on straight-line distance alone. With a + * wall between, the caller then interacted from the wrong side and failed while the walker claimed + * success — the silent-wrong-success case. + */ + @Test + public void hasReachableNeighbour_trueWhenWeCanStandBesideTheTarget() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 0), 1); // directly south of it + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_acceptsDiagonalNeighbours() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3201, 3201, 0), 1); + assertTrue(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** Near in a straight line, but every adjacent tile is on the far side of a wall. */ + @Test + public void hasReachableNeighbour_falseWhenOnlyDistantTilesAreReachable() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3205, 3200, 0), 5); + reachable.put(new WorldPoint(3200, 3205, 0), 5); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** The target's own tile being reachable is not the question — we must stand BESIDE it. */ + @Test + public void hasReachableNeighbour_targetTileItselfDoesNotCount() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(chest, 0); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + /** A neighbour on another plane is not somewhere we can stand to use it. */ + @Test + public void hasReachableNeighbour_ignoresOtherPlanes() { + WorldPoint chest = new WorldPoint(3200, 3200, 0); + java.util.Map reachable = new java.util.HashMap<>(); + reachable.put(new WorldPoint(3200, 3199, 1), 1); + assertFalse(Rs2Walker.hasReachableNeighbour(chest, reachable)); + } + + @Test + public void hasReachableNeighbour_toleratesMissingInputs() { + assertFalse(Rs2Walker.hasReachableNeighbour(null, new java.util.HashMap<>())); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), null)); + assertFalse(Rs2Walker.hasReachableNeighbour(new WorldPoint(3200, 3200, 0), new java.util.HashMap<>())); + } + + // ---- walled route edge learning (the Sinclair Mansion deadlock) --------------------------------- + + private static java.util.Map reachableSet(WorldPoint... tiles) { + java.util.Map m = new java.util.HashMap<>(); + for (int i = 0; i < tiles.length; i++) { + m.put(tiles[i], i); + } + return m; + } + + /** + * The route steps out of the BFS at b -> that edge is what is actually walled, whatever the shipped + * map claims. Learning it is what turns a permanent refuse/replan oscillation into one replan. + */ + @Test + public void firstWalledRawEdge_findsTheStepThatLeavesTheBfs() { + WorldPoint p = new WorldPoint(2740, 3469, 0); + WorldPoint a = new WorldPoint(2740, 3468, 0); + WorldPoint b = new WorldPoint(2740, 3467, 0); + java.util.List raw = java.util.Arrays.asList(p, a, b, new WorldPoint(2740, 3466, 0)); + WorldPoint[] edge = 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)); + } + + /** + * 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); + 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)); + } + + // ---- 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)); + } + + // ---- 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/WalkExitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java new file mode 100644 index 00000000000..314f7a21939 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/WalkExitTest.java @@ -0,0 +1,278 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.client.plugins.microbot.util.walker.state.WalkExit; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The meaning of every walk-loop exit reason, held as data. + * + *

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

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

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

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

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

The leak was subtle because walk-session start did clear the transport context — but + * only the three location fields, not {@code lastTransportHandledAtMs}, which is the field every + * window check actually reads. So the window stayed armed for its full 15 seconds while the + * destination it describes was already null. + */ +public class WalkSessionStateResetTest +{ + private WalkerRouteState routeState; + + @Before + public void setUp() + { + routeState = Rs2Walker.routeStateForTesting(); + routeState.clearRecentTransportContext(); + } + + /** + * The regression itself. A walk that ends without clearing its target — an exception, the tail + * cap tripping, or an external cancellation — leaves the window armed; starting the next walk has + * to disarm it. + */ + @Test + public void startingAWalkEndsAnyPostTransportWindowLeftByThePreviousOne() + { + routeState.lastTransportHandledAtMs = System.currentTimeMillis(); + routeState.lastTransportOriginLocation = new WorldPoint(3200, 3200, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3200, 3210, 1); + + Rs2Walker.resetWalkSessionState(); + + assertEquals("the post-transport window must be disarmed at walk start; every window check " + + "reads this timestamp, so leaving it set suppresses the new walk's handlers", + 0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** + * Clearing the locations alone is what the bug was. Pin the timestamp explicitly so a future + * edit cannot reintroduce a partial clear that looks right and does nothing. + */ + @Test + public void clearingTheTransportContextClearsTheTimestampNotJustTheLocations() + { + routeState.lastTransportHandledAtMs = 1_234_567L; + routeState.lastTransportOriginLocation = new WorldPoint(1, 2, 0); + routeState.lastTransportDestinationLocation = new WorldPoint(3, 4, 0); + + routeState.clearRecentTransportContext(); + + assertEquals(0L, routeState.lastTransportHandledAtMs); + assertNull(routeState.lastTransportOriginLocation); + assertNull(routeState.lastTransportDestinationLocation); + } + + /** + * Walk start also withdraws the previous walk's door claim, for the same staleness reason — + * but deliberately NOT the per-edge cooldowns: hammering one door across two walks is still + * hammering. The two lifetimes used to live in two stores; the ledger keeps both facts and + * this test pins that the reset touches only the claim. + */ + @Test + public void startingAWalkDropsThePreviousWalksDoorClaimButKeepsTheEdgeCooldown() + { + DoorAttemptLedger ledger = 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("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. */ + @Test + public void startingAWalkResetsRouteProgress() + { + routeState.routeProgressIdx = 42; + routeState.routeProgressAdvancedAtMs = System.currentTimeMillis(); + routeState.stagnationReplansSpent = 2; + + Rs2Walker.resetWalkSessionState(); + + assertEquals(-1, routeState.routeProgressIdx); + assertEquals(0L, routeState.routeProgressAdvancedAtMs); + assertEquals("a fresh walk owes a fresh stagnation budget", 0, routeState.stagnationReplansSpent); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java new file mode 100644 index 00000000000..d430cd3d13e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/DoorAttemptLedgerTest.java @@ -0,0 +1,366 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Characterization of the ATTEMPTED facet of the door-attempt ledger (D3 slice 1). Every row pins a + * behaviour the two pre-ledger stores ({@code recentDoorAttemptByEdge} and + * {@code routeState.lastDoorAttempt*}) exhibited live — the fold must change where the facts live, + * not what they say. + */ +public class DoorAttemptLedgerTest +{ + private static final long COOLDOWN_MS = 2_500; + private static final long T0 = 1_000_000L; + + private final WorldPoint near = new WorldPoint(1875, 5240, 0); + private final WorldPoint far = new WorldPoint(1876, 5239, 0); + private final WorldPoint otherNear = new WorldPoint(1879, 5239, 0); + private final WorldPoint otherFar = new WorldPoint(1879, 5240, 0); + + private DoorAttemptLedger ledger; + + @Before + public void setUp() + { + ledger = new DoorAttemptLedger(); + } + + // ---- the anti-hammer cooldown (formerly recentDoorAttemptByEdge) ---- + + @Test + public void attemptThrottlesTheSameEdgeWithinTheCooldown() + { + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownIsDirectionBlind() + { + // The edge key normalizes direction: clicking the gate from the far side one second after + // clicking it from the near side is still hammering the same door. + ledger.markAttempt(null, near, far, T0); + assertTrue(ledger.shouldThrottleAttempt(null, far, near, COOLDOWN_MS, T0 + 1_000)); + } + + @Test + public void theCooldownExpires() + { + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + COOLDOWN_MS + 1)); + } + + @Test + public void aDifferentEdgeIsNeverThrottledByThisOne() + { + // Chaining is not hammering — the Stronghold's gates are three tiles apart and the walk + // must be free to attempt the NEXT gate immediately. + ledger.markAttempt(null, near, far, T0); + assertFalse(ledger.shouldThrottleAttempt(null, otherNear, otherFar, COOLDOWN_MS, T0 + 100)); + } + + @Test + public void tileKeyedAttemptsFeedTheCooldownButNeverBecomeTheClaim() + { + // A probe-only door (no resolved edge) has always been cooldown-tracked by its tile without + // becoming "the door the walker is working on". + WorldPoint doorTile = new WorldPoint(1859, 5239, 0); + ledger.markAttempt(doorTile, null, null, T0); + assertTrue(ledger.shouldThrottleAttempt(doorTile, null, null, COOLDOWN_MS, T0 + 100)); + assertNull(ledger.latestAttempt()); + } + + @Test + public void attemptTimesAreReadableForTheAgeHeuristics() + { + ledger.markAttempt(null, near, far, T0); + assertEquals(Long.valueOf(T0), ledger.attemptAtMs(near, far)); + assertEquals("age reads are direction-blind like the cooldown", + Long.valueOf(T0), ledger.attemptAtMs(far, near)); + assertNull(ledger.attemptAtMs(otherNear, otherFar)); + } + + // ---- the latest claim (formerly routeState.lastDoorAttempt*) ---- + + @Test + public void theLatestClaimAnswersTheActiveEdgeQuestionInBothDirections() + { + // The live-collision route validator asks "does the executor own this edge" without caring + // which way the crossing runs (fightarena_door1 lesson). + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 1_000); + assertNotNull(claim); + assertTrue(claim.matchesEdge(near, far)); + assertTrue(claim.matchesEdge(far, near)); + assertFalse(claim.matchesEdge(otherNear, otherFar)); + } + + @Test + public void theClaimGoesStale() + { + ledger.markAttempt(null, near, far, T0); + assertNull("a claim older than its window satisfies nothing", + ledger.latestAttempt(6_000, T0 + 6_001)); + assertNotNull("but the un-aged read still sees it (same-edge cooldown semantics)", + ledger.latestAttempt()); + } + + @Test + public void theSameEdgeCooldownCheckIsDirectionAware() + { + // shouldThrottleGlobalDoorInteraction's same-edge test was always directional — approaching + // the door from the other side is a new interaction context, not a re-click. + ledger.markAttempt(null, near, far, T0); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(); + assertTrue(claim.isSameDirectedEdge(near, far)); + assertFalse(claim.isSameDirectedEdge(far, near)); + } + + @Test + public void aNewerAttemptReplacesTheClaim() + { + // Stronghold 2026-08-12: once gate 2 is attempted, gate 1 must no longer be "the door the + // walker is working on" — the victory-lap bug was exactly a stale claim outliving its door. + ledger.markAttempt(null, near, far, T0); + ledger.markAttempt(null, otherNear, otherFar, T0 + 500); + DoorAttemptLedger.Attempt claim = ledger.latestAttempt(6_000, T0 + 600); + assertTrue(claim.matchesEdge(otherNear, otherFar)); + assertFalse(claim.matchesEdge(near, far)); + } + + // ---- the REFUSED facet: strike counting (formerly Rs2DoorHandler.registerDoorCrossFailure) ---- + // + // Seeded from the Tithe Farm incident (2026-08-12): Farm door 27445 refused to pass a seedless + // player, and with no strike-out the walker ping-ponged door->recovery for 4+ minutes until a + // human cancelled it. Three concluded-but-uncrossed attempts must strike the edge out. + + private static final long DECAY_MS = 300_000L; + private static final int STRIKE_LIMIT = 3; + + @Test + public void thirdConclusiveFailureStrikesOut() + { + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 12_000, DECAY_MS, STRIKE_LIMIT)); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 24_000, DECAY_MS, STRIKE_LIMIT)); + // The strike-out consumed the entry: the edge starts fresh if it is ever attempted again. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 25_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes are direction-blind like every other edge fact: refusing to pass is a property of the door. */ + @Test + public void strikesAccumulateAcrossDirections() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(far, near, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.STRIKE_OUT, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A moving or cancelled sample proves only that the approach was in flight — the Wydin lesson. */ + @Test + public void inconclusiveSamplesNeverCount() + { + for (int i = 0; i < 10; i++) + { + assertEquals(DoorAttemptLedger.Strike.NOT_COUNTED, + ledger.registerCrossFailure(near, far, false, T0 + i, DECAY_MS, STRIKE_LIMIT)); + } + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 100, DECAY_MS, STRIKE_LIMIT)); + } + + /** Strikes older than the decay window reset; two failures an hour apart are not a pattern. */ + @Test + public void staleStrikesDecay() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + // Third failure arrives after the decay window: the old two evaporate, count restarts at 1. + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 1_000 + DECAY_MS + 1, DECAY_MS, STRIKE_LIMIT)); + } + + @Test + public void edgesStrikeIndependently() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(otherNear, otherFar, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + /** A successful crossing forgives accumulated strikes (transient refusals must not accrue). */ + @Test + public void successfulCrossingClearsStrikes() + { + ledger.registerCrossFailure(near, far, true, T0, DECAY_MS, STRIKE_LIMIT); + ledger.registerCrossFailure(near, far, true, T0 + 1_000, DECAY_MS, STRIKE_LIMIT); + ledger.clearCrossFailures(near, far); + assertEquals(DoorAttemptLedger.Strike.COUNTED, + ledger.registerCrossFailure(near, far, true, T0 + 2_000, DECAY_MS, STRIKE_LIMIT)); + } + + // ---- the tile facets: recently-opened suppression and the session blacklist ---- + + private static final long SUPPRESS_MS = 10_000L; + + /** Re-clicking a just-opened door closes it again — the original two-clicks-per-door bug. */ + @Test + public void aJustOpenedDoorSuppressesProbesOnItsSegment() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertTrue("segment ending beside the opened door must be suppressed", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + assertTrue(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void theSuppressionExpires() + { + WorldPoint doorTile = new WorldPoint(1875, 5240, 0); + ledger.markStationaryDoorOpened(doorTile, T0); + assertFalse(ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + assertFalse(ledger.wasStationaryDoorOpenedWithin(doorTile, SUPPRESS_MS, T0 + SUPPRESS_MS + 1)); + } + + @Test + public void aFarAwayOpenedDoorSuppressesNothing() + { + ledger.markStationaryDoorOpened(new WorldPoint(1990, 5300, 0), T0); + assertFalse("suppression is local (within 2 tiles of a segment end), not global", + ledger.recentlyOpenedDoorOnSegment(near, far, SUPPRESS_MS, T0 + 1_000)); + } + + @Test + public void blacklistedDoorsAreSessionPermanent() + { + WorldPoint doorTile = new WorldPoint(1907, 5223, 0); + assertFalse(ledger.isDoorBlacklisted(doorTile)); + ledger.blacklistDoor(doorTile); + assertTrue(ledger.isDoorBlacklisted(doorTile)); + assertFalse("plane is part of the tile identity", + ledger.isDoorBlacklisted(new WorldPoint(1907, 5223, 1))); + } + + // ---- the REFUSED facet: walk-scoped blocks ---- + + /** The museum lesson: a strike-out blocks the edge for THIS walk only; the next walk withdraws it. */ + @Test + public void walkScopedBlocksDrainOnceAndInOrder() + { + ledger.recordWalkScopedBlock(near, far); + ledger.recordWalkScopedBlock(far, near); + + java.util.List drained = ledger.drainWalkScopedBlocks(); + assertEquals(2, drained.size()); + assertEquals(near, drained.get(0)[0]); + assertEquals(far, drained.get(0)[1]); + assertEquals(far, drained.get(1)[0]); + assertEquals(near, drained.get(1)[1]); + assertTrue("a second drain must find nothing — blocks are withdrawn exactly once", + ledger.drainWalkScopedBlocks().isEmpty()); + } + + // ---- the pass budget (formerly processWalk's doorEdgesAttemptedThisTail map) ---- + + @Test + public void anEdgeIsClaimableOncePerPassFromTheSameStand() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + @Test + public void theReverseEdgeIsTheSameClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + assertFalse(ledger.tryClaimEdgeThisPass(toWp, fromWp, stand)); + } + + @Test + public void movingReArmsTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + assertTrue(ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2465, 3494, 0))); + assertTrue("retry should be allowed after moving away from same-edge attempt tile", + ledger.tryClaimEdgeThisPass(fromWp, toWp, new WorldPoint(2462, 3491, 0))); + } + + @Test + public void aNewPassAndAReleaseEachReArmTheClaim() + { + WorldPoint fromWp = new WorldPoint(2465, 3494, 0); + WorldPoint toWp = new WorldPoint(2465, 3493, 0); + WorldPoint stand = new WorldPoint(2465, 3494, 0); + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand); + ledger.releaseEdgeThisPass(fromWp, toWp); + assertTrue("a released claim (no interaction happened) must be attemptable this pass", + ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + ledger.beginTailPass(); + assertTrue("a new pass owes a fresh budget", ledger.tryClaimEdgeThisPass(fromWp, toWp, stand)); + } + + // ---- the settle window, global cooldown and raw-scan focus (walk-runtime facets) ---- + + @Test + public void theSettleWindowStoresAndEndsEarly() + { + WorldPoint farSide = new WorldPoint(1876, 5239, 0); + ledger.markSettling(farSide, T0, 900); + assertEquals(T0, ledger.settleStartedAtMs()); + assertEquals(T0 + 900, ledger.settleUntilMs()); + assertEquals(farSide, ledger.settleFarSide()); + ledger.endSettleEarly(); + assertEquals("early end clears the ceiling, not the start (heartbeat still reads it)", + 0L, ledger.settleUntilMs()); + assertNull(ledger.settleFarSide()); + assertEquals(T0, ledger.settleStartedAtMs()); + } + + @Test + public void theRawScanFocusIsABoundedCommitment() + { + ledger.setRawScanFocus(7, T0); + assertEquals(Integer.valueOf(7), ledger.rawScanFocusDoorIdx()); + assertEquals(T0, ledger.rawScanFocusSetAtMs()); + ledger.recordRawScanFocusAttempt(); + ledger.recordRawScanFocusAttempt(); + assertEquals(2, ledger.rawScanFocusAttempts()); + ledger.clearRawScanFocus(); + assertNull(ledger.rawScanFocusDoorIdx()); + assertEquals(0, ledger.rawScanFocusAttempts()); + } + + @Test + public void withdrawingTheClaimLeavesTheCooldownStanding() + { + // The crossed-axis clearing (conquered door) and the walk-start reset both withdraw the + // claim; neither may forgive the anti-hammer cooldown. Two lifetimes, one owner. + ledger.markAttempt(null, near, far, T0); + ledger.clearLatestAttempt(); + assertNull(ledger.latestAttempt()); + assertTrue(ledger.shouldThrottleAttempt(null, near, far, COOLDOWN_MS, T0 + 100)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java index 6c85efd9c44..7fe2cb9861e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorClassifierTest.java @@ -134,4 +134,44 @@ public void getDoorActionReturnsHighestPriorityConfiguredMatch() { assertNull(Rs2DoorClassifier.getDoorAction(compWithActions("Examine", "Look-at"), doorActions)); assertNull(Rs2DoorClassifier.getDoorAction(null, doorActions)); } + + // ---- route-door classification (D3 requirement #3 — the Gift of Peace lesson) ---- + // + // An Open-actioned GameObject with a non-door name is scenery. The Stronghold's goal chest was + // Open-clicked as a route door en route (2026-08-13, 7-9s of failed traversal per encounter); + // the rule is name-or-traversal-verb because large double gates ARE GameObjects, so a flat name + // filter (the old segment-probe contains("door")) missed real doors while the flat action rule + // (the old segment-door site) admitted chests. + + @Test + public void anOpenActionedChestIsNotARouteDoor() { + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Gift of Peace", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Sarcophagus", "Open")); + assertFalse(Rs2DoorClassifier.isRouteDoorObject(false, "Cupboard", "Open")); + } + + @Test + public void aGameObjectGateIsARouteDoorByName() { + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Gate of War", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Temple door", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Curtain", "Open")); + } + + @Test + public void aTraversalVerbProvesDoorhoodWhateverTheName() { + // Field entrances, tollgates and the like carry inherently-traversal verbs. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Wheat", "Walk-through")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(false, "Ornate railing", "Pay-toll")); + assertFalse("Enter is scenery-shared, not traversal-proof", + Rs2DoorClassifier.isRouteDoorObject(false, "Cave entrance", "Enter")); + } + + @Test + public void aWallThatOpensIsADoorWhateverItsName() { + // Unchanged wall semantics: quest walls with odd names still open. + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Oozing barrier", "Open")); + assertTrue(Rs2DoorClassifier.isRouteDoorObject(true, "Strange wall", "Push")); + assertFalse("an actionless, namelessly-non-door wall is still nothing", + Rs2DoorClassifier.isRouteDoorObject(true, "Wall", null)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java index 2d066ecf5cc..267d585c48b 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorGeometryTest.java @@ -108,4 +108,68 @@ public void interactionRejectsNonPositiveRangeAndNullPlayer() { wp(3200, 3200), 0)); assertFalse(Rs2DoorGeometry.isDoorInteractionWithinRange(null, wp(3200, 3200), null, null, null, 2)); } + + // ---- playerBeyondWallFace -------------------------------------------------------------------- + // + // THE STRONGHOLD GATE BOUNCE (2026-08-12). A west-facing moves-you Gate of War at (1887,5244); + // the route step (1886,5244)->(1887,5243) crossed its face DIAGONALLY, and the gate deposited + // the player at (1887,5244) — off the planned to-tile — so the segment-based crossing test + // answered false while the raw scan's backtrack window kept re-finding the gate. Each re-click + // carried the player back through it. + + private static final int WEST = 1; + private static final int NORTH = 2; + private static final int EAST = 4; + private static final int SOUTH = 8; + + /** The bounce itself: carried past the face, even a tile off the planned to-tile, is crossed. */ + @Test + public void depositedBeyondTheFaceIsCrossedEvenOffThePlannedTile() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1887, 5244))); + } + + /** Approaching from the near side — including standing ON the approach tile — is not crossed. */ + @Test + public void approachingTheFaceIsNotCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1885, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1886, 5244), wp(1886, 5244))); + } + + /** Standing on the wall's own tile counts as its side of the face: the second Stronghold gate. */ + @Test + public void standingOnTheWallTileIsBeyondAWestFaceApproachedFromTheWest() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1904, 5242), + wp(1903, 5242), wp(1904, 5242))); + } + + /** The same boundary read from the other direction: crossing east-to-west is symmetric. */ + @Test + public void crossingIsSymmetricAcrossTheFace() { + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1886, 5244))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(WEST, wp(1887, 5244), + wp(1887, 5244), wp(1888, 5244))); + } + + @Test + public void everyCardinalFaceDividesAlongItsOwnAxis() { + // East face of (10,10): boundary between x=10 and x=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(EAST, wp(10, 10), wp(10, 10), wp(9, 10))); + // North face of (10,10): boundary between y=10 and y=11. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(NORTH, wp(10, 10), wp(10, 10), wp(10, 11))); + // South face of (10,10): boundary between y=9 and y=10. + assertTrue(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 9))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(SOUTH, wp(10, 10), wp(10, 10), wp(10, 10))); + } + + /** A corner wall's face does not divide the plane along one axis: never claim crossed. */ + @Test + public void cornerWallsNeverReadAsCrossed() { + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(16, wp(10, 10), wp(9, 10), wp(11, 10))); + assertFalse(Rs2DoorGeometry.playerBeyondWallFace(128, wp(10, 10), wp(9, 10), wp(11, 10))); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java new file mode 100644 index 00000000000..6325d7df8ae --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandlerTest.java @@ -0,0 +1,49 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * The edge-scoped global door cooldown. The full window is anti-hammer for re-clicking ONE door; a + * different door immediately after a successful open is chaining, not hammering, and holding it for + * the full window serialised every pair of nearby doors at ~1.8s each. + */ +public class Rs2DoorHandlerTest { + + private static final long FULL = 1_800L; + private static final long CROSS = 600L; + private static final long CLICKED_AT = 100_000L; + private static final long NEXT_ALLOWED = CLICKED_AT + FULL; + + @Test + public void sameEdgeKeepsTheFullWindow() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + FULL, NEXT_ALLOWED, true, FULL, CROSS)); + } + + /** A different door owes one tick, no more — that is what a player chaining two doors looks like. */ + @Test + public void differentEdgeOwesOnlyTheCrossEdgeFloor() { + assertTrue(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 200L, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + CROSS, NEXT_ALLOWED, false, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 1_000L, NEXT_ALLOWED, false, FULL, CROSS)); + } + + /** No window stamped (or long expired): nothing throttles either way. */ + @Test + public void expiredWindowThrottlesNothing() { + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT + 10_000L, NEXT_ALLOWED, true, FULL, CROSS)); + assertFalse(Rs2DoorHandler.shouldThrottleGlobalDoorInteraction( + CLICKED_AT, 0L, false, FULL, CROSS)); + } + +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java index 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)); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java index e77d06f98e7..c797826da39 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -2,6 +2,7 @@ import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -33,4 +34,60 @@ public void shouldAcceptIdleDoorAwait_rejectsBeforeMinimumElapsed() { assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1200L, true)); assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 800L, true)); } + + // ---- door-open observation throttle ------------------------------------------------------------- + + /** + * An unlocked door opens within one game tick, so an observation before then can only report + * "still shut". The observation is a scene scan, not a field read, which is why it is rationed at + * all rather than run on every poll of the surrounding wait. + */ + @Test + public void shouldPollDoorOpen_notBeforeADoorCouldHaveOpened() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(0L, 10_000L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(100L, 10_000L)); + } + + @Test + public void shouldPollDoorOpen_onceTheFirstTickHasPassed() { + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(250L, 10_000L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(600L, 10_000L)); + } + + /** Rationed: a fresh observation is not worth a scene scan on every poll of the wait. */ + @Test + public void shouldPollDoorOpen_notMoreOftenThanTheInterval() { + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 0L)); + assertFalse(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 100L)); + assertTrue(Rs2WalkerAwaits.shouldPollDoorOpen(1_000L, 250L)); + } + + // ---- traversal budget by click distance --------------------------------------------------------- + + /** Adjacent clicks keep the flat cap they were sized for — no behaviour change for the legacy band. */ + @Test + public void traversalBudget_adjacentClicksKeepTheLegacyCap() { + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(0)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(1)); + assertEquals(2_200L, Rs2WalkerAwaits.traversalBudgetMs(2)); + } + + /** + * A ranged click spends its first seconds being WALKED to the door, at one tile per 0.6s. The + * flat cap expired mid-approach — measured releasedBy=timeout at 11 tiles with the player still + * walking — which handed the recovery machinery its window and cost a second interaction. + */ + @Test + public void traversalBudget_rangedClicksAreGivenTheApproachTime() { + assertEquals(2_200L + 600L, Rs2WalkerAwaits.traversalBudgetMs(3)); + assertEquals(2_200L + 5 * 600L, Rs2WalkerAwaits.traversalBudgetMs(7)); + assertEquals(2_200L + 9 * 600L, Rs2WalkerAwaits.traversalBudgetMs(11)); + } + + /** The stall release bounds a wedged approach, but a hard ceiling still caps the worst case. */ + @Test + public void traversalBudget_isCapped() { + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(12)); + assertEquals(8_000L, Rs2WalkerAwaits.traversalBudgetMs(50)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/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..91fa0fa63d8 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/FrontierDecisionTest.java @@ -0,0 +1,569 @@ +package net.runelite.client.plugins.microbot.util.walker.recovery; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * The frontier cascade's pure decisions, seeded with the incidents that produced them. + * + *

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

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

These conditions were inline boolean soup in {@code processWalk}, and the interaction between + * them — a skipped segment silently withdrawing the right to click a door at range — is what + * produced the Falador U-turn. + */ +public class SegmentGateTest +{ + /** Steady state, nothing special: examine the segment. */ + private static SegmentAction decide(boolean recentTransportWindow, + boolean upcomingNearbyTransport, + boolean recentDoorAttemptNearSegment, + boolean doorSettling, + boolean recoveryInFlight, + boolean tileReachable, + boolean startupBeforeFirstClick, + boolean immediateSegmentTransportStep, + int segmentIdx, + int routeStartIdx) + { + return SegmentGate.decide(recentTransportWindow, upcomingNearbyTransport, + recentDoorAttemptNearSegment, doorSettling, recoveryInFlight, tileReachable, + startupBeforeFirstClick, immediateSegmentTransportStep, segmentIdx, routeStartIdx); + } + + @Test + public void steadyStateRunsTheHandlers() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, false, false, 5, 5)); + } + + @Test + public void postTransportWindowSkipsWhenNoTransportIsComingUp() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, false, false, 5, 5)); + } + + /** The window must not hide the transport it is a window for. */ + @Test + public void aPlannedTransportNearbyOverridesThePostTransportSkip() + { + assertEquals(SegmentAction.RUN, + decide(true, true, false, false, false, true, false, false, 5, 5)); + } + + /** An unreachable segment tile is the case the handlers exist for, so it is never skipped. */ + @Test + public void anUnreachableSegmentIsNeverSkippedByTheTransportWindow() + { + assertEquals(SegmentAction.RUN, + decide(true, false, false, false, false, false, false, false, 5, 5)); + } + + @Test + public void doorWorkInFlightOverridesThePostTransportSkip() + { + assertEquals("a recent door attempt near this segment must still be examined", + SegmentAction.RUN, decide(true, false, true, false, false, true, false, false, 5, 5)); + assertEquals("a settling door must still be examined", + SegmentAction.RUN, decide(true, false, false, true, false, true, false, false, 5, 5)); + assertEquals("recovery movement in flight must still be examined", + SegmentAction.RUN, decide(true, false, false, false, true, true, false, false, 5, 5)); + } + + @Test + public void startupSkipsSegmentsUntilTheFirstMovementClick() + { + assertEquals(SegmentAction.SKIP_STARTUP_PRECLICK, + decide(false, false, false, false, false, true, true, false, 5, 5)); + } + + /** A transport we are standing next to is taken at startup rather than deferred. */ + @Test + public void anImmediateTransportStepIsNotSkippedAtStartup() + { + assertEquals(SegmentAction.RUN, + decide(false, false, false, false, false, true, true, true, 5, 5)); + } + + @Test + public void startupSkipDoesNotApplyBehindTheRouteStartOrOutsideStartup() + { + assertEquals("segments behind the route start are not startup-skipped", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 3, 5)); + assertEquals("a negative route start means we do not know where the route begins", + SegmentAction.RUN, decide(false, false, false, false, false, true, true, false, 5, -1)); + assertEquals("not in startup", + SegmentAction.RUN, decide(false, false, false, false, false, true, false, false, 8, 5)); + } + + @Test + public void startupSkipYieldsToDoorWorkInFlight() + { + assertEquals(SegmentAction.RUN, + decide(false, false, true, false, false, true, true, false, 8, 5)); + } + + /** Both apply: the post-transport reason wins, matching the original reason ternary. */ + @Test + public void postTransportReasonWinsWhenBothSkipsApply() + { + assertEquals(SegmentAction.SKIP_POST_TRANSPORT_WINDOW, + decide(true, false, false, false, false, true, true, false, 5, 5)); + } + + /** Log consumers key off these strings; they must not drift. */ + @Test + public void wireReasonsAreStable() + { + assertEquals("no_nearby_planned_transport", + SegmentAction.SKIP_POST_TRANSPORT_WINDOW.wireReason()); + assertEquals("startup_before_first_click", SegmentAction.SKIP_STARTUP_PRECLICK.wireReason()); + assertFalse(SegmentAction.RUN.isSkip()); + assertTrue(SegmentAction.SKIP_POST_TRANSPORT_WINDOW.isSkip()); + assertTrue(SegmentAction.SKIP_STARTUP_PRECLICK.isSkip()); + } + + /** + * The Falador invariant. A skipped segment was never examined, so the first segment that DOES run + * is not the nearest unresolved obstacle just because it is the first one handled — and only the + * nearest may be clicked at range. + */ + @Test + public void aSkippedSegmentWithdrawsTheRightToClickADoorAtRange() + { + assertTrue("first handler this pass, nothing skipped before it", + SegmentGate.mayDispatchDoorAtRange(false, false)); + assertFalse("an earlier segment was skipped and never examined", + SegmentGate.mayDispatchDoorAtRange(false, true)); + assertFalse("something already handled this pass, so this is not the nearest", + SegmentGate.mayDispatchDoorAtRange(true, false)); + assertFalse(SegmentGate.mayDispatchDoorAtRange(true, true)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java new file mode 100644 index 00000000000..fd38ecda653 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicyTest.java @@ -0,0 +1,73 @@ +package net.runelite.client.plugins.microbot.util.walker.stall; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The walker's stall detector, and specifically what it is allowed to call "movement". + */ +public class Rs2WalkerStallPolicyTest +{ + private static final long WINDOW = 2_500L; + + /** + * The bug this exists for. {@code Rs2Player.isMoving()} compares the pose animation against the + * idle pose, so it reads TRUE while the player merely turns on the spot. Crediting that as + * progress refreshed the stall clock, so a player wedged against a wall or a door who kept + * re-facing it could never be declared stuck — the exact state the detector exists to catch. + */ + @Test + public void turningOnTheSpotIsNotProgress() + { + assertFalse("pose says moving, but no tile has changed in ten seconds", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 10_000L, WINDOW)); + } + + /** + * And the reason it cannot simply require a tile change: a walking step is ~600ms while the check + * samples faster, so "same tile as the last sample" is the normal state of a healthy walk. + */ + @Test + public void walkingBetweenTilesIsStillProgress() + { + assertTrue("mid-step, tile changed 400ms ago", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, 400L, WINDOW)); + assertTrue("just inside the window", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW - 1, WINDOW)); + assertFalse("just outside it", + Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, WINDOW, WINDOW)); + } + + /** An unknown tile-change time must not manufacture a stall. */ + @Test + public void unknownTileChangeTimeCreditsThePose() + { + assertTrue(Rs2WalkerStallPolicy.poseCountsAsProgress(true, true, -1L, WINDOW)); + } + + /** Both original conditions still gate it: off-path movement was never route progress. */ + @Test + public void poseAndNearPathAreStillBothRequired() + { + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(false, true, 100L, WINDOW)); + assertFalse(Rs2WalkerStallPolicy.poseCountsAsProgress(true, false, 100L, WINDOW)); + } + + /** The threshold takes the largest applicable multiplier, not their product. */ + @Test + public void thresholdUsesTheLargestMultiplierNotTheProduct() + { + assertEquals(24_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + true, true, true, true, true)); + assertEquals(12_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, false, false)); + assertEquals("an interim waypoint alone", 21_000L, Rs2WalkerStallPolicy.computeThresholdMs( + 12_000L, 2.0, 1.5, 1.35, 1.75, 1.5, + false, false, false, true, false)); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index b06d5fcac2a..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 @@ -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 @@ -685,6 +690,12 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int) net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isBankBoothInternal(WorldPoint): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isEdgePassableInternal(WorldPoint, WorldPoint): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView @@ -699,7 +710,7 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boo net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$32(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$33(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] @@ -726,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 -> 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#doorObjectStillHasAction(TileObject, WorldPoint, WorldPoint, WorldPoint, List, String, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint 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[] @@ -750,43 +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 @@ -798,34 +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$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$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$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$handleWildernessObelisk$163(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$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 @@ -845,9 +801,9 @@ 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#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean): 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 net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint @@ -856,6 +812,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 @@ -875,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[] @@ -887,7 +901,8 @@ 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#isDoorCandidateOnSegment(DoorProbeContext, DoorAttemptLedger, TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$4(DoorProbeContext, DoorAttemptLedger, WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorProbe#lambda$findDoorNearSegment$5(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.obstacle.Rs2ObstacleHandler#handleRockfall(List, int): Rs2ObstacleHandler$RockfallResult -> net.runelite.api.TileObject#getId(): int