diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 79508a22..cb0ff87c 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -744,7 +744,7 @@ default boolean levelNotifyCombat() {
@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 +776,37 @@ default int levelIntervalOverride() {
return 0;
}
+ @ConfigItem(
+ keyName = "xpInterval",
+ name = "XP Interval",
+ description = "XP interval at which to fire notifications (disabled if set to 0)",
+ position = 27,
+ section = levelSection
+ )
+ default int xpInterval() {
+ return 5_000_000;
+ }
+
+ @ConfigItem(
+ keyName = "ignoreXpBelowMax",
+ name = "Skip XP Interval below Lvl 99",
+ description = "Prevent XP interval notifications for levels below 99",
+ position = 28,
+ section = levelSection
+ )
+ default boolean ignoreXpBelowMax() {
+ return true;
+ }
+
@ConfigItem(
keyName = "levelNotifMessage",
name = "Notification Message",
description = "The message to be sent through the webhook.
" +
"Use %USERNAME% to insert your username
" +
"Use %SKILL% to insert the levelled skill(s)
" +
- "Use %TOTAL_LEVEL% to insert the updated total level",
- position = 27,
+ "Use %TOTAL_LEVEL% to insert the updated total level
" +
+ "Use %TOTAL_XP% to insert the updated overall experience",
+ position = 29,
section = levelSection
)
default String levelNotifyMessage() {
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index d438e41c..c649b515 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("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 83a1c9ed..cfa49c8e 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.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,14 @@
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.Collection;
+import java.util.EnumMap;
+import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -38,7 +43,9 @@ public class LevelNotifier extends BaseNotifier {
private static final String COMBAT_NAME = "Combat";
private static final Set COMBAT_COMPONENTS;
private final BlockingQueue levelledSkills = new ArrayBlockingQueue<>(Skill.values().length + 1);
+ private final Collection xpReached = EnumSet.noneOf(Skill.class);
private final Map currentLevels = new HashMap<>();
+ private final Map 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 +60,13 @@ protected String getWebhookUrl() {
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 +78,9 @@ public void reset() {
initTicks.set(0);
levelledSkills.clear();
ticksWaited.set(0);
+ clientThread.invoke(xpReached::clear);
clientThread.invoke(currentLevels::clear);
+ clientThread.invokeLater(currentXp::clear);
}
public void onTick() {
@@ -83,7 +94,7 @@ public void onTick() {
return;
}
- if (levelledSkills.isEmpty()) {
+ if (levelledSkills.isEmpty() && xpReached.isEmpty()) {
return;
}
@@ -101,7 +112,7 @@ public void onTick() {
}
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 +121,44 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) {
}
}
- 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);
+ boolean enabled = config.notifyLevel();
+ checkLevelUp(enabled, skillName, previousLevel, virtualLevel);
+
+ // Check if xp milestone reached
+ int xpInterval = config.xpInterval();
+ if (enabled && xpInterval > 0 && (level >= MAX_REAL_LEVEL || !config.ignoreXpBelowMax())) {
+ int remainder = xp % xpInterval;
+ if (remainder == 0 || xp - remainder > previousXp) {
+ 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,7 +168,7 @@ private void handleLevelUp(String skill, int level, int xp) {
}
// 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);
@@ -170,11 +200,60 @@ private void checkLevelUp(boolean configEnabled, String skill, Integer previousL
}
private void attemptNotify() {
+ notifyLevels();
+ notifyXp();
+ }
+
+ private void notifyXp() {
+ final int n = xpReached.size();
+ if (n == 0) return;
+
+ int interval = config.xpInterval();
+ Map current = new EnumMap<>(currentXp);
+ List milestones = new ArrayList<>(n);
+ JoiningReplacement.JoiningReplacementBuilder skillMessage = JoiningReplacement.builder().delimiter(", ");
+ for (Skill skill : xpReached) {
+ int xp = current.getOrDefault(skill, 0);
+ xp -= xp % interval;
+ milestones.add(skill);
+ 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).getName()) : 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, totalXp))
+ .type(NotificationType.XP)
+ .thumbnailUrl(thumbnail)
+ .build());
+ }
+
+ private void notifyLevels() {
+ int n = levelledSkills.size();
+ if (n == 0) return;
+
// Prepare level state
int totalLevel = client.getTotalLevel();
- List levelled = new ArrayList<>(levelledSkills.size());
- levelledSkills.drainTo(levelled);
- int count = levelled.size();
+ List levelled = new ArrayList<>(n);
+ int count = levelledSkills.drainTo(levelled);
+ if (count == 0) return;
+
Map lSkills = new HashMap<>(count);
Map currentLevels = new HashMap<>(this.currentLevels);
@@ -232,6 +311,7 @@ private void attemptNotify() {
.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 00000000..33c619cc
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/XpNotificationData.java
@@ -0,0 +1,41 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.message.Field;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import net.runelite.api.Experience;
+import net.runelite.api.Skill;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class XpNotificationData extends NotificationData {
+ Map xpData;
+ Collection milestoneAchieved;
+ int interval;
+ transient String totalXp;
+
+ @Override
+ public List getFields() {
+ List fields = new ArrayList<>(2);
+ fields.add(new Field("Total XP", Field.formatBlock("", totalXp)));
+
+ SortedSet maxed = new TreeSet<>();
+ for (var entry : xpData.entrySet()) {
+ if (entry.getValue() >= Experience.MAX_SKILL_XP) {
+ maxed.add(entry.getKey().getName());
+ }
+ }
+ if (!maxed.isEmpty()) {
+ fields.add(new Field("Maxed Skills", Field.formatBlock("", String.join(", ", maxed))));
+ }
+
+ return fields;
+ }
+}
diff --git a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
index d6b08c10..02a983f9 100644
--- a/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LevelNotifierTest.java
@@ -101,7 +101,7 @@ void testNotifyJump() {
Map 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());