diff --git a/CHANGELOG.md b/CHANGELOG.md index b226918f..9718318f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- Minor: Include rarity and luck for pet notifications. (#433) - Dev: Add raid party members to loot notification metadata. (#478) ## 1.10.4 diff --git a/src/main/java/dinkplugin/message/Field.java b/src/main/java/dinkplugin/message/Field.java index 8c46a5a7..41f0a239 100644 --- a/src/main/java/dinkplugin/message/Field.java +++ b/src/main/java/dinkplugin/message/Field.java @@ -25,6 +25,17 @@ public Field(String name, String value) { this(name, value, true); } + public static Field ofLuck(double probability, int kTrials) { + return ofLuck(MathUtils.cumulativeGeometric(probability, kTrials)); + } + + public static Field ofLuck(double geomCdf) { + String percentile = geomCdf < 0.5 + ? "Top " + MathUtils.formatPercentage(geomCdf, 2) + " (Lucky)" + : "Bottom " + MathUtils.formatPercentage(1 - geomCdf, 2) + " (Unlucky)"; + return new Field("Luck", Field.formatBlock("", percentile)); + } + public static String formatBlock(String codeBlockLanguage, String content) { return String.format("```%s\n%s\n```", StringUtils.defaultString(codeBlockLanguage), content); } diff --git a/src/main/java/dinkplugin/notifiers/PetNotifier.java b/src/main/java/dinkplugin/notifiers/PetNotifier.java index d046f6cd..caf85fc0 100644 --- a/src/main/java/dinkplugin/notifiers/PetNotifier.java +++ b/src/main/java/dinkplugin/notifiers/PetNotifier.java @@ -1,6 +1,5 @@ package dinkplugin.notifiers; -import com.google.common.collect.ImmutableSet; import dinkplugin.message.NotificationBody; import dinkplugin.message.NotificationType; import dinkplugin.message.templating.Replacements; @@ -8,24 +7,37 @@ import dinkplugin.notifiers.data.PetNotificationData; import dinkplugin.util.ItemSearcher; import dinkplugin.util.ItemUtils; +import dinkplugin.util.KillCountService; +import dinkplugin.util.MathUtils; +import dinkplugin.util.SerializedLoot; import dinkplugin.util.Utils; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Setter; import lombok.Value; +import net.runelite.api.Client; +import net.runelite.api.Experience; +import net.runelite.api.ItemID; +import net.runelite.api.Skill; import net.runelite.api.Varbits; import net.runelite.api.annotations.Varbit; +import net.runelite.http.api.loottracker.LootRecordType; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.VisibleForTesting; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntToDoubleFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import static dinkplugin.notifiers.CollectionNotifier.COLLECTION_LOG_REGEX; +import static java.util.Map.entry; @Singleton public class PetNotifier extends BaseNotifier { @@ -45,7 +57,7 @@ public class PetNotifier extends BaseNotifier { static final Pattern CLAN_REGEX = Pattern.compile("\\b(?[\\w\\s]+) (?:has a funny feeling like .+ followed|feels something weird sneaking into .+ backpack): (?.+) at (?.+)"); private static final Pattern UNTRADEABLE_REGEX = Pattern.compile("Untradeable drop: (.+)"); - private static final Set PET_NAMES; + private static final Map PET_NAMES_TO_SOURCE; private static final String PRIMED_NAME = ""; /** @@ -68,6 +80,9 @@ public class PetNotifier extends BaseNotifier { @Inject private ItemSearcher itemSearcher; + @Inject + private KillCountService killCountService; + @Setter(AccessLevel.PRIVATE) private volatile String petName = null; @@ -100,7 +115,7 @@ public void onChatMessage(String chatMessage) { } } else if (PRIMED_NAME.equals(petName) || !collection) { parseItemFromGameMessage(chatMessage) - .filter(item -> item.getItemName().startsWith("Pet ") || PET_NAMES.contains(Utils.ucFirst(item.getItemName()))) + .filter(item -> item.getItemName().startsWith("Pet ") || PET_NAMES_TO_SOURCE.containsKey(Utils.ucFirst(item.getItemName()))) .ifPresent(parseResult -> { setPetName(parseResult.getItemName()); if (parseResult.isCollectionLog()) { @@ -178,14 +193,20 @@ private void handleNotify() { .replacement("%GAME_MESSAGE%", Replacements.ofText(gameMessage)) .build(); - String thumbnail = Optional.ofNullable(petName) + String pet = petName != null ? Utils.ucFirst(petName) : null; + String thumbnail = Optional.ofNullable(pet) .filter(s -> !s.isEmpty()) - .map(Utils::ucFirst) .map(itemSearcher::findItemId) .map(ItemUtils::getItemImageUrl) .orElse(null); - PetNotificationData extra = new PetNotificationData(StringUtils.defaultIfEmpty(petName, null), milestone, duplicate, previouslyOwned); + Source source = petName != null ? PET_NAMES_TO_SOURCE.get(pet) : null; + Double rarity = source != null ? source.getProbability(client, killCountService) : null; + Integer actions = rarity != null ? source.estimateActions(client, killCountService) : null; + Double luck = actions != null && (previouslyOwned == null || !previouslyOwned) + ? source.calculateLuck(client, killCountService, rarity, actions) : null; + + PetNotificationData extra = new PetNotificationData(StringUtils.defaultIfEmpty(petName, null), milestone, duplicate, previouslyOwned, rarity, actions, luck); createMessage(config.petSendImage(), NotificationBody.builder() .extra(extra) @@ -215,57 +236,324 @@ private static class ParseResult { boolean collectionLog; } + private static abstract class Source { + abstract Double getProbability(Client client, KillCountService kcService); + + abstract Integer estimateActions(Client client, KillCountService kcService); + + Double calculateLuck(Client client, KillCountService kcService, double probability, int killCount) { + return MathUtils.cumulativeGeometric(probability, killCount); + } + } + + private static abstract class MultiSource extends Source { + abstract double[] getRates(Client client); + + abstract int[] getActions(Client client, KillCountService kcService); + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + int sum = MathUtils.sum(getActions(client, kcService)); + return sum > 0 ? sum : null; + } + + @Override + Double getProbability(Client client, KillCountService kcService) { + final int[] actions = getActions(client, kcService); + final int totalActions = MathUtils.sum(actions); + if (totalActions <= 0) return null; + + final double[] rates = getRates(client); + double weighted = 0; + for (int i = 0, n = actions.length; i < n; i++) { + weighted += rates[i] * actions[i] / totalActions; + } + return weighted; + } + + @Override + Double calculateLuck(Client client, KillCountService kcService, double probability, int killCount) { + final int[] actions = getActions(client, kcService); + final double[] rates = getRates(client); + double p = 1; + for (int i = 0, n = actions.length; i < n; i++) { + p *= Math.pow(1 - rates[i], actions[i]); // similar to geometric distribution survival function + } + return 1 - p; + } + } + + @Value + @EqualsAndHashCode(callSuper = true) + private static class SkillSource extends MultiSource { + Skill skill; + int baseChance; + int actionXp; + + @Override + double[] getRates(Client client) { + final int n = Experience.MAX_REAL_LEVEL; + double[] rates = new double[n]; + for (int level = 1; level <= n; level++) { + rates[level - 1] = 1.0 / (baseChance - 25 * level); + } + return rates; + } + + @Override + int[] getActions(Client client, KillCountService kcService) { + final int currentLevel = client.getRealSkillLevel(skill); + final int currentXp = client.getSkillExperience(skill); + final int[] actions = new int[currentLevel]; + int prevXp = 0; + for (int lvl = 1; lvl <= currentLevel; lvl++) { + int xp = Math.min(Experience.getXpForLevel(lvl + 1), currentXp); + int deltaXp = xp - prevXp; + prevXp = xp; + actions[lvl - 1] = Math.round(1f * deltaXp / actionXp); + } + return actions; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + return client.getSkillExperience(skill) / actionXp; + } + } + + @Value + @EqualsAndHashCode(callSuper = true) + private static class KcSource extends Source { + String name; + double probability; + + @Override + Double getProbability(Client client, KillCountService kcService) { + return this.probability; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + Integer kc = kcService.getKillCount(LootRecordType.UNKNOWN, name); + return kc != null && kc > 0 ? kc : null; + } + } + + @Value + @EqualsAndHashCode(callSuper = false) + private static class MultiKcSource extends MultiSource { + String[] names; + double[] rates; + + public MultiKcSource(String source1, double prob1, String source2, double prob2) { + this.names = new String[] { source1, source2 }; + this.rates = new double[] { prob1, prob2 }; + } + + @Override + int[] getActions(Client client, KillCountService kcService) { + final int n = names.length; + int[] actions = new int[n]; + for (int i = 0; i < n; i++) { + Integer kc = kcService.getKillCount(LootRecordType.UNKNOWN, names[i]); + if (kc == null) continue; + actions[i] = kc; + } + return actions; + } + + @Override + double[] getRates(Client client) { + return this.rates; + } + } + static { - // Note: We don't explicitly list out names that have the "Pet " prefix - // since they are matched by filter(item -> item.startsWith("Pet ")) above - PET_NAMES = ImmutableSet.of( - "Abyssal orphan", - "Abyssal protector", - "Baby chinchompa", - "Baby mole", - "Baron", - "Butch", - "Beaver", - "Bloodhound", - "Callisto cub", - "Chompy chick", - "Giant squirrel", - "Hellcat", - "Hellpuppy", - "Herbi", - "Heron", - "Ikkle hydra", - "Jal-nib-rek", - "Kalphite princess", - "Lil' creator", - "Lil' zik", - "Lil'viathan", - "Little nightmare", - "Muphin", - "Nexling", - "Noon", - "Olmlet", - "Phoenix", - "Prince black dragon", - "Quetzin", - "Rift guardian", - "Rock golem", - "Rocky", - "Scorpia's offspring", - "Scurry", - "Skotos", - "Smolcano", - "Smol heredit", - "Sraracha", - "Tangleroot", - "Tiny tempor", - "Tumeken's guardian", - "Tzrek-jad", - "Venenatis spiderling", - "Vet'ion jr.", - "Vorki", - "Wisp", - "Youngllef" + PET_NAMES_TO_SOURCE = Map.ofEntries( + entry("Abyssal orphan", new KcSource("Abyssal Sire", 1.0 / 2_560)), + entry("Abyssal protector", new Source() { + @Override + Double getProbability(Client client, KillCountService kcService) { + return 1.0 / 4_000; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + Integer kc = kcService.getKillCount(LootRecordType.EVENT, "Guardians of the Rift"); + return kc != null && kc > 0 ? kc * 3 : null; + } + }), + entry("Baby chinchompa", new SkillSource(Skill.HUNTER, 82_758, 315)), // black chinchompas + entry("Baby mole", new KcSource("Giant Mole", 1.0 / 3_000)), + entry("Baron", new KcSource("Duke Sucellus", 1.0 / 2_500)), + entry("Butch", new KcSource("Vardorvis", 1.0 / 3_000)), + entry("Beaver", new SkillSource(Skill.WOODCUTTING, 264_336, 85)), // teaks + entry("Bloodhound", new KcSource("Clue Scroll (master)", 1.0 / 1_000)), + entry("Callisto cub", new MultiKcSource("Callisto", 1.0 / 1_500, "Artio", 1.0 / 2_800)), + entry("Chompy chick", new KcSource("Chompy bird", 1.0 / 500)), + entry("Giant squirrel", new MultiSource() { + private final Map courses = Map.ofEntries( + entry("Gnome Stronghold Agility", 35_609), + entry("Shayzien Agility Course", 31_804), + entry("Shayzien Advanced Agility Course", 29_738), + entry("Agility Pyramid", 9_901), + entry("Penguin Agility", 9_779), + entry("Barbarian Outpost", 44_376), + entry("Agility Arena", 26_404), + entry("Ape Atoll Agility", 37_720), + entry("Wilderness Agility", 34_666), + entry("Werewolf Agility", 32_597), + entry("Dorgesh-Kaan Agility Course", 10_561), + entry("Prifddinas Agility Course", 25_146), + entry("Draynor Village Rooftop", 33_005), + entry("Al Kharid Rooftop", 26_648), + entry("Varrock Rooftop", 24_410), + entry("Canifis Rooftop", 36_842), + entry("Falador Rooftop", 26_806), + entry("Seers' Village Rooftop", 35_205), + entry("Pollnivneach Rooftop", 33_422), + entry("Rellekka Rooftop", 31_063), + entry("Ardougne Rooftop", 34_440), + entry("Hallowed Sepulchre Floor 1", 35_000), + entry("Hallowed Sepulchre Floor 2", 16_000), + entry("Hallowed Sepulchre Floor 3", 8_000), + entry("Hallowed Sepulchre Floor 4", 4_000), + entry("Hallowed Sepulchre Floor 5", 2_000) + ); + + @Override + public int[] getActions(Client client, KillCountService kcService) { + return courses.keySet() + .stream() + .mapToInt(course -> { + Integer count = kcService.getKillCount(LootRecordType.UNKNOWN, course); + return count != null && count > 0 ? count + 1 : 0; + }) + .toArray(); + } + + @Override + public double[] getRates(Client client) { + int level = client.getRealSkillLevel(Skill.AGILITY); + IntToDoubleFunction calc = base -> 1.0 / (base - level * 25); // see SkillSource + return courses.entrySet() + .stream() + .mapToDouble(entry -> { + if (entry.getKey().startsWith("Hallowed")) + return 1.0 / entry.getValue(); + return calc.applyAsDouble(entry.getValue()); + }) + .toArray(); + } + }), + entry("Hellpuppy", new KcSource("Cerberus", 1.0 / 3_000)), + entry("Herbi", new KcSource("Herbiboar", 1.0 / 6_500)), + entry("Heron", new SkillSource(Skill.FISHING, 257_770, 100)), // swordfish + entry("Ikkle hydra", new KcSource("Alchemical Hydra", 1.0 / 3_000)), + entry("Jal-nib-rek", new KcSource("TzKal-Zuk", 1.0 / 100)), + entry("Kalphite princess", new KcSource("Kalphite Queen", 1.0 / 3_000)), + entry("Lil' creator", new KcSource("Spoils of war", 1.0 / 400)), + entry("Lil' zik", new KcSource(" Theatre of Blood", 1.0 / 650)), // assume normal mode + entry("Lil'viathan", new KcSource("The Leviathan", 1.0 / 2_500)), + entry("Little nightmare", new KcSource("Nightmare", 1.0 / 3_200)), // assume team size 4 + entry("Muphin", new KcSource("Phantom Muspah", 1.0 / 2_500)), + entry("Nexling", new KcSource("Nex", 1.0 / 500)), + entry("Noon", new KcSource("Grotesque Guardians", 1.0 / 3_000)), + entry("Olmlet", new Source() { + @Override + Double getProbability(Client client, KillCountService kcService) { + // https://oldschool.runescape.wiki/w/Ancient_chest#Unique_drop_table + int totalPoints = client.getVarbitValue(Varbits.TOTAL_POINTS); + if (totalPoints <= 0) { + totalPoints = 26_025; + } + return 0.01 * (totalPoints / 8_676) / 53; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + return Stream.of("", " Challenge Mode") + .map(suffix -> "Chambers of Xeric" + suffix) + .map(event -> kcService.getKillCount(LootRecordType.EVENT, event)) + .filter(Objects::nonNull) + .reduce(Integer::sum) + .orElse(null); + } + }), + entry("Pet chaos elemental", new MultiKcSource("Chaos Elemental", 1.0 / 300, "Chaos Fanatic", 1.0 / 1_000)), + entry("Pet dagannoth prime", new KcSource("Dagannoth Prime", 1.0 / 5_000)), + entry("Pet dagannoth rex", new KcSource("Dagannoth Rex", 1.0 / 5_000)), + entry("Pet dagannoth supreme", new KcSource("Dagannoth Supreme", 1.0 / 5_000)), + entry("Pet dark core", new KcSource("Corporeal Beast", 1.0 / 5_000)), + entry("Pet general graardor", new KcSource("General Graardor", 1.0 / 5_000)), + entry("Pet k'ril tsutsaroth", new KcSource("K'ril Tsutsaroth", 1.0 / 5_000)), + entry("Pet kraken", new KcSource("Kraken", 1.0 / 5_000)), + entry("Pet penance queen", new Source() { + @Override + Double getProbability(Client client, KillCountService kcService) { + return 1.0 / 1_000; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + return client.getVarbitValue(Varbits.BA_GC); + } + }), + entry("Pet smoke devil", new KcSource("Thermonuclear smoke devil", 1.0 / 3_000)), + entry("Pet snakeling", new KcSource("Zulrah", 1.0 / 4_000)), + entry("Pet zilyana", new KcSource("Commander Zilyana", 1.0 / 5_000)), + entry("Phoenix", new KcSource("Wintertodt", 1.0 / 5_000)), + entry("Prince black dragon", new KcSource("King Black Dragon", 1.0 / 3_000)), + entry("Quetzin", new MultiKcSource("Hunters' loot sack (expert)", 1.0 / 1_000, "Hunters' loot sack (master)", 1.0 / 1_000)), + entry("Rift guardian", new SkillSource(Skill.RUNECRAFT, 1_795_758, 10)), // lava runes + entry("Rock golem", new SkillSource(Skill.MINING, 211_886, 65)), // gemstones + entry("Rocky", new SkillSource(Skill.THIEVING, 36_490, 42)), // stalls + entry("Scorpia's offspring", new KcSource("Scorpia", 1 / 2015.75)), + entry("Scurry", new KcSource("Scurrius", 1.0 / 3_000)), + entry("Skotos", new KcSource("Skotizo", 1.0 / 65)), + entry("Smolcano", new KcSource("Zalcano", 1.0 / 2_250)), + entry("Smol heredit", new Source() { + @Override + Double getProbability(Client client, KillCountService kcService) { + return 1.0 / 200; + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + SerializedLoot lootRecord = kcService.getLootTrackerRecord(LootRecordType.EVENT, "Fortis Colosseum"); + return lootRecord != null ? lootRecord.getQuantity(ItemID.DIZANAS_QUIVER_UNCHARGED) : null; + } + }), + entry("Sraracha", new KcSource("Sarachnis", 1.0 / 3_000)), + entry("Tangleroot", new SkillSource(Skill.FARMING, 7_500, 119)), // mushrooms + entry("Tiny tempor", new KcSource("Reward pool (Tempoross)", 1.0 / 8_000)), + entry("Tumeken's guardian", new Source() { + @Override + Double getProbability(Client client, KillCountService kcService) { + // https://oldschool.runescape.wiki/w/Chest_(Tombs_of_Amascut)#Tertiary_rewards + int rewardPoints = client.getVarbitValue(Varbits.TOTAL_POINTS); + int raidLevels = Math.min(client.getVarbitValue(Varbits.TOA_RAID_LEVEL), 550); + int x = Math.min(raidLevels, 400); + int y = Math.max(raidLevels - 400, 0); + return 0.01 * rewardPoints / (350_000 - 700 * (x + y / 3)); // assume latest is representative + } + + @Override + Integer estimateActions(Client client, KillCountService kcService) { + return Stream.of("", ": Entry Mode", ": Expert Mode") + .map(suffix -> "Tombs of Amascut" + suffix) + .map(event -> kcService.getKillCount(LootRecordType.EVENT, event)) + .filter(Objects::nonNull) + .reduce(Integer::sum) + .orElse(null); + } + }), + entry("Tzrek-jad", new KcSource("TzTok-Jad", 1.0 / 200)), + entry("Venenatis spiderling", new MultiKcSource("Venenatis", 1.0 / 1_500, "Spindel", 1.0 / 2_800)), + entry("Vet'ion jr.", new MultiKcSource("Vet'ion", 1.0 / 1_500, "Calvar'ion", 1.0 / 2_800)), + entry("Vorki", new KcSource("Vorkath", 1.0 / 3_000)), + entry("Wisp", new KcSource("The Whisperer", 1.0 / 2_000)), + entry("Youngllef", new MultiKcSource("Gauntlet", 1.0 / 2_000, "Corrupted Gauntlet", 1.0 / 800)) ); } } diff --git a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java index c9ef4f7c..d4c6ee1b 100644 --- a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java +++ b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java @@ -2,7 +2,6 @@ import dinkplugin.message.Field; import dinkplugin.util.Drop; -import dinkplugin.util.MathUtils; import lombok.EqualsAndHashCode; import lombok.Value; import net.runelite.client.util.QuantityFormatter; @@ -68,11 +67,7 @@ public List getFields() { fields.add(new Field("Drop Rate", Field.formatProbability(dropRate))); } if (dropperKillCount != null && dropRate != null) { - double geomCdf = MathUtils.cumulativeGeometric(dropRate, dropperKillCount); - String percentile = geomCdf < 0.5 - ? "Top " + MathUtils.formatPercentage(geomCdf, 2) + " (Lucky)" - : "Bottom " + MathUtils.formatPercentage(1 - geomCdf, 2) + " (Unlucky)"; - fields.add(new Field("Luck", Field.formatBlock("", percentile))); + fields.add(Field.ofLuck(dropRate, dropperKillCount)); } return fields; } diff --git a/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java b/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java index 93a1ed07..c3242f55 100644 --- a/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java +++ b/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java @@ -41,18 +41,41 @@ public class PetNotificationData extends NotificationData { @Nullable Boolean previouslyOwned; + /** + * The approximate drop rate of the pet. + *

+ * This value is least accurate for skilling pets and raids. + */ + @Nullable + Double rarity; + + /** + * The approximate number of actions performed that would roll a drop table containing the pet. + *

+ * This value is least accurate for skilling pets and pets dropped by multiple NPCs. + */ + @Nullable + Integer estimatedActions; + + @Nullable + transient Double luck; + @Override public List getFields() { if (petName == null || petName.isEmpty()) return super.getFields(); - List fields = new ArrayList<>(3); + List fields = new ArrayList<>(5); fields.add(new Field("Name", Field.formatBlock("", petName))); String status = getStatus(); if (status != null) fields.add(new Field("Status", Field.formatBlock("", status))); if (milestone != null) fields.add(new Field("Milestone", Field.formatBlock("", milestone))); + if (rarity != null) + fields.add(new Field("Rarity", Field.formatProbability(rarity))); + if (luck != null) + fields.add(Field.ofLuck(luck)); return fields; } diff --git a/src/main/java/dinkplugin/util/KillCountService.java b/src/main/java/dinkplugin/util/KillCountService.java index 33103e54..cf87d696 100644 --- a/src/main/java/dinkplugin/util/KillCountService.java +++ b/src/main/java/dinkplugin/util/KillCountService.java @@ -45,6 +45,8 @@ public class KillCountService { private static final String RL_CHAT_CMD_PLUGIN_NAME = ChatCommandsPlugin.class.getSimpleName().toLowerCase(); private static final String RL_LOOT_PLUGIN_NAME = LootTrackerPlugin.class.getSimpleName().toLowerCase(); + private static final String RIFT_PREFIX = "Amount of rifts you have closed: "; + private static final String HERBIBOAR_PREFIX = "Your herbiboar harvest count is: "; @Inject private ConfigManager configManager; @@ -128,6 +130,20 @@ public void onGameMessage(String message) { return; } + // guardians of the rift count (for pet tracking) + if (message.startsWith(RIFT_PREFIX)) { + int riftCount = Integer.parseInt(message.substring(RIFT_PREFIX.length(), message.length() - 1)); + killCounts.put("Guardians of the Rift", riftCount); + return; + } + + // herbiboar count (for pet tracking) + if (message.startsWith(HERBIBOAR_PREFIX)) { + int harvestCount = Integer.parseInt(message.substring(HERBIBOAR_PREFIX.length(), message.length() - 1)); + killCounts.put("Herbiboar", harvestCount); + return; + } + // update cached KC via boss chat message with robustness for chat event coming before OR after the loot event KillCountNotifier.parseBoss(message).ifPresent(pair -> { String boss = pair.getKey(); @@ -225,6 +241,12 @@ private Integer getStoredKillCount(@NotNull LootRecordType type, @NotNull String } } + SerializedLoot lootRecord = getLootTrackerRecord(type, sourceName); + return lootRecord != null ? lootRecord.getKills() : null; + } + + @Nullable + public SerializedLoot getLootTrackerRecord(@NotNull LootRecordType type, @NotNull String sourceName) { if (ConfigUtil.isPluginDisabled(configManager, RL_LOOT_PLUGIN_NAME)) { // assume stored kc is useless if loot tracker plugin is disabled return null; @@ -235,19 +257,20 @@ private Integer getStoredKillCount(@NotNull LootRecordType type, @NotNull String ); if (json == null) { // no kc stored implies first kill - return 0; + return new SerializedLoot(); } try { - int kc = gson.fromJson(json, SerializedLoot.class).getKills(); + SerializedLoot lootRecord = gson.fromJson(json, SerializedLoot.class); // loot tracker doesn't count kill if no loot - https://github.com/runelite/runelite/issues/5077 OptionalDouble nothingProbability = rarityService.getRarity(sourceName, -1, 0); if (nothingProbability.isPresent() && nothingProbability.getAsDouble() < 1.0) { // estimate the actual kc (including kills with no loot) - kc = (int) Math.round(kc / (1 - nothingProbability.getAsDouble())); + int kc = (int) Math.round(lootRecord.getKills() / (1 - nothingProbability.getAsDouble())); + return lootRecord.withKills(kc); + } else { + return lootRecord; } - - return kc; } catch (JsonSyntaxException e) { // should not occur unless loot tracker changes stored loot POJO structure log.warn("Failed to read kills from loot tracker config", e); diff --git a/src/main/java/dinkplugin/util/MathUtils.java b/src/main/java/dinkplugin/util/MathUtils.java index a8c4ba88..8a68efe0 100644 --- a/src/main/java/dinkplugin/util/MathUtils.java +++ b/src/main/java/dinkplugin/util/MathUtils.java @@ -11,6 +11,14 @@ public class MathUtils { public static final double EPSILON = 0.00001; private static final int[] FACTORIALS; + public int sum(int[] array) { + int x = 0; + for (int i : array) { + x += i; + } + return x; + } + public boolean lessThanOrEqual(double a, double b) { return a < b || DoubleMath.fuzzyEquals(a, b, EPSILON); } diff --git a/src/main/java/dinkplugin/util/SerializedLoot.java b/src/main/java/dinkplugin/util/SerializedLoot.java index 9818c083..39e00b90 100644 --- a/src/main/java/dinkplugin/util/SerializedLoot.java +++ b/src/main/java/dinkplugin/util/SerializedLoot.java @@ -1,8 +1,11 @@ package dinkplugin.util; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.With; /** * Contains kill count observed by base runelite loot tracker plugin, stored in profile configuration. @@ -10,7 +13,21 @@ * @see RuneLite class */ @Data +@With @Setter(AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor public class SerializedLoot { private int kills; + private int[] drops; + + public int getQuantity(int itemId) { + final int n = drops != null ? drops.length : 0; + for (int i = 0; i < n; i += 2) { + if (drops[i] == itemId) { + return drops[i + 1]; + } + } + return 0; + } } diff --git a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java index a0d24a44..5d5fae7a 100644 --- a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java +++ b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java @@ -7,20 +7,27 @@ import dinkplugin.notifiers.data.PetNotificationData; import dinkplugin.util.ItemSearcher; import dinkplugin.util.ItemUtils; +import dinkplugin.util.KillCountService; +import dinkplugin.util.MathUtils; import net.runelite.api.ItemID; +import net.runelite.api.NPC; +import net.runelite.api.NpcID; import net.runelite.api.Varbits; import net.runelite.api.WorldType; +import net.runelite.client.events.NpcLootReceived; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import java.util.Collections; import java.util.EnumSet; import java.util.stream.IntStream; import static dinkplugin.notifiers.PetNotifier.MAX_TICKS_WAIT; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,6 +42,10 @@ class PetNotifierTest extends MockedNotifierTest { @Mock ItemSearcher itemSearcher; + @Bind + @InjectMocks + KillCountService killCountService; + @Override @BeforeEach protected void setUp() { @@ -60,7 +71,7 @@ void testNotify() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " feels something weird sneaking into their backpack")) .type(NotificationType.PET) .build() @@ -81,7 +92,7 @@ void testNotifyDuplicate() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, true, true)) + .extra(new PetNotificationData(null, null, true, true, null, null, null)) .text(buildTemplate(PLAYER_NAME + " has a funny feeling like they would have been followed...")) .type(NotificationType.PET) .build() @@ -96,6 +107,15 @@ void testNotifyCollection() { // prepare mocks when(itemSearcher.findItemId("Tzrek-jad")).thenReturn(itemId); when(client.getVarbitValue(Varbits.COLLECTION_LOG_NOTIFICATION)).thenReturn(1); + String npcName = "TzTok-Jad"; + NPC npc = mock(NPC.class); + when(npc.getName()).thenReturn(npcName); + when(npc.getId()).thenReturn(NpcID.TZTOKJAD); + int kc = 100; + double rarity = 1.0 / 200; + double luck = MathUtils.cumulativeGeometric(rarity, kc); + when(configManager.getRSProfileConfiguration("killcount", npcName.toLowerCase(), int.class)).thenReturn(kc); + killCountService.onNpcKill(new NpcLootReceived(npc, Collections.emptyList())); // send fake message notifier.onChatMessage("You have a funny feeling like you're being followed."); @@ -107,7 +127,7 @@ void testNotifyCollection() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, null, false, false)) + .extra(new PetNotificationData(petName, null, false, false, rarity, kc, luck)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -134,7 +154,7 @@ void testNotifyLostExistingCollection() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, null, false, true)) + .extra(new PetNotificationData(petName, null, false, true, 1.0 / 200, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -160,7 +180,7 @@ void testNotifyUntradeable() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, null, false, null)) + .extra(new PetNotificationData(petName, null, false, null, 1.0 / 200, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -186,7 +206,7 @@ void testNotifyUntradeableDuplicate() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, null, true, true)) + .extra(new PetNotificationData(petName, null, true, true, 1.0 / 200, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -209,7 +229,7 @@ void testNotifyUntradeableNotARealPet() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -248,7 +268,7 @@ void testNotifyMultipleSameName() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, "50 killcount", false, null)) + .extra(new PetNotificationData(petName, "50 killcount", false, null, 1.0 / 200, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -286,7 +306,7 @@ void testNotifyClan() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(petName, "50 killcount", false, null)) + .extra(new PetNotificationData(petName, "50 killcount", false, null, 1.0 / 200, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .thumbnailUrl(ItemUtils.getItemImageUrl(itemId)) .type(NotificationType.PET) @@ -311,7 +331,7 @@ void testNotifyOverride() { "https://example.com", false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " has a funny feeling like they're being followed")) .type(NotificationType.PET) .build() @@ -356,7 +376,7 @@ void testNotifySeasonal() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .type(NotificationType.PET) .build() @@ -392,7 +412,7 @@ void testNotifyIrrelevantNameIgnore() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .type(NotificationType.PET) .build() @@ -415,7 +435,7 @@ void testNotifyNameAllowList() { PRIMARY_WEBHOOK_URL, false, NotificationBody.builder() - .extra(new PetNotificationData(null, null, false, null)) + .extra(new PetNotificationData(null, null, false, null, null, null, null)) .text(buildTemplate(PLAYER_NAME + " got a pet")) .type(NotificationType.PET) .build()); diff --git a/src/test/java/dinkplugin/util/SerializedLootTest.java b/src/test/java/dinkplugin/util/SerializedLootTest.java new file mode 100644 index 00000000..6e266d09 --- /dev/null +++ b/src/test/java/dinkplugin/util/SerializedLootTest.java @@ -0,0 +1,19 @@ +package dinkplugin.util; + +import com.google.gson.Gson; +import net.runelite.api.ItemID; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SerializedLootTest { + + @Test + void deserialize() { + String json = "{\"type\":\"NPC\",\"name\":\"Bryophyta\",\"kills\":16,\"first\":1708910620551,\"last\":1708983457752,\"drops\":[23182,16,532,16,1618,5,1620,5,2363,2,560,100,1079,2,890,100,1303,1,1113,2,1147,2,562,200,1124,5,1289,4,563,200]}"; + SerializedLoot lootRecord = new Gson().fromJson(json, SerializedLoot.class); + assertEquals(16, lootRecord.getKills()); + assertEquals(2, lootRecord.getQuantity(ItemID.RUNE_CHAINBODY)); + } + +}