Repository: pajlads/DinkPlugin
Pull request #236 (2023-05-19 16:08:13)

feat: track combat task points unlock progress
Tracks total points achieved and next unlock progress via varbits (and calculations)

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/28bbb780-6676-4cfa-b861-797a49051c44)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33fdb34..5bc2373 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Track points progress towards next combat achievement tier rewards unlock. (#236)
 - Minor: Add combat achievement points as rich embed field. (#235)
 - Minor: Use item image as rich embed thumbnail for loot notifications. (#234)
 - Dev: Redefine account type enum to workaround upstream deprecation. (#238)
diff --git a/README.md b/README.md
index e879317..ddefc73 100644
--- a/README.md
+++ b/README.md
@@ -412,6 +412,10 @@ Lastly, Dink makes exceptions for Inferno and TzHaar Fight Cave; deaths in these
 
 `%POINTS%` will be replaced with the number of points you earned from the combat achievement.
 
+`%TOTAL_POINTS%` will be replaced with the total points that have been earned across tasks.
+
+If the task completion unlocked rewards for a tier, `%COMPLETED%` will be replaced with the tier that was completed.
+
 <details>
   <summary>JSON for Combat Achievement Notifications:</summary>
 
@@ -421,7 +425,31 @@ Lastly, Dink makes exceptions for Inferno and TzHaar Fight Cave; deaths in these
   "extra": {
     "tier": "GRANDMASTER",
     "task": "Peach Conjurer",
-    "taskPoints": 6
+    "taskPoints": 6,
+    "totalPoints": 1337,
+    "tierProgress": 517,
+    "tierTotalPoints": 645
+  },
+  "type": "COMBAT_ACHIEVEMENT"
+}
+```
+
+</details>
+
+<details>
+  <summary>JSON for Combat Achievement Tier Completion Notifications:</summary>
+
+```json5
+{
+  "content": "%USERNAME% has unlocked the rewards for the %COMPLETED% tier, by completing the combat task: %TASK%",
+  "extra": {
+    "tier": "GRANDMASTER",
+    "task": "Peach Conjurer",
+    "taskPoints": 6,
+    "totalPoints": 1465,
+    "tierProgress": 0,
+    "tierTotalPoints": 540,
+    "justCompletedTier": "MASTER"
   },
   "type": "COMBAT_ACHIEVEMENT"
 }
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index d8a2b0e..3ae906f 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -143,6 +143,7 @@ public class DinkPlugin extends Plugin {
         clueNotifier.onTick();
         slayerNotifier.onTick();
         levelNotifier.onTick();
+        combatTaskNotifier.onTick();
         diaryNotifier.onTick();
         killCountNotifier.onTick();
         pkNotifier.onTick();
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 58ca673..aafffb4 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -1140,7 +1140,9 @@ public interface DinkPluginConfig extends Config {
         description = "The message to be sent to the webhook.<br/>" +
             "Use %USERNAME% to insert your username<br/>" +
             "Use %TIER% to insert the task tier<br/>" +
-            "Use %TASK% to insert the task name",
+            "Use %TASK% to insert the task name<br/>" +
+            "Use %POINTS% to insert the task points<br/>" +
+            "Use %TOTAL_POINTS% to insert the total points earned across tasks",
         position = 103,
         section = combatTaskSection
     )
@@ -1148,6 +1150,23 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% has completed %TIER% combat task: %TASK%";
     }
 
+    @ConfigItem(
+        keyName = "combatTaskUnlockMessage",
+        name = "Reward Unlock Notification Message",
+        description = "The message to be sent to the webhook upon unlocking the rewards for a tier completion.<br/>" +
+            "Use %USERNAME% to insert your username<br/>" +
+            "Use %TIER% to insert the task tier<br/>" +
+            "Use %TASK% to insert the task name<br/>" +
+            "Use %POINTS% to insert the task points<br/>" +
+            "Use %TOTAL_POINTS% to insert the total points earned across tasks<br/>" +
+            "Use %COMPLETED% to insert the completed tier",
+        position = 104,
+        section = combatTaskSection
+    )
+    default String combatTaskUnlockMessage() {
+        return "%USERNAME% has unlocked the rewards for the %COMPLETED% tier, by completing the combat task: %TASK%";
+    }
+
     @ConfigItem(
         keyName = "diaryEnabled",
         name = "Enable Diary",
diff --git a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
index 15f3a33..add2269 100644
--- a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
@@ -1,27 +1,58 @@
 package dinkplugin.notifiers;
 
+import com.google.common.collect.ImmutableMap;
 import dinkplugin.util.Utils;
 import dinkplugin.domain.CombatAchievementTier;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.CombatAchievementData;
 import net.runelite.api.annotations.Varbit;
+import net.runelite.client.callback.ClientThread;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.jetbrains.annotations.VisibleForTesting;
 
+import javax.inject.Inject;
+import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Optional;
+import java.util.TreeMap;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 public class CombatTaskNotifier extends BaseNotifier {
     private static final Pattern ACHIEVEMENT_PATTERN = Pattern.compile("Congratulations, you've completed an? (?<tier>\\w+) combat task: (?<task>.+)\\.");
-    private static final Pattern TASK_POINTS = Pattern.compile("\\s+\\(\\d+ points\\)$");
+    private static final Pattern TASK_POINTS = Pattern.compile("\\s+\\(\\d+ points?\\)$");
     public static final String REPEAT_WARNING = "Combat Task notifier will fire duplicates unless you disable the game setting: Combat Achievement Tasks - Repeat completion";
 
     @Varbit
     public static final int COMBAT_TASK_REPEAT_POPUP = 12456;
 
+    /**
+     * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,ca_tasks_progress_bar%5D.cs2#L58">CS2 Reference</a>
+     */
+    @Varbit
+    @VisibleForTesting
+    public static final int TOTAL_POINTS_ID = 14815;
+
+    /**
+     * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,ca_tasks_progress_bar%5D.cs2#L6-L11">CS2 Reference</a>
+     */
+    @VisibleForTesting
+    public static final Map<CombatAchievementTier, Integer> CUM_POINTS_VARBIT_BY_TIER;
+
+    /**
+     * The cumulative points needed to unlock rewards for each tier, in a Red-Black tree.
+     * <p>
+     * This is populated by {@link #initThresholds()} based on {@link #CUM_POINTS_VARBIT_BY_TIER}.
+     *
+     * @see <a href="https://gachi.gay/01CAv">Rewards Thresholds at the launch of the points-based system</a>
+     */
+    private final NavigableMap<Integer, CombatAchievementTier> cumulativeUnlockPoints = new TreeMap<>();
+
+    @Inject
+    private ClientThread clientThread;
+
     @Override
     public boolean isEnabled() {
         return config.notifyCombatTask() && super.isEnabled();
@@ -32,6 +63,11 @@ public class CombatTaskNotifier extends BaseNotifier {
         return config.combatTaskWebhook();
     }
 
+    public void onTick() {
+        if (cumulativeUnlockPoints.size() < CUM_POINTS_VARBIT_BY_TIER.size())
+            initThresholds();
+    }
+
     public void onGameMessage(String message) {
         if (isEnabled())
             parse(message).ifPresent(pair -> handle(pair.getLeft(), pair.getRight()));
@@ -41,19 +77,49 @@ public class CombatTaskNotifier extends BaseNotifier {
         if (tier.ordinal() < config.minCombatAchievementTier().ordinal())
             return;
 
-        String player = Utils.getPlayerName(client);
-        String message = StringUtils.replaceEach(
-            config.combatTaskMessage(),
-            new String[] { "%USERNAME%", "%TIER%", "%TASK%", "%POINTS%" },
-            new String[] { player, tier.toString(), task, String.valueOf(tier.getPoints()) }
-        );
-
-        createMessage(config.combatTaskSendImage(), NotificationBody.<CombatAchievementData>builder()
-            .type(NotificationType.COMBAT_ACHIEVEMENT)
-            .text(message)
-            .playerName(player)
-            .extra(new CombatAchievementData(tier, task, tier.getPoints()))
-            .build());
+        // delay notification for varbits to be updated
+        clientThread.invokeAtTickEnd(() -> {
+            int taskPoints = tier.getPoints();
+            int totalPoints = client.getVarbitValue(TOTAL_POINTS_ID);
+
+            Integer nextUnlockPointsThreshold = cumulativeUnlockPoints.ceilingKey(totalPoints + 1);
+            Map.Entry<Integer, CombatAchievementTier> prev = cumulativeUnlockPoints.floorEntry(totalPoints);
+            int prevThreshold = prev != null ? prev.getKey() : 0;
+
+            Integer tierProgress, tierTotalPoints;
+            if (nextUnlockPointsThreshold != null) {
+                tierProgress = totalPoints - prevThreshold;
+                tierTotalPoints = nextUnlockPointsThreshold - prevThreshold;
+            } else {
+                tierProgress = tierTotalPoints = null;
+            }
+
+            boolean crossedThreshold = prevThreshold > 0 && totalPoints - taskPoints < prevThreshold;
+            CombatAchievementTier completedTier = crossedThreshold ? prev.getValue() : null;
+            String completedTierName = completedTier != null ? completedTier.getDisplayName() : "N/A";
+
+            String player = Utils.getPlayerName(client);
+            String message = StringUtils.replaceEach(
+                crossedThreshold ? config.combatTaskUnlockMessage() : config.combatTaskMessage(),
+                new String[] { "%USERNAME%", "%TIER%", "%TASK%", "%POINTS%", "%TOTAL_POINTS%", "%COMPLETED%" },
+                new String[] { player, tier.toString(), task, String.valueOf(taskPoints), String.valueOf(totalPoints), completedTierName }
+            );
+
+            createMessage(config.combatTaskSendImage(), NotificationBody.<CombatAchievementData>builder()
+                .type(NotificationType.COMBAT_ACHIEVEMENT)
+                .text(message)
+                .playerName(player)
+                .extra(new CombatAchievementData(tier, task, taskPoints, totalPoints, tierProgress, tierTotalPoints, completedTier))
+                .build());
+        });
+    }
+
+    private void initThresholds() {
+        CUM_POINTS_VARBIT_BY_TIER.forEach((tier, varbitId) -> {
+            int cumulativePoints = client.getVarbitValue(varbitId);
+            if (cumulativePoints > 0)
+                cumulativeUnlockPoints.put(cumulativePoints, tier);
+        });
     }
 
     @VisibleForTesting
@@ -69,4 +135,16 @@ public class CombatTaskNotifier extends BaseNotifier {
                 ).replaceFirst("") // remove points suffix
             ));
     }
+
+    static {
+        // noinspection UnstableApiUsage (builderWithExpectedSize is no longer @Beta in snapshot guava)
+        CUM_POINTS_VARBIT_BY_TIER = ImmutableMap.<CombatAchievementTier, Integer>builderWithExpectedSize(6)
+            .put(CombatAchievementTier.EASY, 4132) // 33 = 33 * 1
+            .put(CombatAchievementTier.MEDIUM, 10660) // 115 = 33 + 41 * 2
+            .put(CombatAchievementTier.HARD, 10661) // 304 = 115 + 63 * 3
+            .put(CombatAchievementTier.ELITE, 14812) // 820 = 304 + 129 * 4
+            .put(CombatAchievementTier.MASTER, 14813) // 1465 = 820 + 129 * 5
+            .put(CombatAchievementTier.GRANDMASTER, 14814) // 2005 = 1465 + 90 * 6
+            .build();
+    }
 }
diff --git a/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java b/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java
index db903a3..f1976a4 100644
--- a/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java
+++ b/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java
@@ -4,6 +4,8 @@ import dinkplugin.domain.CombatAchievementTier;
 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;
@@ -11,16 +13,68 @@ import java.util.List;
 @Value
 @EqualsAndHashCode(callSuper = false)
 public class CombatAchievementData extends NotificationData {
+
+    /**
+     * The tier of the task that was just completed.
+     */
+    @NotNull
     CombatAchievementTier tier;
+
+    /**
+     * The name of the task that was just completed.
+     */
+    @NotNull
     String task;
+
+    /**
+     * The number of points rewarded for completing this task.
+     */
     int taskPoints;
 
+    /**
+     * The total points that have been earned by the player from tasks across tiers.
+     */
+    int totalPoints;
+
+    /**
+     * The marginal points that have been obtained towards the next rewards unlock.
+     * <p>
+     * Formula: totalPoints - previousUnlockThreshold
+     * <p>
+     * Range: [0, tierTotalPoints - 1]
+     * <p>
+     * This field is <i>not</i> populated if the player has completed all tiers.
+     */
+    @Nullable
+    Integer tierProgress;
+
+    /**
+     * The total points within a tier to unlock the next reward.
+     * <p>
+     * Formula: {@code tierProgress + pointsUntilNextUnlock}
+     * <p>
+     * This can be transformed to the cumulative points threshold via:
+     * {@code (totalPoints - tierProgress) + tierTotalPoints}
+     * <p>
+     * This field is <i>not</i> populated if the player has completed all tiers.
+     */
+    @Nullable
+    Integer tierTotalPoints;
+
+    /**
+     * The tier whose rewards were just unlocked,
+     * <i>if</i> the player just completed the tier.
+     */
+    @Nullable
+    CombatAchievementTier justCompletedTier;
+
     @Override
     public List<Field> getFields() {
-        List<Field> fields = new ArrayList<>(1);
-        fields.add(
-            new Field("Points Earned", Field.formatBlock(null, String.valueOf(taskPoints)))
-        );
+        List<Field> fields = new ArrayList<>(3);
+        fields.add(new Field("Points Earned", Field.formatBlock(null, String.valueOf(taskPoints))));
+        fields.add(new Field("Total Points", Field.formatBlock(null, String.valueOf(totalPoints))));
+        if (tierProgress != null && tierTotalPoints != null)
+            fields.add(new Field("Next Unlock Progress", Field.formatProgress(tierProgress, tierTotalPoints)));
         return fields;
     }
 }


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
index a58527c..24ea4d5 100644
--- a/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
@@ -30,12 +30,34 @@ class CombatTaskNotifierTest extends MockedNotifierTest {
         when(config.notifyCombatTask()).thenReturn(true);
         when(config.combatTaskSendImage()).thenReturn(false);
         when(config.combatTaskMessage()).thenReturn("%USERNAME% has completed %TIER% combat task: %TASK%");
+        when(config.combatTaskUnlockMessage()).thenReturn("%USERNAME% has unlocked the rewards for the %COMPLETED% tier, by completing the combat task: %TASK%");
         when(config.minCombatAchievementTier()).thenReturn(CombatAchievementTier.HARD);
+
+        // init client mocks
+        when(client.getVarbitValue(
+            CombatTaskNotifier.CUM_POINTS_VARBIT_BY_TIER.get(CombatAchievementTier.EASY)
+        )).thenReturn(33);
+        when(client.getVarbitValue(
+            CombatTaskNotifier.CUM_POINTS_VARBIT_BY_TIER.get(CombatAchievementTier.MEDIUM)
+        )).thenReturn(115);
+        when(client.getVarbitValue(
+            CombatTaskNotifier.CUM_POINTS_VARBIT_BY_TIER.get(CombatAchievementTier.HARD)
+        )).thenReturn(304);
+        when(client.getVarbitValue(
+            CombatTaskNotifier.CUM_POINTS_VARBIT_BY_TIER.get(CombatAchievementTier.MASTER)
+        )).thenReturn(1465);
+        when(client.getVarbitValue(
+            CombatTaskNotifier.CUM_POINTS_VARBIT_BY_TIER.get(CombatAchievementTier.GRANDMASTER)
+        )).thenReturn(2005);
     }
 
     @Test
     void testNotify() {
+        // update mock
+        when(client.getVarbitValue(CombatTaskNotifier.TOTAL_POINTS_ID)).thenReturn(200);
+
         // send fake message
+        notifier.onTick();
         notifier.onGameMessage("Congratulations, you've completed a hard combat task: Whack-a-Mole.");
 
         // verify handled
@@ -44,7 +66,61 @@ class CombatTaskNotifierTest extends MockedNotifierTest {
             false,
             NotificationBody.builder()
                 .text(String.format("%s has completed %s combat task: %s", PLAYER_NAME, "Hard", "Whack-a-Mole"))
-                .extra(new CombatAchievementData(CombatAchievementTier.HARD, "Whack-a-Mole", 3))
+                .extra(new CombatAchievementData(CombatAchievementTier.HARD, "Whack-a-Mole", 3, 200, 85, 189, null))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.COMBAT_ACHIEVEMENT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyUnlock() {
+        // init thresholds
+        notifier.onTick();
+
+        // calculate points
+        int oldPoints = 1460;
+        CombatAchievementTier taskTier = CombatAchievementTier.GRANDMASTER;
+        int newPoints = oldPoints + taskTier.getPoints();
+        when(client.getVarbitValue(CombatTaskNotifier.TOTAL_POINTS_ID)).thenReturn(newPoints);
+
+        // fire completion message
+        notifier.onGameMessage("Congratulations, you've completed a grandmaster combat task: No Pressure (6 points).");
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has unlocked the rewards for the %s tier, by completing the combat task: %s", PLAYER_NAME, "Master", "No Pressure"))
+                .extra(new CombatAchievementData(CombatAchievementTier.GRANDMASTER, "No Pressure", 6, 1466, 1466 - 1465, 2005 - 1465, CombatAchievementTier.MASTER))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.COMBAT_ACHIEVEMENT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyUnlockGrand() {
+        // init thresholds
+        notifier.onTick();
+
+        // calculate points
+        int oldPoints = 1999;
+        CombatAchievementTier taskTier = CombatAchievementTier.GRANDMASTER;
+        int newPoints = oldPoints + taskTier.getPoints();
+        when(client.getVarbitValue(CombatTaskNotifier.TOTAL_POINTS_ID)).thenReturn(newPoints);
+
+        // fire completion message
+        notifier.onGameMessage("Congratulations, you've completed a grandmaster combat task: No Pressure (6 points).");
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has unlocked the rewards for the %s tier, by completing the combat task: %s", PLAYER_NAME, "Grandmaster", "No Pressure"))
+                .extra(new CombatAchievementData(CombatAchievementTier.GRANDMASTER, "No Pressure", 6, 2005, null, null, CombatAchievementTier.GRANDMASTER))
                 .playerName(PLAYER_NAME)
                 .type(NotificationType.COMBAT_ACHIEVEMENT)
                 .build()