aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRime <81419447+Emirlol@users.noreply.github.com>2025-07-08 08:38:04 +0300
committerGitHub <noreply@github.com>2025-07-08 13:38:04 +0800
commitd4068a43de3438ea223eff63ebe2f982ca969d1d (patch)
treee534781a652558f7760d6983bb153f1a688d6b11 /src
parent2d8144c11c3b5224e0d894c40d9edb9129ecf3d4 (diff)
downloadSkyblocker-d4068a43de3438ea223eff63ebe2f982ca969d1d.tar.gz
Skyblocker-d4068a43de3438ea223eff63ebe2f982ca969d1d.tar.bz2
Skyblocker-d4068a43de3438ea223eff63ebe2f982ca969d1d.zip
Add Sack Message Prices (#1379)
* Add SackMessagePrice * More documentation, renamed stuff, removed wrong logs * Explicit types * Add text content comment
Diffstat (limited to 'src')
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/chat/SackMessagePrice.java235
1 files changed, 235 insertions, 0 deletions
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/chat/SackMessagePrice.java b/src/main/java/de/hysky/skyblocker/skyblock/chat/SackMessagePrice.java
new file mode 100644
index 00000000..57205e6f
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/chat/SackMessagePrice.java
@@ -0,0 +1,235 @@
+package de.hysky.skyblocker.skyblock.chat;
+
+import de.hysky.skyblocker.annotations.Init;
+import de.hysky.skyblocker.skyblock.item.tooltip.ItemTooltip;
+import de.hysky.skyblocker.skyblock.item.tooltip.adders.LineSmoothener;
+import de.hysky.skyblocker.skyblock.item.tooltip.info.TooltipInfoType;
+import de.hysky.skyblocker.utils.BazaarProduct;
+import de.hysky.skyblocker.utils.NEURepoManager;
+import de.hysky.skyblocker.utils.RegexUtils;
+import io.github.moulberry.repo.data.NEUItem;
+import it.unimi.dsi.fastutil.objects.*;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientWorldEvents;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.HoverEvent.ShowText;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.PlainTextContent.Literal;
+import net.minecraft.text.Style;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.OptionalDouble;
+import java.util.OptionalInt;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Adds npc/bazaar prices to the sack messages sent by the server when items are added or removed from a sack.
+ */
+public class SackMessagePrice {
+ private static final Pattern ITEM_COUNT_PATTERN = Pattern.compile("([-+][\\d,]+)");
+ private static final Logger LOGGER = LoggerFactory.getLogger(SackMessagePrice.class);
+ /**
+ * <p>
+ * Cache that holds item name to NEU ID mappings.
+ * This helps over time when farming similar items in the same world, as it avoids repeated lookups in a very large item list.
+ * The Sack message is only sent like every 30s in a normal farming case, so there's not much performance impact anyway, but it still helps.
+ * </p>
+ * <p>
+ * Additionally, there are 2 identical hover events per message, and they are processed separately, so this cache will be hit at least once per message, cutting the lookup time in half or more.
+ * </p>
+ */
+ private static final Object2ObjectOpenHashMap<String, String> NAME_2_ID_CACHE = new Object2ObjectOpenHashMap<>();
+
+ @Init
+ public static void init() {
+ ClientWorldEvents.AFTER_CLIENT_WORLD_CHANGE.register(((client, world) -> NAME_2_ID_CACHE.clear()));
+ ClientReceiveMessageEvents.MODIFY_GAME.register(SackMessagePrice::onMessage);
+ }
+
+ // This can probably be split into a few methods, but it'll require so much argument passing that it's going to be a mess anyhow.
+ private static Text onMessage(Text original, boolean overlay) {
+ if (overlay) return original;
+
+ String string = original.getString();
+ if (!string.startsWith("[Sacks] ")) return original;
+
+ MutableText copy = deepCopy(original); // We need to copy the original since it's completely immutable when constructed from a packet.
+
+ ObjectArrayList<List<Text>> listList = getHoverEventSiblings(copy); // We use the copied one here so that any changes to the lists do not mutate the original
+ if (listList.isEmpty()) return original;
+
+ for (List<Text> textList : listList) {
+ Object2IntMap<String> items = parseItems(textList);
+ if (items.isEmpty()) {
+ LOGGER.warn("No items found in sack message: `{}`", original.getString());
+ return original; // If we couldn't parse any items, we return the original text
+ }
+ double npcPrice = 0;
+ double bazaarBuyPrice = 0;
+ double bazaarSellPrice = 0;
+ for (var entry : items.object2IntEntrySet()) {
+ String itemName = entry.getKey();
+ int count = entry.getIntValue();
+
+ String neuId = getNeuId(itemName);
+ if (neuId == null) {
+ LOGGER.warn("Failed to find NEU ID for item: `{}`. This item will not be priced.", itemName);
+ continue; // If we couldn't find the item ID, we skip this item
+ }
+
+ Object2DoubleMap<String> npcData = TooltipInfoType.NPC.getData();
+ if (npcData != null) npcPrice += npcData.getOrDefault(neuId, 0) * count;
+ else LOGGER.warn("No NPC data found for item: `{}`", neuId);
+
+ Object2ObjectMap<String, BazaarProduct> bazaarData = TooltipInfoType.BAZAAR.getData();
+ if (bazaarData != null) {
+ BazaarProduct itemData = bazaarData.get(neuId);
+ if (itemData != null) {
+ OptionalDouble buyPrice = itemData.buyPrice();
+ if (buyPrice.isPresent()) bazaarBuyPrice += buyPrice.getAsDouble() * count;
+ OptionalDouble sellPrice = itemData.sellPrice();
+ if (sellPrice.isPresent()) bazaarSellPrice += sellPrice.getAsDouble() * count;
+ } else {
+ LOGGER.warn("No item data found for item `{}` in bazaar price data.", neuId);
+ }
+ }
+ }
+ textList.add(ScreenTexts.LINE_BREAK);
+ textList.add(LineSmoothener.createSmoothLine());
+ textList.add(ScreenTexts.LINE_BREAK);
+
+ textList.add(Text.empty()
+ .append(Text.literal("NPC Sell Price: ").formatted(Formatting.YELLOW))
+ .append(npcPrice > 0
+ ? ItemTooltip.getCoinsMessage(npcPrice, 1)
+ : Text.literal("No data").formatted(Formatting.RED)));
+ textList.add(ScreenTexts.LINE_BREAK);
+ textList.add(Text.empty()
+ .append(Text.literal("Bazaar Buy Price: ").formatted(Formatting.GOLD))
+ .append(bazaarBuyPrice > 0
+ ? ItemTooltip.getCoinsMessage(bazaarBuyPrice, 1)
+ : Text.literal("No data").formatted(Formatting.RED)));
+ textList.add(ScreenTexts.LINE_BREAK);
+ textList.add(Text.empty()
+ .append(Text.literal("Bazaar Sell Price: ").formatted(Formatting.GOLD))
+ .append(bazaarSellPrice > 0
+ ? ItemTooltip.getCoinsMessage(bazaarSellPrice, 1)
+ : Text.literal("No data").formatted(Formatting.RED)));
+ }
+
+ return copy;
+ }
+
+ /**
+ * Recursively creates a deep copy of a {@link Text} object.
+ *
+ * @param text The text to copy
+ * @return A deep copy of the text, with the same content and style, but no references to the original text
+ * @implNote Technically, this is not a deep <i>deep</i> copy, as it does not clone the underlying objects in the style.
+ * However, there's a special case for hover events, which are cloned to ensure that the hover text is also a deep copy, and that's enough for our use case.
+ * If you need a true deep copy, do not copy this directly and expect it to work.
+ */
+ private static MutableText deepCopy(Text text) {
+ MutableText copy = text.copyContentOnly();
+
+ if (text.getStyle().getHoverEvent() instanceof ShowText(Text showText)) {
+ // DO NOT simplify to `text.getStyle().withHoverEvent(new ShowText(showText);`,
+ // Style.withHoverEvent(hoverEvent) checks if the given value is equal to the current one, and since our text clone is equal by value, it will not create a new style.
+ // This means the original hover event will still be used which is immutable, and our list modifications will fail with an UnsupportedOperationException in #onMessage.
+ copy.setStyle(Style.EMPTY.withHoverEvent(new ShowText(deepCopy(showText))).withParent(text.getStyle()));
+ } else copy.setStyle(text.getStyle());
+
+ for (Text sibling : text.getSiblings()) {
+ copy.append(deepCopy(sibling));
+ }
+
+ return copy;
+ }
+
+ @Nullable
+ private static String getNeuId(@NotNull String itemName) {
+ return NAME_2_ID_CACHE.computeIfAbsent(itemName, ignored ->
+ NEURepoManager.NEU_REPO.getItems()
+ .getItems()
+ .values()
+ .stream()
+ .filter(item -> Formatting.strip(item.getDisplayName()).equals(itemName))
+ .findFirst()
+ .map(NEUItem::getSkyblockItemId)
+ .orElseGet(() -> {
+ LOGGER.warn("Failed to find item ID for item: {}", itemName);
+ return null; // This won't be entered into the cache, nor will it be used to calculate the price
+ })
+ );
+ }
+
+ @NotNull
+ private static ObjectArrayList<List<Text>> getHoverEventSiblings(@NotNull Text text) {
+ ObjectArrayList<List<Text>> listList = new ObjectArrayList<>();
+ for (Text sibling : text.getSiblings()) {
+ if (sibling.getStyle().getHoverEvent() instanceof ShowText(Text hoverText)
+ && hoverText.getContent() instanceof Literal(String rootContent) // Only match the root content since we only need the root content.
+ && StringUtils.startsWithAny(rootContent, "Added items:", "Removed items:")) {
+ listList.add(hoverText.getSiblings());
+ }
+ }
+ return listList;
+ }
+
+ /**
+ * Parses the items and their counts from the siblings of the hover text.
+ *
+ * @return A map of item names to their counts.
+ */
+ @SuppressWarnings("ConstantValue") // It's much easier to read this way.
+ @NotNull
+ private static Object2IntArrayMap<String> parseItems(@NotNull List<Text> texts) {
+ /*
+ The hover message's structure is as follows:
+ - Added items:
+ - item count
+ - item name
+ - sack name
+ (for any other item, repeat the above three lines with a newline in between)
+ - \n\n
+ - `This message can be toggled off in the settings` warning
+
+ We only care about the groups of three lines that make up each item entry.
+ */
+ Object2IntArrayMap<String> items = new Object2IntArrayMap<>();
+ Integer lastCount = null;
+ String lastItemName = null;
+ for (Text text : texts) {
+ if (text.getContent() instanceof Literal(String content)) { // Only match the root content since we only need the root content.
+ if (content.equals("\n\n")) break; // End of items list, we can stop parsing here - NOTE: This has to come before the isBlank check, otherwise it will be skipped.
+ if (content.isBlank()) continue; // This includes \n lines, which we don't want to try and parse as item names or counts
+
+ String trimmed = content.trim();
+ if (lastCount == null && lastItemName == null) { // Initial state, item count comes first
+ Matcher matcher = ITEM_COUNT_PATTERN.matcher(trimmed);
+ OptionalInt count = RegexUtils.findIntFromMatcher(matcher);
+ if (count.isEmpty()) {
+ LOGGER.error("Failed to parse item count from text content: `{}`", trimmed);
+ return new Object2IntArrayMap<>(); // Something went wrong, so we panic and not modify the text.
+ }
+ lastCount = count.getAsInt();
+ } else if (lastCount != null && lastItemName == null) { // The item name comes next
+ lastItemName = trimmed;
+ } else if (lastCount != null && lastItemName != null) { // Then comes the sack name, which we can ignore but this iteration can still be used for finalizing the cycle
+ items.put(lastItemName, lastCount.intValue());
+ lastCount = null;
+ lastItemName = null;
+ }
+ }
+ }
+ return items;
+ }
+}