22
33import lombok .Getter ;
44import lombok .extern .slf4j .Slf4j ;
5+ import net .runelite .api .MenuAction ;
56import net .runelite .api .Player ;
67import net .runelite .api .annotations .Component ;
78import net .runelite .api .coords .WorldPoint ;
1314import net .runelite .api .gameval .VarbitID ;
1415import net .runelite .api .widgets .Widget ;
1516import net .runelite .client .plugins .microbot .Microbot ;
17+ import net .runelite .client .plugins .microbot .util .menu .NewMenuEntry ;
1618import net .runelite .client .plugins .microbot .api .npc .models .Rs2NpcModel ;
1719import net .runelite .client .plugins .microbot .api .tileobject .models .Rs2TileObjectModel ;
1820import net .runelite .client .plugins .microbot .util .Global ;
2628import net .runelite .client .plugins .microbot .util .walker .Rs2Walker ;
2729import net .runelite .client .plugins .microbot .util .widget .Rs2Widget ;
2830
31+ import java .awt .Rectangle ;
2932import java .awt .event .KeyEvent ;
3033import java .time .Duration ;
3134import java .time .Instant ;
3235import java .util .ArrayList ;
3336import java .util .Collections ;
3437import java .util .List ;
38+ import java .util .function .Predicate ;
3539import java .util .regex .Matcher ;
3640import java .util .regex .Pattern ;
3741
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 -> i.getName().contains("rune")); // leaves the rest
78+ *
79+ * Rs2Death.openDeathsOffice();
80+ * Rs2Death.reclaimItems(i -> 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