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/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index f0f37a9cfb5..ffafb969af5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -21,6 +21,7 @@ import net.runelite.client.plugins.microbot.ui.MicrobotPluginListPanel; import net.runelite.client.plugins.microbot.ui.MicrobotTopLevelConfigPanel; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.death.Rs2Death; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.huntkit.Rs2HuntKit; import net.runelite.client.plugins.microbot.util.inventory.Rs2Gembag; @@ -366,6 +367,7 @@ public void onVarbitChanged(VarbitChanged event) Rs2Player.handlePotionTimers(event); Rs2Player.handleTeleblockTimer(event); Rs2RunePouch.onVarbitChanged(event); + Rs2Death.onVarbitChanged(event); } @Subscribe @@ -374,6 +376,12 @@ public void onAnimationChanged(AnimationChanged event) Rs2Player.handleAnimationChanged(event); } + @Subscribe + public void onActorDeath(ActorDeath event) + { + Rs2Death.handleActorDeath(event); + } + @Subscribe(priority = 999) private void onMenuEntryAdded(MenuEntryAdded event) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/DeathsOfficeLocation.java new file mode 100644 index 00000000000..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/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index b06d5fcac2a..a3768ffeb3f 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -146,6 +146,11 @@ net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint, boolean): List -> net.runelite.api.Tile#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#pathTo(WorldPoint, boolean): List -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint#toLocalInstance(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.Player#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.death.Rs2Death#handleActorDeath(ActorDeath): void -> net.runelite.api.events.ActorDeath#getActor(): Actor +net.runelite.client.plugins.microbot.util.death.Rs2Death#onVarbitChanged(VarbitChanged): void -> net.runelite.api.events.VarbitChanged#getValue(): int +net.runelite.client.plugins.microbot.util.death.Rs2Death#onVarbitChanged(VarbitChanged): void -> net.runelite.api.events.VarbitChanged#getVarbitId(): int net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#getDepositBoxBounds(): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#getItems(): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#itemBounds(Rs2ItemModel): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle