Repository: pajlads/DinkPlugin (java)

Issue:
XP Milestones instead of Virtual Levels for Post 99 XP
### Checklist

- [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.
- [X] I've searched the [issues and pull requests](https://github.com/pajlads/Dinkplugin/issues?q=) for similar looking suggestions.

### Describe your Suggestion

Since virtual levels are just... virtual.
XP milestones feel more meaningfull after 99 in a skill.
Similar to how Clans in game have Post-99 XP broadcast intervals 
![image](https://github.com/pajlads/DinkPlugin/assets/86596679/d701f8ff-c13e-4116-91ab-56e14d73d010)
The clan broadcast follows this format: $rsn has reached 50,000,000 XP in $skill .
The interval value can be customizable to someone's liking.
Since others might like virtual levels instead, could be as a switch between the two options.

### Reasoning

_No response_

Pull request description:
feat: fire notification on xp milestones
Closes #399

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/f3b7b269-7106-442e-ac9a-b76a633d7b9f)

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/17751c34-3e85-43fc-a8cc-4ac07ebe327b)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0fd1707..71a8a3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Fire notifications for XP milestones beyond level 99. (#462)
 - Dev: Avoid benign NPE when config items are reset. (#464)
 
 ## 1.10.0
diff --git a/README.md b/README.md
index 2797e15..b859107 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w
 
 - [Death](#death): Send a webhook message upon dying (with special configuration for PK deaths)
 - [Collection](#collection): Send a webhook message upon adding an item to your collection log
-- [Level](#level): Send a webhook message upon leveling up a skill (with support for virtual levels)
+- [Level](#level): Send a webhook message upon leveling up a skill (with support for virtual levels and XP milestones)
 - [Loot](#loot): Send a webhook message upon receiving valuable loot (with item rarity for monster drops)
 - [Slayer](#slayer): Send a webhook message upon completing a slayer task (with a customizable point threshold)
 - [Quests](#quests): Send a webhook message upon completing a quest
@@ -161,6 +161,8 @@ Note: `%COMPLETED%` may not be populated if the [Character Summary](https://olds
 
 `%TOTAL_LEVEL%` will be replaced with the updated total level across all skills.
 
+`%TOTAL_XP%` will be replaced with the updated total experience across all skills.
+
 ### Loot:
 
 `%LOOT%` will be replaced with a list of the loot and value of said loot
diff --git a/docs/json-examples.md b/docs/json-examples.md
index cd986db..a98401c 100644
--- a/docs/json-examples.md
+++ b/docs/json-examples.md
@@ -192,6 +192,25 @@ JSON for Levelups:
 
 Note: Level 127 in JSON corresponds to attaining max experience in a skill (200M).
 
+JSON for XP Milestones:
+
+```json5
+{
+  "content": "%USERNAME% has levelled %SKILL%",
+  "extra": {
+    "xpData": {
+      // The XP for each skill
+      "Skill one": 1234567,
+      "Skill two": 2345678,
+      "Skill three": 3456789
+    },
+    "milestoneAchieved": ["Skill name"], // The skill(s) that hit a milestone
+    "interval": 5000000 // The configured XP interval
+  },
+  "type": "XP_MILESTONE"
+}
+```
+
 ### Loot
 
 JSON for Loot Notifications:
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 79508a2..bd65eec 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -9,6 +9,7 @@ import dinkplugin.domain.FilterMode;
 import dinkplugin.domain.LeagueTaskDifficulty;
 import dinkplugin.domain.PlayerLookupService;
 import dinkplugin.notifiers.ChatNotifier;
+import net.runelite.api.Experience;
 import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
 import net.runelite.client.config.ConfigItem;
@@ -722,7 +723,8 @@ public interface DinkPluginConfig extends Config {
     @ConfigItem(
         keyName = "levelNotifyVirtual",
         name = "Notify on Virtual Levels",
-        description = "Whether notifications should be fired beyond level 99",
+        description = "Whether level notifications should be fired beyond level 99.<br/>" +
+            "Will also notify upon reaching 200M XP",
         position = 22,
         section = levelSection
     )
@@ -744,7 +746,7 @@ public interface DinkPluginConfig extends Config {
     @ConfigItem(
         keyName = "levelInterval",
         name = "Notify Interval",
-        description = "Interval between when a notification should be sent",
+        description = "Level interval between when a notification should be sent",
         position = 24,
         section = levelSection
     )
@@ -776,14 +778,32 @@ public interface DinkPluginConfig extends Config {
         return 0;
     }
 
+    @ConfigItem(
+        keyName = "xpInterval",
+        name = "Post-99 XP Interval",
+        description = "XP interval at which to fire notifications (in millions).<br/>" +
+            "Does not apply to skills that are below level 99.<br/>" +
+            "Does <i>not</i> depend on the 'Notify on Virtual Levels' setting.<br/>" +
+            "If enabled, fires for 200M XP, even if not divisible by the interval.<br/>" +
+            "Disabled if set to 0",
+        position = 27,
+        section = levelSection
+    )
+    @Units("M xp")
+    @Range(max = Experience.MAX_SKILL_XP / 1_000_000) // [0, 200]
+    default int xpInterval() {
+        return 5;
+    }
+
     @ConfigItem(
         keyName = "levelNotifMessage",
         name = "Notification Message",
         description = "The message to be sent through the webhook.<br/>" +
             "Use %USERNAME% to insert your username<br/>" +
             "Use %SKILL% to insert the levelled skill(s)<br/>" +
-            "Use %TOTAL_LEVEL% to insert the updated total level",
-        position = 27,
+            "Use %TOTAL_LEVEL% to insert the updated total level<br/>" +
+            "Use %TOTAL_XP% to insert the updated overall experience",
+        position = 29,
         section = levelSection
     )
     default String levelNotifyMessage() {
diff --git a/src/main/java/dinkplugin/VersionManager.java b/src/main/java/dinkplugin/VersionManager.java
index fdfa193..9c86068 100644
--- a/src/main/java/dinkplugin/VersionManager.java
+++ b/src/main/java/dinkplugin/VersionManager.java
@@ -127,5 +127,6 @@ public class VersionManager {
     static {
         register("1.9.0", "Notifications now report monster drop rarity");
         register("1.10.0", "Chat messages that match custom patterns can trigger notifications");
+        register("1.10.1", "Level notifier now triggers at XP milestones with 5M as the default interval");
     }
 }
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index d438e41..725dc74 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -29,7 +29,8 @@ public enum NotificationType {
     LEAGUES_TASK("Task Completed", "leaguesTask.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_icon.png"),
     LOGIN("Player Login", "login.png", WIKI_IMG_BASE_URL + "Prop_sword.png"),
     TRADE("Player Trade", "trade.png", WIKI_IMG_BASE_URL + "Inventory.png"),
-    CHAT("Chat Notification", "chat.png", WIKI_IMG_BASE_URL + "Toggle_Chat_effects.png");
+    CHAT("Chat Notification", "chat.png", WIKI_IMG_BASE_URL + "Toggle_Chat_effects.png"),
+    XP_MILESTONE("XP Milestone", "xpImage.png", WIKI_IMG_BASE_URL + "Lamp.png");
 
     private final String title;
 
diff --git a/src/main/java/dinkplugin/notifiers/LevelNotifier.java b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
index 83a1c9e..0bebb4b 100644
--- a/src/main/java/dinkplugin/notifiers/LevelNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
@@ -7,6 +7,7 @@ import dinkplugin.message.templating.Replacements;
 import dinkplugin.message.templating.Template;
 import dinkplugin.message.templating.impl.JoiningReplacement;
 import dinkplugin.notifiers.data.LevelNotificationData;
+import dinkplugin.notifiers.data.XpNotificationData;
 import dinkplugin.util.Utils;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Experience;
@@ -15,10 +16,13 @@ import net.runelite.api.Skill;
 import net.runelite.api.events.GameStateChanged;
 import net.runelite.api.events.StatChanged;
 import net.runelite.client.callback.ClientThread;
+import net.runelite.client.util.QuantityFormatter;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
 import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -38,7 +42,9 @@ public class LevelNotifier extends BaseNotifier {
     private static final String COMBAT_NAME = "Combat";
     private static final Set<String> COMBAT_COMPONENTS;
     private final BlockingQueue<String> levelledSkills = new ArrayBlockingQueue<>(Skill.values().length + 1);
+    private final Set<Skill> xpReached = EnumSet.noneOf(Skill.class);
     private final Map<String, Integer> currentLevels = new HashMap<>();
+    private final Map<Skill, Integer> currentXp = new EnumMap<>(Skill.class);
     private final AtomicInteger ticksWaited = new AtomicInteger();
     private final AtomicInteger initTicks = new AtomicInteger();
     private final AtomicBoolean shouldInit = new AtomicBoolean();
@@ -53,11 +59,13 @@ public class LevelNotifier extends BaseNotifier {
 
     private void initLevels() {
         for (Skill skill : Skill.values()) {
+            int xp = client.getSkillExperience(skill);
             int level = client.getRealSkillLevel(skill); // O(1)
             if (level >= MAX_REAL_LEVEL) {
-                level = getLevel(client.getSkillExperience(skill));
+                level = getLevel(xp);
             }
             currentLevels.put(skill.getName(), level);
+            currentXp.put(skill, xp);
         }
         currentLevels.put(COMBAT_NAME, calculateCombatLevel());
         initTicks.set(0);
@@ -69,7 +77,11 @@ public class LevelNotifier extends BaseNotifier {
         initTicks.set(0);
         levelledSkills.clear();
         ticksWaited.set(0);
-        clientThread.invoke(currentLevels::clear);
+        clientThread.invoke(() -> {
+            xpReached.clear();
+            currentXp.clear();
+            currentLevels.clear();
+        });
     }
 
     public void onTick() {
@@ -83,7 +95,7 @@ public class LevelNotifier extends BaseNotifier {
             return;
         }
 
-        if (levelledSkills.isEmpty()) {
+        if (levelledSkills.isEmpty() && xpReached.isEmpty()) {
             return;
         }
 
@@ -101,7 +113,7 @@ public class LevelNotifier extends BaseNotifier {
     }
 
     public void onStatChanged(StatChanged statChange) {
-        this.handleLevelUp(statChange.getSkill().getName(), statChange.getLevel(), statChange.getXp());
+        this.handleLevelUp(statChange.getSkill(), statChange.getLevel(), statChange.getXp());
     }
 
     public void onGameStateChanged(GameStateChanged gameStateChanged) {
@@ -110,25 +122,44 @@ public class LevelNotifier extends BaseNotifier {
         }
     }
 
-    private void handleLevelUp(String skill, int level, int xp) {
+    private void handleLevelUp(Skill skill, int level, int xp) {
         if (!isEnabled()) return;
 
+        Integer previousXp = currentXp.put(skill, xp);
+        if (previousXp == null) {
+            shouldInit.set(true);
+            return;
+        }
+
+        String skillName = skill.getName();
         int virtualLevel = level < MAX_REAL_LEVEL ? level : getLevel(xp); // avoid log(n) query when not needed
-        Integer previousLevel = currentLevels.put(skill, virtualLevel);
+        Integer previousLevel = currentLevels.put(skillName, virtualLevel);
 
         if (previousLevel == null) {
             shouldInit.set(true);
             return;
         }
 
-        if (virtualLevel < previousLevel) {
+        if (virtualLevel < previousLevel || xp < previousXp) {
             // base skill level should never regress; reset notifier state
             reset();
             return;
         }
 
         // Check normal skill level up
-        checkLevelUp(config.notifyLevel(), skill, previousLevel, virtualLevel);
+        final boolean enabled = config.notifyLevel();
+        checkLevelUp(enabled, skillName, previousLevel, virtualLevel);
+
+        // Check if xp milestone reached
+        int xpInterval = config.xpInterval() * 1_000_000;
+        if (enabled && xpInterval > 0 && level >= MAX_REAL_LEVEL && xp > previousXp) {
+            int remainder = xp % xpInterval;
+            if (remainder == 0 || xp - remainder > previousXp || xp >= Experience.MAX_SKILL_XP) {
+                log.debug("Observed XP milestone for {} to {}", skill, xp);
+                xpReached.add(skill);
+                ticksWaited.set(0);
+            }
+        }
 
         // Skip combat level checking if no level up has occurred
         if (virtualLevel <= previousLevel) {
@@ -138,10 +169,10 @@ public class LevelNotifier extends BaseNotifier {
         }
 
         // Check for combat level increase
-        if (COMBAT_COMPONENTS.contains(skill)) {
+        if (COMBAT_COMPONENTS.contains(skillName)) {
             int combatLevel = calculateCombatLevel();
             Integer previousCombatLevel = currentLevels.put(COMBAT_NAME, combatLevel);
-            checkLevelUp(config.notifyLevel() && config.levelNotifyCombat(), COMBAT_NAME, previousCombatLevel, combatLevel);
+            checkLevelUp(enabled && config.levelNotifyCombat(), COMBAT_NAME, previousCombatLevel, combatLevel);
         }
     }
 
@@ -170,11 +201,61 @@ public class LevelNotifier extends BaseNotifier {
     }
 
     private void attemptNotify() {
+        notifyLevels();
+        notifyXp();
+    }
+
+    private void notifyXp() {
+        final int n = xpReached.size();
+        if (n == 0) return;
+
+        int interval = Math.max(config.xpInterval(), 1) * 1_000_000;
+        Map<String, Integer> current = new HashMap<>(32);
+        currentXp.forEach((k, v) -> current.put(k.getName(), v));
+        List<String> milestones = new ArrayList<>(n);
+        JoiningReplacement.JoiningReplacementBuilder skillMessage = JoiningReplacement.builder().delimiter(", ");
+        for (Skill skill : xpReached) {
+            int xp = currentXp.getOrDefault(skill, 0);
+            xp -= xp % interval;
+            milestones.add(skill.getName());
+            skillMessage.component(
+                JoiningReplacement.builder()
+                    .component(Replacements.ofWiki(skill.getName()))
+                    .component(Replacements.ofText(String.format(" to %s XP", QuantityFormatter.formatNumber(xp))))
+                    .build()
+            );
+        }
+        xpReached.clear();
+
+        String totalXp = QuantityFormatter.formatNumber(client.getOverallExperience());
+        String thumbnail = n == 1 ? getSkillIcon(milestones.get(0)) : null;
+        Template fullNotification = Template.builder()
+            .template(config.levelNotifyMessage())
+            .replacementBoundary("%")
+            .replacement("%USERNAME%", Replacements.ofText(Utils.getPlayerName(client)))
+            .replacement("%SKILL%", skillMessage.build())
+            .replacement("%TOTAL_LEVEL%", Replacements.ofText(String.valueOf(client.getTotalLevel())))
+            .replacement("%TOTAL_XP%", Replacements.ofText(totalXp))
+            .build();
+
+        createMessage(config.levelSendImage(), NotificationBody.builder()
+            .text(fullNotification)
+            .extra(new XpNotificationData(current, milestones, interval))
+            .type(NotificationType.XP_MILESTONE)
+            .thumbnailUrl(thumbnail)
+            .build());
+    }
+
+    private void notifyLevels() {
+        int n = levelledSkills.size();
+        if (n == 0) return;
+
         // Prepare level state
         int totalLevel = client.getTotalLevel();
-        List<String> levelled = new ArrayList<>(levelledSkills.size());
-        levelledSkills.drainTo(levelled);
-        int count = levelled.size();
+        List<String> levelled = new ArrayList<>(n);
+        int count = levelledSkills.drainTo(levelled);
+        if (count == 0) return;
+
         Map<String, Integer> lSkills = new HashMap<>(count);
         Map<String, Integer> currentLevels = new HashMap<>(this.currentLevels);
 
@@ -232,6 +313,7 @@ public class LevelNotifier extends BaseNotifier {
             .replacement("%USERNAME%", Replacements.ofText(Utils.getPlayerName(client)))
             .replacement("%SKILL%", skillMessage.build())
             .replacement("%TOTAL_LEVEL%", Replacements.ofText(String.valueOf(totalLevel)))
+            .replacement("%TOTAL_XP%", Replacements.ofText(QuantityFormatter.formatNumber(client.getOverallExperience())))
             .build();
 
         // Fire notification
diff --git a/src/main/java/dinkplugin/notifiers/data/XpNotificationData.java b/src/main/java/dinkplugin/notifiers/data/XpNotificationData.java
new file mode 100644
index 0000000..4d5110f
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/XpNotificationData.java
@@ -0,0 +1,15 @@
+package dinkplugin.notifiers.data;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.Collection;
+import java.util.Map;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class XpNotificationData extends NotificationData {
+    Map<String, Integer> xpData;
+    Collection<String> milestoneAchieved;
+    int interval;
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
index d6b08c1..8796f2a 100644
--- a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
@@ -7,6 +7,7 @@ import dinkplugin.message.NotificationType;
 import dinkplugin.message.templating.Replacements;
 import dinkplugin.message.templating.Template;
 import dinkplugin.notifiers.data.LevelNotificationData;
+import dinkplugin.notifiers.data.XpNotificationData;
 import net.runelite.api.Experience;
 import net.runelite.api.Skill;
 import net.runelite.api.events.StatChanged;
@@ -17,6 +18,8 @@ import org.mockito.InjectMocks;
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -55,6 +58,8 @@ class LevelNotifierTest extends MockedNotifierTest {
         mockLevel(Skill.HITPOINTS, 10);
         mockLevel(Skill.HUNTER, 4);
         mockLevel(Skill.SLAYER, 96);
+        mockLevel(Skill.FARMING, 99);
+        when(client.getSkillExperience(Skill.FARMING)).thenReturn(Experience.MAX_SKILL_XP);
         initialCombatLevel = Experience.getCombatLevel(99, 1, 1, 10, 1, 1, 1);
         unchangedCombatLevel = new LevelNotificationData.CombatLevel(initialCombatLevel, false);
         plugin.onStatChanged(new StatChanged(Skill.AGILITY, 0, 1, 1));
@@ -62,6 +67,7 @@ class LevelNotifierTest extends MockedNotifierTest {
         plugin.onStatChanged(new StatChanged(Skill.HITPOINTS, 1200, 10, 10));
         plugin.onStatChanged(new StatChanged(Skill.HUNTER, 300, 4, 4));
         plugin.onStatChanged(new StatChanged(Skill.SLAYER, 9_800_000, 96, 96));
+        plugin.onStatChanged(new StatChanged(Skill.FARMING, Experience.MAX_SKILL_XP, 99, 99));
         notifier.onTick();
     }
 
@@ -101,7 +107,7 @@ class LevelNotifierTest extends MockedNotifierTest {
         Map<String, Integer> expectedSkills = skillsMap("Hunter", 6);
 
         // fire skill event (4 => 6, skipping 5 while 5 is level interval)
-        plugin.onStatChanged(new StatChanged(Skill.HUNTER, 200, 6, 6));
+        plugin.onStatChanged(new StatChanged(Skill.HUNTER, 600, 6, 6));
 
         // let ticks pass
         IntStream.range(0, 4).forEach(i -> notifier.onTick());
@@ -308,6 +314,170 @@ class LevelNotifierTest extends MockedNotifierTest {
         );
     }
 
+    @Test
+    void testNotifyXp() {
+        // update config mocks
+        when(config.xpInterval()).thenReturn(5);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // prepare state
+        int attackXp = 15_000_100;
+        when(client.getSkillExperience(Skill.ATTACK)).thenReturn(attackXp);
+        Map<String, Integer> map = xpMap();
+        long total = map.values().stream().mapToInt(i -> i).sum();
+        when(client.getOverallExperience()).thenReturn(total);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.ATTACK, attackXp, 99, 99));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(PLAYER_NAME + " has levelled {{skill}} to 15,000,000 XP")
+                        .replacement("{{skill}}", Replacements.ofWiki("Attack"))
+                        .build()
+                )
+                .extra(new XpNotificationData(map, List.of(Skill.ATTACK.getName()), 5_000_000))
+                .type(NotificationType.XP_MILESTONE)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyXpExact() {
+        // update config mocks
+        when(config.xpInterval()).thenReturn(5);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // prepare state
+        int attackXp = 15_000_000;
+        when(client.getSkillExperience(Skill.ATTACK)).thenReturn(attackXp);
+        Map<String, Integer> map = xpMap();
+        long total = map.values().stream().mapToInt(i -> i).sum();
+        when(client.getOverallExperience()).thenReturn(total);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.ATTACK, attackXp, 99, 99));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(PLAYER_NAME + " has levelled {{skill}} to 15,000,000 XP")
+                        .replacement("{{skill}}", Replacements.ofWiki("Attack"))
+                        .build()
+                )
+                .extra(new XpNotificationData(map, List.of(Skill.ATTACK.getName()), 5_000_000))
+                .type(NotificationType.XP_MILESTONE)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyXpJump() {
+        // update config mocks
+        when(config.xpInterval()).thenReturn(5);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // prepare state
+        int attackXp = 20_000_999;
+        when(client.getSkillExperience(Skill.ATTACK)).thenReturn(attackXp);
+        Map<String, Integer> map = xpMap();
+        long total = map.values().stream().mapToInt(i -> i).sum();
+        when(client.getOverallExperience()).thenReturn(total);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.ATTACK, attackXp, 99, 99));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(PLAYER_NAME + " has levelled {{skill}} to 20,000,000 XP")
+                        .replacement("{{skill}}", Replacements.ofWiki("Attack"))
+                        .build()
+                )
+                .extra(new XpNotificationData(map, List.of(Skill.ATTACK.getName()), 5_000_000))
+                .type(NotificationType.XP_MILESTONE)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyXpMultiple() {
+        // update config mocks
+        when(config.xpInterval()).thenReturn(5);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // prepare state
+        int skillXp = 15_000_100;
+        when(client.getSkillExperience(Skill.ATTACK)).thenReturn(skillXp);
+        when(client.getSkillExperience(Skill.SLAYER)).thenReturn(skillXp);
+
+        Map<String, Integer> map = xpMap();
+        long total = map.values().stream().mapToInt(i -> i).sum();
+        when(client.getOverallExperience()).thenReturn(total);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.ATTACK, skillXp, 99, 99));
+        plugin.onStatChanged(new StatChanged(Skill.SLAYER, skillXp, 99, 99));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(PLAYER_NAME + " has levelled {{s1}} to 15,000,000 XP, {{s2}} to 15,000,000 XP")
+                        .replacement("{{s1}}", Replacements.ofWiki("Attack"))
+                        .replacement("{{s2}}", Replacements.ofWiki("Slayer"))
+                        .build()
+                )
+                .extra(new XpNotificationData(map, List.of(Skill.ATTACK.getName(), Skill.SLAYER.getName()), 5_000_000))
+                .type(NotificationType.XP_MILESTONE)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreXpMax() {
+        // update config mocks
+        when(config.xpInterval()).thenReturn(5);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.FARMING, Experience.MAX_SKILL_XP, 99, 99));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
     @Test
     @DisplayName("Ensure the combat level notification isn't fired when notifyLevel is disabled")
     void testDisabledCombatLevel() {
@@ -441,10 +611,25 @@ class LevelNotifierTest extends MockedNotifierTest {
 
     private Map<String, Integer> skillsMap(String[] skills, int[] updatedLevels) {
         Map<String, Integer> m = Arrays.stream(Skill.values())
-            .collect(Collectors.toMap(Skill::getName, client::getRealSkillLevel));
+            .collect(Collectors.toMap(Skill::getName, skill -> {
+                int lvl = client.getRealSkillLevel(skill);
+                if (lvl < 99) return lvl;
+                int xp = client.getSkillExperience(skill);
+                if (xp == Experience.MAX_SKILL_XP) return LevelNotifier.LEVEL_FOR_MAX_XP;
+                return Experience.getLevelForXp(xp);
+            }));
         for (int i = 0; i < skills.length; i++) {
             m.put(skills[i], updatedLevels[i]);
         }
         return m;
     }
+
+    private Map<String, Integer> xpMap() {
+        Skill[] skills = Skill.values();
+        Map<String, Integer> m = new HashMap<>(skills.length);
+        for (Skill skill : skills) {
+            m.put(skill.getName(), client.getSkillExperience(skill));
+        }
+        return m;
+    }
 }