diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b78874..d372a1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- Minor: Include NPC ID in loot notification metadata. (#550) - Minor: Allow customization of rich embed highlight color. (#534) - Minor: Include Moxi pet name in pet notifications. (#557) - Bugfix: Treat elite diary resurrections as safe deaths. (#555) diff --git a/docs/json-examples.md b/docs/json-examples.md index 2a0cfbbb..d34c5129 100644 --- a/docs/json-examples.md +++ b/docs/json-examples.md @@ -237,7 +237,8 @@ JSON for Loot Notifications: "party": ["%USERNAME%", "another RSN", "yet another RSN"], "category": "EVENT", "killCount": 60, - "rarestProbability": 0.001 + "rarestProbability": 0.001, + "npcId": null }, "type": "LOOT" } @@ -253,6 +254,8 @@ The items are valued at GE prices (when possible) if the user has not disabled t The `extra.party` field is only populated for raids loot (i.e., COX, TOA, TOB). +The `extra.npcId` field is populated only if the `category` is NPC or PICKPOCKET. + ### Slayer JSON for Slayer Notifications: diff --git a/src/main/java/dinkplugin/notifiers/LootNotifier.java b/src/main/java/dinkplugin/notifiers/LootNotifier.java index cf3af2b8..fb93963e 100644 --- a/src/main/java/dinkplugin/notifiers/LootNotifier.java +++ b/src/main/java/dinkplugin/notifiers/LootNotifier.java @@ -110,7 +110,7 @@ public void onNpcLootReceived(NpcLootReceived event) { return; } - this.handleNotify(event.getItems(), npc.getName(), LootRecordType.NPC); + this.handleNotify(event.getItems(), npc.getName(), LootRecordType.NPC, id); } public void onPlayerLootReceived(PlayerLootReceived event) { @@ -118,7 +118,7 @@ public void onPlayerLootReceived(PlayerLootReceived event) { return; if (config.includePlayerLoot() && isEnabled()) - this.handleNotify(event.getItems(), event.getPlayer().getName(), LootRecordType.PLAYER); + this.handleNotify(event.getItems(), event.getPlayer().getName(), LootRecordType.PLAYER, null); } public void onLootReceived(LootReceived lootReceived) { @@ -132,14 +132,14 @@ public void onLootReceived(LootReceived lootReceived) { } String source = killCountService.getStandardizedSource(lootReceived); - this.handleNotify(lootReceived.getItems(), source, lootReceived.getType()); + this.handleNotify(lootReceived.getItems(), source, lootReceived.getType(), null); } else if (lootReceived.getType() == LootRecordType.NPC && KillCountService.SPECIAL_LOOT_NPC_NAMES.contains(lootReceived.getName())) { // Special case: upstream fires LootReceived for certain NPCs, but not NpcLootReceived - this.handleNotify(lootReceived.getItems(), lootReceived.getName(), lootReceived.getType()); + this.handleNotify(lootReceived.getItems(), lootReceived.getName(), lootReceived.getType(), null); } } - private void handleNotify(Collection items, String dropper, LootRecordType type) { + private void handleNotify(Collection items, String dropper, LootRecordType type, Integer npcId) { final Integer kc = killCountService.getKillCount(type, dropper); final int minValue = config.minLootValue(); final boolean icons = config.lootIcons(); @@ -218,6 +218,14 @@ private void handleNotify(Collection items, String dropper, LootRecor } if (sendMessage) { + if (npcId == null && (type == LootRecordType.NPC || type == LootRecordType.PICKPOCKET)) { + npcId = client.getTopLevelWorldView().npcs().stream() + .filter(npc -> dropper.equals(npc.getName())) + .findAny() + .map(NPC::getId) + .orElse(null); + } + String overrideUrl = getWebhookUrl(); if (config.lootRedirectPlayerKill() && !config.pkWebhook().isBlank()) { if (type == LootRecordType.PLAYER || (type == LootRecordType.EVENT && "Loot Chest".equals(dropper))) { @@ -242,7 +250,7 @@ private void handleNotify(Collection items, String dropper, LootRecor NotificationBody.builder() .text(notifyMessage) .embeds(embeds) - .extra(new LootNotificationData(serializedItems, dropper, type, kc, rarity, party)) + .extra(new LootNotificationData(serializedItems, dropper, type, kc, rarity, party, npcId)) .type(NotificationType.LOOT) .thumbnailUrl(ItemUtils.getItemImageUrl(max.getId())) .build() diff --git a/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java index 78236ff1..dbe24a01 100644 --- a/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java +++ b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java @@ -28,6 +28,9 @@ public class LootNotificationData extends NotificationData { @Nullable Collection party; + @Nullable + Integer npcId; + @Override public List getFields() { List fields = new ArrayList<>(4); diff --git a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java index 4a811731..d4216bba 100644 --- a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java +++ b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java @@ -10,10 +10,12 @@ import dinkplugin.notifiers.data.SerializedItemStack; import dinkplugin.util.ItemUtils; import dinkplugin.util.KillCountService; +import net.runelite.api.IndexedObjectSet; import net.runelite.api.ItemID; import net.runelite.api.NPC; import net.runelite.api.NpcID; import net.runelite.api.Player; +import net.runelite.api.WorldView; import net.runelite.api.coords.WorldPoint; import net.runelite.client.events.NpcLootReceived; import net.runelite.client.events.PlayerLootReceived; @@ -26,10 +28,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; +import org.mockito.Mockito; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.stream.IntStream; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -80,12 +84,22 @@ protected void setUp() { mockItem(ItemID.TUNA, TUNA_PRICE, "Tuna"); } + @SuppressWarnings({ "rawtypes", "unchecked" }) + private void mockWorldNpcs(NPC... npcs) { + WorldView worldView = Mockito.mock(WorldView.class); + when(client.getTopLevelWorldView()).thenReturn(worldView); + IndexedObjectSet ios = new IndexedObjectSet<>(npcs, IntStream.range(0, npcs.length).toArray(), npcs.length); + when(worldView.npcs()).thenReturn(ios); + } + @Test void testNotifyNpc() { // prepare mocks NPC npc = mock(NPC.class); String name = "Rasmus"; when(npc.getName()).thenReturn(name); + when(npc.getId()).thenReturn(9999); + mockWorldNpcs(npc); int kc = 69; when(configManager.getConfiguration(eq(LootTrackerConfig.GROUP), any(), eq("drops_NPC_" + name))) .thenReturn("{\"type\":\"NPC\",\"name\":\"Rasmus\",\"kills\":" + kc + @@ -107,7 +121,7 @@ void testNotifyNpc() { .replacement("{{source}}", Replacements.ofWiki(name)) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), name, LootRecordType.NPC, kc + 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), name, LootRecordType.NPC, kc + 1, null, null, 9999)) .type(NotificationType.LOOT) .build() ); @@ -123,6 +137,8 @@ void testNotifyNpcRarity() { NPC npc = mock(NPC.class); String name = "Ice spider"; when(npc.getName()).thenReturn(name); + when(npc.getId()).thenReturn(NpcID.ICE_SPIDER); + mockWorldNpcs(npc); // fire event double rarity = 1.0 / 208; @@ -142,7 +158,7 @@ void testNotifyNpcRarity() { .replacement("{{source}}", Replacements.ofWiki(name)) .build() ) - .extra(new LootNotificationData(List.of(new RareItemStack(ItemID.LARRANS_KEY, 1, LARRAN_PRICE, "Larran's key", rarity)), name, LootRecordType.NPC, 1, rarity, null)) + .extra(new LootNotificationData(List.of(new RareItemStack(ItemID.LARRANS_KEY, 1, LARRAN_PRICE, "Larran's key", rarity)), name, LootRecordType.NPC, 1, rarity, null, NpcID.ICE_SPIDER)) .type(NotificationType.LOOT) .thumbnailUrl(ItemUtils.getItemImageUrl(ItemID.LARRANS_KEY)) .build() @@ -215,7 +231,7 @@ void testNotifyAllowlist() { .replacement("{{source}}", Replacements.ofLink(LOOTED_NAME, config.playerLookupService().getPlayerUrl(LOOTED_NAME))) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -244,7 +260,7 @@ void testNotifyAllowlistWildcard() { .replacement("{{source}}", Replacements.ofLink(LOOTED_NAME, config.playerLookupService().getPlayerUrl(LOOTED_NAME))) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, 1, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -276,6 +292,10 @@ void testIgnoreDenylistWildcard() { @DisplayName("Ensure LootReceived event for The Whisperer fires a notification - https://github.com/pajlads/DinkPlugin/pull/286") void testNotifyWhisperer() { String name = "The Whisperer"; + NPC npc = Mockito.mock(NPC.class); + when(npc.getName()).thenReturn(name); + when(npc.getId()).thenReturn(NpcID.THE_WHISPERER); + mockWorldNpcs(npc); // fire event LootReceived event = new LootReceived(name, 99, LootRecordType.NPC, Collections.singletonList(new ItemStack(ItemID.RUBY, 1)), 1); @@ -293,7 +313,7 @@ void testNotifyWhisperer() { .replacement("{{source}}", Replacements.ofWiki(name)) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), name, LootRecordType.NPC, 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), name, LootRecordType.NPC, 1, null, null, NpcID.THE_WHISPERER)) .type(NotificationType.LOOT) .build() ); @@ -332,6 +352,11 @@ void testIgnoreNpc() { @Test void testNotifyPickpocket() { + NPC npc = Mockito.mock(NPC.class); + when(npc.getName()).thenReturn(LOOTED_NAME); + when(npc.getId()).thenReturn(9999); + mockWorldNpcs(npc); + // fire event LootReceived event = new LootReceived(LOOTED_NAME, 99, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.RUBY, 1)), 1); plugin.onLootReceived(event); @@ -348,7 +373,7 @@ void testNotifyPickpocket() { .replacement("{{source}}", Replacements.ofWiki(LOOTED_NAME)) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME, LootRecordType.PICKPOCKET, 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME, LootRecordType.PICKPOCKET, 1, null, null, 9999)) .type(NotificationType.LOOT) .build() ); @@ -386,7 +411,7 @@ void testNotifyClue() { .replacement("{{source}}", Replacements.ofWiki(source)) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, 42, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, 42, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -429,7 +454,7 @@ void testNotifyPlayer() { .replacement("{{source}}", Replacements.ofLink(LOOTED_NAME, config.playerLookupService().getPlayerUrl(LOOTED_NAME))) .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, 1, null, 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, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -460,7 +485,7 @@ void testNotifyPlayerForwarded() { .replacement("{{source}}", Replacements.ofLink(LOOTED_NAME, config.playerLookupService().getPlayerUrl(LOOTED_NAME))) .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, 1, null, 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, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -490,7 +515,7 @@ void testNotifyPlayerForwardBlank() { .replacement("{{source}}", Replacements.ofLink(LOOTED_NAME, config.playerLookupService().getPlayerUrl(LOOTED_NAME))) .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, 1, null, 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, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -522,7 +547,7 @@ void testNotifyPkChest() { .replacement("{{source}}", Replacements.ofWiki(source)) .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, 1, null, 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, null, null, null)) .type(NotificationType.LOOT) .thumbnailUrl(ItemUtils.getItemImageUrl(ItemID.TUNA)) .build() @@ -591,7 +616,7 @@ void testNotifyMultiple() { .replacement("{{source}}", Replacements.ofWiki(LOOTED_NAME)) .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, 1, null, 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, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -628,7 +653,7 @@ void testNotifyRepeated() { .replacement("{{source}}", Replacements.ofWiki(LOOTED_NAME)) .build() ) - .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 5, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, 1, null, null)) + .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 5, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.EVENT, 1, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -680,7 +705,7 @@ void testNotifyGauntlet() { .replacement("{{source}}", Replacements.ofWiki(source)) .build() ) - .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null)) + .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -712,7 +737,7 @@ void testNotifyCorruptedGauntlet() { .replacement("{{source}}", Replacements.ofWiki(realSource)) .build() ) - .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), realSource, LootRecordType.EVENT, kc, null, null)) + .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), realSource, LootRecordType.EVENT, kc, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -743,7 +768,7 @@ void testNotifyAmascut() { .replacement("{{source}}", Replacements.ofWiki(source)) .build() ) - .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null)) + .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -774,7 +799,7 @@ void testNotifyAmascutExpert() { .replacement("{{source}}", Replacements.ofWiki(source)) .build() ) - .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null)) + .extra(new LootNotificationData(List.of(new SerializedItemStack(ItemID.RUBY, quantity, RUBY_PRICE, "Ruby")), source, LootRecordType.EVENT, kc, null, null, null)) .type(NotificationType.LOOT) .build() ); @@ -791,6 +816,8 @@ void testNotifyRarityValueIntersectionValue() { NPC npc = mock(NPC.class); String name = "Ice spider"; when(npc.getName()).thenReturn(name); + when(npc.getId()).thenReturn(NpcID.ICE_SPIDER); + mockWorldNpcs(npc); // fire event double rarity = 1.0 / 208; @@ -810,7 +837,7 @@ void testNotifyRarityValueIntersectionValue() { .replacement("{{source}}", Replacements.ofWiki(name)) .build() ) - .extra(new LootNotificationData(List.of(new RareItemStack(ItemID.LARRANS_KEY, 1, LARRAN_PRICE, "Larran's key", rarity)), name, LootRecordType.NPC, 1, rarity, null)) + .extra(new LootNotificationData(List.of(new RareItemStack(ItemID.LARRANS_KEY, 1, LARRAN_PRICE, "Larran's key", rarity)), name, LootRecordType.NPC, 1, rarity, null, NpcID.ICE_SPIDER)) .type(NotificationType.LOOT) .thumbnailUrl(ItemUtils.getItemImageUrl(ItemID.LARRANS_KEY)) .build()