From e2659c8ced1685fa0c231195db53df45500a6144 Mon Sep 17 00:00:00 2001 From: Rime <81419447+Emirlol@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:49:13 +0300 Subject: Corpse profit tracker (#1152) * Corpse profit tracker initial commit * A bunch of stuff - Extract `CorpseLoot` and `Reward` inner classes into separate classes - Use translatables where it makes sense - Add config option to toggle corpse profit tracker (can't believe I forgot this on corpse tracker as well) - Some minor command adjustments and fixes to both `PowderMiningTracker` and `CorpseProfitTracker` - Refactored `CorpseProfitHistoryScreen` into `CorpseProfitScreen`. The button that switched from one to the other is now a view switch button that changes the displayed list, and functionality of both screens is combined in one. - Add hover/click events to the profit text sent when a corpse is looted that opens the screen * Round the chat message so it's more copy-friendly * Fix keys' total price not being multiplied by key amount * Fix `pricePerUnit` display of items to match keys This way, they don't display the price per unit when the amount is 1. --- .../config/categories/MiningCategory.java | 33 +- .../skyblocker/config/configs/MiningConfig.java | 3 + .../powdertracker/PowderFilterConfigScreen.java | 4 +- .../skyblocker/skyblock/dwarven/CorpseFinder.java | 57 +--- .../skyblocker/skyblock/dwarven/CorpseType.java | 71 +++++ .../skyblock/dwarven/PowderMiningTracker.java | 319 -------------------- .../profittrackers/AbstractProfitTracker.java | 26 ++ .../profittrackers/PowderMiningTracker.java | 331 +++++++++++++++++++++ .../dwarven/profittrackers/corpse/CorpseList.java | 251 ++++++++++++++++ .../dwarven/profittrackers/corpse/CorpseLoot.java | 82 +++++ .../profittrackers/corpse/CorpseProfitScreen.java | 103 +++++++ .../profittrackers/corpse/CorpseProfitTracker.java | 279 +++++++++++++++++ .../dwarven/profittrackers/corpse/Reward.java | 46 +++ .../dwarven/profittrackers/corpse/RewardList.java | 256 ++++++++++++++++ 14 files changed, 1479 insertions(+), 382 deletions(-) create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseType.java delete mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/AbstractProfitTracker.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseList.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseLoot.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitScreen.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitTracker.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/Reward.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/RewardList.java (limited to 'src/main/java') diff --git a/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java b/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java index 3eed81da..4b7d198d 100644 --- a/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java +++ b/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java @@ -6,7 +6,7 @@ import de.hysky.skyblocker.config.configs.MiningConfig; import de.hysky.skyblocker.config.screens.powdertracker.PowderFilterConfigScreen; import de.hysky.skyblocker.skyblock.dwarven.CrystalsHudWidget; import de.hysky.skyblocker.skyblock.dwarven.CarpetHighlighter; -import de.hysky.skyblocker.skyblock.dwarven.PowderMiningTracker; +import de.hysky.skyblocker.skyblock.dwarven.profittrackers.PowderMiningTracker; import de.hysky.skyblocker.skyblock.tabhud.widget.CommsWidget; import dev.isxander.yacl3.api.*; import dev.isxander.yacl3.api.controller.ColorControllerBuilder; @@ -122,12 +122,23 @@ public class MiningCategory { newValue -> config.mining.crystalHollows.chestHighlightColor = newValue) .controller(v -> ColorControllerBuilder.create(v).allowAlpha(true)) .build()) - .option(ButtonOption.createBuilder() - .name(Text.translatable("skyblocker.config.mining.crystalHollows.powderTrackerFilter")) - .description(OptionDescription.of(Text.translatable("skyblocker.config.mining.crystalHollows.powderTrackerFilter.@Tooltip"))) - .text(Text.translatable("text.skyblocker.open")) - .action((screen, opt) -> MinecraftClient.getInstance().setScreen(new PowderFilterConfigScreen(screen, new ObjectImmutableList<>(PowderMiningTracker.getName2IdMap().keySet())))) - .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.mining.crystalHollows.enablePowderTracker")) + .description(OptionDescription.of(Text.translatable("skyblocker.config.mining.crystalHollows.enablePowderTracker.@Tooltip"))) + .binding(defaults.mining.crystalHollows.enablePowderTracker, + () -> config.mining.crystalHollows.enablePowderTracker, + newValue -> { + config.mining.crystalHollows.enablePowderTracker = newValue; + if (newValue) PowderMiningTracker.INSTANCE.recalculateAll(); + }) + .controller(ConfigUtils::createBooleanController) + .build()) + .option(ButtonOption.createBuilder() + .name(Text.translatable("skyblocker.config.mining.crystalHollows.powderTrackerFilter")) + .description(OptionDescription.of(Text.translatable("skyblocker.config.mining.crystalHollows.powderTrackerFilter.@Tooltip"))) + .text(Text.translatable("text.skyblocker.open")) + .action((screen, opt) -> MinecraftClient.getInstance().setScreen(new PowderFilterConfigScreen(screen, new ObjectImmutableList<>(PowderMiningTracker.getName2IdMap().keySet())))) + .build()) .build()) //Crystal Hollows Map @@ -287,6 +298,14 @@ public class MiningCategory { newValue -> config.mining.glacite.autoShareCorpses = newValue) .controller(ConfigUtils::createBooleanController) .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.mining.glacite.enableCorpseProfitTracker")) + .description(OptionDescription.of(Text.translatable("skyblocker.config.mining.glacite.enableCorpseProfitTracker.@Tooltip"))) + .binding(defaults.mining.glacite.enableCorpseProfitTracker, + () -> config.mining.glacite.enableCorpseProfitTracker, + newValue -> config.mining.glacite.enableCorpseProfitTracker = newValue) + .controller(ConfigUtils::createBooleanController) + .build()) .build()) .build(); } diff --git a/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java b/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java index 34c59429..33ed1b05 100644 --- a/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java +++ b/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java @@ -173,6 +173,9 @@ public class MiningConfig { @SerialEntry public boolean autoShareCorpses = false; + + @SerialEntry + public boolean enableCorpseProfitTracker = true; } /** diff --git a/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java b/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java index fbd2668a..a5c90a2a 100644 --- a/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java +++ b/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java @@ -1,7 +1,7 @@ package de.hysky.skyblocker.config.screens.powdertracker; import de.hysky.skyblocker.config.SkyblockerConfigManager; -import de.hysky.skyblocker.skyblock.dwarven.PowderMiningTracker; +import de.hysky.skyblocker.skyblock.dwarven.profittrackers.PowderMiningTracker; import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.widget.ButtonWidget; @@ -63,7 +63,7 @@ public class PowderFilterConfigScreen extends Screen { public void saveFilters() { SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter = filters; SkyblockerConfigManager.save(); - PowderMiningTracker.recalculateAll(); + PowderMiningTracker.INSTANCE.recalculateAll(); } @Override diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseFinder.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseFinder.java index f3dd9aab..3f4ec90a 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseFinder.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseFinder.java @@ -1,13 +1,12 @@ package de.hysky.skyblocker.skyblock.dwarven; import com.mojang.brigadier.Command; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.serialization.Codec; import de.hysky.skyblocker.SkyblockerMod; import de.hysky.skyblocker.annotations.Init; import de.hysky.skyblocker.config.SkyblockerConfigManager; import de.hysky.skyblocker.debug.Debug; import de.hysky.skyblocker.events.SkyblockEvents; +import de.hysky.skyblocker.skyblock.dwarven.CorpseType.CorpseTypeArgumentType; import de.hysky.skyblocker.utils.*; import de.hysky.skyblocker.utils.command.argumenttypes.blockpos.ClientBlockPosArgumentType; import de.hysky.skyblocker.utils.scheduler.MessageScheduler; @@ -19,7 +18,6 @@ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents; import net.minecraft.client.MinecraftClient; -import net.minecraft.command.argument.EnumArgumentType; import net.minecraft.entity.Entity; import net.minecraft.entity.EquipmentSlot; import net.minecraft.entity.decoration.ArmorStandEntity; @@ -27,7 +25,6 @@ import net.minecraft.text.ClickEvent; import net.minecraft.text.HoverEvent; import net.minecraft.text.Text; import net.minecraft.util.Formatting; -import net.minecraft.util.StringIdentifiable; import net.minecraft.util.Util; import net.minecraft.util.math.BlockPos; import org.apache.commons.lang3.EnumUtils; @@ -49,10 +46,6 @@ public class CorpseFinder { private static final String PREFIX = "[Skyblocker Corpse Finder] "; private static final Logger LOGGER = LoggerFactory.getLogger(CorpseFinder.class); private static final Map> corpsesByType = new EnumMap<>(CorpseType.class); - private static final String LAPIS_HELMET = "LAPIS_ARMOR_HELMET"; - private static final String UMBER_HELMET = "ARMOR_OF_YOG_HELMET"; - private static final String TUNGSTEN_HELMET = "MINERAL_HELMET"; - private static final String VANGUARD_HELMET = "VANGUARD_HELMET"; @Init public static void init() { @@ -78,9 +71,9 @@ public class CorpseFinder { .then(literal("corpseHelper") .then(literal("shareLocation") .then(argument("blockPos", ClientBlockPosArgumentType.blockPos()) - .then(argument("corpseType", CorpseType.CorpseTypeArgumentType.corpseType()) + .then(argument("corpseType", CorpseTypeArgumentType.corpseType()) .executes(context -> { - shareLocation(ClientBlockPosArgumentType.getBlockPos(context, "blockPos"), CorpseType.CorpseTypeArgumentType.getCorpseType(context, "corpseType")); + shareLocation(ClientBlockPosArgumentType.getBlockPos(context, "blockPos"), CorpseTypeArgumentType.getCorpseType(context, "corpseType")); return Command.SINGLE_SUCCESS; }) ) @@ -250,50 +243,6 @@ public class CorpseFinder { } } - enum CorpseType implements StringIdentifiable { - LAPIS(LAPIS_HELMET, Formatting.BLUE), // dark blue looks bad and these two never exist in same shaft - UMBER(UMBER_HELMET, Formatting.RED), - TUNGSTEN(TUNGSTEN_HELMET, Formatting.GRAY), - VANGUARD(VANGUARD_HELMET, Formatting.BLUE), - UNKNOWN("UNKNOWN", Formatting.YELLOW); - private static final Codec CODEC = StringIdentifiable.createCodec(CorpseType::values); - private final String helmetItemId; - private final Formatting color; - - CorpseType(String helmetItemId, Formatting color) { - this.helmetItemId = helmetItemId; - this.color = color; - } - - static CorpseType fromHelmetItemId(String helmetItemId) { - for (CorpseType value : values()) { - if (value.helmetItemId.equals(helmetItemId)) { - return value; - } - } - return UNKNOWN; - } - - @Override - public String asString() { - return name().toLowerCase(); - } - - static class CorpseTypeArgumentType extends EnumArgumentType { - protected CorpseTypeArgumentType() { - super(CODEC, CorpseType::values); - } - - static CorpseTypeArgumentType corpseType() { - return new CorpseTypeArgumentType(); - } - - static CorpseType getCorpseType(CommandContext context, String name) { - return context.getArgument(name, CorpseType.class); - } - } - } - static class Corpse { private final ArmorStandEntity entity; /** diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseType.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseType.java new file mode 100644 index 00000000..b48ff153 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CorpseType.java @@ -0,0 +1,71 @@ +package de.hysky.skyblocker.skyblock.dwarven; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.serialization.Codec; +import de.hysky.skyblocker.utils.ItemUtils; +import net.minecraft.command.argument.EnumArgumentType; +import net.minecraft.util.Formatting; +import net.minecraft.util.StringIdentifiable; + +public enum CorpseType implements StringIdentifiable { + LAPIS("LAPIS_ARMOR_HELMET", null, Formatting.BLUE), // dark blue looks bad and these two never exist in same shaft + UMBER("ARMOR_OF_YOG_HELMET", "UMBER_KEY", Formatting.GOLD), + TUNGSTEN("MINERAL_HELMET", "TUNGSTEN_KEY", Formatting.GRAY), + VANGUARD("VANGUARD_HELMET", "SKELETON_KEY", Formatting.AQUA), + UNKNOWN("UNKNOWN", null, Formatting.RED); + + public static final Codec CODEC = StringIdentifiable.createCodec(CorpseType::values); + public final String helmetItemId; + public final String keyItemId; + public final Formatting color; + + CorpseType(String helmetItemId, String keyItemId, Formatting color) { + this.helmetItemId = helmetItemId; + this.keyItemId = keyItemId; + this.color = color; + } + + static CorpseType fromHelmetItemId(String helmetItemId) { + for (CorpseType value : values()) { + if (value.helmetItemId.equals(helmetItemId)) { + return value; + } + } + return UNKNOWN; + } + + @Override + public String asString() { + return name().toLowerCase(); + } + + /** + * @return the price of the key item for this corpse type + * @throws IllegalStateException when there's no price found for the key item, or when the corpse type is UNKNOWN + */ + public double getKeyPrice() throws IllegalStateException { + return switch (this) { + case UNKNOWN -> throw new IllegalStateException("There's no key or key price for the UNKNOWN corpse type!"); + case LAPIS -> 0; // Lapis corpses don't need a key + default -> { + var result = ItemUtils.getItemPrice(keyItemId); + if (!result.rightBoolean()) throw new IllegalStateException("No price found for key item `" + keyItemId + "`!"); + yield result.leftDouble(); + } + }; + } + + public static class CorpseTypeArgumentType extends EnumArgumentType { + protected CorpseTypeArgumentType() { + super(CODEC, CorpseType::values); + } + + static CorpseTypeArgumentType corpseType() { + return new CorpseTypeArgumentType(); + } + + static CorpseType getCorpseType(CommandContext context, String name) { + return context.getArgument(name, CorpseType.class); + } + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java deleted file mode 100644 index 121422d5..00000000 --- a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java +++ /dev/null @@ -1,319 +0,0 @@ -package de.hysky.skyblocker.skyblock.dwarven; - -import com.mojang.serialization.Codec; -import de.hysky.skyblocker.SkyblockerMod; -import de.hysky.skyblocker.annotations.Init; -import de.hysky.skyblocker.config.SkyblockerConfigManager; -import de.hysky.skyblocker.events.ChatEvents; -import de.hysky.skyblocker.events.HudRenderEvents; -import de.hysky.skyblocker.events.ItemPriceUpdateEvent; -import de.hysky.skyblocker.events.SkyblockEvents; -import de.hysky.skyblocker.skyblock.itemlist.ItemRepository; -import de.hysky.skyblocker.utils.CodecUtils; -import de.hysky.skyblocker.utils.ItemUtils; -import de.hysky.skyblocker.utils.Location; -import de.hysky.skyblocker.utils.Utils; -import de.hysky.skyblocker.utils.profile.ProfiledData; -import it.unimi.dsi.fastutil.doubles.DoubleBooleanPair; -import it.unimi.dsi.fastutil.objects.*; -import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.hud.ChatHud; -import net.minecraft.client.render.RenderTickCounter; -import net.minecraft.item.ItemStack; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; -import org.apache.commons.lang3.math.NumberUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Unmodifiable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.text.NumberFormat; -import java.util.Comparator; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; - -public class PowderMiningTracker { - private static final Logger LOGGER = LoggerFactory.getLogger("Skyblocker Powder Mining Tracker"); - private static final Pattern GEMSTONE_SYMBOLS = Pattern.compile("[α☘☠✎✧❁❂❈❤⸕] "); - private static final Pattern REWARD_PATTERN = Pattern.compile(" {4}(.*?) ?x?([\\d,]*)"); - private static final Codec> REWARDS_CODEC = CodecUtils.object2IntMapCodec(Codec.STRING); - private static final Object2ObjectArrayMap NAME2ID_MAP = new Object2ObjectArrayMap<>(50); - - // This constructor takes in a comparator that is triggered to decide where to add the element in the tree map - // This causes it to be sorted at all times. This is for rendering them in a sort of easy-to-read manner. - private static final Object2IntAVLTreeMap SHOWN_REWARDS = new Object2IntAVLTreeMap<>(Comparator.comparingInt(text -> comparePriority(text.getString())).thenComparing(Text::getString)); - - /** - * Holds the total reward maps for all accounts and profiles. {@link #currentProfileRewards} is a subset of this map, updated on profile change. - */ - private static final ProfiledData> ALL_REWARDS = new ProfiledData<>(getRewardFilePath(), REWARDS_CODEC); - - /** - *

- * Holds the total amount of each reward obtained for the current profile. - * If any items are filtered out, they are still added to this map but not to the {@link #SHOWN_REWARDS} map. - * Once the filter is changed, the {@link #SHOWN_REWARDS} map is cleared and recalculated based on this map. - *

- *

This is similar to how {@link ChatHud#messages} and {@link ChatHud#visibleMessages} behave.

- * - * @implNote This is a map of item IDs to the amount of that item obtained. - */ - @SuppressWarnings("JavadocReference") - private static Object2IntMap currentProfileRewards = new Object2IntOpenHashMap<>(); - private static boolean insideChestMessage = false; - private static double profit = 0; - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private static boolean isEnabled() { - return SkyblockerConfigManager.get().mining.crystalHollows.enablePowderTracker; - } - - @Init - public static void init() { - ChatEvents.RECEIVE_STRING.register(PowderMiningTracker::onChatMessage); - HudRenderEvents.AFTER_MAIN_HUD.register(PowderMiningTracker::render); - - ItemPriceUpdateEvent.ON_PRICE_UPDATE.register(() -> { - if (isEnabled()) recalculatePrices(); - }); - - ALL_REWARDS.init(); - - SkyblockEvents.PROFILE_CHANGE.register(PowderMiningTracker::onProfileChange); - SkyblockEvents.PROFILE_INIT.register(PowderMiningTracker::onProfileInit); - - //TODO: Sort out proper commands for this - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register( - literal(SkyblockerMod.NAMESPACE) - .then( - literal("clearrewards") - .executes(context -> { - SHOWN_REWARDS.clear(); - currentProfileRewards.clear(); - profit = 0; - return 1; - }) - ) - .then( - literal("listrewards") - .executes(context -> { - var set = SHOWN_REWARDS.object2IntEntrySet(); - for (Object2IntMap.Entry entry : set) { - MinecraftClient.getInstance().inGameHud.getChatHud().addMessage(entry.getKey().copy().append(" ").append(Text.of(String.valueOf(entry.getIntValue())))); - } - return 1; - }) - ) - )); - } - - private static void onChatMessage(String text) { - if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !isEnabled()) return; - // Reward messages end with a separator like so - if (insideChestMessage && text.equals("▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")) { - insideChestMessage = false; - return; - } - - if (!insideChestMessage && (text.equals(" CHEST LOCKPICKED ") || (SkyblockerConfigManager.get().mining.crystalHollows.countNaturalChestsInTracker && text.equals(" LOOT CHEST COLLECTED ")))) { - insideChestMessage = true; - return; - } - - if (!insideChestMessage) return; - Matcher matcher = REWARD_PATTERN.matcher(text); - if (!matcher.matches()) return; - String itemName = matcher.group(1); - int amount = NumberUtils.toInt(matcher.group(2).replace(",", ""), 1); - - String itemId = getItemId(itemName); - if (itemId.isEmpty()) { - LOGGER.error("No matching item id for name `{}`. Report this!", itemName); - return; - } - incrementReward(itemName, itemId, amount); - calculateProfitForItem(itemId, amount); - } - - private static void onProfileChange(String prevProfileId, String newProfileId) { - onProfileInit(newProfileId); - } - - private static void onProfileInit(String profileId) { - if (!isEnabled()) return; - currentProfileRewards = ALL_REWARDS.computeIfAbsent(Object2IntArrayMap::new); - recalculateAll(); - } - - private static void incrementReward(String itemName, String itemId, int amount) { - currentProfileRewards.mergeInt(itemId, amount, Integer::sum); - if (SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.contains(itemName)) return; - if (itemId.equals("GEMSTONE_POWDER")) { - SHOWN_REWARDS.merge(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), amount, Integer::sum); - } else { - ItemStack stack = ItemRepository.getItemStack(itemId); - if (stack == null) { - LOGGER.warn("Item stack for id `{}` is null! This might be caused by failed item repository downloads.", itemId); - return; - } - SHOWN_REWARDS.merge(stack.getName(), amount, Integer::sum); - } - } - - private static int comparePriority(String string) { - string = GEMSTONE_SYMBOLS.matcher(string).replaceAll(""); // Removes the gemstone symbol from the string to make it easier to compare - // Puts gemstone powder at the top of the list, then gold and diamond essence, then gemstones by ascending rarity and then whatever else. - return switch (string) { - case "Gemstone Powder" -> 1; - case "Gold Essence" -> 2; - case "Diamond Essence" -> 3; - case String s when s.startsWith("Rough") -> 4; - case String s when s.startsWith("Flawed") -> 5; - case String s when s.startsWith("Fine") -> 6; - case String s when s.startsWith("Flawless") -> 7; - default -> 8; - }; - } - - /** - * Normally, the price is calculated on a per-reward basis as they are obtained. This is what this method does. - */ - private static void calculateProfitForItem(String itemId, int amount) { - DoubleBooleanPair price = ItemUtils.getItemPrice(itemId); - if (price.rightBoolean()) profit += price.leftDouble() * amount; - } - - /** - * When the bz/ah prices are updated, this method recalculates the profit for all rewards at once. - */ - private static void recalculatePrices() { - profit = 0; - ObjectSortedSet> set = SHOWN_REWARDS.object2IntEntrySet(); - for (Object2IntMap.Entry entry : set) { - calculateProfitForItem(getItemId(entry.getKey().getString()), entry.getIntValue()); - } - } - - /** - * Resets the shown rewards and profit to 0 and recalculates rewards for the current profile based on the config filter. - */ - public static void recalculateAll() { - SHOWN_REWARDS.clear(); - ObjectSet> set = currentProfileRewards.object2IntEntrySet(); - // The filters are actually item names so that they would look nice and not need a lot of mapping under the screen code - // Here they are converted to item IDs for comparison - List filters = SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.stream().map(PowderMiningTracker::getItemId).toList(); - for (Object2IntMap.Entry entry : set) { - if (filters.contains(entry.getKey())) continue; - - if (entry.getKey().equals("GEMSTONE_POWDER")) { - SHOWN_REWARDS.put(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), entry.getIntValue()); - } else { - ItemStack stack = ItemRepository.getItemStack(entry.getKey()); - if (stack == null) { - LOGGER.warn("Item stack for id `{}` is null! This might be caused by failed item repository downloads.", entry.getKey()); - continue; - } - SHOWN_REWARDS.put(stack.getName(), entry.getIntValue()); - } - } - recalculatePrices(); - } - - @Unmodifiable - public static Object2ObjectMap getName2IdMap() { - return Object2ObjectMaps.unmodifiable(NAME2ID_MAP); - } - - static { - NAME2ID_MAP.put("Gemstone Powder", "GEMSTONE_POWDER"); // Not an actual item, but since we're using IDs for mapping to colored text we need to have this here - - NAME2ID_MAP.put("❤ Rough Ruby Gemstone", "ROUGH_RUBY_GEM"); - NAME2ID_MAP.put("❤ Flawed Ruby Gemstone", "FLAWED_RUBY_GEM"); - NAME2ID_MAP.put("❤ Fine Ruby Gemstone", "FINE_RUBY_GEM"); - NAME2ID_MAP.put("❤ Flawless Ruby Gemstone", "FLAWLESS_RUBY_GEM"); - - NAME2ID_MAP.put("❈ Rough Amethyst Gemstone", "ROUGH_AMETHYST_GEM"); - NAME2ID_MAP.put("❈ Flawed Amethyst Gemstone", "FLAWED_AMETHYST_GEM"); - NAME2ID_MAP.put("❈ Fine Amethyst Gemstone", "FINE_AMETHYST_GEM"); - NAME2ID_MAP.put("❈ Flawless Amethyst Gemstone", "FLAWLESS_AMETHYST_GEM"); - - NAME2ID_MAP.put("☘ Rough Jade Gemstone", "ROUGH_JADE_GEM"); - NAME2ID_MAP.put("☘ Flawed Jade Gemstone", "FLAWED_JADE_GEM"); - NAME2ID_MAP.put("☘ Fine Jade Gemstone", "FINE_JADE_GEM"); - NAME2ID_MAP.put("☘ Flawless Jade Gemstone", "FLAWLESS_JADE_GEM"); - - NAME2ID_MAP.put("⸕ Rough Amber Gemstone", "ROUGH_AMBER_GEM"); - NAME2ID_MAP.put("⸕ Flawed Amber Gemstone", "FLAWED_AMBER_GEM"); - NAME2ID_MAP.put("⸕ Fine Amber Gemstone", "FINE_AMBER_GEM"); - NAME2ID_MAP.put("⸕ Flawless Amber Gemstone", "FLAWLESS_AMBER_GEM"); - - NAME2ID_MAP.put("✎ Rough Sapphire Gemstone", "ROUGH_SAPPHIRE_GEM"); - NAME2ID_MAP.put("✎ Flawed Sapphire Gemstone", "FLAWED_SAPPHIRE_GEM"); - NAME2ID_MAP.put("✎ Fine Sapphire Gemstone", "FINE_SAPPHIRE_GEM"); - NAME2ID_MAP.put("✎ Flawless Sapphire Gemstone", "FLAWLESS_SAPPHIRE_GEM"); - - NAME2ID_MAP.put("✧ Rough Topaz Gemstone", "ROUGH_TOPAZ_GEM"); - NAME2ID_MAP.put("✧ Flawed Topaz Gemstone", "FLAWED_TOPAZ_GEM"); - NAME2ID_MAP.put("✧ Fine Topaz Gemstone", "FINE_TOPAZ_GEM"); - NAME2ID_MAP.put("✧ Flawless Topaz Gemstone", "FLAWLESS_TOPAZ_GEM"); - - NAME2ID_MAP.put("❁ Rough Jasper Gemstone", "ROUGH_JASPER_GEM"); - NAME2ID_MAP.put("❁ Flawed Jasper Gemstone", "FLAWED_JASPER_GEM"); - NAME2ID_MAP.put("❁ Fine Jasper Gemstone", "FINE_JASPER_GEM"); - NAME2ID_MAP.put("❁ Flawless Jasper Gemstone", "FLAWLESS_JASPER_GEM"); - - NAME2ID_MAP.put("Pickonimbus 2000", "PICKONIMBUS"); - NAME2ID_MAP.put("Ascension Rope", "ASCENSION_ROPE"); - NAME2ID_MAP.put("Wishing Compass", "WISHING_COMPASS"); - NAME2ID_MAP.put("Gold Essence", "ESSENCE_GOLD"); - NAME2ID_MAP.put("Diamond Essence", "ESSENCE_DIAMOND"); - NAME2ID_MAP.put("Prehistoric Egg", "PREHISTORIC_EGG"); - NAME2ID_MAP.put("Sludge Juice", "SLUDGE_JUICE"); - NAME2ID_MAP.put("Oil Barrel", "OIL_BARREL"); - NAME2ID_MAP.put("Jungle Heart", "JUNGLE_HEART"); - NAME2ID_MAP.put("Treasurite", "TREASURITE"); - NAME2ID_MAP.put("Yoggie", "YOGGIE"); - - NAME2ID_MAP.put("Goblin Egg", "GOBLIN_EGG"); - NAME2ID_MAP.put("Green Goblin Egg", "GOBLIN_EGG_GREEN"); - NAME2ID_MAP.put("Blue Goblin Egg", "GOBLIN_EGG_BLUE"); - NAME2ID_MAP.put("Red Goblin Egg", "GOBLIN_EGG_RED"); - NAME2ID_MAP.put("Yellow Goblin Egg", "GOBLIN_EGG_YELLOW"); - - NAME2ID_MAP.put("Control Switch", "CONTROL_SWITCH"); - NAME2ID_MAP.put("Electron Transmitter", "ELECTRON_TRANSMITTER"); - NAME2ID_MAP.put("FTX 3070", "FTX_3070"); - NAME2ID_MAP.put("Synthetic Heart", "SYNTHETIC_HEART"); - NAME2ID_MAP.put("Robotron Reflector", "ROBOTRON_REFLECTOR"); - NAME2ID_MAP.put("Superlite Motor", "SUPERLITE_MOTOR"); - } - - @NotNull - private static String getItemId(String itemName) { - return NAME2ID_MAP.getOrDefault(itemName, ""); - } - - private static Path getRewardFilePath() { - return SkyblockerMod.CONFIG_DIR.resolve("reward-trackers/powder-mining.json"); - } - - private static void render(DrawContext context, RenderTickCounter tickCounter) { - if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !isEnabled()) return; - int y = MinecraftClient.getInstance().getWindow().getScaledHeight() / 2 - 100; - var set = SHOWN_REWARDS.object2IntEntrySet(); - for (Object2IntMap.Entry entry : set) { - context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, entry.getKey(), 5, y, 0xFFFFFF); - context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.of(NumberFormat.getInstance().format(entry.getIntValue())), 10 + MinecraftClient.getInstance().textRenderer.getWidth(entry.getKey()), y, 0xFFFFFF); - y += 10; - } - if (!set.isEmpty()) context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.literal("Gain: " + NumberFormat.getInstance().format(profit) + " coins").formatted(Formatting.GOLD), 5, y + 10, 0xFFFFFF); - } -} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/AbstractProfitTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/AbstractProfitTracker.java new file mode 100644 index 00000000..172764e6 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/AbstractProfitTracker.java @@ -0,0 +1,26 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers; + +import de.hysky.skyblocker.SkyblockerMod; + +import java.nio.file.Path; +import java.util.regex.Pattern; + +/** + * Abstract class for profit trackers that use the chat messages. + *
+ * There isn't meant to be much inheritance from this class, it's more of a util class that provides some common methods. + */ +public abstract class AbstractProfitTracker { + private static final String REWARD_TRACKERS_DIR = "reward-trackers"; + protected static final Pattern REWARD_PATTERN = Pattern.compile(" {4}(.*?) ?x?([\\d,]*)"); + protected static final Pattern HOTM_XP_PATTERN = Pattern.compile(" {4}\\+[\\d,]+ HOTM Experience"); + protected static final Pattern GEMSTONE_SYMBOLS = Pattern.compile("[α☘☠✎✧❁❂❈❤⸕] "); + + protected static String replaceGemstoneSymbols(String reward) { + return GEMSTONE_SYMBOLS.matcher(reward).replaceAll(""); + } + + protected Path getRewardFilePath(String fileName) { + return SkyblockerMod.CONFIG_DIR.resolve(REWARD_TRACKERS_DIR).resolve(fileName); // 2 resolve calls to avoid the need for a possibly confusing / placement + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java new file mode 100644 index 00000000..10b9d776 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java @@ -0,0 +1,331 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers; + +import com.mojang.brigadier.Command; +import com.mojang.serialization.Codec; +import de.hysky.skyblocker.SkyblockerMod; +import de.hysky.skyblocker.annotations.Init; +import de.hysky.skyblocker.config.SkyblockerConfigManager; +import de.hysky.skyblocker.events.ChatEvents; +import de.hysky.skyblocker.events.HudRenderEvents; +import de.hysky.skyblocker.events.ItemPriceUpdateEvent; +import de.hysky.skyblocker.events.SkyblockEvents; +import de.hysky.skyblocker.skyblock.itemlist.ItemRepository; +import de.hysky.skyblocker.utils.*; +import de.hysky.skyblocker.utils.profile.ProfiledData; +import it.unimi.dsi.fastutil.doubles.DoubleBooleanPair; +import it.unimi.dsi.fastutil.objects.*; +import it.unimi.dsi.fastutil.objects.Object2IntMap.Entry; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.hud.ChatHud; +import net.minecraft.client.render.RenderTickCounter; +import net.minecraft.item.ItemStack; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.apache.commons.lang3.math.NumberUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.NumberFormat; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Matcher; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public final class PowderMiningTracker extends AbstractProfitTracker { + public static final PowderMiningTracker INSTANCE = new PowderMiningTracker(); + private static final Logger LOGGER = LoggerFactory.getLogger("Skyblocker Powder Mining Tracker"); + private static final Codec> REWARDS_CODEC = CodecUtils.object2IntMapCodec(Codec.STRING); + private static final Object2ObjectArrayMap NAME2ID_MAP = new Object2ObjectArrayMap<>(50); + + /** + *

+ * Holds the total amount of each reward obtained for the current profile. + * If any items are filtered out, they are still added to this map but not to the {@link #shownRewards} map. + * Once the filter is changed, the {@link #shownRewards} map is cleared and recalculated based on this map. + *

+ *

This is similar to how {@link ChatHud#messages} and {@link ChatHud#visibleMessages} behave.

+ * + * @implNote This is a map of item IDs to the amount of that item obtained. + */ + @SuppressWarnings("JavadocReference") + private Object2IntMap currentProfileRewards = new Object2IntOpenHashMap<>(); + + // This constructor takes in a comparator that is triggered to decide where to add the element in the tree map + // This causes it to be sorted at all times. This is for rendering them in a sort of easy-to-read manner. + private final Object2IntAVLTreeMap shownRewards = new Object2IntAVLTreeMap<>(Comparator.comparingInt(text -> comparePriority(text.getString())).thenComparing(Text::getString)); + + /** + * Holds the total reward maps for all accounts and profiles. {@link #currentProfileRewards} is a subset of this map, updated on profile change. + */ + private final ProfiledData> allRewards = new ProfiledData<>(getRewardFilePath("powder-mining.json"), REWARDS_CODEC); + private boolean insideChestMessage = false; + private double profit = 0; + + private PowderMiningTracker() {} // Singleton + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean isEnabled() { + return SkyblockerConfigManager.get().mining.crystalHollows.enablePowderTracker; + } + + @Init + public static void init() { + ChatEvents.RECEIVE_STRING.register(INSTANCE::onChatMessage); + HudRenderEvents.AFTER_MAIN_HUD.register(PowderMiningTracker::render); + ItemPriceUpdateEvent.ON_PRICE_UPDATE.register(INSTANCE::onPriceUpdate); + + INSTANCE.allRewards.init(); + + // @formatter:off // Don't you hate it when your format style for chained method calls makes a chain like this incredibly ugly? + ClientCommandRegistrationCallback.EVENT.register((dispatcher, dedicated) -> dispatcher.register( + literal(SkyblockerMod.NAMESPACE) + .then(literal("rewardTrackers") + .then(literal("powderMining") + .then(literal("list") + .executes(ctx -> { + if (INSTANCE.currentProfileRewards.isEmpty()) { + ctx.getSource().sendFeedback(Constants.PREFIX.get().append(Text.translatable("skyblocker.powderTracker.emptyHistory").formatted(Formatting.RED))); + return Command.SINGLE_SUCCESS; + } else if (INSTANCE.shownRewards.isEmpty()) { + ctx.getSource().sendFeedback(Constants.PREFIX.get().append(Text.translatable("skyblocker.powderTracker.rewardsFilteredOut").formatted(Formatting.RED))); + return Command.SINGLE_SUCCESS; + } + + for (Entry entry : INSTANCE.shownRewards.object2IntEntrySet()) { + ctx.getSource().sendFeedback( + Text.empty() + .append(entry.getKey()) + .append(Text.literal(": ").formatted(Formatting.GRAY)) + .append(Text.literal(String.valueOf(entry.getIntValue())))); + } + ctx.getSource().sendFeedback(Text.translatable("skyblocker.powderTracker.profit", NumberFormat.getInstance().format(INSTANCE.profit)).formatted(Formatting.GOLD)); + return Command.SINGLE_SUCCESS; + }) + ) + .then(literal("reset") + .executes(ctx -> { + INSTANCE.currentProfileRewards.clear(); + INSTANCE.allRewards.save(); + ctx.getSource().sendFeedback(Constants.PREFIX.get().append(Text.translatable("skyblocker.powderTracker.historyReset").formatted(Formatting.GREEN))); + return Command.SINGLE_SUCCESS; + }) + ) + ) + ) + )); // @formatter:on + + SkyblockEvents.PROFILE_CHANGE.register(INSTANCE::onProfileChange); + SkyblockEvents.PROFILE_INIT.register(INSTANCE::onProfileInit); + } + + private void onProfileChange(String prevProfileId, String newProfileId) { + onProfileInit(newProfileId); + } + + private void onProfileInit(String profileId) { + if (!isEnabled()) return; + currentProfileRewards = allRewards.computeIfAbsent(Object2IntArrayMap::new); + recalculateAll(); + } + + private void onChatMessage(String message) { + if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !INSTANCE.isEnabled()) return; + // Reward messages end with a separator like so + if (insideChestMessage && message.equals("▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")) { + insideChestMessage = false; + return; + } + + if (!insideChestMessage && (message.equals(" CHEST LOCKPICKED ") || (SkyblockerConfigManager.get().mining.crystalHollows.countNaturalChestsInTracker && message.equals(" LOOT CHEST COLLECTED ")))) { + insideChestMessage = true; + return; + } + + if (!insideChestMessage) return; + Matcher matcher = REWARD_PATTERN.matcher(message); + if (!matcher.matches()) return; + String itemName = matcher.group(1); + int amount = NumberUtils.toInt(matcher.group(2).replace(",", ""), 1); + + String itemId = getItemId(itemName); + if (itemId.isEmpty()) { + LOGGER.error("No matching item id for name `{}`. Report this!", itemName); + return; + } + incrementReward(itemName, itemId, amount); + calculateProfitForItem(itemId, amount); + } + + private void incrementReward(String itemName, String itemId, int amount) { + currentProfileRewards.mergeInt(itemId, amount, Integer::sum); + if (!SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.contains(itemName)) { + if (itemId.equals("GEMSTONE_POWDER")) { + shownRewards.merge(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), amount, Integer::sum); + } else { + ItemStack stack = ItemRepository.getItemStack(itemId); + if (stack == null) { + LOGGER.warn("Item stack for id `{}` is null! This might be caused by failed item repository downloads.", itemId); + return; + } + shownRewards.merge(stack.getName(), amount, Integer::sum); + } + } + } + + private static int comparePriority(String itemName) { + // Puts gemstone powder at the top of the list, then gold and diamond essence, then gemstones by ascending rarity and then whatever else. + return switch (replaceGemstoneSymbols(itemName)) { + case "Gemstone Powder" -> 1; + case "Gold Essence" -> 2; + case "Diamond Essence" -> 3; + case String s when s.startsWith("Rough") -> 4; + case String s when s.startsWith("Flawed") -> 5; + case String s when s.startsWith("Fine") -> 6; + case String s when s.startsWith("Flawless") -> 7; + default -> 8; + }; + } + + private void onPriceUpdate() { + if (isEnabled()) recalculatePrices(); + } + + /** + * Normally, the price is calculated on a per-reward basis as they are obtained. This is what this method does. + */ + private void calculateProfitForItem(String itemId, int amount) { + DoubleBooleanPair price = ItemUtils.getItemPrice(itemId); + if (price.rightBoolean()) profit += price.leftDouble() * amount; + } + + /** + * When the bz/ah prices are updated, this method recalculates the profit for all rewards at once. + */ + private void recalculatePrices() { + profit = 0; + ObjectSortedSet> set = shownRewards.object2IntEntrySet(); + for (Entry entry : set) { + calculateProfitForItem(getItemId(entry.getKey().getString()), entry.getIntValue()); + } + } + + /** + *

Resets the shown rewards and profit to 0 and recalculates rewards for the current profile based on the config filter.

+ *

This is also called from the config when the feature is enabled, as the periodic recalculation doesn't happen when the feature is disabled.

+ */ + public void recalculateAll() { + shownRewards.clear(); + ObjectSet> set = currentProfileRewards.object2IntEntrySet(); + // The filters are actually item names so that they would look nice and not need a lot of mapping under the screen code + // Here they are converted to item IDs for comparison + List filters = SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.stream().map(INSTANCE::getItemId).toList(); + for (Entry entry : set) { + if (filters.contains(entry.getKey())) continue; + + if (entry.getKey().equals("GEMSTONE_POWDER")) { + shownRewards.put(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), entry.getIntValue()); + } else { + ItemStack stack = ItemRepository.getItemStack(entry.getKey()); + if (stack == null) { + LOGGER.warn("Item stack for id `{}` is null! This might be caused by failed item repository downloads.", entry.getKey()); + continue; + } + shownRewards.put(stack.getName(), entry.getIntValue()); + } + } + recalculatePrices(); + } + + @Unmodifiable + public static Object2ObjectMap getName2IdMap() { + return Object2ObjectMaps.unmodifiable(NAME2ID_MAP); + } + + // TODO: Perhaps make a little something in the skyblocker-assets repo for this in case it needs updating in the future + static { + NAME2ID_MAP.put("Gemstone Powder", "GEMSTONE_POWDER"); // Not an actual item, but since we're using IDs for mapping to colored text we need to have this here + + NAME2ID_MAP.put("❤ Rough Ruby Gemstone", "ROUGH_RUBY_GEM"); + NAME2ID_MAP.put("❤ Flawed Ruby Gemstone", "FLAWED_RUBY_GEM"); + NAME2ID_MAP.put("❤ Fine Ruby Gemstone", "FINE_RUBY_GEM"); + NAME2ID_MAP.put("❤ Flawless Ruby Gemstone", "FLAWLESS_RUBY_GEM"); + + NAME2ID_MAP.put("❈ Rough Amethyst Gemstone", "ROUGH_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Flawed Amethyst Gemstone", "FLAWED_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Fine Amethyst Gemstone", "FINE_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Flawless Amethyst Gemstone", "FLAWLESS_AMETHYST_GEM"); + + NAME2ID_MAP.put("☘ Rough Jade Gemstone", "ROUGH_JADE_GEM"); + NAME2ID_MAP.put("☘ Flawed Jade Gemstone", "FLAWED_JADE_GEM"); + NAME2ID_MAP.put("☘ Fine Jade Gemstone", "FINE_JADE_GEM"); + NAME2ID_MAP.put("☘ Flawless Jade Gemstone", "FLAWLESS_JADE_GEM"); + + NAME2ID_MAP.put("⸕ Rough Amber Gemstone", "ROUGH_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Flawed Amber Gemstone", "FLAWED_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Fine Amber Gemstone", "FINE_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Flawless Amber Gemstone", "FLAWLESS_AMBER_GEM"); + + NAME2ID_MAP.put("✎ Rough Sapphire Gemstone", "ROUGH_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Flawed Sapphire Gemstone", "FLAWED_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Fine Sapphire Gemstone", "FINE_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Flawless Sapphire Gemstone", "FLAWLESS_SAPPHIRE_GEM"); + + NAME2ID_MAP.put("✧ Rough Topaz Gemstone", "ROUGH_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Flawed Topaz Gemstone", "FLAWED_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Fine Topaz Gemstone", "FINE_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Flawless Topaz Gemstone", "FLAWLESS_TOPAZ_GEM"); + + NAME2ID_MAP.put("❁ Rough Jasper Gemstone", "ROUGH_JASPER_GEM"); + NAME2ID_MAP.put("❁ Flawed Jasper Gemstone", "FLAWED_JASPER_GEM"); + NAME2ID_MAP.put("❁ Fine Jasper Gemstone", "FINE_JASPER_GEM"); + NAME2ID_MAP.put("❁ Flawless Jasper Gemstone", "FLAWLESS_JASPER_GEM"); + + NAME2ID_MAP.put("Pickonimbus 2000", "PICKONIMBUS"); + NAME2ID_MAP.put("Ascension Rope", "ASCENSION_ROPE"); + NAME2ID_MAP.put("Wishing Compass", "WISHING_COMPASS"); + NAME2ID_MAP.put("Gold Essence", "ESSENCE_GOLD"); + NAME2ID_MAP.put("Diamond Essence", "ESSENCE_DIAMOND"); + NAME2ID_MAP.put("Prehistoric Egg", "PREHISTORIC_EGG"); + NAME2ID_MAP.put("Sludge Juice", "SLUDGE_JUICE"); + NAME2ID_MAP.put("Oil Barrel", "OIL_BARREL"); + NAME2ID_MAP.put("Jungle Heart", "JUNGLE_HEART"); + NAME2ID_MAP.put("Treasurite", "TREASURITE"); + NAME2ID_MAP.put("Yoggie", "YOGGIE"); + + NAME2ID_MAP.put("Goblin Egg", "GOBLIN_EGG"); + NAME2ID_MAP.put("Green Goblin Egg", "GOBLIN_EGG_GREEN"); + NAME2ID_MAP.put("Blue Goblin Egg", "GOBLIN_EGG_BLUE"); + NAME2ID_MAP.put("Red Goblin Egg", "GOBLIN_EGG_RED"); + NAME2ID_MAP.put("Yellow Goblin Egg", "GOBLIN_EGG_YELLOW"); + + NAME2ID_MAP.put("Control Switch", "CONTROL_SWITCH"); + NAME2ID_MAP.put("Electron Transmitter", "ELECTRON_TRANSMITTER"); + NAME2ID_MAP.put("FTX 3070", "FTX_3070"); + NAME2ID_MAP.put("Synthetic Heart", "SYNTHETIC_HEART"); + NAME2ID_MAP.put("Robotron Reflector", "ROBOTRON_REFLECTOR"); + NAME2ID_MAP.put("Superlite Motor", "SUPERLITE_MOTOR"); + } + + @NotNull + private String getItemId(String itemName) { + return NAME2ID_MAP.getOrDefault(itemName, ""); + } + + // TODO: Make this a hud widget without the background (optional), needs to be moveable + private static void render(DrawContext context, RenderTickCounter tickCounter) { + if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !INSTANCE.isEnabled()) return; + int y = MinecraftClient.getInstance().getWindow().getScaledHeight() / 2 - 100; + var set = INSTANCE.shownRewards.object2IntEntrySet(); + for (Entry entry : set) { + context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, entry.getKey(), 5, y, 0xFFFFFF); + context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.of(NumberFormat.getInstance().format(entry.getIntValue())), 10 + MinecraftClient.getInstance().textRenderer.getWidth(entry.getKey()), y, 0xFFFFFF); + y += 10; + } + context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.translatable("skyblocker.powderTracker.profit", NumberFormat.getInstance().format(INSTANCE.profit)).formatted(Formatting.GOLD), 5, y + 10, 0xFFFFFF); + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseList.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseList.java new file mode 100644 index 00000000..5e4eaec5 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseList.java @@ -0,0 +1,251 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import de.hysky.skyblocker.skyblock.dwarven.CorpseType; +import de.hysky.skyblocker.skyblock.itemlist.ItemRepository; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.Selectable; +import net.minecraft.client.gui.widget.ClickableWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.client.gui.widget.TextWidget; +import net.minecraft.item.ItemStack; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.apache.commons.text.WordUtils; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.NumberFormat; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse.CorpseProfitTracker.*; + +public class CorpseList extends ElementListWidget { + private static final Logger LOGGER = LoggerFactory.getLogger(CorpseList.class); + private static final int BORDER_COLOR = 0xFF6C7086; + private static final int INNER_MARGIN = 2; + + public CorpseList(MinecraftClient client, int width, int height, int y, int entryHeight, List lootList) { + super(client, width, height, y, entryHeight); + if (lootList.isEmpty()) { + addEmptyEntry(); + addEmptyEntry(); + addEmptyEntry(); + addEntry(new CorpseList.SingleEntry(Text.literal("Your corpse history list is empty :(").formatted(Formatting.RED), false)); + return; + } + + for (int i = 0; i < lootList.size(); i++) { + CorpseLoot loot = lootList.get(i); + CorpseType type = loot.corpseType(); + addEntry(new CorpseList.SingleEntry(Text.literal(WordUtils.capitalizeFully(type.name()) + " Corpse").formatted(type.color))); + //TODO: Make this use the Formatters class instead when it's added + addEntry(new CorpseList.SingleEntry(Text.literal(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.ofInstant(loot.timestamp(), ZoneId.systemDefault()))).formatted(Formatting.LIGHT_PURPLE))); + + List entries = loot.rewards(); + for (Reward reward : entries) { + Text itemName = getItemName(reward.itemId()); + + // If the item is priceless, don't show the prices + if (PRICELESS_ITEMS.contains(reward.itemId())) addEntry(new CorpseList.MultiEntry(itemName, reward.amount())); + else addEntry(new CorpseList.MultiEntry(itemName, reward.amount(), reward.pricePerUnit())); + } + + if (type != CorpseType.LAPIS && type != CorpseType.UNKNOWN) { + addEntry(new CorpseList.MultiEntry(type.getKeyPrice(), true)); + } + + if (loot.isPriceDataComplete()) addEntry(new CorpseList.MultiEntry(loot.profit())); + else addEntry(new CorpseList.SingleEntry(Text.literal("Price data incomplete, can't calculate profit").formatted(Formatting.RED))); + + if (i < lootList.size() - 1) { + addEmptyEntry(); + addEmptyEntry(); + } + } + } + + public static Text getItemName(String itemId) { + return switch (itemId) { + case GLACITE_POWDER -> Text.literal("Glacite Powder").formatted(Formatting.AQUA); + case OPAL_CRYSTAL -> Text.literal("Opal Crystal").formatted(Formatting.WHITE); + case ONYX_CRYSTAL -> Text.literal("Onyx Crystal").formatted(Formatting.DARK_GRAY); + case AQUAMARINE_CRYSTAL -> Text.literal("Aquamarine Crystal").formatted(Formatting.BLUE); + case PERIDOT_CRYSTAL -> Text.literal("Peridot Crystal").formatted(Formatting.DARK_GREEN); + case CITRINE_CRYSTAL -> Text.literal("Citrine Crystal").formatted(Formatting.DARK_RED); + case RUBY_CRYSTAL -> Text.literal("Ruby Crystal").formatted(Formatting.RED); + case JASPER_CRYSTAL -> Text.literal("Jasper Crystal").formatted(Formatting.LIGHT_PURPLE); + default -> { + ItemStack itemStack = ItemRepository.getItemStack(itemId); + if (itemStack == null) { + LOGGER.error("Item stack for item ID {} is null", itemId); + yield Text.empty(); + } + yield itemStack.getName(); + } + }; + } + + private void addEmptyEntry() { + addEntry(new EmptyEntry()); + } + + @Override + public int getRowWidth() { + return 500; + } + + @Override + public int getRowTop(int index) { + return this.getY() - (int) this.getScrollY() + index * this.itemHeight + this.headerHeight; + } + + @Override + protected void renderList(DrawContext context, int mouseX, int mouseY, float delta) { + int i = this.getRowLeft(); + int j = this.getRowWidth(); + int l = this.getEntryCount(); + + for (int m = 0; m < l; m++) { + int n = this.getRowTop(m); + int o = this.getRowBottom(m); + if (o >= this.getY() && n <= this.getBottom()) { + this.renderEntry(context, mouseX, mouseY, delta, m, i, n, j, this.itemHeight); + } + } + } + + public abstract static class AbstractEntry extends ElementListWidget.Entry { + protected List children; + + @Override + public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) {} + + @Override + public List selectableChildren() { + return children; + } + + @Override + public List children() { + return children; + } + } + + // As a separator between entries + public static class EmptyEntry extends AbstractEntry { + public EmptyEntry() { + children = List.of(); + } + } + + // For a single line of text, allows for a border to be drawn or not + public static class SingleEntry extends AbstractEntry { + private boolean drawBorder = true; + + public SingleEntry(Text text) { + children = List.of(new TextWidget(text, MinecraftClient.getInstance().textRenderer).alignCenter()); + } + + public SingleEntry(Text text, boolean drawBorder) { + this(text); + this.drawBorder = drawBorder; + } + + @Override + public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { + if (drawBorder) context.drawBorder(x, y, entryWidth, entryHeight + 1, BORDER_COLOR); + for (var child : children) { + child.setX(x + INNER_MARGIN); + child.setY(y + INNER_MARGIN); + child.setWidth(entryWidth - 2 * INNER_MARGIN); + child.render(context, mouseX, mouseY, tickDelta); + } + } + } + + // The main grid structure + public static class MultiEntry extends AbstractEntry { + protected @Nullable TextWidget itemName; + protected @Nullable TextWidget amount = null; + protected @Nullable TextWidget totalPrice; + protected @Nullable TextWidget pricePerUnit = null; + + // For the items + public MultiEntry(Text itemName, int amount, double pricePerUnit) { + this.itemName = new TextWidget(itemName, MinecraftClient.getInstance().textRenderer).alignLeft(); + this.amount = new TextWidget(Text.literal("x" + amount).formatted(Formatting.AQUA), MinecraftClient.getInstance().textRenderer).alignCenter(); + this.totalPrice = new TextWidget(Text.literal(NumberFormat.getInstance().format(amount * pricePerUnit) + " Coins").formatted(Formatting.GOLD), MinecraftClient.getInstance().textRenderer); + this.pricePerUnit = new TextWidget(Text.literal(NumberFormat.getInstance().format(pricePerUnit) + " each").formatted(Formatting.GRAY), MinecraftClient.getInstance().textRenderer); + children = List.of(this.itemName, this.amount, this.totalPrice, this.pricePerUnit); + } + + // For the items + public MultiEntry(Text itemName, int amount) { + this.itemName = new TextWidget(itemName, MinecraftClient.getInstance().textRenderer).alignLeft(); + this.amount = new TextWidget(Text.literal("x" + amount).formatted(Formatting.AQUA), MinecraftClient.getInstance().textRenderer).alignCenter(); + children = List.of(this.itemName, this.amount); + } + + // For the total profit line + public MultiEntry(double profit) { + this.itemName = new TextWidget(Text.literal("Total Profit").formatted(Formatting.BOLD, Formatting.GOLD), MinecraftClient.getInstance().textRenderer).alignLeft(); + this.totalPrice = new TextWidget(Text.literal(NumberFormat.getInstance().format(profit) + " Coins").formatted(profit > 0 ? Formatting.GREEN : Formatting.RED), MinecraftClient.getInstance().textRenderer); + children = List.of(this.itemName, this.totalPrice); + } + + // For the keys + public MultiEntry(double keyPrice, boolean isKey) { // The extra boolean is just to prevent constructor overloading conflicts + if (!isKey) throw new IllegalArgumentException("This constructor is only for key entries"); + this.itemName = new TextWidget(Text.literal("Key Price").formatted(Formatting.RED, Formatting.BOLD), MinecraftClient.getInstance().textRenderer).alignLeft(); + this.amount = new TextWidget(Text.literal("x1").formatted(Formatting.AQUA), MinecraftClient.getInstance().textRenderer).alignCenter(); + this.totalPrice = new TextWidget(Text.literal("-" + NumberFormat.getInstance().format(keyPrice) + " Coins").formatted(Formatting.RED), MinecraftClient.getInstance().textRenderer); + children = List.of(this.itemName, this.amount, this.totalPrice); + } + + // Space distribution: + // Name | amount | total price | price per unit + // 33.3% | 16.6% | 25% | 25% + @Override + public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { + // The +1 is to make the borders stack on top of each other + context.drawBorder(x, y, entryWidth, entryHeight + 1, BORDER_COLOR); + context.drawBorder(x + entryWidth / 3, y, entryWidth / 6 + 2, entryHeight + 1, BORDER_COLOR); + context.drawBorder(x + entryWidth / 2, y, entryWidth / 4, entryHeight + 1, BORDER_COLOR); + + int entryY = y + INNER_MARGIN; + if (itemName != null) { + itemName.setX(x + INNER_MARGIN); + itemName.setY(entryY); + itemName.setWidth(entryWidth / 3 - 2 * INNER_MARGIN); + itemName.render(context, mouseX, mouseY, tickDelta); + } + + if (amount != null) { + amount.setX(x + entryWidth / 3 + INNER_MARGIN); + amount.setY(entryY); + amount.setWidth(entryWidth / 6 - 2 * INNER_MARGIN); + amount.render(context, mouseX, mouseY, tickDelta); + } + + if (totalPrice != null) { + totalPrice.setX(x + entryWidth / 2 + INNER_MARGIN); + totalPrice.setY(entryY); + totalPrice.setWidth(entryWidth / 4 - 2 * INNER_MARGIN); + totalPrice.render(context, mouseX, mouseY, tickDelta); + } + + if (pricePerUnit != null) { + pricePerUnit.setX(x + 3 * entryWidth / 4 + INNER_MARGIN); + pricePerUnit.setY(entryY); + pricePerUnit.setWidth(entryWidth / 4 - 2 * INNER_MARGIN); + pricePerUnit.render(context, mouseX, mouseY, tickDelta); + } + } + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseLoot.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseLoot.java new file mode 100644 index 00000000..6ce8cf6b --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseLoot.java @@ -0,0 +1,82 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import de.hysky.skyblocker.skyblock.dwarven.CorpseType; +import de.hysky.skyblocker.utils.ItemUtils; +import it.unimi.dsi.fastutil.doubles.DoubleBooleanPair; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.List; + +public final class CorpseLoot { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + CorpseType.CODEC.fieldOf("corpseType").forGetter(CorpseLoot::corpseType), + Reward.CODEC.listOf().fieldOf("rewards").forGetter(CorpseLoot::rewards), + Codec.LONG.xmap(Instant::ofEpochMilli, Instant::toEpochMilli).fieldOf("timestamp").forGetter(CorpseLoot::timestamp), + Codec.DOUBLE.fieldOf("profit").forGetter(CorpseLoot::profit) + ).apply(instance, CorpseLoot::new)); + public static final Logger LOGGER = LoggerFactory.getLogger(CorpseLoot.class); + + private final @NotNull CorpseType corpseType; + private final @NotNull List rewards; + private final @NotNull Instant timestamp; + private double profit; + private boolean isPriceDataComplete = true; + + CorpseLoot(@NotNull CorpseType corpseType, @NotNull List rewards, @NotNull Instant timestamp, double profit) { + this.corpseType = corpseType; + this.rewards = rewards; + this.timestamp = timestamp; + this.profit = profit; + } + + CorpseLoot(@NotNull CorpseType corpseType, @NotNull List rewards, @NotNull Instant timestamp) { + this(corpseType, rewards, timestamp, 0); + } + + public @NotNull CorpseType corpseType() { return corpseType; } + + public @NotNull List rewards() { return rewards; } + + public @NotNull Instant timestamp() { return timestamp; } + + public double profit() { return profit; } + + public void profit(double profit) { this.profit = profit; } + + public void addLoot(@NotNull String itemName, int amount) { + String itemId = getItemId(itemName); + if (itemId.isEmpty()) { + LOGGER.error("No matching item id for name `{}`. Report this!", itemName); + return; + } + Reward reward = new Reward(amount, itemId); + rewards.add(reward); + if (CorpseProfitTracker.PRICELESS_ITEMS.contains(itemId)) return; + + DoubleBooleanPair price = ItemUtils.getItemPrice(itemId); + if (!price.rightBoolean()) { + LOGGER.warn("No price found for item `{}`.", itemId); + // Only fired once per corpse + if (isPriceDataComplete) LOGGER.warn("Profit calculation will not be accurate due to missing item price, therefore it will not be sent to chat. It will still be added to the corpse history."); + markPriceDataIncomplete(); + return; + } + profit += price.leftDouble() * amount; + reward.pricePerUnit(price.leftDouble()); + } + + public boolean isPriceDataComplete() { return isPriceDataComplete; } + + public void markPriceDataIncomplete() { isPriceDataComplete = false; } + + public void markPriceDataComplete() { isPriceDataComplete = true; } + + private static @NotNull String getItemId(String itemName) { + return CorpseProfitTracker.getName2IdMap().getOrDefault(itemName, ""); + } +} \ No newline at end of file diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitScreen.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitScreen.java new file mode 100644 index 00000000..b3efbb12 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitScreen.java @@ -0,0 +1,103 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import it.unimi.dsi.fastutil.double