Repository: pajlads/DinkPlugin (java)

Issue:
Kill count for new collection log entries 
### Checklist

- [X] I've searched the [issues and pull requests](https://github.com/pajlads/Dinkplugin/issues?q=) for similar looking suggestions.
- [X] I've checked the `Unreleased` section of the [changelog](https://github.com/pajlads/DinkPlugin/blob/master/CHANGELOG.md) for newly added features that sound like my suggestion.

### Describe your Suggestion

If available and possible add the kill count and monster that dropped the new collection log entry.

### Reasoning

It would be nice to see the information since some items can be dropped by multiple bosses and to see if someone is getting spooned

Pull request description:
feat: add kill count to collection log notifications
Follow-up from #392; Closes #371

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/2b6577bb-c9e9-4dba-8fd9-6275745f2df6)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 227c1db..e59c0ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- 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)
 - Dev: Refactor kill count tracking from `LootNotifier` to `KillCountService`. (#392)
diff --git a/docs/json-examples.md b/docs/json-examples.md
index 5c7821c..3ea69db 100644
--- a/docs/json-examples.md
+++ b/docs/json-examples.md
@@ -133,12 +133,17 @@ JSON for Collection Notifications:
     "itemId": 10372,
     "price": 500812,
     "completedEntries": 420,
-    "totalEntries": 1443
+    "totalEntries": 1443,
+    "dropperName": "Clue Scroll (Hard)",
+    "dropperType": "EVENT",
+    "dropperKillCount": 1500
   },
   "type": "COLLECTION"
 }
 ```
 
+Note: `dropperName`/`dropperType`/`dropperKillCount` may not be present for all collection log notifications.
+
 ### Level
 
 JSON for Levelups:
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index b9744e4..b83ac9c 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -197,7 +197,7 @@ public class DinkPlugin extends Plugin {
         metaNotifier.onTick();
     }
 
-    @Subscribe
+    @Subscribe(priority = 1) // run before the base loot tracker plugin
     public void onChatMessage(ChatMessage message) {
         String chatMessage = Utils.sanitize(message.getMessage());
         switch (message.getType()) {
@@ -272,6 +272,7 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onPlayerLootReceived(PlayerLootReceived playerLootReceived) {
+        killCountService.onPlayerKill(playerLootReceived);
         lootNotifier.onPlayerLootReceived(playerLootReceived);
     }
 
diff --git a/src/main/java/dinkplugin/notifiers/ClueNotifier.java b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
index f86aca9..6a67ca7 100644
--- a/src/main/java/dinkplugin/notifiers/ClueNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
@@ -19,6 +19,7 @@ import net.runelite.api.widgets.InterfaceID;
 import net.runelite.api.widgets.Widget;
 import net.runelite.client.game.ItemManager;
 import net.runelite.client.util.QuantityFormatter;
+import org.jetbrains.annotations.Nullable;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -37,7 +38,7 @@ import java.util.regex.Pattern;
 public class ClueNotifier extends BaseNotifier {
     private static final Pattern CLUE_SCROLL_REGEX = Pattern.compile("You have completed (?<scrollCount>\\d+) (?<scrollType>\\w+) Treasure Trails\\.");
     private final AtomicInteger badTicks = new AtomicInteger(); // used to prevent notifs from using stale data
-    private volatile String clueCount = "";
+    private volatile int clueCount = -1;
     private volatile String clueType = "";
 
     @Inject
@@ -55,14 +56,11 @@ public class ClueNotifier extends BaseNotifier {
 
     public void onChatMessage(String chatMessage) {
         if (isEnabled()) {
-            Matcher clueMatcher = CLUE_SCROLL_REGEX.matcher(chatMessage);
-            if (clueMatcher.find()) {
-                String tier = clueMatcher.group("scrollType");
-                if (checkClueTier(tier)) {
-                    // game message always occurs before widget load; save this data
-                    this.clueCount = clueMatcher.group("scrollCount");
-                    this.clueType = tier;
-                }
+            Map.Entry<String, Integer> result = parse(chatMessage);
+            if (result != null && checkClueTier(result.getKey())) {
+                // game message always occurs before widget load; save this data
+                this.clueCount = result.getValue();
+                this.clueType = result.getKey();
             }
         }
     }
@@ -120,14 +118,14 @@ public class ClueNotifier extends BaseNotifier {
                 .replacementBoundary("%")
                 .replacement("%USERNAME%", Replacements.ofText(Utils.getPlayerName(client)))
                 .replacement("%CLUE%", Replacements.ofWiki(clueType, "Clue scroll (" + clueType + ")"))
-                .replacement("%COUNT%", Replacements.ofText(clueCount))
+                .replacement("%COUNT%", Replacements.ofText(String.valueOf(clueCount)))
                 .replacement("%TOTAL_VALUE%", Replacements.ofText(QuantityFormatter.quantityToStackSize(totalPrice.get())))
                 .replacement("%LOOT%", lootMessage.build())
                 .build();
             createMessage(screenshot,
                 NotificationBody.builder()
                     .text(notifyMessage)
-                    .extra(new ClueNotificationData(clueType, Integer.parseInt(clueCount), itemStacks))
+                    .extra(new ClueNotificationData(clueType, clueCount, itemStacks))
                     .type(NotificationType.CLUE)
                     .embeds(embeds)
                     .build()
@@ -153,8 +151,18 @@ public class ClueNotifier extends BaseNotifier {
     }
 
     public void reset() {
-        this.clueCount = "";
+        this.clueCount = -1;
         this.clueType = "";
         this.badTicks.set(0);
     }
+
+    @Nullable
+    public static Map.Entry<String, Integer> parse(String gameMessage) {
+        Matcher clueMatcher = CLUE_SCROLL_REGEX.matcher(gameMessage);
+        if (!clueMatcher.find()) return null;
+        String tier = clueMatcher.group("scrollType");
+        String count = clueMatcher.group("scrollCount");
+        return Map.entry(tier, Integer.parseInt(count));
+    }
+
 }
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
index fb7898b..a9d1aa1 100644
--- a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -4,10 +4,12 @@ import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.message.templating.Replacements;
 import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.CollectionNotificationData;
+import dinkplugin.util.Drop;
 import dinkplugin.util.ItemSearcher;
 import dinkplugin.util.ItemUtils;
+import dinkplugin.util.KillCountService;
 import dinkplugin.util.Utils;
-import dinkplugin.notifiers.data.CollectionNotificationData;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
 import net.runelite.api.GameState;
@@ -18,8 +20,12 @@ import net.runelite.api.annotations.Varp;
 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 org.jetbrains.annotations.Nullable;
 
 import javax.inject.Inject;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.regex.Matcher;
@@ -36,6 +42,8 @@ public class CollectionNotifier extends BaseNotifier {
      */
     public static final @Varp int COMPLETED_VARP = 2943, TOTAL_VARP = 2944;
 
+    private static final Duration RECENT_DROP = Duration.ofSeconds(30L);
+
     /**
      * The number of completed entries in the collection log, as implied by {@link #COMPLETED_VARP}.
      */
@@ -55,6 +63,9 @@ public class CollectionNotifier extends BaseNotifier {
     @Inject
     private ItemSearcher itemSearcher;
 
+    @Inject
+    private KillCountService killCountService;
+
     @Override
     public boolean isEnabled() {
         return config.notifyCollectionLog() && super.isEnabled();
@@ -146,12 +157,17 @@ public class CollectionNotifier extends BaseNotifier {
 
         Integer itemId = itemSearcher.findItemId(itemName);
         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;
         CollectionNotificationData extra = new CollectionNotificationData(
             itemName,
             itemId,
             price,
             varpValid ? completed : null,
-            varpValid ? total : null
+            varpValid ? total : null,
+            loot != null ? loot.getSource() : null,
+            loot != null ? loot.getCategory() : null,
+            killCount
         );
 
         createMessage(config.collectionSendImage(), NotificationBody.builder()
@@ -161,4 +177,18 @@ public class CollectionNotifier extends BaseNotifier {
             .type(NotificationType.COLLECTION)
             .build());
     }
+
+    @Nullable
+    private Drop getLootSource(int itemId) {
+        Drop drop = killCountService.getLastDrop();
+        if (drop == null) return null;
+        if (Duration.between(drop.getTime(), Instant.now()).compareTo(RECENT_DROP) > 0) return null;
+        for (ItemStack item : drop.getItems()) {
+            if (item.getId() == itemId) {
+                return drop;
+            }
+        }
+        return null;
+    }
+
 }
diff --git a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
index 90a225b..ec853c2 100644
--- a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
@@ -1,12 +1,15 @@
 package dinkplugin.notifiers.data;
 
 import dinkplugin.message.Field;
+import dinkplugin.util.Drop;
 import lombok.EqualsAndHashCode;
 import lombok.Value;
+import net.runelite.client.util.QuantityFormatter;
+import net.runelite.http.api.loottracker.LootRecordType;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
-import java.util.Collections;
+import java.util.ArrayList;
 import java.util.List;
 
 @Value
@@ -28,13 +31,35 @@ public class CollectionNotificationData extends NotificationData {
     @Nullable
     Integer totalEntries;
 
+    @Nullable
+    String dropperName;
+
+    @Nullable
+    LootRecordType dropperType;
+
+    @Nullable
+    Integer dropperKillCount;
+
     @Override
     public List<Field> getFields() {
-        if (completedEntries == null || totalEntries == null)
-            return super.getFields();
-
-        return Collections.singletonList(
-            new Field("Completed Entries", Field.formatProgress(completedEntries, totalEntries))
-        );
+        List<Field> fields = new ArrayList<>(3);
+        if (completedEntries != null && totalEntries != null) {
+            fields.add(
+                new Field("Completed Entries", Field.formatProgress(completedEntries, totalEntries))
+            );
+        }
+        if (dropperKillCount != null) {
+            assert dropperName != null && dropperType != null;
+            fields.add(
+                new Field("Source", Field.formatBlock("", dropperName))
+            );
+            fields.add(
+                new Field(
+                    Drop.getAction(dropperType) + " Count",
+                    Field.formatBlock("", QuantityFormatter.quantityToStackSize(dropperKillCount))
+                )
+            );
+        }
+        return fields;
     }
 }
diff --git a/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
index b3eefe2..6bc81f9 100644
--- a/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
@@ -1,6 +1,7 @@
 package dinkplugin.notifiers.data;
 
 import dinkplugin.message.Field;
+import dinkplugin.util.Drop;
 import dinkplugin.util.ItemUtils;
 import lombok.EqualsAndHashCode;
 import lombok.Value;
@@ -24,7 +25,7 @@ public class LootNotificationData extends NotificationData {
         if (killCount != null) {
             fields.add(
                 new Field(
-                    "Kill Count",
+                    Drop.getAction(category) + " Count",
                     Field.formatBlock("", QuantityFormatter.quantityToStackSize(killCount))
                 )
             );
diff --git a/src/main/java/dinkplugin/util/Drop.java b/src/main/java/dinkplugin/util/Drop.java
new file mode 100644
index 0000000..d7e6302
--- /dev/null
+++ b/src/main/java/dinkplugin/util/Drop.java
@@ -0,0 +1,29 @@
+package dinkplugin.util;
+
+import lombok.Value;
+import net.runelite.client.game.ItemStack;
+import net.runelite.http.api.loottracker.LootRecordType;
+
+import java.time.Instant;
+import java.util.Collection;
+
+@Value
+public class Drop {
+    String source;
+    LootRecordType category;
+    Collection<ItemStack> items;
+    Instant time = Instant.now();
+
+    public static String getAction(LootRecordType type) {
+        switch (type) {
+            case NPC:
+                return "Kill";
+            case PLAYER:
+                return "Player Kill";
+            case PICKPOCKET:
+                return "Pickpocket";
+            default:
+                return "Completion";
+        }
+    }
+}
diff --git a/src/main/java/dinkplugin/util/KillCountService.java b/src/main/java/dinkplugin/util/KillCountService.java
index f39015d..9abb136 100644
--- a/src/main/java/dinkplugin/util/KillCountService.java
+++ b/src/main/java/dinkplugin/util/KillCountService.java
@@ -4,12 +4,16 @@ import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gson.Gson;
 import com.google.gson.JsonSyntaxException;
+import dinkplugin.notifiers.ClueNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
+import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.NPC;
 import net.runelite.api.NpcID;
 import net.runelite.client.config.ConfigManager;
 import net.runelite.client.events.NpcLootReceived;
+import net.runelite.client.events.PlayerLootReceived;
+import net.runelite.client.game.ItemStack;
 import net.runelite.client.plugins.chatcommands.ChatCommandsPlugin;
 import net.runelite.client.plugins.loottracker.LootReceived;
 import net.runelite.client.plugins.loottracker.LootTrackerConfig;
@@ -21,6 +25,8 @@ import org.jetbrains.annotations.Nullable;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
+import java.util.Collection;
+import java.util.Map;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
@@ -42,9 +48,15 @@ public class KillCountService {
 
     private final Cache<String, Integer> killCounts = CacheBuilder.newBuilder()
         .expireAfterAccess(10, TimeUnit.MINUTES)
+        .maximumSize(64L)
         .build();
 
+    @Getter
+    @Nullable
+    private Drop lastDrop = null;
+
     public void reset() {
+        this.lastDrop = null;
         this.killCounts.invalidateAll();
     }
 
@@ -59,7 +71,14 @@ public class KillCountService {
 
         String name = npc.getName();
         if (name != null) {
-            this.incrementKillCount(LootRecordType.NPC, name);
+            this.incrementKills(LootRecordType.NPC, name, event.getItems());
+        }
+    }
+
+    public void onPlayerKill(PlayerLootReceived event) {
+        String name = event.getPlayer().getName();
+        if (name != null) {
+            this.incrementKills(LootRecordType.PLAYER, name, event.getItems());
         }
     }
 
@@ -70,33 +89,43 @@ public class KillCountService {
                 // Special case: upstream fires LootReceived for the whisperer, but not NpcLootReceived
                 increment = "The Whisperer".equalsIgnoreCase(event.getName());
                 break;
-            case EVENT:
-                increment = true;
+            case PLAYER:
+                increment = false; // handled by PlayerLootReceived
                 break;
             default:
-                increment = false;
+                increment = true;
                 break;
         }
 
         if (increment) {
-            this.incrementKillCount(event.getType(), event.getName());
+            this.incrementKills(event.getType(), event.getName(), event.getItems());
         }
     }
 
     public void onGameMessage(String message) {
+        // update cached clue casket count
+        Map.Entry<String, Integer> clue = ClueNotifier.parse(message);
+        if (clue != null) {
+            String tier = Utils.ucFirst(clue.getKey());
+            int count = clue.getValue() - 1; // decremented since onLoot will increment
+            killCounts.put("Clue Scroll (" + tier + ")", count);
+            return;
+        }
+
         // update cached KC via boss chat message with robustness for chat event coming before OR after the loot event
         KillCountNotifier.parseBoss(message).ifPresent(pair -> {
             String boss = pair.getKey();
             Integer kc = pair.getValue();
 
             // Update cache. We store kc - 1 since onNpcLootReceived will increment; kc - 1 + 1 == kc
-            killCounts.asMap().merge(boss, kc - 1, Math::max);
+            String cacheKey = getCacheKey(LootRecordType.UNKNOWN, boss);
+            killCounts.asMap().merge(cacheKey, kc - 1, Math::max);
 
             // However: we don't know if boss message appeared before/after the loot event.
             // If after, we should store kc. If before, we should store kc - 1.
             // Given this uncertainty, we wait so that the loot event has passed, and then we can store latest kc.
             executor.schedule(() -> {
-                killCounts.asMap().merge(boss, kc, Math::max);
+                killCounts.asMap().merge(cacheKey, kc, Math::max);
             }, 15, TimeUnit.SECONDS);
         });
     }
@@ -104,27 +133,27 @@ public class KillCountService {
     @Nullable
     public Integer getKillCount(LootRecordType type, String sourceName) {
         if (sourceName == null) return null;
-        if (type != LootRecordType.NPC && type != LootRecordType.EVENT) return null;
-
         Integer stored = getStoredKillCount(type, sourceName);
         if (stored != null) {
-            return killCounts.asMap().merge(sourceName, stored, Math::max);
+            return killCounts.asMap().merge(getCacheKey(type, sourceName), stored, Math::max);
         }
-        return killCounts.getIfPresent(sourceName);
+        return killCounts.getIfPresent(getCacheKey(type, sourceName));
     }
 
-    private void incrementKillCount(@NotNull LootRecordType type, @NotNull String sourceName) {
-        killCounts.asMap().compute(sourceName, (name, cachedKc) -> {
+    private void incrementKills(@NotNull LootRecordType type, @NotNull String sourceName, @NotNull Collection<ItemStack> items) {
+        String cacheKey = getCacheKey(type, sourceName);
+        killCounts.asMap().compute(cacheKey, (key, cachedKc) -> {
             if (cachedKc != null) {
                 // increment kill count
                 return cachedKc + 1;
             } else {
                 // pull kc from loot tracker or chat commands plugin
-                Integer kc = getStoredKillCount(type, name);
+                Integer kc = getStoredKillCount(type, sourceName);
                 // increment if found
                 return kc != null ? kc + 1 : null;
             }
         });
+        this.lastDrop = new Drop(sourceName, type, items);
     }
 
     /**
@@ -134,8 +163,6 @@ public class KillCountService {
      */
     @Nullable
     private Integer getStoredKillCount(@NotNull LootRecordType type, @NotNull String sourceName) {
-        assert type == LootRecordType.NPC || type == LootRecordType.EVENT;
-
         // get kc from base runelite chat commands plugin (if enabled)
         if (!ConfigUtil.isPluginDisabled(configManager, RL_CHAT_CMD_PLUGIN_NAME)) {
             Integer kc = configManager.getRSProfileConfiguration("killcount", cleanBossName(sourceName), int.class);
@@ -144,13 +171,13 @@ public class KillCountService {
             }
         }
 
-        if (type != LootRecordType.NPC || ConfigUtil.isPluginDisabled(configManager, RL_LOOT_PLUGIN_NAME)) {
+        if (ConfigUtil.isPluginDisabled(configManager, RL_LOOT_PLUGIN_NAME)) {
             // assume stored kc is useless if loot tracker plugin is disabled
             return null;
         }
         String json = configManager.getConfiguration(LootTrackerConfig.GROUP,
             configManager.getRSProfileKey(),
-            "drops_NPC_" + sourceName
+            "drops_" + type + "_" + sourceName
         );
         if (json == null) {
             // no kc stored implies first kill
@@ -180,4 +207,15 @@ public class KillCountService {
         return StringUtils.remove(boss.toLowerCase(), ':');
     }
 
+    private static String getCacheKey(@NotNull LootRecordType type, @NotNull String sourceName) {
+        switch (type) {
+            case PICKPOCKET:
+                return "pickpocket_" + sourceName;
+            case PLAYER:
+                return "player_" + sourceName;
+            default:
+                return sourceName;
+        }
+    }
+
 }


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
index bd03363..8a8a00c 100644
--- a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
@@ -7,22 +7,30 @@ import dinkplugin.message.templating.Replacements;
 import dinkplugin.message.templating.Template;
 import dinkplugin.notifiers.data.CollectionNotificationData;
 import dinkplugin.util.ItemSearcher;
+import dinkplugin.util.KillCountService;
 import net.runelite.api.GameState;
 import net.runelite.api.ItemID;
+import net.runelite.api.NPC;
+import net.runelite.api.NpcID;
 import net.runelite.api.ScriptID;
 import net.runelite.api.VarClientStr;
 import net.runelite.api.Varbits;
 import net.runelite.api.events.VarbitChanged;
+import net.runelite.client.events.NpcLootReceived;
+import net.runelite.client.game.ItemStack;
+import net.runelite.http.api.loottracker.LootRecordType;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Mockito;
 
+import java.util.List;
 import java.util.function.BiFunction;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -39,6 +47,10 @@ class CollectionNotifierTest extends MockedNotifierTest {
     @Mock
     ItemSearcher itemSearcher;
 
+    @Bind
+    @InjectMocks
+    KillCountService killCountService;
+
     @Override
     @BeforeEach
     protected void setUp() {
@@ -69,6 +81,15 @@ class CollectionNotifierTest extends MockedNotifierTest {
         when(itemSearcher.findItemId(item)).thenReturn(ItemID.SEERCULL);
         when(itemManager.getItemPrice(ItemID.SEERCULL)).thenReturn(price);
 
+        // prepare kc
+        int kc = 150;
+        String source = "Dagannoth Supreme";
+        NPC npc = mock(NPC.class);
+        when(npc.getName()).thenReturn(source);
+        when(npc.getId()).thenReturn(NpcID.DAGANNOTH_SUPREME);
+        when(configManager.getRSProfileConfiguration("killcount", source.toLowerCase(), int.class)).thenReturn(kc);
+        killCountService.onNpcKill(new NpcLootReceived(npc, List.of(new ItemStack(ItemID.SEERCULL, 1, null))));
+
         // send fake message
         notifier.onChatMessage("New item added to your collection log: " + item);
 
@@ -83,7 +104,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, source, LootRecordType.NPC, kc))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -123,7 +144,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, 1, TOTAL_ENTRIES, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -158,7 +179,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, null, null))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price, null, null, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
@@ -206,7 +227,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
                         .replacement("{{item}}", Replacements.ofWiki(item2))
                         .build()
                 )
-                .extra(new CollectionNotificationData(item2, ItemID.SEERS_RING, (long) price2, 101, TOTAL_ENTRIES))
+                .extra(new CollectionNotificationData(item2, ItemID.SEERS_RING, (long) price2, 101, TOTAL_ENTRIES, null, null, null))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
diff --git a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
index 7d3bc34..63563af 100644
--- a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
@@ -8,6 +8,7 @@ import dinkplugin.message.templating.Template;
 import dinkplugin.notifiers.data.LootNotificationData;
 import dinkplugin.notifiers.data.SerializedItemStack;
 import dinkplugin.util.ItemUtils;
+import dinkplugin.util.KillCountService;
 import net.runelite.api.ItemID;
 import net.runelite.api.NPC;
 import net.runelite.api.NpcID;
@@ -48,6 +49,10 @@ class LootNotifierTest extends MockedNotifierTest {
     @InjectMocks
     LootNotifier notifier;
 
+    @Bind
+    @InjectMocks
+    KillCountService killCountService;
+
     @Override
     @BeforeEach
     protected void setUp() {
@@ -126,7 +131,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{tuna}}", Replacements.ofWiki("Tuna"))
                         .build()
                 )
-                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -154,7 +159,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{tuna}}", Replacements.ofWiki("Tuna"))
                         .build()
                 )
-                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -256,7 +261,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
                         .build()
                 )
-                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME, LootRecordType.PICKPOCKET, null))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME, LootRecordType.PICKPOCKET, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -274,6 +279,9 @@ class LootNotifierTest extends MockedNotifierTest {
 
     @Test
     void testNotifyClue() {
+        // prepare completion count
+        killCountService.onGameMessage("You have completed 42 medium Treasure Trails.");
+
         // fire event
         String source = "Clue Scroll (Medium)";
         LootReceived event = new LootReceived(source, -1, LootRecordType.EVENT, Collections.singletonList(new ItemStack(ItemID.RUBY, 1, null)), 1);
@@ -290,7 +298,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
                         .build()
                 )
-                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, null))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, 42))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -332,7 +340,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
                         .build()
                 )
-                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -362,7 +370,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
                         .build()
                 )
-                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -391,7 +399,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
                         .build()
                 )
-                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -422,7 +430,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{opal}}", Replacements.ofWiki("Opal"))
                         .build()
                 )
-                .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal"), new SerializedItemStack(ItemID.TUNA, 2, TUNA_PRICE, "Tuna")), source, LootRecordType.EVENT, null))
+                .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal"), new SerializedItemStack(ItemID.TUNA, 2, TUNA_PRICE, "Tuna")), source, LootRecordType.EVENT, 1))
                 .type(NotificationType.LOOT)
                 .thumbnailUrl(ItemUtils.getItemImageUrl(ItemID.TUNA))
                 .build()
@@ -490,7 +498,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{opal}}", Replacements.ofWiki("Opal"))
                         .build()
                 )
-                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, null))
+                .extra(new LootNotificationData(Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal"), new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );
@@ -526,7 +534,7 @@ class LootNotifierTest extends MockedNotifierTest {
                         .replacement("{{tuna}}", Replacements.ofWiki("Tuna"))
                         .build()
                 )
-                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 5, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, null))
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 5, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, 1))
                 .type(NotificationType.LOOT)
                 .build()
         );