Repository: pajlads/DinkPlugin
Pull request #214 (2023-04-18 06:24:25)

feat: add player kill notifier
![image](https://user-images.githubusercontent.com/8106344/232982288-7df843e7-9c61-44d6-ae21-598051c610d2.png)

```json
{
  "type":"PLAYER_KILL",
  "playerName":"dankgg",
  "accountType":"NORMAL",
  "extra":{
    "name":"Shipy",
    "combatLevel":74,
    "equipment":{
      "AMULET":{
        "id":1731,
        "quantity":1,
        "priceEach":1987,
        "name":"Amulet of power"
      },
      "WEAPON":{
        "id":1333,
        "quantity":1,
        "priceEach":14971,
        "name":"Rune scimitar"
      },
      "TORSO":{
        "id":1135,
        "quantity":1,
        "priceEach":4343,
        "name":"Green d\u0027hide body"
      },
      "LEGS":{
        "id":1099,
        "quantity":1,
        "priceEach":2077,
        "name":"Green d\u0027hide chaps"
      },
      "HANDS":{
        "id":1065,
        "quantity":1,
        "priceEach":1392,
        "name":"Green d\u0027hide vambraces"
      }

    },
    "world":394,
    "location":{
      "x":3334,
      "y":4761,
      "plane":0
    },
    "myHitpoints":20,
    "myLastDamage":12
  }
}
```

iProdigy: Known shortcoming: if a player dies to poision (while we hit 0 on the same tick), the notifier will *not* be triggered because we can't (easily) distinguish whether that poison was inflicted by the local player or another source. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce0081c..389ffa5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Major: Add notifier to capture player kills while hitsplats are still visible. (#214)
 - Bugfix: Improve reliablity of hidden chat setting for collection log screenshots. (#217)
 - Bugfix: Fire death notification when multiple similar NPCs are attacking the player that lack scraped wiki HP data. (#216)
 - Dev: Update gradle wrapper to v8.1.1 patch version. (#215)
diff --git a/README.md b/README.md
index 4004c37..a56e45e 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Have a suggestion (e.g., new notifier, additional data), bug report (as rare as
 - [Pet](#pet): Send a webhook message upon receiving a pet
 - [Speedrunning](#speedrunning): Send a webhook message upon completing a quest speedrun (with special configuration for personal best times)
 - [BA Gambles](#ba-gambles): Sends a webhook message upon receiving high level gambles from Barbarian Assault
+- [Player Kills](#player-kills): Sends a webhook message upon killing another player (while hitsplats are still visible)
 
 ## Other Setup
 
@@ -524,6 +525,64 @@ Note: `petName` is only included if the game sent it to your chat via untradeabl
 ```
 </details>
 
+### Player Kills:
+
+`%TARGET%` will be replaced with the victim's user name
+
+Note: `world` and `location` are *not* sent if the user has disabled the "Include Location" notifier setting.
+
+<details>
+  <summary>JSON for PK Notifications:</summary>
+
+```json5
+{
+  "content": "%USERNAME% has PK'd %TARGET%",
+  "type": "PLAYER_KILL",
+  "playerName": "%USERNAME%",
+  "accountType": "NORMAL",
+  "extra": {
+    "victimName": "%TARGET%",
+    "victimCombatLevel": 69,
+    "victimEquipment": {
+      "AMULET": {
+        "id": 1731,
+        "priceEach": 1987,
+        "name": "Amulet of power"
+      },
+      "WEAPON": {
+        "id": 1333,
+        "priceEach": 14971,
+        "name": "Rune scimitar"
+      },
+      "TORSO": {
+        "id": 1135,
+        "priceEach": 4343,
+        "name": "Green d'hide body"
+      },
+      "LEGS": {
+        "id": 1099,
+        "priceEach": 2077,
+        "name": "Green d'hide chaps"
+      },
+      "HANDS": {
+        "id": 1065,
+        "priceEach": 1392,
+        "name": "Green d'hide vambraces"
+      }
+    },
+    "world": 394,
+    "location": {
+      "x": 3334,
+      "y": 4761,
+      "plane": 0
+    },
+    "myHitpoints": 20,
+    "myLastDamage": 12
+  }
+}
+```
+</details>
+
 ## Credits
 
 This plugin uses code from [Universal Discord Notifier](https://github.com/MidgetJake/UniversalDiscordNotifier).
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 9ec3acf..4348ccd 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -11,6 +11,7 @@ import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LevelNotifier;
 import dinkplugin.notifiers.LootNotifier;
 import dinkplugin.notifiers.PetNotifier;
+import dinkplugin.notifiers.PlayerKillNotifier;
 import dinkplugin.notifiers.QuestNotifier;
 import dinkplugin.notifiers.SlayerNotifier;
 import dinkplugin.notifiers.SpeedrunNotifier;
@@ -22,6 +23,7 @@ import net.runelite.api.events.ChatMessage;
 import net.runelite.api.events.CommandExecuted;
 import net.runelite.api.events.GameStateChanged;
 import net.runelite.api.events.GameTick;
+import net.runelite.api.events.HitsplatApplied;
 import net.runelite.api.events.InteractingChanged;
 import net.runelite.api.events.StatChanged;
 import net.runelite.api.events.UsernameChanged;
@@ -71,6 +73,7 @@ public class DinkPlugin extends Plugin {
     private @Inject CombatTaskNotifier combatTaskNotifier;
     private @Inject DiaryNotifier diaryNotifier;
     private @Inject GambleNotifier gambleNotifier;
+    private @Inject PlayerKillNotifier pkNotifier;
 
     @Override
     protected void startUp() {
@@ -138,6 +141,7 @@ public class DinkPlugin extends Plugin {
         levelNotifier.onTick();
         diaryNotifier.onTick();
         killCountNotifier.onTick();
+        pkNotifier.onTick();
     }
 
     @Subscribe
@@ -174,6 +178,11 @@ public class DinkPlugin extends Plugin {
         }
     }
 
+    @Subscribe
+    public void onHitsplatApplied(HitsplatApplied event) {
+        pkNotifier.onHitsplat(event);
+    }
+
     @Subscribe
     public void onActorDeath(ActorDeath actor) {
         deathNotifier.onActorDeath(actor);
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 8410108..b4e954c 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -126,6 +126,14 @@ public interface DinkPluginConfig extends Config {
     )
     String gambleSection = "BA Gambles";
 
+    @ConfigSection(
+        name = "Player Kills",
+        description = "Settings for notifying when you kill another player",
+        position = 130,
+        closedByDefault = true
+    )
+    String pkSection = "Player Kills";
+
     @ConfigSection(
         name = "Advanced",
         description = "Do not modify without fully understanding these settings",
@@ -404,6 +412,17 @@ public interface DinkPluginConfig extends Config {
         return "";
     }
 
+    @ConfigItem(
+        keyName = "pkWebhook",
+        name = "Player Kill Webhook Override",
+        description = "If non-empty, PK messages are sent to this URL, instead of the primary URL",
+        position = -6,
+        section = webhookSection
+    )
+    default String pkWebhook() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "collectionLogEnabled",
         name = "Enable collection log",
@@ -1206,4 +1225,86 @@ public interface DinkPluginConfig extends Config {
     default String gambleRareNotifyMessage() {
         return "%USERNAME% has received rare loot at gamble count %COUNT%: \n\n%LOOT%";
     }
+
+    @ConfigItem(
+        keyName = "pkEnabled",
+        name = "Enable Player Kills",
+        description = "Enable notifications upon killing other players",
+        position = 130,
+        section = pkSection
+    )
+    default boolean notifyPk() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "pkSendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 131,
+        section = pkSection
+    )
+    default boolean pkSendImage() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "pkSkipSafe",
+        name = "Ignore Safe Deaths",
+        description = "Whether notifications should be skipped for kills in safe areas.<br/>" +
+            "This includes Clan Wars, Duel Arena, Fight Pit, Last Man Standing, etc.",
+        position = 132,
+        section = pkSection
+    )
+    default boolean pkSkipSafe() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "pkSkipFriendly",
+        name = "Ignore Friendlies",
+        description = "Whether notifications should be skipped upon killing friends, clan members, or team mates",
+        position = 133,
+        section = pkSection
+    )
+    default boolean pkSkipFriendly() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "pkMinValue",
+        name = "Min Value",
+        description = "The minimum value of the victim's visible equipment to send a notification.<br/>" +
+            "This does not include equipment you cannot see or items in their inventory.",
+        position = 134,
+        section = pkSection
+    )
+    default int pkMinValue() {
+        return 0;
+    }
+
+    @ConfigItem(
+        keyName = "pkIncludeLocation",
+        name = "Include Location",
+        description = "Whether notifications should include the world and location of the killed player.",
+        position = 135,
+        section = pkSection
+    )
+    default boolean pkIncludeLocation() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "pkNotifyMessage",
+        name = "Notification Message",
+        description = "The message to be sent through the webhook.<br/>" +
+            "Use %USERNAME% to insert your username<br/>" +
+            "Use %TARGET% to insert the victim's username",
+        position = 136,
+        section = pkSection
+    )
+    default String pkNotifyMessage() {
+        return "%USERNAME% has PK'd %TARGET%";
+    }
+
 }
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index 1ebb087..ca4acac 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -20,7 +20,8 @@ public enum NotificationType {
     KILL_COUNT("Completion Count", "killCountImage.png", WIKI_IMG_BASE_URL + "Enchanted_gem.png"),
     COMBAT_ACHIEVEMENT("Combat Achievement", "combatTaskImage.png", WIKI_IMG_BASE_URL + "Combat_achievements.png"),
     ACHIEVEMENT_DIARY("Achievement Diary", "achievementDiaryImage.png", WIKI_IMG_BASE_URL + "Achievement_Diaries_icon.png"),
-    BARBARIAN_ASSAULT_GAMBLE("Barbarian Assault Gamble", "baGambleImage.png", WIKI_IMG_BASE_URL + "Barbarian_Assault_logo.jpg");
+    BARBARIAN_ASSAULT_GAMBLE("Barbarian Assault Gamble", "baGambleImage.png", WIKI_IMG_BASE_URL + "Barbarian_Assault_logo.jpg"),
+    PLAYER_KILL("Player Kill", "playerKillImage.png", WIKI_IMG_BASE_URL + "Skull_(status)_icon.png");
 
     private final String title;
 
diff --git a/src/main/java/dinkplugin/notifiers/PlayerKillNotifier.java b/src/main/java/dinkplugin/notifiers/PlayerKillNotifier.java
new file mode 100644
index 0000000..5d7032c
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/PlayerKillNotifier.java
@@ -0,0 +1,167 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.PlayerKillNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.WorldUtils;
+import net.runelite.api.Actor;
+import net.runelite.api.Hitsplat;
+import net.runelite.api.Player;
+import net.runelite.api.PlayerComposition;
+import net.runelite.api.Skill;
+import net.runelite.api.WorldType;
+import net.runelite.api.events.HitsplatApplied;
+import net.runelite.api.kit.KitType;
+import net.runelite.client.game.ItemManager;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+@Singleton
+public class PlayerKillNotifier extends BaseNotifier {
+
+    private static final KitType[] EQUIPMENT;
+
+    /**
+     * Contains the damage inflicted by the local player on various targets in a short time period.
+     * <p>
+     * Uses weak keys to not hinder garbage collection
+     * (and avoid synchronization if we were forced to do Map#clear on plugin shutdown).
+     */
+    private final Map<Player, Integer> attacked = new WeakHashMap<>(4);
+
+    @Inject
+    private ItemManager itemManager;
+
+    @Override
+    public boolean isEnabled() {
+        if (!config.notifyPk())
+            return false;
+
+        // duplicated logic from super class but allow Duel Arena
+        EnumSet<WorldType> world = client.getWorldType().clone(); // fast on RegularEnumSet
+        world.remove(WorldType.PVP_ARENA);
+        return !WorldUtils.isIgnoredWorld(world) && settingsManager.isNamePermitted(client.getLocalPlayer().getName());
+    }
+
+    @Override
+    protected String getWebhookUrl() {
+        return config.pkWebhook();
+    }
+
+    public void onHitsplat(HitsplatApplied event) {
+        Hitsplat hit = event.getHitsplat();
+        int amount = hit.getAmount();
+        if (amount <= 0 || !hit.isMine())
+            return;
+
+        if (!config.notifyPk())
+            return;
+
+        Actor actor = event.getActor();
+        if (actor == client.getLocalPlayer() || !(actor instanceof Player))
+            return;
+
+        Player target = (Player) actor;
+        attacked.merge(target, amount, Integer::sum);
+    }
+
+    public void onTick() {
+        // micro-optimization: this check is very fast for empty WeakHashMap & can avoid creating a HashIterator
+        if (attacked.isEmpty())
+            return;
+
+        attacked.forEach((target, damage) -> {
+            if (target.isDead())
+                handleKill(target, damage);
+        });
+
+        attacked.clear();
+    }
+
+    private void handleKill(Player target, int myLastDamage) {
+        if (!isEnabled())
+            return;
+
+        if (config.pkSkipFriendly() && isFriendly(target))
+            return;
+
+        if (config.pkSkipSafe() && (WorldUtils.isSafeArea(client) || client.getWorldType().contains(WorldType.PVP_ARENA)))
+            return;
+
+        Map<KitType, SerializedItemStack> equipment = getEquipment(target.getPlayerComposition());
+        long value = ItemUtils.getTotalPrice(equipment.values());
+        long minValue = config.pkMinValue();
+        if (value < minValue)
+            return;
+
+        boolean sendLocation = config.pkIncludeLocation();
+        PlayerKillNotificationData extra = new PlayerKillNotificationData(
+            target.getName(),
+            target.getCombatLevel(),
+            equipment,
+            sendLocation ? client.getWorld() : null,
+            sendLocation ? target.getWorldLocation() : null,
+            client.getBoostedSkillLevel(Skill.HITPOINTS),
+            myLastDamage
+        );
+
+        String localPlayer = client.getLocalPlayer().getName();
+        String message = config.pkNotifyMessage()
+            .replace("%USERNAME%", localPlayer)
+            .replace("%TARGET%", target.getName());
+
+        createMessage(config.pkSendImage(), NotificationBody.builder()
+            .type(NotificationType.PLAYER_KILL)
+            .text(message)
+            .extra(extra)
+            .playerName(localPlayer)
+            .build());
+    }
+
+    private boolean isFriendly(Player target) {
+        return target.isFriend() || target.isClanMember() || target.isFriendsChatMember()
+            || (target.getTeam() != 0 && target.getTeam() == client.getLocalPlayer().getTeam());
+    }
+
+    private Map<KitType, SerializedItemStack> getEquipment(PlayerComposition comp) {
+        if (comp == null) return Collections.emptyMap();
+
+        int[] equipmentIds = comp.getEquipmentIds();
+        int n = equipmentIds.length;
+
+        Map<KitType, SerializedItemStack> map = new EnumMap<>(KitType.class);
+        for (KitType slot : EQUIPMENT) {
+            int index = slot.getIndex();
+            if (index >= n) continue;
+            int id = equipmentIds[index];
+            if (id >= 512) {
+                SerializedItemStack item = ItemUtils.stackFromItem(itemManager, id - 512, 1);
+                map.put(slot, item);
+            }
+        }
+        return map;
+    }
+
+    static {
+        // omits ARMS, HAIR, JAW because they don't correspond to items
+        EQUIPMENT = new KitType[] {
+            KitType.HEAD,
+            KitType.CAPE,
+            KitType.AMULET,
+            KitType.WEAPON,
+            KitType.TORSO,
+            KitType.SHIELD,
+            KitType.LEGS,
+            KitType.HANDS,
+            KitType.BOOTS
+        };
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/PlayerKillNotificationData.java b/src/main/java/dinkplugin/notifiers/data/PlayerKillNotificationData.java
new file mode 100644
index 0000000..5a57341
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/PlayerKillNotificationData.java
@@ -0,0 +1,51 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.message.Field;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.api.kit.KitType;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class PlayerKillNotificationData extends NotificationData {
+
+    String victimName;
+    int victimCombatLevel;
+    Map<KitType, SerializedItemStack> victimEquipment;
+    @Nullable Integer world;
+    @Nullable WorldPoint location;
+    int myHitpoints;
+    int myLastDamage;
+
+    @Override
+    public List<Field> getFields() {
+        if (location == null)
+            return super.getFields();
+
+        List<Field> fields = new ArrayList<>(2);
+
+        fields.add(
+            new Field(
+                "World",
+                Field.formatBlock("", String.valueOf(world))
+            )
+        );
+
+        fields.add(
+            new Field(
+                "Location",
+                Field.formatBlock("",
+                    String.format("X: %d, Y: %d, Plane: %d", location.getX(), location.getY(), location.getPlane())
+                )
+            )
+        );
+
+        return fields;
+    }
+}
diff --git a/src/main/java/dinkplugin/util/ItemUtils.java b/src/main/java/dinkplugin/util/ItemUtils.java
index 368bfb9..e164044 100644
--- a/src/main/java/dinkplugin/util/ItemUtils.java
+++ b/src/main/java/dinkplugin/util/ItemUtils.java
@@ -75,6 +75,14 @@ public class ItemUtils {
         return price;
     }
 
+    public long getTotalPrice(@NotNull Iterable<SerializedItemStack> items) {
+        long sum = 0L;
+        for (SerializedItemStack item : items) {
+            sum += item.getTotalPrice();
+        }
+        return sum;
+    }
+
     public Collection<Item> getItems(Client client) {
         return Stream.of(InventoryID.INVENTORY, InventoryID.EQUIPMENT)
             .map(client::getItemContainer)
diff --git a/src/main/java/dinkplugin/util/WorldUtils.java b/src/main/java/dinkplugin/util/WorldUtils.java
index 0a7c366..3309ce4 100644
--- a/src/main/java/dinkplugin/util/WorldUtils.java
+++ b/src/main/java/dinkplugin/util/WorldUtils.java
@@ -7,6 +7,7 @@ import net.runelite.api.WorldType;
 import net.runelite.api.vars.AccountType;
 import net.runelite.api.widgets.Widget;
 import net.runelite.api.widgets.WidgetInfo;
+import org.jetbrains.annotations.VisibleForTesting;
 
 import java.util.Collections;
 import java.util.EnumSet;
@@ -28,7 +29,7 @@ public class WorldUtils {
     private final int INFERNO_REGION = 9043;
     private final int NMZ_REGION = 9033;
     private final int TZHAAR_CAVE = 9551;
-    private final int TZHAAR_PIT = 9552;
+    public final @VisibleForTesting int TZHAAR_PIT = 9552;
 
     public boolean isIgnoredWorld(Set<WorldType> worldType) {
         return !Collections.disjoint(IGNORED_WORLDS, worldType);


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java b/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java
new file mode 100644
index 0000000..20f5c9a
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java
@@ -0,0 +1,280 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.PlayerKillNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.WorldUtils;
+import net.runelite.api.Actor;
+import net.runelite.api.Hitsplat;
+import net.runelite.api.HitsplatID;
+import net.runelite.api.ItemID;
+import net.runelite.api.Player;
+import net.runelite.api.PlayerComposition;
+import net.runelite.api.Skill;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.api.events.HitsplatApplied;
+import net.runelite.api.kit.KitType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Map;
+
+import static net.runelite.api.HitsplatID.DAMAGE_OTHER;
+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;
+
+public class PlayerKillNotifierTest extends MockedNotifierTest {
+
+    private static final String TARGET = "Romy";
+    private static final int LEVEL = 99;
+    private static final int MY_HP = 10;
+    private static final int WORLD = 420;
+    private static final WorldPoint LOCATION = new WorldPoint(3000, 4000, 0);
+    private static final int WEAPON_PRICE = 100_000;
+    private static final int TOP_PRICE = 120_000;
+    private static final int LEGS_PRICE = 80_000;
+    private static final int HAND_PRICE = 100_000;
+    private static final int SHIELD_PRICE = 200;
+    private static final int EQUIPMENT_VALUE = WEAPON_PRICE + TOP_PRICE + LEGS_PRICE + HAND_PRICE + SHIELD_PRICE;
+    private static final Map<KitType, SerializedItemStack> EQUIPMENT;
+
+    @Bind
+    @InjectMocks
+    PlayerKillNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyPk()).thenReturn(true);
+        when(config.pkMinValue()).thenReturn(EQUIPMENT_VALUE);
+        when(config.pkIncludeLocation()).thenReturn(true);
+        when(config.pkNotifyMessage()).thenReturn("%USERNAME% has PK'd %TARGET%");
+
+        // init client mocks
+        when(client.getWorld()).thenReturn(WORLD);
+        when(client.getBoostedSkillLevel(Skill.HITPOINTS)).thenReturn(MY_HP);
+
+        // init item mocks
+        for (SerializedItemStack item : EQUIPMENT.values()) {
+            mockItem(item.getId(), item.getPriceEach(), item.getName());
+        }
+    }
+
+    @Test
+    void testNotify() {
+        // init mocks
+        Player target = mockPlayer();
+
+        // fire event
+        int damage = 12;
+        notifier.onHitsplat(event(target, damage));
+        notifier.onTick();
+
+        // verify notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has PK'd %s", PLAYER_NAME, TARGET))
+                .type(NotificationType.PLAYER_KILL)
+                .playerName(PLAYER_NAME)
+                .extra(new PlayerKillNotificationData(TARGET, LEVEL, EQUIPMENT, WORLD, LOCATION, MY_HP, damage))
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyMulti() {
+        // init mocks
+        Player target = mockPlayer();
+
+        // fire events
+        notifier.onHitsplat(event(target, 5));
+        notifier.onHitsplat(event(target, 7));
+        notifier.onTick();
+
+        // verify notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has PK'd %s", PLAYER_NAME, TARGET))
+                .type(NotificationType.PLAYER_KILL)
+                .playerName(PLAYER_NAME)
+                .extra(new PlayerKillNotificationData(TARGET, LEVEL, EQUIPMENT, WORLD, LOCATION, MY_HP, 12))
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyMultiOther() {
+        // init mocks
+        Player target = mockPlayer();
+
+        // fire event
+        int damage = 12;
+        notifier.onHitsplat(event(target, damage));
+        HitsplatApplied eventOther = new HitsplatApplied();
+        eventOther.setActor(target);
+        eventOther.setHitsplat(new Hitsplat(DAMAGE_OTHER, 13, 1));
+        notifier.onHitsplat(eventOther);
+        notifier.onTick();
+
+        // verify notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has PK'd %s", PLAYER_NAME, TARGET))
+                .type(NotificationType.PLAYER_KILL)
+                .playerName(PLAYER_NAME)
+                .extra(new PlayerKillNotificationData(TARGET, LEVEL, EQUIPMENT, WORLD, LOCATION, MY_HP, damage))
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreValue() {
+        // init mocks
+        Player target = mockPlayer();
+        target.getPlayerComposition().getEquipmentIds()[KitType.SHIELD.getIndex()] = 0;
+
+        // fire event
+        notifier.onHitsplat(event(target, 13));
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreZero() {
+        // init mocks
+        Player target = mockPlayer();
+
+        // fire event
+        notifier.onHitsplat(event(target, 0));
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreSafe() {
+        // init mocks
+        Player target = mockPlayer();
+        when(config.pkSkipSafe()).thenReturn(true);
+        when(localPlayer.getWorldLocation()).thenReturn(
+            WorldPoint.fromRegion(WorldUtils.TZHAAR_PIT, 0, 0, 0)
+        );
+
+        // fire event
+        notifier.onHitsplat(event(target, 14));
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreFriendly() {
+        // init mocks
+        Player target = mockPlayer();
+        when(config.pkSkipFriendly()).thenReturn(true);
+        when(target.isFriend()).thenReturn(true);
+
+        // fire event
+        notifier.onHitsplat(event(target, 15));
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testDisabled() {
+        // init mocks
+        Player target = mockPlayer();
+        when(config.notifyPk()).thenReturn(false);
+
+        // fire event
+        notifier.onHitsplat(event(target, 16));
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreOther() {
+        // init mocks
+        Player target = mockPlayer();
+
+        // fire event
+        HitsplatApplied event = new HitsplatApplied();
+        event.setActor(target);
+        event.setHitsplat(new Hitsplat(DAMAGE_OTHER, 17, 1));
+        notifier.onHitsplat(event);
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreSelf() {
+        // fire event
+        HitsplatApplied event = new HitsplatApplied();
+        event.setActor(localPlayer);
+        event.setHitsplat(new Hitsplat(HitsplatID.DAMAGE_ME, 18, 1));
+        notifier.onHitsplat(event);
+        notifier.onTick();
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    private static Player mockPlayer() {
+        Player target = mock(Player.class);
+        when(target.getName()).thenReturn(TARGET);
+        when(target.isDead()).thenReturn(true);
+        when(target.getCombatLevel()).thenReturn(LEVEL);
+        when(target.getWorldLocation()).thenReturn(LOCATION);
+        PlayerComposition comp = mock(PlayerComposition.class);
+        when(target.getPlayerComposition()).thenReturn(comp);
+        int[] equipment = new int[KitType.values().length];
+        EQUIPMENT.forEach((kit, item) -> equipment[kit.getIndex()] = item.getId() + 512);
+        when(comp.getEquipmentIds()).thenReturn(equipment);
+        return target;
+    }
+
+    private static HitsplatApplied event(Actor actor, int amount) {
+        HitsplatApplied e = new HitsplatApplied();
+        e.setActor(actor);
+        e.setHitsplat(new Hitsplat(HitsplatID.DAMAGE_ME, amount, 1));
+        return e;
+    }
+
+    static {
+        Map<KitType, SerializedItemStack> m = new EnumMap<>(KitType.class);
+        m.put(KitType.WEAPON, new SerializedItemStack(ItemID.ANCIENT_STAFF, 1, WEAPON_PRICE, "Ancient staff"));
+        m.put(KitType.TORSO, new SerializedItemStack(ItemID.MYSTIC_ROBE_TOP, 1, TOP_PRICE, "Mystic robe top"));
+        m.put(KitType.LEGS, new SerializedItemStack(ItemID.MYSTIC_ROBE_BOTTOM, 1, LEGS_PRICE, "Mystic robe bottom"));
+        m.put(KitType.HANDS, new SerializedItemStack(ItemID.BARROWS_GLOVES, 1, HAND_PRICE, "Barrows gloves"));
+        m.put(KitType.SHIELD, new SerializedItemStack(ItemID.UNHOLY_BOOK, 1, SHIELD_PRICE, "Unholy book"));
+        EQUIPMENT = Collections.unmodifiableMap(m);
+    }
+}