Repository: pajlads/DinkPlugin
Pull request #426 (2024-02-20 12:47:26)

feat: add rarity threshold for loot notifs
Follow-up to #425 


pajlada: I have not tested this in-game but it looks sane
iProdigy: Confirmed works in-game

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f2f146..13b9163 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 ## Unreleased
 
 - Major: Add item rarity to NPC loot notifications. (#425)
+- Minor: Allow loot notifications for rare items below the min value threshold. (#426)
 - Minor: Include relevant kill count in collection log notifications. (#424)
 - Minor: Obtain kill count from chat commands plugin for loot notifications. (#392)
 - Minor: Add chat command to obtain the player's dink hash. (#408)
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index fa1acc4..3012a89 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -866,13 +866,27 @@ public interface DinkPluginConfig extends Config {
         name = "Item Denylist",
         description = "Never fire notifications for these items, despite value settings.<br/>" +
             "Place one item name per line (case-insensitive; asterisks are wildcards)",
-        position = 38,
+        position = 37,
         section = lootSection
     )
     default String lootItemDenylist() {
         return "";
     }
 
+    @ConfigItem(
+        keyName = "lootRarityThreshold",
+        name = "Rarity Override (1 in X)",
+        description = "Fires notifications for sufficiently rare drops, despite the 'Min Loot value' threshold.<br/>" +
+            "Corresponds to a 1 in X chance. For example, 100 notifies for items with 1% drop rate or rarer.<br/>" +
+            "Has no effect when set to zero.<br/>" +
+            "Currently only applies to NPC drops",
+        position = 38,
+        section = lootSection
+    )
+    default int lootRarityThreshold() {
+        return 0;
+    }
+
     @ConfigItem(
         keyName = "lootNotifMessage",
         name = "Notification Message",
diff --git a/src/main/java/dinkplugin/notifiers/LootNotifier.java b/src/main/java/dinkplugin/notifiers/LootNotifier.java
index 8631f9e..c5b049e 100644
--- a/src/main/java/dinkplugin/notifiers/LootNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LootNotifier.java
@@ -1,5 +1,6 @@
 package dinkplugin.notifiers;
 
+import com.google.common.math.DoubleMath;
 import dinkplugin.message.Embed;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
@@ -12,6 +13,7 @@ import dinkplugin.notifiers.data.SerializedItemStack;
 import dinkplugin.util.ConfigUtil;
 import dinkplugin.util.ItemUtils;
 import dinkplugin.util.KillCountService;
+import dinkplugin.util.MathUtils;
 import dinkplugin.util.RarityService;
 import dinkplugin.util.Utils;
 import dinkplugin.util.WorldUtils;
@@ -175,20 +177,24 @@ public class LootNotifier extends BaseNotifier {
         Map.Entry<SerializedItemStack, Double> rarest = getRarestDropRate(items, dropper, type);
 
         Evaluable lootMsg;
-        if (!sendMessage && totalStackValue >= minValue && max != null && "Loot Chest".equalsIgnoreCase(dropper)) {
-            // Special case: PK loot keys should trigger notification if total value exceeds configured minimum even
-            // if no single item itself would exceed the min value config - github.com/pajlads/DinkPlugin/issues/403
-            sendMessage = true;
-
-            JoiningReplacement msg = lootMessage.build();
-            if (msg.getComponents().isEmpty()) {
-                // ensure %LOOT% isn't empty
+        if (!sendMessage) {
+            if (sufficientlyRare(rarest)) {
+                // allow notifications for rare drops, even if below configured min loot value
+                sendMessage = true;
+                lootMsg = Replacements.ofMultiple(" ",
+                    Replacements.ofText("Various items including:"),
+                    ItemUtils.templateStack(rarest.getKey(), false)
+                );
+            } else if (totalStackValue >= minValue && max != null && "Loot Chest".equalsIgnoreCase(dropper)) {
+                // Special case: PK loot keys should trigger notification if total value exceeds configured minimum even
+                // if no single item itself would exceed the min value config - github.com/pajlads/DinkPlugin/issues/403
+                sendMessage = true;
                 lootMsg = Replacements.ofMultiple(" ",
                     Replacements.ofText("Various items including:"),
                     ItemUtils.templateStack(max, true)
                 );
             } else {
-                lootMsg = msg;
+                lootMsg = null;
             }
         } else {
             lootMsg = lootMessage.build();
@@ -201,6 +207,7 @@ public class LootNotifier extends BaseNotifier {
                     overrideUrl = config.pkWebhook();
                 }
             }
+            SerializedItemStack keyItem = rarest != null ? rarest.getKey() : max;
             Double rarity = rarest != null ? rarest.getValue() : null;
             boolean screenshot = config.lootSendImage() && totalStackValue >= config.lootImageMinValue();
             Template notifyMessage = Template.builder()
@@ -217,7 +224,7 @@ public class LootNotifier extends BaseNotifier {
                     .embeds(embeds)
                     .extra(new LootNotificationData(serializedItems, dropper, type, kc, rarity))
                     .type(NotificationType.LOOT)
-                    .thumbnailUrl(ItemUtils.getItemImageUrl(max.getId()))
+                    .thumbnailUrl(ItemUtils.getItemImageUrl(keyItem.getId()))
                     .build()
             );
         }
@@ -242,6 +249,14 @@ public class LootNotifier extends BaseNotifier {
             .orElse(null);
     }
 
+    private boolean sufficientlyRare(@Nullable Map.Entry<SerializedItemStack, Double> rarest) {
+        if (rarest == null) return false;
+        int configRareDenominator = config.lootRarityThreshold();
+        if (configRareDenominator <= 0) return false;
+        double rarityThreshold = 1.0 / configRareDenominator;
+        return DoubleMath.fuzzyCompare(rarest.getValue(), rarityThreshold, MathUtils.EPSILON) <= 0;
+    }
+
     private static boolean matches(Collection<Pattern> regexps, String input) {
         for (Pattern regex : regexps) {
             if (regex.matcher(input).find())


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
index d8c2150..254eb58 100644
--- a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
@@ -113,6 +113,10 @@ class LootNotifierTest extends MockedNotifierTest {
 
     @Test
     void testNotifyNpcRarity() {
+        // update config mocks
+        when(config.minLootValue()).thenReturn(LARRAN_PRICE + 1);
+        when(config.lootRarityThreshold()).thenReturn(100);
+
         // prepare mocks
         NPC npc = mock(NPC.class);
         String name = "Ice spider";
@@ -131,7 +135,7 @@ class LootNotifierTest extends MockedNotifierTest {
             NotificationBody.builder()
                 .text(
                     Template.builder()
-                        .template(String.format("%s has looted: 1 x {{key}} (%s) from %s for %s gp", PLAYER_NAME, value, name, value))
+                        .template(String.format("%s has looted: Various items including: 1 x {{key}} from %s for %s gp", PLAYER_NAME, name, value))
                         .replacement("{{key}}", Replacements.ofWiki("Larran's key"))
                         .build()
                 )
@@ -142,6 +146,26 @@ class LootNotifierTest extends MockedNotifierTest {
         );
     }
 
+    @Test
+    void testIgnoreNpcRarity() {
+        // update config mocks
+        when(config.minLootValue()).thenReturn(LARRAN_PRICE + 1);
+        when(config.lootRarityThreshold()).thenReturn(300);
+
+        // prepare mocks
+        NPC npc = mock(NPC.class);
+        String name = "Ice spider";
+        when(npc.getName()).thenReturn(name);
+
+        // fire event
+        double rarity = 1.0 / 208;
+        NpcLootReceived event = new NpcLootReceived(npc, List.of(new ItemStack(ItemID.LARRANS_KEY, 1, null)));
+        plugin.onNpcLootReceived(event);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
     @Test
     void testNotifyAllowlist() {
         // prepare mocks