Skip to content

Commit

Permalink
feat: include rarity and luck in pet notifications (#433)
Browse files Browse the repository at this point in the history
  • Loading branch information
iProdigy authored Aug 5, 2024
1 parent 79c9ce5 commit 3806909
Show file tree
Hide file tree
Showing 10 changed files with 487 additions and 82 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/dinkplugin/message/Field.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
402 changes: 345 additions & 57 deletions src/main/java/dinkplugin/notifiers/PetNotifier.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,11 +67,7 @@ public List<Field> 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;
}
Expand Down
25 changes: 24 additions & 1 deletion src/main/java/dinkplugin/notifiers/data/PetNotificationData.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,41 @@ public class PetNotificationData extends NotificationData {
@Nullable
Boolean previouslyOwned;

/**
* The approximate drop rate of the pet.
* <p>
* 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.
* <p>
* This value is least accurate for skilling pets and pets dropped by multiple NPCs.
*/
@Nullable
Integer estimatedActions;

@Nullable
transient Double luck;

@Override
public List<Field> getFields() {
if (petName == null || petName.isEmpty())
return super.getFields();

List<Field> fields = new ArrayList<>(3);
List<Field> 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;
}

Expand Down
33 changes: 28 additions & 5 deletions src/main/java/dinkplugin/util/KillCountService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/dinkplugin/util/MathUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/dinkplugin/util/SerializedLoot.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
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.
*
* @see <a href="https://github.com/runelite/runelite/blob/master/runelite-client/src/main/java/net/runelite/client/plugins/loottracker/ConfigLoot.java#L41">RuneLite class</a>
*/
@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;
}
}
46 changes: 33 additions & 13 deletions src/test/java/dinkplugin/notifiers/PetNotifierTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +42,10 @@ class PetNotifierTest extends MockedNotifierTest {
@Mock
ItemSearcher itemSearcher;

@Bind
@InjectMocks
KillCountService killCountService;

@Override
@BeforeEach
protected void setUp() {
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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.");
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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());
Expand Down
19 changes: 19 additions & 0 deletions src/test/java/dinkplugin/util/SerializedLootTest.java
Original file line number Diff line number Diff line change
@@ -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));
}

}

0 comments on commit 3806909

Please sign in to comment.