Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add item rarity to loot notifications for monsters #425

Merged
merged 15 commits into from
Feb 20, 2024
Merged
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Ignore build directory
build/

# Ignore runtime resources
src/main/resources/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- Major: Add item rarity to NPC loot notifications. (#425)
- Minor: Include relevant kill count in collection log notifications. (#424)
- Minor: Obtain kill count from chat commands plugin for loot notifications. (#392)
- Minor: Add chat command to obtain the player's dink hash. (#408)
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w
- [Death](#death): Send a webhook message upon dying (with special configuration for PK deaths)
- [Collection](#collection): Send a webhook message upon adding an item to your collection log
- [Level](#level): Send a webhook message upon leveling up a skill (with support for virtual levels)
- [Loot](#loot): Send a webhook message upon receiving valuable loot
- [Loot](#loot): Send a webhook message upon receiving valuable loot (with item rarity for monster drops)
- [Slayer](#slayer): Send a webhook message upon completing a slayer task (with a customizable point threshold)
- [Quests](#quests): Send a webhook message upon completing a quest
- [Clue Scrolls](#clue-scrolls): Send a webhook message upon solving a clue scroll (with customizable tier/value thresholds)
Expand Down Expand Up @@ -276,3 +276,6 @@ On login, Dink can submit a character summary containing data that spans multipl
## Credits

This plugin uses code from [Universal Discord Notifier](https://github.com/MidgetJake/UniversalDiscordNotifier).

Item rarity data is sourced from the OSRS Wiki (licensed under [CC BY-NC-SA 3.0](https://creativecommons.org/licenses/by-nc-sa/3.0/)),
which was conveniently parsed by [Flipping Utilities](https://github.com/Flipping-Utilities/parsed-osrs) (and [transformed](https://github.com/pajlads/DinkPlugin/blob/master/src/test/java/dinkplugin/RarityCalculator.java) by pajlads).
10 changes: 9 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ tasks.withType<JavaCompile> {
}

tasks.test {
useJUnitPlatform()
useJUnitPlatform {
excludeTags("generator")
}
}

tasks.register(name = "generateResources", type = Test::class) {
useJUnitPlatform {
includeTags("generator")
}
}

tasks.register(name = "shadowJar", type = Jar::class) {
Expand Down
3 changes: 2 additions & 1 deletion docs/json-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ JSON for Loot Notifications:
],
"source": "Giant rat",
"category": "NPC",
"killCount": 60
"killCount": 60,
"rarestProbability": 0.001
},
"type": "LOOT"
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/dinkplugin/message/Field.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dinkplugin.message;

import dinkplugin.util.MathUtils;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
Expand Down Expand Up @@ -33,4 +34,10 @@ public static String formatProgress(int completed, int total) {
double percent = 100.0 * completed / total;
return Field.formatBlock("", String.format("%d/%d (%.1f%%)", completed, total, percent));
}

public static String formatProbability(double p) {
String percentage = MathUtils.formatPercentage(p, 3);
double denominator = 1 / p;
return Field.formatBlock("", String.format("1 in %.1f (%s)", denominator, percentage));
}
}
33 changes: 32 additions & 1 deletion src/main/java/dinkplugin/notifiers/LootNotifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import dinkplugin.util.ConfigUtil;
import dinkplugin.util.ItemUtils;
import dinkplugin.util.KillCountService;
import dinkplugin.util.RarityService;
import dinkplugin.util.Utils;
import dinkplugin.util.WorldUtils;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -25,12 +26,17 @@
import net.runelite.client.util.QuantityFormatter;
import net.runelite.http.api.loottracker.LootRecordType;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.Nullable;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand All @@ -45,6 +51,9 @@ public class LootNotifier extends BaseNotifier {
@Inject
private KillCountService killCountService;

@Inject
private RarityService rarityService;

private final Collection<Pattern> itemNameAllowlist = new CopyOnWriteArrayList<>();
private final Collection<Pattern> itemNameDenylist = new CopyOnWriteArrayList<>();

Expand Down Expand Up @@ -163,6 +172,8 @@ private void handleNotify(Collection<ItemStack> items, String dropper, LootRecor
totalStackValue += totalPrice;
}

Map.Entry<SerializedItemStack, Double> rarest = getRarestDropRate(items, dropper, type);

Evaluable lootMsg;
if (!sendMessage && totalStackValue >= minValue && max != null && "Loot Chest".equalsIgnoreCase(dropper)) {
// Special case: PK loot keys should trigger notification if total value exceeds configured minimum even
Expand Down Expand Up @@ -190,6 +201,7 @@ private void handleNotify(Collection<ItemStack> items, String dropper, LootRecor
overrideUrl = config.pkWebhook();
}
}
Double rarity = rarest != null ? rarest.getValue() : null;
boolean screenshot = config.lootSendImage() && totalStackValue >= config.lootImageMinValue();
Template notifyMessage = Template.builder()
.template(config.lootNotifyMessage())
Expand All @@ -203,14 +215,33 @@ private void handleNotify(Collection<ItemStack> items, String dropper, LootRecor
NotificationBody.builder()
.text(notifyMessage)
.embeds(embeds)
.extra(new LootNotificationData(serializedItems, dropper, type, kc))
.extra(new LootNotificationData(serializedItems, dropper, type, kc, rarity))
.type(NotificationType.LOOT)
.thumbnailUrl(ItemUtils.getItemImageUrl(max.getId()))
.build()
);
}
}

@Nullable
private Map.Entry<SerializedItemStack, Double> getRarestDropRate(Collection<ItemStack> items, String dropper, LootRecordType type) {
if (type != LootRecordType.NPC) return null;
return items.stream()
.map(item -> {
OptionalDouble rarity = rarityService.getRarity(dropper, item.getId(), item.getQuantity());
if (rarity.isEmpty()) return null;
return Map.entry(item, rarity.getAsDouble());
})
.filter(Objects::nonNull)
.min(Comparator.comparingDouble(Map.Entry::getValue))
.map(pair -> {
ItemStack item = pair.getKey();
SerializedItemStack stack = ItemUtils.stackFromItem(itemManager, item.getId(), item.getQuantity());
return Map.entry(stack, pair.getValue());
})
.orElse(null);
}

private static boolean matches(Collection<Pattern> regexps, String input) {
for (Pattern regex : regexps) {
if (regex.matcher(input).find())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import lombok.Value;
import net.runelite.client.util.QuantityFormatter;
import net.runelite.http.api.loottracker.LootRecordType;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.List;
Expand All @@ -17,11 +18,16 @@ public class LootNotificationData extends NotificationData {
List<SerializedItemStack> items;
String source;
LootRecordType category;

@Nullable
Integer killCount;

@Nullable
Double rarestProbability;

@Override
public List<Field> getFields() {
List<Field> fields = new ArrayList<>(2);
List<Field> fields = new ArrayList<>(3);
if (killCount != null) {
fields.add(
new Field(
Expand All @@ -36,6 +42,9 @@ public List<Field> getFields() {
ItemUtils.formatGold(items.stream().mapToLong(SerializedItemStack::getTotalPrice).sum())
)
);
if (rarestProbability != null) {
fields.add(new Field("Item Rarity", Field.formatProbability(rarestProbability)));
}
return fields;
}
}
40 changes: 40 additions & 0 deletions src/main/java/dinkplugin/util/MathUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package dinkplugin.util;

import lombok.experimental.UtilityClass;

import java.math.BigDecimal;
import java.math.MathContext;

@UtilityClass
public class MathUtils {
public static final double EPSILON = 0.00001;
private static final int[] FACTORIALS;

public String formatPercentage(double d, int sigFigs) {
return BigDecimal.valueOf(d * 100)
.round(new MathContext(sigFigs))
.stripTrailingZeros()
.toPlainString() + '%';
}

public double binomialProbability(double p, int nTrials, int kSuccess) {
// https://en.wikipedia.org/wiki/Binomial_distribution#Probability_mass_function
return binomialCoefficient(nTrials, kSuccess) * Math.pow(p, kSuccess) * Math.pow(1 - p, nTrials - kSuccess);
}

private int binomialCoefficient(int n, int k) {
assert n < FACTORIALS.length && k <= n && k >= 0;
return FACTORIALS[n] / (FACTORIALS[k] * FACTORIALS[n - k]); // https://en.wikipedia.org/wiki/nCk
}

static {
// precompute factorials from 0 to 9 for n-choose-k formula
int n = 10; // max rolls in npc_drops.json is 9 (for Bloodthirsty Leagues IV tier 5 relic)
int[] facts = new int[n];
facts[0] = 1; // 0! = 1
for (int i = 1; i < n; i++) {
facts[i] = i * facts[i - 1];
}
FACTORIALS = facts;
}
}
114 changes: 114 additions & 0 deletions src/main/java/dinkplugin/util/RarityService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package dinkplugin.util;

import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ItemComposition;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.ItemVariationMapping;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalDouble;
import java.util.stream.Collectors;

@Slf4j
@Singleton
public class RarityService {
private final Map<String, Collection<Drop>> dropsByNpcName = new HashMap<>(1024);
private @Inject Gson gson;
private @Inject ItemManager itemManager;

@Inject
void init() {
Map<String, List<RawDrop>> raw;
try (InputStream is = getClass().getResourceAsStream("/npc_drops.json");
Reader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(is)))) {
raw = gson.fromJson(reader,
new TypeToken<Map<String, List<RawDrop>>>() {}.getType());
} catch (Exception e) {
log.error("Failed to read monster drop rates", e);
return;
}

raw.forEach((npcName, rawDrops) -> {
List<Drop> drops = rawDrops.stream()
.map(RawDrop::transform)
.flatMap(Collection::stream)
.collect(Collectors.toList());
dropsByNpcName.put(npcName, drops);
});
}

public OptionalDouble getRarity(String npcName, int itemId, int quantity) {
ItemComposition composition = itemManager.getItemComposition(itemId);
int canonical = composition.getNote() != -1 ? composition.getLinkedNoteId() : itemId;
String itemName = composition.getMembersName();
Collection<Integer> variants = new HashSet<>(
ItemVariationMapping.getVariations(ItemVariationMapping.map(canonical))
);
return dropsByNpcName.getOrDefault(npcName, Collections.emptyList())
.stream()
.filter(drop -> drop.getMinQuantity() <= quantity && quantity <= drop.getMaxQuantity())
.filter(drop -> {
int id = drop.getItemId();
if (id == canonical) return true;
return variants.contains(id) && itemName.equals(itemManager.getItemComposition(id).getMembersName());
})
.mapToDouble(Drop::getProbability)
.reduce(Double::sum);
}

@Value
private static class Drop {
int itemId;
int minQuantity;
int maxQuantity;
double probability;
}

@Data
@Setter(AccessLevel.PRIVATE)
private static class RawDrop {
private @SerializedName("i") int itemId;
private @SerializedName("r") Integer rolls;
private @SerializedName("d") double denominator;
private @SerializedName("q") Integer quantity;
private @SerializedName("m") Integer quantMin;
private @SerializedName("n") Integer quantMax;

Collection<Drop> transform() {
int rounds = rolls != null ? rolls : 1;
int min = quantMin != null ? quantMin : quantity;
int max = quantMax != null ? quantMax : quantity;
double prob = 1 / denominator;

if (rounds == 1) {
return List.of(new Drop(itemId, min, max, prob));
}
List<Drop> drops = new ArrayList<>(rounds);
for (int successCount = 1; successCount <= rounds; successCount++) {
double density = MathUtils.binomialProbability(prob, rounds, successCount);
drops.add(new Drop(itemId, min * successCount, max * successCount, density));
}
return drops;
}
}
}
1 change: 1 addition & 0 deletions src/main/resources/npc_drops.json

Large diffs are not rendered by default.

Loading
Loading