Repository: pajlads/DinkPlugin (java)

Issue:
200M experience notification
Currently, our post-99 notifications are enabled with the "Notify on Virtual Levels" option - I feel like this option should also notify when the user reaches 200M experience

There are some things that would need to be tested in-game for this feature to make sure it doesn't continue to notify

1. When a user has 199,999,999 XP and receives 2 XP
2. When a user has 200,000,000 XP - what do further skill change events look like?

Pull request description:
feat(level): send notification upon 200M xp
Closes #244

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/1bbd2ed3-e59b-4c55-bfa1-418947f7f63b)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40fc47e..276ca09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Fire level notification upon reaching max skill experience of 200M. (#273)
 - Minor: Include clan name in notificiation metadata. (#274)
 - Dev: Update gradle wrapper to v8.2.1 patch version. (#276)
 
diff --git a/README.md b/README.md
index bd86115..1a10461 100644
--- a/README.md
+++ b/README.md
@@ -270,6 +270,8 @@ Lastly, Dink makes exceptions for Inferno and TzHaar Fight Cave; deaths in these
 }
 ```
 
+Note: Level 127 in JSON corresponds to attaining max experience in a skill (200M).
+
 </details>
 
 ### Loot:
diff --git a/src/main/java/dinkplugin/notifiers/LevelNotifier.java b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
index 1db40a1..7a94db5 100644
--- a/src/main/java/dinkplugin/notifiers/LevelNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
@@ -32,6 +32,7 @@ import static net.runelite.api.Experience.MAX_REAL_LEVEL;
 @Slf4j
 @Singleton
 public class LevelNotifier extends BaseNotifier {
+    public static final int LEVEL_FOR_MAX_XP = Experience.MAX_VIRT_LEVEL + 1; // 127
     private static final int INIT_CLIENT_TICKS = 50; // 1000ms
     private static final String COMBAT_NAME = "Combat";
     private static final Set<String> COMBAT_COMPONENTS;
@@ -62,8 +63,7 @@ public class LevelNotifier extends BaseNotifier {
             for (Skill skill : Skill.values()) {
                 int level = client.getRealSkillLevel(skill); // O(1)
                 if (level >= MAX_REAL_LEVEL) {
-                    // uses log(n) operation to support virtual levels
-                    level = Experience.getLevelForXp(client.getSkillExperience(skill));
+                    level = getLevel(client.getSkillExperience(skill));
                 }
                 currentLevels.put(skill.getName(), level);
             }
@@ -112,7 +112,7 @@ public class LevelNotifier extends BaseNotifier {
     private void handleLevelUp(String skill, int level, int xp) {
         if (!isEnabled()) return;
 
-        int virtualLevel = level < MAX_REAL_LEVEL ? level : Experience.getLevelForXp(xp); // avoid log(n) query when not needed
+        int virtualLevel = level < MAX_REAL_LEVEL ? level : getLevel(xp); // avoid log(n) query when not needed
         Integer previousLevel = currentLevels.put(skill, virtualLevel);
 
         if (previousLevel == null) {
@@ -192,7 +192,7 @@ public class LevelNotifier extends BaseNotifier {
             Integer level = currentLevels.get(skill);
             skillMessage
                 .component(Replacements.ofWiki(skill, COMBAT_NAME.equals(skill) ? "Combat level" : skill))
-                .component(Replacements.ofText(" to " + level));
+                .component(Replacements.ofText(" to " + (level < LEVEL_FOR_MAX_XP ? level : "Max XP (200M)")));
             lSkills.put(skill, level);
         }
 
@@ -248,7 +248,7 @@ public class LevelNotifier extends BaseNotifier {
             return false;
 
         int interval = config.levelInterval();
-        if (interval <= 1 || level == MAX_REAL_LEVEL)
+        if (interval <= 1 || level == MAX_REAL_LEVEL || level == LEVEL_FOR_MAX_XP)
             return true;
 
         int intervalOverride = config.levelIntervalOverride();
@@ -280,6 +280,15 @@ public class LevelNotifier extends BaseNotifier {
             : client.getRealSkillLevel(skill);
     }
 
+    private int getLevel(int xp) {
+        // treat 200M XP as level 127
+        if (xp >= Experience.MAX_SKILL_XP)
+            return LEVEL_FOR_MAX_XP;
+
+        // log(n) operation to support virtual levels
+        return Experience.getLevelForXp(xp);
+    }
+
     private static String getSkillIcon(String skillName) {
         return Utils.WIKI_IMG_BASE_URL + skillName + "_icon.png";
     }
diff --git a/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
index 91f6b97..fc6455a 100644
--- a/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
@@ -11,6 +11,7 @@ import java.util.Map;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
 
+import static dinkplugin.notifiers.LevelNotifier.LEVEL_FOR_MAX_XP;
 import static net.runelite.api.Experience.MAX_REAL_LEVEL;
 
 @Value
@@ -23,15 +24,19 @@ public class LevelNotificationData extends NotificationData {
     @Override
     public List<Field> getFields() {
         if (levelledSkills.containsValue(MAX_REAL_LEVEL)) {
-            Collection<String> maxed = allSkills.entrySet().stream()
-                .filter(e -> e.getValue() >= MAX_REAL_LEVEL)
-                .map(Map.Entry::getKey)
-                .collect(Collectors.toCollection(TreeSet::new));
-
             return Collections.singletonList(
                 new Field(
                     "Total Skills at Level 99+",
-                    Field.formatBlock("", maxed.size() + ": " + String.join(", ", maxed))
+                    collectMaxedSkills(MAX_REAL_LEVEL)
+                )
+            );
+        }
+
+        if (levelledSkills.containsValue(LEVEL_FOR_MAX_XP)) {
+            return Collections.singletonList(
+                new Field(
+                    "Total Skills at Max XP",
+                    collectMaxedSkills(LEVEL_FOR_MAX_XP)
                 )
             );
         }
@@ -39,6 +44,14 @@ public class LevelNotificationData extends NotificationData {
         return super.getFields();
     }
 
+    private String collectMaxedSkills(int minLevel) {
+        Collection<String> maxed = allSkills.entrySet().stream()
+            .filter(e -> e.getValue() >= minLevel)
+            .map(Map.Entry::getKey)
+            .collect(Collectors.toCollection(TreeSet::new));
+        return Field.formatBlock("", maxed.size() + ": " + String.join(", ", maxed));
+    }
+
     @Value
     public static class CombatLevel {
         int value;


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
index 68246b3..80d7252 100644
--- a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
@@ -145,6 +145,33 @@ class LevelNotifierTest extends MockedNotifierTest {
         );
     }
 
+    @Test
+    void testNotifyMaxExperience() {
+        Map<String, Integer> expectedSkills = skillsMap("Hunter", 127);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.HUNTER, 200_000_000, 99, 126));
+
+        // 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 Max XP (200M)")
+                        .replacement("{{skill}}", Replacements.ofWiki("Hunter"))
+                        .build()
+                )
+                .extra(new LevelNotificationData(ImmutableMap.of("Hunter", 127), expectedSkills, unchangedCombatLevel))
+                .type(NotificationType.LEVEL)
+                .build()
+        );
+    }
+
     @Test
     void testNotifyTwo() {
         Map<String, Integer> expectedSkills = skillsMap(
@@ -348,6 +375,41 @@ class LevelNotifierTest extends MockedNotifierTest {
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
     }
 
+    @Test
+    void testIgnoreMaxExperience() {
+        // update config mock
+        when(config.levelNotifyVirtual()).thenReturn(false);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(Skill.HUNTER, 200_000_000, 99, 126));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreAlreadyMaxExperience() {
+        // update skill mocks
+        Skill skill = Skill.CONSTRUCTION;
+        when(client.getRealSkillLevel(skill)).thenReturn(99);
+        when(client.getSkillExperience(skill)).thenReturn(Experience.MAX_SKILL_XP);
+        when(config.levelNotifyVirtual()).thenReturn(false);
+        plugin.onStatChanged(new StatChanged(skill, Experience.MAX_SKILL_XP, 99, 126));
+        when(config.levelNotifyVirtual()).thenReturn(true);
+
+        // fire skill event
+        plugin.onStatChanged(new StatChanged(skill, 200_000_001, 99, 126));
+
+        // let ticks pass
+        IntStream.range(0, 4).forEach(i -> notifier.onTick());
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
     @Test
     void testDisabled() {
         // disable notifier