diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f340df3..ee66a13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- Major: Add leagues notifier for areas, relics, and tasks. (#366) + ## 1.6.5 - Minor: Allow notifications on seasonal worlds to be ignored via advanced config. (#357) diff --git a/README.md b/README.md index 4eb88045..28575bb0 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w - [Player Kills](#player-kills): Sends a webhook message upon killing another player (while hitsplats are still visible) - [Group Storage](#group-storage): Sends a webhook message upon Group Ironman Shared Bank transactions (i.e., depositing or withdrawing items) - [Grand Exchange](#grand-exchange): Sends a webhook message upon buying or selling items on the GE (with customizable value threshold) +- [Leagues](#leagues): Sends a webhook message upon completing a Leagues IV task or unlocking a region/relic ## Other Setup @@ -759,6 +760,112 @@ See [javadocs](https://static.runelite.net/api/runelite-api/net/runelite/api/Gra +### Leagues: + +Leagues notifications include: region unlocked, relic unlocked, and task completed (with customizable difficulty threshold). + +Each of these events can be independently enabled or disabled in the notifier settings. + +
+ JSON for Area Unlock Notifications: + +```json5 +{ + "type": "LEAGUES_AREA", + "content": "%USERNAME% selected their second region: Kandarin.", + "playerName": "%USERNAME%", + "accountType": "IRONMAN", + "seasonalWorld": true, + "extra": { + "area": "Kandarin", + "index": 2, + "tasksCompleted": 200, + "tasksUntilNextArea": 200 + } +} +``` + +Note: `index` refers to the order of region unlocks. +Here, Kandarin was the second region selected. +For all players, Karamja is the _zeroth_ region selected (and there is no notification for Misthalin). + +
+ +
+ JSON for Relic Chosen Notifications: + +```json5 +{ + "type": "LEAGUES_RELIC", + "content": "%USERNAME% unlocked a Tier 1 Relic: Production Prodigy.", + "playerName": "%USERNAME%", + "accountType": "IRONMAN", + "seasonalWorld": true, + "extra": { + "relic": "Production Prodigy", + "tier": 1, + "requiredPoints": 0, + "totalPoints": 20, + "pointsUntilNextTier": 480 + } +} +``` + +
+ +
+ JSON for Task Completed Notifications: + +```json5 +{ + "type": "LEAGUES_TASK", + "content": "%USERNAME% completed a Easy task: Pickpocket a Citizen.", + "playerName": "%USERNAME%", + "accountType": "IRONMAN", + "seasonalWorld": true, + "extra": { + "taskName": "Pickpocket a Citizen", + "difficulty": "EASY", + "taskPoints": 10, + "totalPoints": 30, + "tasksCompleted": 3, + "pointsUntilNextRelic": 470, + "pointsUntilNextTrophy": 2470 + } +} +``` + +
+ +
+ JSON for Task Notifications that unlocked a Trophy: + +```json5 +{ + "type": "LEAGUES_TASK", + "content": "%USERNAME% completed a Hard task, The Frozen Door, unlocking the Bronze trophy!", + "playerName": "%USERNAME%", + "accountType": "IRONMAN", + "seasonalWorld": true, + "extra": { + "taskName": "The Frozen Door", + "difficulty": "HARD", + "taskPoints": 80, + "totalPoints": 2520, + "tasksCompleted": 119, + "tasksUntilNextArea": 81, + "pointsUntilNextRelic": 1480, + "pointsUntilNextTrophy": 2480, + "earnedTrophy": "Bronze" + } +} +``` + +
+ +Note: Fields like `tasksUntilNextArea`, `pointsUntilNextRelic`, and `pointsUntilNextTrophy` can be omitted +if there is no next level of progression (i.e., all three regions selected, all relic tiers unlocked, all trophies acquired). + ### Metadata: On login, Dink can submit a character summary containing data that spans multiple notifiers to a custom webhook handler (configurable in the `Advanced` section). This login notification is delayed by at least 5 seconds in order to gather all of the relevant data. However, `collectionLog` data can be missing if the user does not have the Character Summary tab selected (since the client otherwise is not sent that data). diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java index 93aacbef..4b1792b4 100644 --- a/src/main/java/dinkplugin/DinkPlugin.java +++ b/src/main/java/dinkplugin/DinkPlugin.java @@ -10,6 +10,7 @@ import dinkplugin.notifiers.GrandExchangeNotifier; import dinkplugin.notifiers.GroupStorageNotifier; import dinkplugin.notifiers.KillCountNotifier; +import dinkplugin.notifiers.LeaguesNotifier; import dinkplugin.notifiers.LevelNotifier; import dinkplugin.notifiers.MetaNotifier; import dinkplugin.notifiers.LootNotifier; @@ -82,6 +83,7 @@ public class DinkPlugin extends Plugin { private @Inject PlayerKillNotifier pkNotifier; private @Inject GroupStorageNotifier groupStorageNotifier; private @Inject GrandExchangeNotifier grandExchangeNotifier; + private @Inject LeaguesNotifier leaguesNotifier; private @Inject MetaNotifier metaNotifier; @Override @@ -186,6 +188,7 @@ public void onChatMessage(ChatMessage message) { combatTaskNotifier.onGameMessage(chatMessage); deathNotifier.onGameMessage(chatMessage); speedrunNotifier.onGameMessage(chatMessage); + leaguesNotifier.onGameMessage(chatMessage); break; case FRIENDSCHATNOTIFICATION: diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java index 272b2051..d4160150 100644 --- a/src/main/java/dinkplugin/DinkPluginConfig.java +++ b/src/main/java/dinkplugin/DinkPluginConfig.java @@ -4,6 +4,7 @@ import dinkplugin.domain.ClueTier; import dinkplugin.domain.CombatAchievementTier; import dinkplugin.domain.FilterMode; +import dinkplugin.domain.LeagueTaskDifficulty; import dinkplugin.domain.PlayerLookupService; import net.runelite.client.config.Config; import net.runelite.client.config.ConfigGroup; @@ -151,6 +152,14 @@ public interface DinkPluginConfig extends Config { ) String grandExchangeSection = "Grand Exchange"; + @ConfigSection( + name = "Leagues", + description = "Settings for notifying when you complete league tasks, unlock areas, and redeem relics", + position = 200, + closedByDefault = true + ) + String leaguesSection = "Leagues"; + @ConfigSection( name = "Advanced", description = "Do not modify without fully understanding these settings", @@ -351,7 +360,8 @@ default String metadataWebhook() { @ConfigItem( keyName = "ignoreSeasonalWorlds", name = "Ignore Seasonal Worlds", - description = "Whether to suppress notifications that occur on seasonal worlds like Leagues", + description = "Whether to suppress notifications that occur on seasonal worlds like Leagues.
" + + "Note: the Leagues-specific notifier uses an independent config toggle", position = 1015, section = advancedSection ) @@ -546,6 +556,18 @@ default String grandExchangeWebhook() { return ""; } + @ConfigItem( + keyName = "leaguesWebhook", + name = "Leagues Webhook Override", + description = "If non-empty, Leagues messages are sent to this URL, instead of the primary URL.
" + + "Note: this only applies to the Leagues notifier, not every notifier in a seasonal world", + position = -1, + section = webhookSection + ) + default String leaguesWebhook() { + return ""; + } + @ConfigItem( keyName = "collectionLogEnabled", name = "Enable collection log", @@ -1660,4 +1682,70 @@ default String grandExchangeNotifyMessage() { return "%USERNAME% %TYPE% %ITEM% on the GE"; } + @ConfigItem( + keyName = "notifyLeagues", + name = "Enable Leagues", + description = "Enable notifications upon various leagues events", + position = 200, + section = leaguesSection + ) + default boolean notifyLeagues() { + return false; + } + + @ConfigItem( + keyName = "leaguesSendImage", + name = "Send Image", + description = "Send image with the notification", + position = 201, + section = leaguesSection + ) + default boolean leaguesSendImage() { + return true; + } + + @ConfigItem( + keyName = "leaguesAreaUnlock", + name = "Send Area Unlocks", + description = "Send notifications upon area unlocks", + position = 202, + section = leaguesSection + ) + default boolean leaguesAreaUnlock() { + return true; + } + + @ConfigItem( + keyName = "leaguesRelicUnlock", + name = "Send Relic Unlocks", + description = "Send notifications upon relic unlocks", + position = 203, + section = leaguesSection + ) + default boolean leaguesRelicUnlock() { + return true; + } + + @ConfigItem( + keyName = "leaguesTaskCompletion", + name = "Send Completed Tasks", + description = "Send notifications upon completing a task", + position = 204, + section = leaguesSection + ) + default boolean leaguesTaskCompletion() { + return true; + } + + @ConfigItem( + keyName = "leaguesTaskMinTier", + name = "Task Min Difficulty", + description = "The minimum tier of a task for a notification to be sent", + position = 205, + section = leaguesSection + ) + default LeagueTaskDifficulty leaguesTaskMinTier() { + return LeagueTaskDifficulty.EASY; + } + } diff --git a/src/main/java/dinkplugin/domain/LeagueRelicTier.java b/src/main/java/dinkplugin/domain/LeagueRelicTier.java new file mode 100644 index 00000000..1ae9d4b4 --- /dev/null +++ b/src/main/java/dinkplugin/domain/LeagueRelicTier.java @@ -0,0 +1,38 @@ +package dinkplugin.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Collections; +import java.util.NavigableMap; +import java.util.TreeMap; + +@Getter +@RequiredArgsConstructor +public enum LeagueRelicTier { + ONE(0), + TWO(500), + THREE(1_200), + FOUR(2_000), + FIVE(4_000), + SIX(7_500), + SEVEN(15_000), + EIGHT(24_000); + + /** + * Points required to unlock a relic of a given tier. + * + * @see Wiki Reference + */ + private final int points; + + public static final NavigableMap TIER_BY_POINTS; + + static { + NavigableMap tiers = new TreeMap<>(); + for (LeagueRelicTier tier : values()) { + tiers.put(tier.getPoints(), tier); + } + TIER_BY_POINTS = Collections.unmodifiableNavigableMap(tiers); + } +} diff --git a/src/main/java/dinkplugin/domain/LeagueTaskDifficulty.java b/src/main/java/dinkplugin/domain/LeagueTaskDifficulty.java new file mode 100644 index 00000000..73d73215 --- /dev/null +++ b/src/main/java/dinkplugin/domain/LeagueTaskDifficulty.java @@ -0,0 +1,37 @@ +package dinkplugin.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Getter +@RequiredArgsConstructor +public enum LeagueTaskDifficulty { + EASY(10), + MEDIUM(40), + HARD(80), + ELITE(200), + MASTER(400); + + /** + * Points earned from completed a task of the given difficulty. + * + * @see Wiki Reference + */ + private final int points; + private final String displayName = this.name().charAt(0) + this.name().substring(1).toLowerCase(); + + @Override + public String toString() { + return this.displayName; + } + + public static final Map TIER_BY_LOWER_NAME = Collections.unmodifiableMap( + Arrays.stream(values()).collect(Collectors.toMap(t -> t.name().toLowerCase(), Function.identity())) + ); +} diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java index d2d029bd..be712525 100644 --- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java +++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java @@ -219,12 +219,12 @@ private NotificationBody enrichBody(NotificationBody mBody, boolean sendIm } } - NotificationBody.NotificationBodyBuilder builder = mBody.toBuilder(); - - if (!config.ignoreSeasonal()) { - builder.seasonalWorld(client.getWorldType().contains(WorldType.SEASONAL)); + if (!config.ignoreSeasonal() && !mBody.isSeasonalWorld() && client.getWorldType().contains(WorldType.SEASONAL)) { + mBody = mBody.withSeasonalWorld(true); } + NotificationBody.NotificationBodyBuilder builder = mBody.toBuilder(); + if (config.sendDiscordUser()) { builder.discordUser(DiscordProfile.of(discordService.getCurrentUser())); } @@ -333,7 +333,7 @@ private static List computeEmbeds(@NotNull NotificationBody body, bool Author author = Author.builder() .name(body.getPlayerName()) .url(playerLookupService.getPlayerUrl(body.getPlayerName())) - .iconUrl(Utils.getChatBadge(body.getAccountType())) + .iconUrl(Utils.getChatBadge(body.getAccountType(), body.isSeasonalWorld())) .build(); Footer footer = StringUtils.isBlank(footerText) ? null : Footer.builder() .text(Utils.truncate(footerText, Embed.MAX_FOOTER_LENGTH)) diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java index dfd949ad..67319369 100644 --- a/src/main/java/dinkplugin/message/NotificationType.java +++ b/src/main/java/dinkplugin/message/NotificationType.java @@ -24,6 +24,9 @@ public enum NotificationType { PLAYER_KILL("Player Kill", "playerKillImage.png", WIKI_IMG_BASE_URL + "Skull_(status)_icon.png"), GROUP_STORAGE("Group Shared Storage", "groupStorage.png", WIKI_IMG_BASE_URL + "Coins_10000.png"), GRAND_EXCHANGE("Grand Exchange", "grandExchange.png", WIKI_IMG_BASE_URL + "Grand_Exchange_icon.png"), + LEAGUES_AREA("Area Unlocked", "leaguesArea.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_-_%3F_Relic.png"), + LEAGUES_RELIC("Relic Chosen", "leaguesRelic.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_-_relics_icon.png"), + 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"); private final String title; diff --git a/src/main/java/dinkplugin/notifiers/LeaguesNotifier.java b/src/main/java/dinkplugin/notifiers/LeaguesNotifier.java new file mode 100644 index 00000000..375b2046 --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/LeaguesNotifier.java @@ -0,0 +1,289 @@ +package dinkplugin.notifiers; + +import dinkplugin.domain.LeagueRelicTier; +import dinkplugin.domain.LeagueTaskDifficulty; +import dinkplugin.message.NotificationBody; +import dinkplugin.message.NotificationType; +import dinkplugin.message.templating.Replacements; +import dinkplugin.message.templating.Template; +import dinkplugin.notifiers.data.LeaguesAreaNotificationData; +import dinkplugin.notifiers.data.LeaguesRelicNotificationData; +import dinkplugin.notifiers.data.LeaguesTaskNotificationData; +import dinkplugin.util.Utils; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.WorldType; +import net.runelite.api.annotations.Varbit; +import net.runelite.api.annotations.Varp; +import org.jetbrains.annotations.VisibleForTesting; + +import java.util.Collections; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class LeaguesNotifier extends BaseNotifier { + private static final String AREA_UNLOCK_PREFIX = "Congratulations, you've unlocked a new area: "; + private static final String RELIC_UNLOCK_PREFIX = "Congratulations, you've unlocked a new Relic: "; + private static final Pattern TASK_REGEX = Pattern.compile("Congratulations, you've completed an? (?\\w+) task: (?.+)\\."); + + /** + * @see CS2 Reference + */ + @VisibleForTesting + static final @Varbit int TASKS_COMPLETED_ID = 10046; + + /** + * @see CS2 Reference + */ + @VisibleForTesting + static final @Varp int POINTS_EARNED_ID = 2614; + + /** + * @see CS2 Reference + */ + @VisibleForTesting + static final @Varbit int FIVE_AREAS = 10666, FOUR_AREAS = 10665, THREE_AREAS = 10664, TWO_AREAS = 10663; + + /** + * Trophy name by the required points, in a binary search tree. + * + * @see Wiki Reference + * @see CS2 Reference + */ + private static final NavigableMap TROPHY_BY_POINTS; + + /** + * Mapping of each relic name to the tier (1-8). + * + * @see Wiki Reference + */ + private static final Map TIER_BY_RELIC; + + /** + * Mapping of the number of tasks required to unlock an area to the area index (0-3). + * + * @see Wiki reference + */ + private static final NavigableMap AREA_BY_TASKS; + + @Override + public boolean isEnabled() { + return config.notifyLeagues() && + client.getWorldType().contains(WorldType.SEASONAL) && + settingsManager.isNamePermitted(client.getLocalPlayer().getName()); + } + + @Override + protected String getWebhookUrl() { + return config.leaguesWebhook(); + } + + public void onGameMessage(String message) { + if (!isEnabled()) { + return; + } + if (message.startsWith(AREA_UNLOCK_PREFIX)) { + if (config.leaguesAreaUnlock()) { + String area = message.substring(AREA_UNLOCK_PREFIX.length(), message.length() - 1); + notifyAreaUnlock(area); + } + } else if (message.startsWith(RELIC_UNLOCK_PREFIX)) { + if (config.leaguesRelicUnlock()) { + String relic = message.substring(RELIC_UNLOCK_PREFIX.length(), message.length() - 1); + notifyRelicUnlock(relic); + } + } else if (config.leaguesTaskCompletion()) { + Matcher matcher = TASK_REGEX.matcher(message); + if (matcher.find()) { + LeagueTaskDifficulty tier = LeagueTaskDifficulty.TIER_BY_LOWER_NAME.get(matcher.group("tier")); + if (tier != null && tier.ordinal() >= config.leaguesTaskMinTier().ordinal()) { + notifyTaskCompletion(tier, matcher.group("task")); + } + } + } + } + + private void notifyAreaUnlock(String area) { + Map.Entry unlocked = numAreasUnlocked(); + + int tasksCompleted = client.getVarbitValue(TASKS_COMPLETED_ID); + Integer tasksForNextArea = AREA_BY_TASKS.ceilingKey(tasksCompleted + 1); + Integer tasksUntilNextArea = tasksForNextArea != null ? tasksForNextArea - tasksCompleted : null; + + if (unlocked == null) { + int i = AREA_BY_TASKS.floorEntry(Math.max(tasksCompleted, 0)).getValue(); + unlocked = Map.entry(i, ith(i)); + } + + String playerName = Utils.getPlayerName(client); + Template text = Template.builder() + .template("%USERNAME% selected their %I_TH% region: %AREA%.") + .replacementBoundary("%") + .replacement("%USERNAME%", Replacements.ofText(playerName)) + .replacement("%I_TH%", Replacements.ofText(unlocked.getValue())) + .replacement("%AREA%", Replacements.ofWiki(area, "Trailblazer Reloaded League/Areas/" + area)) + .build(); + createMessage(config.leaguesSendImage(), NotificationBody.builder() + .type(NotificationType.LEAGUES_AREA) + .text(text) + .extra(new LeaguesAreaNotificationData(area, unlocked.getKey(), tasksCompleted, tasksUntilNextArea)) + .playerName(playerName) + .seasonalWorld(true) + .build()); + } + + private void notifyRelicUnlock(String relic) { + int points = client.getVarpValue(POINTS_EARNED_ID); + Integer pointsOfNextTier = LeagueRelicTier.TIER_BY_POINTS.ceilingKey(points + 1); + Integer pointsUntilNextTier = pointsOfNextTier != null ? pointsOfNextTier - points : null; + + LeagueRelicTier relicTier = TIER_BY_RELIC.get(relic); + if (relicTier == null) { + // shouldn't happen, but just to be safe + log.warn("Unknown relic encountered: {}", relic); + if (points >= 0) { + relicTier = LeagueRelicTier.TIER_BY_POINTS.floorEntry(points).getValue(); + } + } + Integer tier = relicTier != null ? relicTier.ordinal() + 1 : null; + Integer requiredPoints = relicTier != null ? relicTier.getPoints() : null; + + String playerName = Utils.getPlayerName(client); + Template text = Template.builder() + .template("%USERNAME% unlocked a Tier %TIER% Relic: %RELIC%.") + .replacementBoundary("%") + .replacement("%USERNAME%", Replacements.ofText(playerName)) + .replacement("%TIER%", Replacements.ofText(tier != null ? tier.toString() : "?")) + .replacement("%RELIC%", Replacements.ofWiki(relic)) + .build(); + createMessage(config.leaguesSendImage(), NotificationBody.builder() + .type(NotificationType.LEAGUES_RELIC) + .text(text) + .extra(new LeaguesRelicNotificationData(relic, tier, requiredPoints, points, pointsUntilNextTier)) + .playerName(playerName) + .seasonalWorld(true) + .build()); + } + + private void notifyTaskCompletion(LeagueTaskDifficulty tier, String task) { + int taskPoints = tier.getPoints(); + int totalPoints = client.getVarpValue(POINTS_EARNED_ID); + int tasksCompleted = client.getVarbitValue(TASKS_COMPLETED_ID); + String playerName = Utils.getPlayerName(client); + + Integer nextAreaTasks = AREA_BY_TASKS.ceilingKey(tasksCompleted + 1); + Integer tasksUntilNextArea = nextAreaTasks != null ? nextAreaTasks - tasksCompleted : null; + + Map.Entry trophy = TROPHY_BY_POINTS.floorEntry(totalPoints); + Integer prevTrophyPoints; + if (trophy != null) { + prevTrophyPoints = TROPHY_BY_POINTS.floorKey(totalPoints - taskPoints); + } else { + prevTrophyPoints = null; + } + boolean newTrophy = trophy != null && (prevTrophyPoints == null || trophy.getKey() > prevTrophyPoints); + String justEarnedTrophy = newTrophy ? trophy.getValue() : null; + Integer nextTrophyPoints = TROPHY_BY_POINTS.ceilingKey(totalPoints + 1); + Integer pointsUntilNextTrophy = nextTrophyPoints != null ? nextTrophyPoints - totalPoints : null; + + Integer nextRelicPoints = LeagueRelicTier.TIER_BY_POINTS.ceilingKey(totalPoints + 1); + Integer pointsUntilNextRelic = nextRelicPoints != null ? nextRelicPoints - totalPoints : null; + + Template text = Template.builder() + .template(newTrophy + ? "%USERNAME% completed a %TIER% task, %TASK%, unlocking the %TROPHY% trophy!" + : "%USERNAME% completed a %TIER% task: %TASK%.") + .replacementBoundary("%") + .replacement("%USERNAME%", Replacements.ofText(playerName)) + .replacement("%TIER%", Replacements.ofText(tier.getDisplayName())) + .replacement("%TASK%", Replacements.ofWiki(task, "Trailblazer_Reloaded_League/Tasks")) + .replacement("%TROPHY%", newTrophy + ? Replacements.ofWiki(trophy.getValue(), String.format("Trailblazer reloaded %s trophy", trophy.getValue().toLowerCase())) + : Replacements.ofText("?")) + .build(); + createMessage(config.leaguesSendImage(), NotificationBody.builder() + .type(NotificationType.LEAGUES_TASK) + .text(text) + .extra(new LeaguesTaskNotificationData(task, tier, taskPoints, totalPoints, tasksCompleted, tasksUntilNextArea, pointsUntilNextRelic, pointsUntilNextTrophy, justEarnedTrophy)) + .playerName(playerName) + .seasonalWorld(true) + .build()); + } + + /** + * @return the number of areas that have been unlocked as integer and human name + */ + private Map.Entry numAreasUnlocked() { + // While Jagex's code has 5 areas (2 default, 3 discretionary), + // most players think just in terms of the 3 discretionary areas, + // so we disregard Misthalin and consider Karamja as the zeroth area. + // Thus, the number of unlocked areas is bounded by 3 (instead of 5). + if (client.getVarbitValue(FIVE_AREAS) > 0) { + return Map.entry(3, ith(3)); + } + if (client.getVarbitValue(FOUR_AREAS) > 0) { + return Map.entry(2, ith(2)); + } + if (client.getVarbitValue(THREE_AREAS) > 0) { + return Map.entry(1, ith(1)); + } + if (client.getVarbitValue(TWO_AREAS) > 0) { + return Map.entry(0, ith(0)); // Karamja + } + return null; + } + + private static String ith(int i) { + if (i == 0) return "zeroth"; + if (i == 1) return "first"; + if (i == 2) return "second"; + if (i == 3) return "third"; + if (i == 4) return "fourth"; + if (i == 5) return "fifth"; + return String.valueOf(i); + } + + static { + AREA_BY_TASKS = Collections.unmodifiableNavigableMap( + new TreeMap<>(Map.of(0, 0, 60, 1, 200, 2, 400, 3)) + ); + + NavigableMap thresholds = new TreeMap<>(); + thresholds.put(2_500, "Bronze"); + thresholds.put(5_000, "Iron"); + thresholds.put(10_000, "Steel"); + thresholds.put(18_000, "Mithril"); + thresholds.put(28_000, "Adamant"); + thresholds.put(42_000, "Rune"); + thresholds.put(56_000, "Dragon"); + TROPHY_BY_POINTS = Collections.unmodifiableNavigableMap(thresholds); + + TIER_BY_RELIC = Map.ofEntries( + Map.entry("Endless Harvest", LeagueRelicTier.ONE), + Map.entry("Production Prodigy", LeagueRelicTier.ONE), + Map.entry("Trickster", LeagueRelicTier.ONE), + Map.entry("Fairy's Flight", LeagueRelicTier.TWO), + Map.entry("Globetrotter", LeagueRelicTier.TWO), + Map.entry("Banker's Note", LeagueRelicTier.THREE), + Map.entry("Fire Sale", LeagueRelicTier.THREE), + Map.entry("Archer's Embrace", LeagueRelicTier.FOUR), + Map.entry("Brawler's Resolve", LeagueRelicTier.FOUR), + Map.entry("Superior Sorcerer", LeagueRelicTier.FOUR), + Map.entry("Bloodthirsty", LeagueRelicTier.FIVE), + Map.entry("Infernal Gathering", LeagueRelicTier.FIVE), + Map.entry("Treasure Seeker", LeagueRelicTier.FIVE), + Map.entry("Equilibrium", LeagueRelicTier.SIX), + Map.entry("Farmer's Fortune", LeagueRelicTier.SIX), + Map.entry("Ruinous Powers", LeagueRelicTier.SIX), + Map.entry("Berserker", LeagueRelicTier.SEVEN), + Map.entry("Soul Stealer", LeagueRelicTier.SEVEN), + Map.entry("Weapon Master", LeagueRelicTier.SEVEN), + Map.entry("Guardian", LeagueRelicTier.EIGHT), + Map.entry("Executioner", LeagueRelicTier.EIGHT), + Map.entry("Undying Retribution", LeagueRelicTier.EIGHT) + ); + } +} diff --git a/src/main/java/dinkplugin/notifiers/data/LeaguesAreaNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LeaguesAreaNotificationData.java new file mode 100644 index 00000000..0cd487c9 --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/data/LeaguesAreaNotificationData.java @@ -0,0 +1,39 @@ +package dinkplugin.notifiers.data; + +import dinkplugin.message.Field; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@Value +@EqualsAndHashCode(callSuper = false) +public class LeaguesAreaNotificationData extends NotificationData { + + @NotNull + String area; + + int index; + + int tasksCompleted; + + @Nullable // if player has already unlocked all three regions + Integer tasksUntilNextArea; + + @Override + public List getFields() { + List fields = new ArrayList<>(2); + fields.add( + new Field("Tasks Completed", Field.formatBlock("", String.valueOf(tasksCompleted))) + ); + if (tasksUntilNextArea != null) { + fields.add( + new Field("Tasks until next Area", Field.formatBlock("", String.valueOf(tasksUntilNextArea))) + ); + } + return fields; + } +} diff --git a/src/main/java/dinkplugin/notifiers/data/LeaguesRelicNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LeaguesRelicNotificationData.java new file mode 100644 index 00000000..a89c74e9 --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/data/LeaguesRelicNotificationData.java @@ -0,0 +1,44 @@ +package dinkplugin.notifiers.data; + +import dinkplugin.message.Field; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@Value +@EqualsAndHashCode(callSuper = false) +public class LeaguesRelicNotificationData extends NotificationData { + + @NotNull + String relic; + Integer tier; + Integer requiredPoints; + int totalPoints; + + @Nullable // if relics for all 8 tiers have now been unlocked + Integer pointsUntilNextTier; + + @Override + public List getFields() { + List fields = new ArrayList<>(2); + fields.add( + new Field( + "Total Points", + Field.formatBlock("", String.valueOf(totalPoints)) + ) + ); + if (pointsUntilNextTier != null) { + fields.add( + new Field( + "Points until next Relic", + Field.formatBlock("", String.valueOf(pointsUntilNextTier)) + ) + ); + } + return fields; + } +} diff --git a/src/main/java/dinkplugin/notifiers/data/LeaguesTaskNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LeaguesTaskNotificationData.java new file mode 100644 index 00000000..f03b18bb --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/data/LeaguesTaskNotificationData.java @@ -0,0 +1,51 @@ +package dinkplugin.notifiers.data; + +import dinkplugin.domain.LeagueTaskDifficulty; +import dinkplugin.message.Field; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@Value +@EqualsAndHashCode(callSuper = false) +public class LeaguesTaskNotificationData extends NotificationData { + + @NotNull + String taskName; + + @NotNull + LeagueTaskDifficulty difficulty; + + int taskPoints; + int totalPoints; + int tasksCompleted; + + @Nullable // if player has already unlocked all three regions + Integer tasksUntilNextArea; + + @Nullable // if player has already unlocked a tier 8 relic (highest) + Integer pointsUntilNextRelic; + + @Nullable // if player has already earned the dragon trophy (highest) + Integer pointsUntilNextTrophy; + + @Nullable // if the player did not earn a new trophy with this task completion + String earnedTrophy; + + @Override + public List getFields() { + List fields = new ArrayList<>(3); + fields.add(new Field("Total Tasks", Field.formatBlock("", String.valueOf(tasksCompleted)))); + fields.add(new Field("Total Points", Field.formatBlock("", String.valueOf(totalPoints)))); + if (earnedTrophy == null && pointsUntilNextRelic != null) { + fields.add(new Field("Points until next Relic", Field.formatBlock("", String.valueOf(pointsUntilNextRelic)))); + } else if (pointsUntilNextTrophy != null) { + fields.add(new Field("Points until next Trophy", Field.formatBlock("", String.valueOf(pointsUntilNextTrophy)))); + } + return fields; + } +} diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java index 1a092c2c..1d4ee0c6 100644 --- a/src/main/java/dinkplugin/util/Utils.java +++ b/src/main/java/dinkplugin/util/Utils.java @@ -170,7 +170,10 @@ public AccountType getAccountType(@NotNull Client client) { } @Nullable - public String getChatBadge(@NotNull AccountType type) { + public String getChatBadge(@NotNull AccountType type, boolean seasonal) { + if (seasonal) { + return WIKI_IMG_BASE_URL + "Leagues_chat_badge.png"; + } switch (type) { case IRONMAN: return WIKI_IMG_BASE_URL + "Ironman_chat_badge.png"; @@ -183,7 +186,7 @@ public String getChatBadge(@NotNull AccountType type) { case HARDCORE_GROUP_IRONMAN: return WIKI_IMG_BASE_URL + "Hardcore_group_ironman_chat_badge.png"; case UNRANKED_GROUP_IRONMAN: - return WIKI_IMG_BASE_URL + "Unranked_group_ironman_chat_badge"; + return WIKI_IMG_BASE_URL + "Unranked_group_ironman_chat_badge.png"; default: return null; } diff --git a/src/test/java/dinkplugin/notifiers/LeaguesNotifierTest.java b/src/test/java/dinkplugin/notifiers/LeaguesNotifierTest.java new file mode 100644 index 00000000..af61408d --- /dev/null +++ b/src/test/java/dinkplugin/notifiers/LeaguesNotifierTest.java @@ -0,0 +1,339 @@ +package dinkplugin.notifiers; + +import com.google.inject.testing.fieldbinder.Bind; +import dinkplugin.domain.AccountType; +import dinkplugin.domain.LeagueRelicTier; +import dinkplugin.domain.LeagueTaskDifficulty; +import dinkplugin.message.NotificationBody; +import dinkplugin.message.NotificationType; +import dinkplugin.message.templating.Replacements; +import dinkplugin.message.templating.Template; +import dinkplugin.notifiers.data.LeaguesAreaNotificationData; +import dinkplugin.notifiers.data.LeaguesRelicNotificationData; +import dinkplugin.notifiers.data.LeaguesTaskNotificationData; +import net.runelite.api.Varbits; +import net.runelite.api.WorldType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.EnumSet; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LeaguesNotifierTest extends MockedNotifierTest { + + @Bind + @InjectMocks + LeaguesNotifier notifier; + + @Override + @BeforeEach + protected void setUp() { + super.setUp(); + + // client mocks + when(client.getWorldType()).thenReturn(EnumSet.of(WorldType.SEASONAL)); + when(client.getVarbitValue(Varbits.ACCOUNT_TYPE)).thenReturn(AccountType.IRONMAN.ordinal()); + + // config mocks + when(config.notifyLeagues()).thenReturn(true); + when(config.leaguesAreaUnlock()).thenReturn(true); + when(config.leaguesRelicUnlock()).thenReturn(true); + when(config.leaguesTaskCompletion()).thenReturn(true); + when(config.leaguesTaskMinTier()).thenReturn(LeagueTaskDifficulty.HARD); + } + + @Test + void notifyArea() { + // update client mocks + int tasksCompleted = 200; + int totalPoints = 100 * 10 + 100 * 40; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + when(client.getVarbitValue(LeaguesNotifier.TWO_AREAS)).thenReturn(2); + when(client.getVarbitValue(LeaguesNotifier.THREE_AREAS)).thenReturn(4); + when(client.getVarbitValue(LeaguesNotifier.FOUR_AREAS)).thenReturn(8); + + // fire event + notifier.onGameMessage("Congratulations, you've unlocked a new area: Kandarin."); + + // verify notification + String area = "Kandarin"; + int tasksUntilNextArea = 400 - tasksCompleted; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_AREA) + .text( + Template.builder() + .template(String.format("%s selected their second region: {{area}}.", PLAYER_NAME)) + .replacement("{{area}}", Replacements.ofWiki(area, "Trailblazer Reloaded League/Areas/" + area)) + .build() + ) + .extra(new LeaguesAreaNotificationData(area, 2, tasksCompleted, tasksUntilNextArea)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void notifyAreaKaramja() { + // update client mocks + int tasksCompleted = 2; + int totalPoints = 2 * 10; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + when(client.getVarbitValue(LeaguesNotifier.TWO_AREAS)).thenReturn(2); + + // fire event + notifier.onGameMessage("Congratulations, you've unlocked a new area: Karamja."); + + // verify notification + String area = "Karamja"; + int tasksUntilNextArea = 60 - tasksCompleted; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_AREA) + .text( + Template.builder() + .template(String.format("%s selected their zeroth region: {{area}}.", PLAYER_NAME)) + .replacement("{{area}}", Replacements.ofWiki(area, "Trailblazer Reloaded League/Areas/" + area)) + .build() + ) + .extra(new LeaguesAreaNotificationData(area, 0, tasksCompleted, tasksUntilNextArea)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void notifyRelic() { + // update client mocks + int tasksCompleted = 2; + int totalPoints = 2 * 10; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + + // fire event + notifier.onGameMessage("Congratulations, you've unlocked a new Relic: Production Prodigy."); + + // verify notification + String relic = "Production Prodigy"; + int pointsUntilNextTier = LeagueRelicTier.TWO.getPoints() - totalPoints; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_RELIC) + .text( + Template.builder() + .template(String.format("%s unlocked a Tier 1 Relic: {{relic}}.", PLAYER_NAME)) + .replacement("{{relic}}", Replacements.ofWiki(relic)) + .build() + ) + .extra(new LeaguesRelicNotificationData(relic, 1, 0, totalPoints, pointsUntilNextTier)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void notifyTask() { + // update client mocks + int tasksCompleted = 101; + int totalPoints = 100 * 10 + 80; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard task: The Frozen Door."); + + // verify notification + String taskName = "The Frozen Door"; + LeagueTaskDifficulty difficulty = LeagueTaskDifficulty.HARD; + int tasksUntilNextArea = 200 - tasksCompleted; + int pointsUntilNextRelic = LeagueRelicTier.THREE.getPoints() - totalPoints; + int pointsUntilNextTrophy = 2_500 - totalPoints; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_TASK) + .text( + Template.builder() + .template(String.format("%s completed a %s task: {{task}}.", PLAYER_NAME, "Hard")) + .replacement("{{task}}", Replacements.ofWiki(taskName, "Trailblazer_Reloaded_League/Tasks")) + .build() + ) + .extra(new LeaguesTaskNotificationData(taskName, difficulty, difficulty.getPoints(), totalPoints, tasksCompleted, tasksUntilNextArea, pointsUntilNextRelic, pointsUntilNextTrophy, null)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void notifyTaskTrophyBronze() { + // update client mocks + int tasksCompleted = 119; + int totalPoints = 100 * 10 + 80 * 19; // 2520 >= 2500 + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard task: The Frozen Door."); + + // verify notification + String taskName = "The Frozen Door"; + LeagueTaskDifficulty difficulty = LeagueTaskDifficulty.HARD; + int tasksUntilNextArea = 200 - tasksCompleted; + int pointsUntilNextRelic = LeagueRelicTier.FIVE.getPoints() - totalPoints; + int pointsUntilNextTrophy = 5_000 - totalPoints; + String trophy = "Bronze"; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_TASK) + .text( + Template.builder() + .template(String.format("%s completed a %s task, {{task}}, unlocking the {{trophy}} trophy!", PLAYER_NAME, "Hard")) + .replacement("{{task}}", Replacements.ofWiki(taskName, "Trailblazer_Reloaded_League/Tasks")) + .replacement("{{trophy}}", Replacements.ofWiki(trophy, "Trailblazer reloaded " + trophy.toLowerCase() + " trophy")) + .build() + ) + .extra(new LeaguesTaskNotificationData(taskName, difficulty, difficulty.getPoints(), totalPoints, tasksCompleted, tasksUntilNextArea, pointsUntilNextRelic, pointsUntilNextTrophy, trophy)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void notifyTaskTrophyIron() { + // update mocks + int tasksCompleted = 200; + int totalPoints = 100 * 10 + 100 * 40; // 5000 >= 5000 + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + when(config.leaguesTaskMinTier()).thenReturn(LeagueTaskDifficulty.EASY); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a medium task: Equip Amy's Saw."); + + // verify notification + String taskName = "Equip Amy's Saw"; + LeagueTaskDifficulty difficulty = LeagueTaskDifficulty.MEDIUM; + int tasksUntilNextArea = 400 - tasksCompleted; + int pointsUntilNextRelic = LeagueRelicTier.SIX.getPoints() - totalPoints; + int pointsUntilNextTrophy = 10_000 - totalPoints; + String trophy = "Iron"; + verify(messageHandler).createMessage( + PRIMARY_WEBHOOK_URL, + false, + NotificationBody.builder() + .type(NotificationType.LEAGUES_TASK) + .text( + Template.builder() + .template(String.format("%s completed a %s task, {{task}}, unlocking the {{trophy}} trophy!", PLAYER_NAME, "Medium")) + .replacement("{{task}}", Replacements.ofWiki(taskName, "Trailblazer_Reloaded_League/Tasks")) + .replacement("{{trophy}}", Replacements.ofWiki(trophy, "Trailblazer reloaded " + trophy.toLowerCase() + " trophy")) + .build() + ) + .extra(new LeaguesTaskNotificationData(taskName, difficulty, difficulty.getPoints(), totalPoints, tasksCompleted, tasksUntilNextArea, pointsUntilNextRelic, pointsUntilNextTrophy, trophy)) + .playerName(PLAYER_NAME) + .seasonalWorld(true) + .build() + ); + } + + @Test + void ignoreTaskTier() { + // update config mock + when(config.leaguesTaskMinTier()).thenReturn(LeagueTaskDifficulty.ELITE); + + // update client mocks + int tasksCompleted = 101; + int totalPoints = 100 * 10 + 40; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard task: The Frozen Door."); + + // ensure no notification occurred + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + @Test + void testIgnored() { + // update config mocks + when(config.leaguesAreaUnlock()).thenReturn(false); + when(config.leaguesRelicUnlock()).thenReturn(false); + when(config.leaguesTaskCompletion()).thenReturn(false); + + // update client mocks + int tasksCompleted = 101; + int totalPoints = 100 * 10 + 80; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + when(client.getVarbitValue(LeaguesNotifier.TWO_AREAS)).thenReturn(2); + when(client.getVarbitValue(LeaguesNotifier.THREE_AREAS)).thenReturn(4); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard task: The Frozen Door."); + notifier.onGameMessage("Congratulations, you've unlocked a new Relic: Production Prodigy."); + notifier.onGameMessage("Congratulations, you've unlocked a new area: Kandarin."); + + // ensure no notification occurred + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + @Test + void testDisabled() { + // update config mocks + when(config.notifyLeagues()).thenReturn(false); + + // update client mocks + int tasksCompleted = 101; + int totalPoints = 100 * 10 + 80; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + when(client.getVarbitValue(LeaguesNotifier.TWO_AREAS)).thenReturn(2); + when(client.getVarbitValue(LeaguesNotifier.THREE_AREAS)).thenReturn(4); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard task: The Frozen Door."); + notifier.onGameMessage("Congratulations, you've unlocked a new Relic: Production Prodigy."); + notifier.onGameMessage("Congratulations, you've unlocked a new area: Kandarin."); + + // ensure no notification occurred + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + @Test + void ignoreIrrelevant() { + // update client mocks + int tasksCompleted = 101; + int totalPoints = 100 * 10 + 80; + when(client.getVarbitValue(LeaguesNotifier.TASKS_COMPLETED_ID)).thenReturn(tasksCompleted); + when(client.getVarpValue(LeaguesNotifier.POINTS_EARNED_ID)).thenReturn(totalPoints); + + // fire event + notifier.onGameMessage("Congratulations, you've completed a hard combat task: Ready to Pounce."); + + // ensure no notification occurred + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } +} diff --git a/src/test/java/dinkplugin/util/UtilsTest.java b/src/test/java/dinkplugin/util/UtilsTest.java index 889ce1ca..1b36998d 100644 --- a/src/test/java/dinkplugin/util/UtilsTest.java +++ b/src/test/java/dinkplugin/util/UtilsTest.java @@ -112,4 +112,17 @@ void regexify() { assertFalse(m.matcher("iron pickaxe").find()); } + @Test + void sanitize() { + assertEquals("Congratulations, you've unlocked a new Relic: Archer's Embrace.", Utils.sanitize("Congratulations, you've unlocked a new Relic: Archer's Embrace.")); + assertEquals("Congratulations, you've completed an easy task: Obtain a Gem While Mining.", Utils.sanitize("Congratulations, you've completed an easy task: Obtain a Gem While Mining.")); + + assertEquals("", Utils.sanitize(null)); + assertEquals("", Utils.sanitize("")); + + assertEquals("foo\nbar", Utils.sanitize("foo
bar")); + + assertEquals("foo bar", Utils.sanitize("foo\u00A0bar")); + } + }