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());