Skip to content

Commit

Permalink
feat: add rarity for npc pickpocketing (#571)
Browse files Browse the repository at this point in the history
  • Loading branch information
iProdigy authored Oct 19, 2024
1 parent 5da1b85 commit 8ae550b
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 160 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: Add rarity information on select pickpocketing drops. (#571)
- Bugfix: Enforce value threshold for always-dropped loot when rarity threshold is 1 and require both value and rarity is true. (#560)
- Dev: Optimize regex performance for looted items on the item denylist. (#565)

Expand Down
2 changes: 1 addition & 1 deletion docs/json-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ The possible values for `extra.category` correspond to the [`LootRecordType`](ht
`killCount` is only specified for NPC/EVENT loot with the base RuneLite Loot Tracker plugin enabled.
`rarity` is currently only populated for NPC drops. This data is (imperfectly) scraped from the wiki, so it may not be 100% accurate. Also, we do not report a rarity if the NPC always drops the item on every kill.
`rarity` is currently only populated for NPC drops (and some pickpocket events). This data is (imperfectly) scraped from the wiki, so it may not be 100% accurate. Also, we do not report a rarity if the NPC always drops the item on every kill.
The items are valued at GE prices (when possible) if the user has not disabled the `Use actively traded price` base RuneLite setting. Otherwise, the store price of the item is used.
Expand Down
1 change: 1 addition & 0 deletions src/main/java/dinkplugin/VersionManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,6 @@ public static Version of(@NotNull String version) {
register("1.9.0", "Notifications now report monster drop rarity");
register("1.10.0", "Chat messages that match custom patterns can trigger notifications");
register("1.10.1", "Level notifier now triggers at XP milestones with 5M as the default interval");
register("1.10.12", "Rarity is now reported for notable pickpocket loot");
}
}
6 changes: 6 additions & 0 deletions src/main/java/dinkplugin/notifiers/LootNotifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import dinkplugin.util.ItemUtils;
import dinkplugin.util.KillCountService;
import dinkplugin.util.MathUtils;
import dinkplugin.util.ThievingService;
import dinkplugin.util.RarityService;
import dinkplugin.util.Utils;
import dinkplugin.util.WorldUtils;
Expand Down Expand Up @@ -53,6 +54,9 @@ public class LootNotifier extends BaseNotifier {
@Inject
private RarityService rarityService;

@Inject
private ThievingService thievingService;

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

Expand Down Expand Up @@ -162,6 +166,8 @@ private void handleNotify(Collection<ItemStack> items, String dropper, LootRecor
OptionalDouble rarity;
if (type == LootRecordType.NPC) {
rarity = rarityService.getRarity(dropper, item.getId(), item.getQuantity());
} else if (type == LootRecordType.PICKPOCKET) {
rarity = thievingService.getRarity(dropper, item.getId(), item.getQuantity());
} else {
rarity = OptionalDouble.empty();
}
Expand Down
107 changes: 107 additions & 0 deletions src/main/java/dinkplugin/util/AbstractRarityService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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 java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
public abstract class AbstractRarityService {

protected final Gson gson;
protected final ItemManager itemManager;
protected final Map<String, Collection<RareDrop>> dropsBySourceName;

AbstractRarityService(String resourceName, int expectedSize, Gson gson, ItemManager itemManager) {
this.gson = gson;
this.itemManager = itemManager;
this.dropsBySourceName = new HashMap<>(expectedSize);

Map<String, List<RawDrop>> raw;
try (InputStream is = getClass().getResourceAsStream(resourceName);
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((sourceName, rawDrops) -> {
ArrayList<RareDrop> drops = rawDrops.stream()
.map(RawDrop::transform)
.flatMap(Collection::stream)
.collect(Collectors.toCollection(ArrayList::new));
drops.trimToSize();
dropsBySourceName.put(sourceName, drops);
});
}

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

@Value
protected static class RareDrop {
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<RareDrop> 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 RareDrop(itemId, min, max, prob));
}
List<RareDrop> drops = new ArrayList<>(rounds);
for (int successCount = 1; successCount <= rounds; successCount++) {
double density = MathUtils.binomialProbability(prob, rounds, successCount);
drops.add(new RareDrop(itemId, min * successCount, max * successCount, density));
}
return drops;
}
}
}
105 changes: 3 additions & 102 deletions src/main/java/dinkplugin/util/RarityService.java
Original file line number Diff line number Diff line change
@@ -1,114 +1,15 @@
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;

public class RarityService extends AbstractRarityService {
@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 = itemId >= 0 ? itemManager.getItemComposition(itemId) : null;
int canonical = composition != null && composition.getNote() != -1 ? composition.getLinkedNoteId() : itemId;
String itemName = composition != null ? 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 == itemId) 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;
}
RarityService(Gson gson, ItemManager itemManager) {
super("/npc_drops.json", 1024, gson, itemManager);
}
}
34 changes: 34 additions & 0 deletions src/main/java/dinkplugin/util/ThievingService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dinkplugin.util;

import com.google.gson.Gson;
import net.runelite.api.ItemID;
import net.runelite.client.game.ItemManager;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.OptionalDouble;

@Singleton
public class ThievingService extends AbstractRarityService {

@Inject
ThievingService(Gson gson, ItemManager itemManager) {
super("/thieving.json", 32, gson, itemManager);
}

@Override
public OptionalDouble getRarity(String sourceName, int itemId, int quantity) {
if (itemId == ItemID.BLOOD_SHARD) {
// https://oldschool.runescape.wiki/w/Blood_shard#Item_sources
return OptionalDouble.of(1.0 / 5000);
}

if (itemId == ItemID.ENHANCED_CRYSTAL_TELEPORT_SEED) {
// https://oldschool.runescape.wiki/w/Enhanced_crystal_teleport_seed#Item_sources
return OptionalDouble.of(1.0 / 1024);
}

return super.getRarity(sourceName, itemId, quantity);
}

}
1 change: 1 addition & 0 deletions src/main/resources/thieving.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"Guard":[{"i":2809,"d":128,"q":1},{"i":22879,"d":896,"q":2}],"Gnome":[{"i":2809,"d":150,"q":1}],"H.A.M. Member":[{"i":4298,"d":100,"q":1},{"i":4300,"d":100,"q":1},{"i":4302,"d":100,"q":1},{"i":4304,"d":100,"q":1},{"i":4306,"d":100,"q":1},{"i":4308,"d":100,"q":1},{"i":4310,"d":100,"q":1}],"Hero":[{"i":12157,"d":1400,"q":1}],"Master Farmer":[{"i":5295,"d":302,"q":1},{"i":5296,"d":443,"q":1},{"i":5298,"d":947,"q":1},{"i":5299,"d":1389,"q":1},{"i":5300,"d":2083,"q":1},{"i":5301,"d":2976,"q":1},{"i":5303,"d":6944,"q":1},{"i":5304,"d":10417,"q":1},{"i":22879,"d":260,"q":1}],"Paladin":[{"i":3560,"d":1000,"q":1}],"TzHaar-Hur":[{"i":1617,"d":195,"q":1}],"Wealthy citizen":[{"i":2711,"d":85,"q":1}]}
19 changes: 12 additions & 7 deletions src/test/java/dinkplugin/notifiers/LootNotifierTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

class LootNotifierTest extends MockedNotifierTest {

private static final int SHARD_PRICE = 10_000_000;
private static final int LARRAN_PRICE = 150_000;
private static final int RUBY_PRICE = 900;
private static final int OPAL_PRICE = 600;
Expand Down Expand Up @@ -78,6 +79,7 @@ protected void setUp() {
when(localPlayer.getWorldLocation()).thenReturn(location);

// init item mocks
mockItem(ItemID.BLOOD_SHARD, SHARD_PRICE, "Blood shard");
mockItem(ItemID.LARRANS_KEY, LARRAN_PRICE, "Larran's key");
mockItem(ItemID.RUBY, RUBY_PRICE, "Ruby");
mockItem(ItemID.OPAL, OPAL_PRICE, "Opal");
Expand Down Expand Up @@ -352,28 +354,31 @@ void testIgnoreNpc() {

@Test
void testNotifyPickpocket() {
String name = "Remus Kaninus";
NPC npc = Mockito.mock(NPC.class);
when(npc.getName()).thenReturn(LOOTED_NAME);
when(npc.getId()).thenReturn(9999);
when(npc.getName()).thenReturn(name);
when(npc.getId()).thenReturn(NpcID.REMUS_KANINUS);
mockWorldNpcs(npc);

// fire event
LootReceived event = new LootReceived(LOOTED_NAME, 99, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.RUBY, 1)), 1);
LootReceived event = new LootReceived(name, -1, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.BLOOD_SHARD, 1)), 1);
plugin.onLootReceived(event);

// verify notification message
double rarity = 1.0 / 5000;
String price = QuantityFormatter.quantityToStackSize(SHARD_PRICE);
verifyCreateMessage(
PRIMARY_WEBHOOK_URL,
false,
NotificationBody.builder()
.text(
Template.builder()
.template(String.format("%s has looted: 1 x {{ruby}} (%d) from {{source}} for %d gp", PLAYER_NAME, RUBY_PRICE, RUBY_PRICE))
.replacement("{{ruby}}", Replacements.ofWiki("Ruby"))
.replacement("{{source}}", Replacements.ofWiki(LOOTED_NAME))
.template(String.format("%s has looted: 1 x {{shard}} (%s) from {{source}} for %s gp", PLAYER_NAME, price, price))
.replacement("{{shard}}", Replacements.ofWiki("Blood shard"))
.replacement("{{source}}", Replacements.ofWiki(name))
.build()
)
.extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")), LOOTED_NAME, LootRecordType.PICKPOCKET, 1, null, null, 9999))
.extra(new LootNotificationData(Collections.singletonList(new RareItemStack(ItemID.BLOOD_SHARD, 1, SHARD_PRICE, "Blood shard", rarity)), name, LootRecordType.PICKPOCKET, 1, rarity, null, NpcID.REMUS_KANINUS))
.type(NotificationType.LOOT)
.build()
);
Expand Down
Loading

0 comments on commit 8ae550b

Please sign in to comment.