Repository: pajlads/DinkPlugin
Pull request #427 (2024-02-20 13:10:28)

feat: add collection item rarity when monster related
Follow-up to #425; work towards #139 

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/15cde840-78b5-4c23-8981-0f7fd6744dca)


iProdigy: works as expected in-game

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/7a018a45-2340-4943-b639-a618b9c58a60)

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/a56fcad6-6a2c-44dc-96eb-2fa6a90daa1d)

however this reveals we need to boost the kill count reported by loot tracker based on rate of `Nothing` drop; the true kc is 20+ since nothing drops about half the time for f2p flesh crawlers (but this problem spans both collection/loot, so will be tackled in a separate PR)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c610592..e7fbe21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 ## Unreleased
 
-- Major: Add item rarity to NPC loot notifications. (#425)
+- Major: Add item rarity to monster loot and collection notifications. (#425, #427)
 - Minor: Add wiki link for loot source in rich embeds. (#431)
 - Minor: Add chat message upon noteworthy plugin updates. (#429)
 - Minor: Allow loot notifications for rare items below the min value threshold. (#426)
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
index a9d1aa1..55a332f 100644
--- a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -9,6 +9,7 @@ import dinkplugin.util.Drop;
 import dinkplugin.util.ItemSearcher;
 import dinkplugin.util.ItemUtils;
 import dinkplugin.util.KillCountService;
+import dinkplugin.util.RarityService;
 import dinkplugin.util.Utils;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
@@ -21,11 +22,13 @@ import net.runelite.api.events.VarbitChanged;
 import net.runelite.client.callback.ClientThread;
 import net.runelite.client.game.ItemManager;
 import net.runelite.client.game.ItemStack;
+import net.runelite.http.api.loottracker.LootRecordType;
 import org.jetbrains.annotations.Nullable;
 
 import javax.inject.Inject;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.OptionalDouble;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.regex.Matcher;
@@ -66,6 +69,9 @@ public class CollectionNotifier extends BaseNotifier {
     @Inject
     private KillCountService killCountService;
 
+    @Inject
+    private RarityService rarityService;
+
     @Override
     public boolean isEnabled() {
         return config.notifyCollectionLog() && super.isEnabled();
@@ -159,6 +165,8 @@ public class CollectionNotifier extends BaseNotifier {
         Long price = itemId != null ? ItemUtils.getPrice(itemManager, itemId) : null;
         Drop loot = itemId != null ? getLootSource(itemId) : null;
         Integer killCount = loot != null ? killCountService.getKillCount(loot.getCategory(), loot.getSource()) : null;
+        OptionalDouble rarity = loot != null && loot.getCategory() == LootRecordType.NPC ?
+            rarityService.getRarity(loot.getSource(), itemId, 1) : OptionalDouble.empty();
         CollectionNotificationData extra = new CollectionNotificationData(
             itemName,
             itemId,
@@ -167,7 +175,8 @@ public class CollectionNotifier extends BaseNotifier {
             varpValid ? total : null,
             loot != null ? loot.getSource() : null,
             loot != null ? loot.getCategory() : null,
-            killCount
+            killCount,
+            rarity.isPresent() ? rarity.getAsDouble() : null
         );
 
         createMessage(config.collectionSendImage(), NotificationBody.builder()
diff --git a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
index ec853c2..4c5524f 100644
--- a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
@@ -2,6 +2,7 @@ package dinkplugin.notifiers.data;
 
 import dinkplugin.message.Field;
 import dinkplugin.util.Drop;
+import dinkplugin.util.MathUtils;
 import lombok.EqualsAndHashCode;
 import lombok.Value;
 import net.runelite.client.util.QuantityFormatter;
@@ -40,9 +41,12 @@ public class CollectionNotificationData extends NotificationData {
     @Nullable
     Integer dropperKillCount;
 
+    @Nullable
+    Double dropRate;
+
     @Override
     public List<Field> getFields() {
-        List<Field> fields = new ArrayList<>(3);
+        List<Field> fields = new ArrayList<>(5);
         if (completedEntries != null && totalEntries != null) {
             fields.add(
                 new Field("Completed Entries", Field.formatProgress(completedEntries, totalEntries))
@@ -60,6 +64,16 @@ public class CollectionNotificationData extends NotificationData {
                 )
             );
         }
+        if (dropRate != null) {
+            fields.add(new Field("Drop Rate", Field.formatProbability(dropRate)));
+        }
+        if (dropperKillCount != null && dropRate != null) {
+            double geomCdf = MathUtils.cumulativeGeometric(dropRate, dropperKillCount);
+            String percentile = geomCdf < 0.5
+                ? "Top " + MathUtils.formatPercentage(geomCdf, 2) + " (Lucky)"
+                : "Bottom " + MathUtils.formatPercentage(1 - geomCdf, 2) + " (Unlucky)";
+            fields.add(new Field("Luck", Field.formatBlock("", percentile)));
+        }
         return fields;
     }
 }
diff --git a/src/main/java/dinkplugin/util/MathUtils.java b/src/main/java/dinkplugin/util/MathUtils.java
index 7a61c52..406fa6b 100644
--- a/src/main/java/dinkplugin/util/MathUtils.java
+++ b/src/main/java/dinkplugin/util/MathUtils.java
@@ -17,6 +17,11 @@ public class MathUtils {
             .toPlainString() + '%';
     }
 
+    public double cumulativeGeometric(double p, int kTrials) {
+        assert kTrials > 0;
+        return 1 - Math.pow(1 - p, kTrials); // https://en.wikipedia.org/wiki/Geometric_distribution
+    }
+
     public double binomialProbability(double p, int nTrials, int kSuccess) {
         // https://en.wikipedia.org/wiki/Binomial_distribution#Probability_mass_function
         return binomialCoefficient(nTrials, kSuccess) * Math.pow(p, kSuccess) * Math.pow(1 - p, nTrials - kSuccess);


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
index 8a8a00c..e011f4b 100644
--- a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
@@ -79,10 +79,11 @@ class CollectionNotifierTest extends MockedNotifierTest {
         String item = "Seercull";
         int price = 23_000;
         when(itemSearcher.findItemId(item)).thenReturn(ItemID.SEERCULL);
-        when(itemManager.getItemPrice(ItemID.SEERCULL)).thenReturn(price);
+        mockItem(ItemID.SEERCULL, price, item);
 
         // prepare kc
         int kc = 150;
+        double rarity = 1.0 / 128;
         String source = "Dagannoth Supreme";
         NPC npc = mock(NPC.class);
         when(npc.getName()).thenReturn(source);
@@ -104,7 +105,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, source, LootRecordType.NPC, kc))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, source, LootRecordType.NPC, kc, rarity))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -144,7 +145,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, null, null, null))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, null, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -179,7 +180,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, null, null, null, null, null))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, null, null, null, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -227,7 +228,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item2))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item2, ItemID.SEERS_RING, (long) price2, 101, TOTAL_ENTRIES, null, null, null))
+                .extra(new CollectionNotificationData(item2, ItemID.SEERS_RING, (long) price2, 101, TOTAL_ENTRIES, null, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );