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 rarity for npc pickpocketing #571

Merged
merged 9 commits into from
Oct 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading