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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ * Calling this authorises an unbounded charge against Death's Coffer, and the bank after that. + * Death's Office holds items indefinitely, so declining to call it is always a safe alternative. + * + * @return {@code true} once the retrieval interface has closed with nothing left to collect. */ + public static boolean reclaimAll() { if (!isDeathsOfficeOpen()) return false; From 9d26b1817c856a83316c74c299ce25238ef11041 Mon Sep 17 00:00:00 2001 From: infuse21 Date: Thu, 6 Aug 2026 01:41:48 +0100 Subject: [PATCH 6/6] fix(death): read the retrieval container that is actually open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDeathsOfficeItems always read InterfaceID.DeathOffice.ITEMS, but isDeathsOfficeOpen accepts either retrieval variant. With the GravestoneRetrieval variant up it returned an empty list, so an office still holding items looked empty — both to callers inspecting it and to reclaimAll's inventory-full warning, which would report "still holds 0 item(s)" while items remained. It now resolves the container from whichever interface is visible, matching how reclaimAll already picks between takeall and button. reclaimItems is unaffected: its guard has already established that the DeathOffice variant is the open one before it reads anything. Co-Authored-By: Claude Opus 4.8 --- .../plugins/microbot/util/death/Rs2Death.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java index 290f9efba23..53cb8728122 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java @@ -685,7 +685,20 @@ private static void advanceReclaimDialogue() { * declining costs nothing. */ public static List getDeathsOfficeItems() { - return readItemContainer(InterfaceID.DeathOffice.ITEMS); + return readItemContainer(activeRetrievalItemsContainer()); + } + + /** + * The item container of whichever retrieval interface is actually open. {@link #isDeathsOfficeOpen()} + * accepts either variant, so reading {@code DeathOffice.ITEMS} unconditionally would return an empty + * list whenever the retrieval-service variant is the one up — making an office that still holds items + * look empty, both to callers and to {@link #reclaimAll()}'s inventory-full warning. + */ + @Component + private static int activeRetrievalItemsContainer() { + return Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ITEMS_CONTAINER) + ? InterfaceID.DeathOffice.ITEMS + : InterfaceID.GravestoneRetrieval.ITEMS; } /**