Skip to content

Commit 5971e54

Browse files
infuse21claude
andcommitted
feat(death): selective looting for graves and Death's Office
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 <noreply@anthropic.com>
1 parent 1b3e9a6 commit 5971e54

1 file changed

Lines changed: 193 additions & 2 deletions

File tree

  • runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death

‎runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/death/Rs2Death.java‎

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import lombok.Getter;
44
import lombok.extern.slf4j.Slf4j;
5+
import net.runelite.api.MenuAction;
56
import net.runelite.api.Player;
67
import net.runelite.api.annotations.Component;
78
import net.runelite.api.coords.WorldPoint;
@@ -13,6 +14,7 @@
1314
import net.runelite.api.gameval.VarbitID;
1415
import net.runelite.api.widgets.Widget;
1516
import net.runelite.client.plugins.microbot.Microbot;
17+
import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry;
1618
import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel;
1719
import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel;
1820
import net.runelite.client.plugins.microbot.util.Global;
@@ -26,12 +28,14 @@
2628
import net.runelite.client.plugins.microbot.util.walker.Rs2Walker;
2729
import net.runelite.client.plugins.microbot.util.widget.Rs2Widget;
2830

31+
import java.awt.Rectangle;
2932
import java.awt.event.KeyEvent;
3033
import java.time.Duration;
3134
import java.time.Instant;
3235
import java.util.ArrayList;
3336
import java.util.Collections;
3437
import java.util.List;
38+
import java.util.function.Predicate;
3539
import java.util.regex.Matcher;
3640
import java.util.regex.Pattern;
3741

@@ -66,6 +70,21 @@
6670
* {@link #getGraveFee()}, {@link #lootGraveFreeItems()}, {@link #lootGravePaidItems(int)} — when the
6771
* script wants its own logic between them.
6872
* <p>
73+
* {@code recoverItems} and the {@code lootGrave*} / {@code reclaimAll} methods take <b>everything</b>.
74+
* To take only some of it, inspect first and filter:
75+
* <pre>
76+
* Rs2Death.openGrave();
77+
* Rs2Death.lootGraveItems(i -&gt; i.getName().contains("rune")); // leaves the rest
78+
*
79+
* Rs2Death.openDeathsOffice();
80+
* Rs2Death.reclaimItems(i -&gt; i.getId() == ItemID.DRAGON_SCIMITAR);
81+
* </pre>
82+
* {@link #getGraveFreeItems()}, {@link #getGravePaidItems()} and {@link #getDeathsOfficeItems()} show
83+
* what is waiting. Note the asymmetry: the office charges per item reclaimed, so taking less costs less,
84+
* whereas a grave's fee covers its whole paid half at once. And anything left in a <b>grave</b> is only
85+
* safe until the timer expires — it then moves to Death's Office at the higher fee — while anything left
86+
* with Death keeps indefinitely.
87+
* <p>
6988
* Items left behind are not destroyed; they keep in Death's Office indefinitely.
7089
* <p>
7190
* Fee schedules, for reference — this class never computes them, it reads what the game reports:
@@ -93,6 +112,12 @@ public class Rs2Death {
93112

94113
private static final String GRAVE_LOOT_ACTION = "Loot";
95114

115+
/** Per-slot action on a grave item, for selective looting. */
116+
private static final String GRAVE_TAKE_ACTION = "Take";
117+
118+
/** Per-slot action in Death's Office — verified live; the office selects first, then takes. */
119+
private static final String DEATH_OFFICE_SELECT_ACTION = "Select";
120+
96121
/** Death's reclaim dialogue choice, verified in game. Matched as a substring, so it tolerates
97122
* reordering and the trailing punctuation ("Yes, have you got anything for me?"). */
98123
private static final String DEATH_RECLAIM_OPTION = "have you got anything for me";
@@ -314,6 +339,15 @@ public static int getRiskValue() {
314339
* caption as a plain child, so entries without an item id are skipped.
315340
*/
316341
private static List<Rs2ItemModel> readDeathkeepItems(@Component int componentId) {
342+
return readItemContainer(componentId);
343+
}
344+
345+
/**
346+
* Reads the item slots out of any of the death interfaces' item containers, in slot order. The
347+
* slot index is preserved on each {@link Rs2ItemModel}, because it is the {@code param0} needed to
348+
* click that specific slot.
349+
*/
350+
private static List<Rs2ItemModel> readItemContainer(@Component int componentId) {
317351
Widget container = Rs2Widget.getWidget(componentId);
318352
if (container == null) return Collections.emptyList();
319353

@@ -425,12 +459,102 @@ public static int getGraveFee() {
425459
/**
426460
* Claims the half of the grave that costs nothing. Items behind a fee are untouched and stay put.
427461
*/
462+
/**
463+
* The items in the grave's free half — everything that costs nothing to reclaim. Requires the grave
464+
* interface to be open ({@link #openGrave()}).
465+
*/
466+
public static List<Rs2ItemModel> getGraveFreeItems() {
467+
return readItemContainer(InterfaceID.GravestoneGeneric.FREEITEMS);
468+
}
469+
470+
/**
471+
* The items in the grave's paid half — those behind the retrieval fee. Requires the grave interface
472+
* to be open ({@link #openGrave()}).
473+
*/
474+
public static List<Rs2ItemModel> getGravePaidItems() {
475+
return readItemContainer(InterfaceID.GravestoneGeneric.PAYITEMS);
476+
}
477+
478+
/**
479+
* Takes <b>everything</b> in the free half. Use {@link #lootGraveItems(Predicate)} to take only some
480+
* of it.
481+
*/
428482
public static boolean lootGraveFreeItems() {
429483
if (!isGraveOpen()) return false;
430484
clickAndSettle(InterfaceID.GravestoneGeneric.FREEBUTTON);
431485
return true;
432486
}
433487

488+
/**
489+
* Takes only the grave items matching {@code filter}, one slot at a time, from both the free and the
490+
* paid half. Anything not matched is left in the grave — and a grave is consumed once emptied, so
491+
* whatever is left behind ends up at Death's Office rather than staying put.
492+
* <p>
493+
* Slots are clicked highest-index first: taking an item re-packs the container, so descending order
494+
* keeps the remaining slot indices valid.
495+
* <p>
496+
* Paying is still all-or-nothing at the game's level — the fee covers the whole paid half — so a
497+
* filter that matches anything in the paid half incurs the full fee. Check {@link #getGraveFee()}
498+
* first if that matters.
499+
*
500+
* @param filter chooses which items to take.
501+
* @return the number of slots successfully clicked.
502+
*/
503+
public static int lootGraveItems(Predicate<Rs2ItemModel> filter) {
504+
if (!isGraveOpen()) return 0;
505+
506+
int taken = takeMatchingSlots(InterfaceID.GravestoneGeneric.FREEITEMS, filter, GRAVE_TAKE_ACTION);
507+
taken += takeMatchingSlots(InterfaceID.GravestoneGeneric.PAYITEMS, filter, GRAVE_TAKE_ACTION);
508+
return taken;
509+
}
510+
511+
/**
512+
* Clicks each slot in {@code containerId} whose item matches {@code filter}, in descending slot
513+
* order so earlier clicks cannot invalidate later indices.
514+
*/
515+
private static int takeMatchingSlots(@Component int containerId, Predicate<Rs2ItemModel> filter,
516+
String action) {
517+
List<Rs2ItemModel> items = readItemContainer(containerId);
518+
int taken = 0;
519+
for (int i = items.size() - 1; i >= 0; i--) {
520+
Rs2ItemModel item = items.get(i);
521+
if (filter != null && !filter.test(item)) continue;
522+
if (Rs2Inventory.isFull()) {
523+
log.warn("Inventory full after taking {} item(s) — {} left in the interface",
524+
taken, i + 1);
525+
break;
526+
}
527+
clickItemSlot(containerId, item, action);
528+
taken++;
529+
}
530+
return taken;
531+
}
532+
533+
/**
534+
* Clicks one item slot in a death interface. {@code param0} is the slot index and {@code param1} the
535+
* container component, matching how {@code Rs2Bank} drives bank slots.
536+
*/
537+
private static void clickItemSlot(@Component int containerId, Rs2ItemModel item, String action) {
538+
Rectangle bounds = Microbot.getClientThread().runOnClientThreadOptional(() -> {
539+
Widget container = Rs2Widget.getWidget(containerId);
540+
if (container == null) return null;
541+
Widget[] children = container.getDynamicChildren();
542+
if (children == null || item.getSlot() >= children.length) return null;
543+
return children[item.getSlot()].getBounds();
544+
}).orElse(null);
545+
546+
Microbot.doInvoke(new NewMenuEntry()
547+
.param0(item.getSlot())
548+
.param1(containerId)
549+
.opcode(MenuAction.CC_OP.getId())
550+
.identifier(1)
551+
.itemId(item.getId())
552+
.option(action)
553+
.target(item.getName()),
554+
bounds == null ? new Rectangle(1, 1) : bounds);
555+
Global.sleepUntilNextTick();
556+
}
557+
434558
/**
435559
* Claims the items behind the retrieval fee, when the account can afford it and the fee fits the
436560
* budget. Everything lands in the inventory — this interface has no send-to-bank option.
@@ -464,7 +588,15 @@ public static boolean lootGravePaidItems(int budget) {
464588

465589
// The varbit is the authoritative signal: the interface can linger open after the last item is
466590
// claimed, so closing is not proof the grave was emptied.
467-
return Global.sleepUntil(() -> !hasGrave(), LOOT_TIMEOUT_MS);
591+
boolean emptied = Global.sleepUntil(() -> !hasGrave(), LOOT_TIMEOUT_MS);
592+
if (!emptied && Rs2Inventory.isFull()) {
593+
// Unlike Death's Office, a grave expires — anything still in it when the timer runs out
594+
// moves on and costs the (usually higher) office fee to get back.
595+
log.warn("Grave not emptied and the inventory is full — {} free item(s) and {} paid item(s) "
596+
+ "remain, with {} left on the grave timer",
597+
getGraveFreeItems().size(), getGravePaidItems().size(), getGraveTimeRemaining());
598+
}
599+
return emptied;
468600
}
469601

470602
// endregion
@@ -565,6 +697,60 @@ private static void advanceReclaimDialogue() {
565697
*
566698
* @return {@code true} once the retrieval interface has closed with nothing left to collect.
567699
*/
700+
/**
701+
* The items Death is currently holding. Requires the retrieval interface to be open
702+
* ({@link #openDeathsOffice()}) — the office cannot be inspected from afar, though walking there and
703+
* declining costs nothing.
704+
*/
705+
public static List<Rs2ItemModel> getDeathsOfficeItems() {
706+
return readItemContainer(InterfaceID.DeathOffice.ITEMS);
707+
}
708+
709+
/**
710+
* Reclaims only the items matching {@code filter}, leaving the rest with Death — where they keep
711+
* indefinitely, so anything skipped can be collected later.
712+
* <p>
713+
* Each slot is taken in two steps, mirroring the interface: click the item ({@code Select}), then the
714+
* {@code All} quantity button that appears. Slots are processed highest-index first so taking one
715+
* cannot shift the indices of those still to come.
716+
* <p>
717+
* The fee is charged per item reclaimed, so taking less costs less — unlike the grave, where paying
718+
* covers the whole paid half at once.
719+
*
720+
* @param filter chooses which items to reclaim.
721+
* @return the number of slots successfully taken.
722+
*/
723+
public static int reclaimItems(Predicate<Rs2ItemModel> filter) {
724+
if (!isDeathsOfficeOpen()) return 0;
725+
726+
List<Rs2ItemModel> items = getDeathsOfficeItems();
727+
int taken = 0;
728+
for (int i = items.size() - 1; i >= 0; i--) {
729+
Rs2ItemModel item = items.get(i);
730+
if (filter != null && !filter.test(item)) continue;
731+
if (Rs2Inventory.isFull()) {
732+
log.warn("Inventory full after reclaiming {} item(s) — {} left with Death", taken, i + 1);
733+
break;
734+
}
735+
736+
// Step 1: select the slot. Step 2: the quantity buttons only become visible once something
737+
// is selected, so "All" is clicked after, not before.
738+
clickItemSlot(InterfaceID.DeathOffice.ITEMS, item, DEATH_OFFICE_SELECT_ACTION);
739+
if (!Global.sleepUntil(() -> Rs2Widget.isWidgetVisible(InterfaceID.DeathOffice.ALL),
740+
INTERFACE_TIMEOUT_MS)) {
741+
log.warn("Quantity buttons did not appear after selecting {} — stopping", item.getName());
742+
break;
743+
}
744+
clickAndSettle(InterfaceID.DeathOffice.ALL);
745+
taken++;
746+
}
747+
return taken;
748+
}
749+
750+
/**
751+
* Reclaims <b>everything</b> Death is holding. Use {@link #reclaimItems(Predicate)} to take only
752+
* some of it.
753+
*/
568754
public static boolean reclaimAll() {
569755
if (!isDeathsOfficeOpen()) return false;
570756

@@ -576,7 +762,12 @@ public static boolean reclaimAll() {
576762
Global.sleepUntil(() -> !isDeathsOfficeOpen() || Rs2Inventory.isFull(), LOOT_TIMEOUT_MS);
577763

578764
if (isDeathsOfficeOpen()) {
579-
log.warn("Death's Office still holds items, most likely because the inventory filled up");
765+
// The office holds up to 120 stacks against 28 inventory slots, so a full reclaim can simply
766+
// not fit. Nothing is lost — Death keeps the remainder indefinitely — but the caller needs to
767+
// know to bank and come back.
768+
log.warn("Death's Office still holds {} item(s) — inventory has {} free slot(s). Bank and "
769+
+ "call reclaimAll() again, or use reclaimItems(filter) to choose.",
770+
getDeathsOfficeItems().size(), Rs2Inventory.emptySlotCount());
580771
return false;
581772
}
582773
return true;

0 commit comments

Comments
 (0)