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 3eed81da63..4b7d198d3a 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.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 static ConfigCategory create(SkyblockerConfig defaults, SkyblockerConfig 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 static ConfigCategory create(SkyblockerConfig defaults, SkyblockerConfig 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 34c594299c..33ed1b050a 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 static class Glacite { @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 fbd2668a23..a5c90a2a6f 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 @@ protected void init() { 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 f3dd9aab44..3f4ec90af1 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.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.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 static void init() { .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 @@ private static void parseCords(Text text) { } } - 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 0000000000..b48ff153e6 --- /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/profittrackers/AbstractProfitTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/AbstractProfitTracker.java new file mode 100644 index 0000000000..172764e628 --- /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/PowderMiningTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java similarity index 60% rename from src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java rename to src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java index 121422d529..10b9d776c7 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/PowderMiningTracker.java @@ -1,5 +1,6 @@ -package de.hysky.skyblocker.skyblock.dwarven; +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; @@ -9,13 +10,11 @@ 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.*; 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; @@ -30,105 +29,125 @@ 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 { +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 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. + * 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 static Object2IntMap currentProfileRewards = new Object2IntOpenHashMap<>(); - private static boolean insideChestMessage = false; - private static double profit = 0; + 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") - private static boolean isEnabled() { + public boolean isEnabled() { return SkyblockerConfigManager.get().mining.crystalHollows.enablePowderTracker; } @Init public static void init() { - ChatEvents.RECEIVE_STRING.register(PowderMiningTracker::onChatMessage); + ChatEvents.RECEIVE_STRING.register(INSTANCE::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; - }) + 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("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; - }) + .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 static void onChatMessage(String text) { - if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !isEnabled()) return; + private void onChatMessage(String message) { + if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !INSTANCE.isEnabled()) return; // Reward messages end with a separator like so - if (insideChestMessage && text.equals("▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")) { + if (insideChestMessage && message.equals("▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")) { insideChestMessage = false; return; } - if (!insideChestMessage && (text.equals(" CHEST LOCKPICKED ") || (SkyblockerConfigManager.get().mining.crystalHollows.countNaturalChestsInTracker && text.equals(" LOOT CHEST COLLECTED ")))) { + 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(text); + Matcher matcher = REWARD_PATTERN.matcher(message); if (!matcher.matches()) return; String itemName = matcher.group(1); int amount = NumberUtils.toInt(matcher.group(2).replace(",", ""), 1); @@ -142,35 +161,25 @@ private static void onChatMessage(String text) { 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) { + private 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; + 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); } - 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 + 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 (string) { + return switch (replaceGemstoneSymbols(itemName)) { case "Gemstone Powder" -> 1; case "Gold Essence" -> 2; case "Diamond Essence" -> 3; @@ -182,10 +191,14 @@ private static int comparePriority(String string) { }; } + 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 static void calculateProfitForItem(String itemId, int amount) { + private void calculateProfitForItem(String itemId, int amount) { DoubleBooleanPair price = ItemUtils.getItemPrice(itemId); if (price.rightBoolean()) profit += price.leftDouble() * amount; } @@ -193,35 +206,36 @@ private static void calculateProfitForItem(String itemId, int amount) { /** * When the bz/ah prices are updated, this method recalculates the profit for all rewards at once. */ - private static void recalculatePrices() { + private void recalculatePrices() { profit = 0; - ObjectSortedSet> set = SHOWN_REWARDS.object2IntEntrySet(); - for (Object2IntMap.Entry entry : set) { + 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. + *

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 static void recalculateAll() { - SHOWN_REWARDS.clear(); - ObjectSet> set = currentProfileRewards.object2IntEntrySet(); + 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(PowderMiningTracker::getItemId).toList(); - for (Object2IntMap.Entry entry : set) { + 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")) { - SHOWN_REWARDS.put(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), entry.getIntValue()); + 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; } - SHOWN_REWARDS.put(stack.getName(), entry.getIntValue()); + shownRewards.put(stack.getName(), entry.getIntValue()); } } recalculatePrices(); @@ -232,6 +246,7 @@ 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 @@ -297,23 +312,20 @@ public static Object2ObjectMap getName2IdMap() { } @NotNull - private static String getItemId(String itemName) { + private String getItemId(String itemName) { return NAME2ID_MAP.getOrDefault(itemName, ""); } - private static Path getRewardFilePath() { - return SkyblockerMod.CONFIG_DIR.resolve("reward-trackers/powder-mining.json"); - } - + // 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 || !isEnabled()) return; + if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !INSTANCE.isEnabled()) return; int y = MinecraftClient.getInstance().getWindow().getScaledHeight() / 2 - 100; - var set = SHOWN_REWARDS.object2IntEntrySet(); - for (Object2IntMap.Entry entry : set) { + 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; } - if (!set.isEmpty()) context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.literal("Gain: " + NumberFormat.getInstance().format(profit) + " coins").formatted(Formatting.GOLD), 5, y + 10, 0xFFFFFF); + 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 0000000000..5e4eaec58a --- /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 0000000000..6ce8cf6b51 --- /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 0000000000..b3efbb1253 --- /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.doubles.DoubleBooleanImmutablePair; +import it.unimi.dsi.fastutil.doubles.DoubleBooleanPair; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.GridWidget; +import net.minecraft.client.gui.widget.SimplePositioningWidget; +import net.minecraft.client.gui.widget.TextWidget; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.text.NumberFormat; +import java.util.List; + +public class CorpseProfitScreen extends Screen { + private static final int ENTRY_HEIGHT = 11; + + private final Screen parent; + private final List rewardsList = CorpseProfitTracker.getCurrentProfileRewards(); + private @Nullable CorpseList corpseList = null; + private @Nullable RewardList rewardList = null; + private final DoubleBooleanPair totalProfit = calculateTotalProfit(rewardsList); + private boolean summaryView; + + public CorpseProfitScreen(Screen parent) { + this(parent, true); + } + + public CorpseProfitScreen(Screen parent, boolean summaryView) { + super(Text.translatable("skyblocker.corpseTracker.screenTitle")); + this.parent = parent; + this.summaryView = summaryView; + } + + @Override + protected void init() { + assert client != null; + addDrawable((context, mouseX, mouseY, delta) -> { + context.drawCenteredTextWithShadow(client.textRenderer, Text.translatable("skyblocker.corpseTracker.screenTitle").formatted(Formatting.BOLD), width / 2, (32 - client.textRenderer.fontHeight) / 2, 0xFFFFFF); + }); + + if (summaryView) addDrawableChild(getRewardList()); + else addDrawableChild(getCorpseList()); + + GridWidget gridWidget = new GridWidget(); + gridWidget.getMainPositioner().marginX(5).marginY(2); + GridWidget.Adder adder = gridWidget.createAdder(2); + + Text totalProfitText = Text.translatable("skyblocker.corpseTracker.totalProfit", + NumberFormat.getInstance().format(totalProfit.leftDouble()).formatted(totalProfit.leftDouble() >= 0 ? Formatting.GREEN : Formatting.RED), // Formatting.GOLD is filled in from parent if it's 0 + totalProfit.rightBoolean() ? Text.empty() : Text.literal("skyblocker.corpseTracker.incompletePriceData").formatted(Formatting.RED) + ).formatted(Formatting.GOLD, Formatting.BOLD); + + adder.add(new TextWidget(ButtonWidget.DEFAULT_WIDTH * 2 + 10, ENTRY_HEIGHT, totalProfitText, client.textRenderer).alignCenter(), 2); + + Text buttonText = summaryView ? Text.translatable("skyblocker.corpseTracker.historyView") : Text.translatable("skyblocker.corpseTracker.summaryView"); + adder.add(ButtonWidget.builder(buttonText, this::changeView).build()); + adder.add(ButtonWidget.builder(ScreenTexts.DONE, button -> close()).build()); + gridWidget.refreshPositions(); + SimplePositioningWidget.setPos(gridWidget, 0, this.height - 64, this.width, 64); + gridWidget.forEachChild(this::addDrawableChild); + } + + // Rebuilds the screen with the new view, the main difference being which list is displayed + private void changeView(ButtonWidget button) { + summaryView = !summaryView; + clearAndInit(); + } + + // Lazy init + @NotNull + private CorpseList getCorpseList() { + return corpseList == null ? corpseList = new CorpseList(MinecraftClient.getInstance(), width, height - 96, 32, ENTRY_HEIGHT, rewardsList) : corpseList; + } + + // Lazy init + @NotNull + private RewardList getRewardList() { + return rewardList == null ? rewardList = new RewardList(MinecraftClient.getInstance(), width, height - 96, 32, ENTRY_HEIGHT, rewardsList) : rewardList; + } + + @NotNull + private static DoubleBooleanPair calculateTotalProfit(List list) { + double total = 0; + boolean isPriceComplete = true; + for (CorpseLoot loot : list) { + total += loot.profit(); + if (!loot.isPriceDataComplete()) isPriceComplete = false; + } + return DoubleBooleanImmutablePair.of(total, isPriceComplete); + } + + @Override + public void close() { + assert client != null; + client.setScreen(parent); + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitTracker.java new file mode 100644 index 0000000000..62f6de4d5a --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/CorpseProfitTracker.java @@ -0,0 +1,279 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.BoolArgumentType; +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.ItemPriceUpdateEvent; +import de.hysky.skyblocker.events.SkyblockEvents; +import de.hysky.skyblocker.skyblock.dwarven.CorpseType; +import de.hysky.skyblocker.skyblock.dwarven.profittrackers.AbstractProfitTracker; +import de.hysky.skyblocker.utils.Constants; +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 de.hysky.skyblocker.utils.scheduler.Scheduler; +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.text.ClickEvent; +import net.minecraft.text.HoverEvent; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.apache.commons.lang3.math.NumberUtils; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jetbrains.annotations.UnmodifiableView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.NumberFormat; +import java.time.Instant; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public final class CorpseProfitTracker extends AbstractProfitTracker { + // Items without a proper item id or price + public static final String GLACITE_POWDER = "GLACITE_POWDER"; + public static final String OPAL_CRYSTAL = "OPAL_CRYSTAL"; + public static final String ONYX_CRYSTAL = "ONYX_CRYSTAL"; + public static final String AQUAMARINE_CRYSTAL = "AQUAMARINE_CRYSTAL"; + public static final String PERIDOT_CRYSTAL = "PERIDOT_CRYSTAL"; + public static final String CITRINE_CRYSTAL = "CITRINE_CRYSTAL"; + public static final String RUBY_CRYSTAL = "RUBY_CRYSTAL"; + public static final String JASPER_CRYSTAL = "JASPER_CRYSTAL"; + public static final @Unmodifiable List PRICELESS_ITEMS = List.of(GLACITE_POWDER, OPAL_CRYSTAL, ONYX_CRYSTAL, AQUAMARINE_CRYSTAL, PERIDOT_CRYSTAL, CITRINE_CRYSTAL, RUBY_CRYSTAL, JASPER_CRYSTAL); + + public static final CorpseProfitTracker INSTANCE = new CorpseProfitTracker(); + + private static final Logger LOGGER = LoggerFactory.getLogger("Skyblocker Corpse Profit Tracker"); + private static final Pattern CORPSE_PATTERN = Pattern.compile(" {2}(LAPIS|UMBER|TUNGSTEN|VANGUARD) CORPSE LOOT! *"); + private static final Object2ObjectArrayMap NAME2ID_MAP = new Object2ObjectArrayMap<>(50); + + private ObjectArrayList currentProfileRewards = new ObjectArrayList<>(); + private final ProfiledData> allRewards = new ProfiledData<>(getRewardFilePath("corpse-profits.json"), CorpseLoot.CODEC.listOf().xmap(ObjectArrayList::new, Function.identity())); + private boolean insideRewardMessage = false; + @Nullable + private CorpseLoot lastCorpseLoot = null; + + private CorpseProfitTracker() {} // Singleton + + @Init + public static void init() { + ChatEvents.RECEIVE_STRING.register(INSTANCE::onChatMessage); + + INSTANCE.allRewards.init(); + + SkyblockEvents.PROFILE_INIT.register(INSTANCE::onProfileInit); + SkyblockEvents.PROFILE_CHANGE.register(INSTANCE::onProfileChange); + + // @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("corpse") + .then(literal("list") + // Optional argument. + .then(argument("summaryView", BoolArgumentType.bool()) + .executes(ctx -> { + Scheduler.queueOpenScreen(new CorpseProfitScreen(ctx.getSource().getClient().currentScreen, BoolArgumentType.getBool(ctx, "summaryView"))); + return Command.SINGLE_SUCCESS; + }) + ) + .executes(ctx -> { + Scheduler.queueOpenScreen(new CorpseProfitScreen(ctx.getSource().getClient().currentScreen)); + 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.corpseTracker.historyReset").formatted(Formatting.GREEN))); + return Command.SINGLE_SUCCESS; + }) + ) + ) + ) + )); // @formatter:on + ItemPriceUpdateEvent.ON_PRICE_UPDATE.register(INSTANCE::recalculateAll); + } + + private void onProfileChange(String prevProfileId, String newProfileId) { + onProfileInit(newProfileId); + } + + private void onProfileInit(String profileId) { + if (!isEnabled()) return; + currentProfileRewards = allRewards.computeIfAbsent(ObjectArrayList::new); + recalculateAll(); + } + + public boolean isEnabled() { + return SkyblockerConfigManager.get().mining.glacite.enableCorpseProfitTracker; + } + + private void onChatMessage(String message) { + if (Utils.getLocation() != Location.GLACITE_MINESHAFT || !INSTANCE.isEnabled()) return; + // Reward messages end with a separator like so + if (insideRewardMessage && message.equals("▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")) { + if (lastCorpseLoot == null) { + LOGGER.error("Received a reward message end without a corresponding start. Report this!"); + return; + } + currentProfileRewards.add(lastCorpseLoot); + if (!lastCorpseLoot.isPriceDataComplete()) { + MinecraftClient.getInstance().inGameHud.getChatHud().addMessage( + Constants.PREFIX.get().append(Text.translatable("skyblocker.corpseTracker.somethingWentWrong").formatted(Formatting.GOLD)) + ); + } else { + MinecraftClient.getInstance().inGameHud.getChatHud().addMessage( + Constants.PREFIX.get() + .append(Text.translatable("skyblocker.corpseTracker.corpseProfit", Text.literal(NumberFormat.getInstance().format(Math.round(lastCorpseLoot.profit()))).formatted(lastCorpseLoot.profit() > 0 ? Formatting.GREEN : Formatting.RED))) + .styled(style -> + style.withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.translatable("skyblocker.corpseTracker.hoverText").formatted(Formatting.GREEN))) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/skyblocker rewardTrackers corpse list false")) + ) + ); + } + lastCorpseLoot = null; + insideRewardMessage = false; + return; + } + Matcher matcher = CORPSE_PATTERN.matcher(message); + if (!insideRewardMessage && matcher.matches()) { + String corpse = matcher.group(1); + CorpseType type; + try { + type = CorpseType.valueOf(corpse.toUpperCase()); // toUpperCase is not strictly necessary here, but it's good practice + lastCorpseLoot = new CorpseLoot( + type, + new ObjectArrayList<>(), + Instant.now() + ); + } catch (IllegalArgumentException e) { + LOGGER.error("Unknown corpse type `{}` for message: `{}`. Report this!", corpse, message); + return; + } + + try { + lastCorpseLoot.profit(lastCorpseLoot.profit() - type.getKeyPrice()); //Negated since the key price is a cost, not a reward + } catch (IllegalStateException e) { // This is thrown when the key price is not found + LOGGER.warn("No key price found for corpse type `{}`. Profit calculation will not be accurate, therefore it will not be sent to chat. It will still be added to the corpse history.", corpse); + lastCorpseLoot.markPriceDataIncomplete(); + } + insideRewardMessage = true; + return; + } + + if (!insideRewardMessage || lastCorpseLoot == null || !matcher.usePattern(REWARD_PATTERN).matches()) return; + + String itemName = matcher.group(1); + int amount = NumberUtils.toInt(matcher.group(2).replace(",", ""), 1); + if (matcher.usePattern(HOTM_XP_PATTERN).matches()) return; // Ignore HOTM XP messages. + lastCorpseLoot.addLoot(itemName, amount); + } + + private void recalculateAll() { + for (CorpseLoot corpseLoot : currentProfileRewards) { + corpseLoot.profit(0); + corpseLoot.markPriceDataComplete(); // Reset the flag + for (Reward reward : corpseLoot.rewards()) { + if (PRICELESS_ITEMS.contains(reward.itemId())) continue; + + DoubleBooleanPair price = ItemUtils.getItemPrice(reward.itemId()); + if (!price.rightBoolean()) { + LOGGER.warn("No price found for item `{}`.", reward.itemId()); + corpseLoot.markPriceDataIncomplete(); + continue; + } + corpseLoot.profit(corpseLoot.profit() + price.leftDouble() * reward.amount()); + reward.pricePerUnit(price.leftDouble()); + } + try { + corpseLoot.profit(corpseLoot.profit() - corpseLoot.corpseType().getKeyPrice()); + } catch (IllegalStateException e) { + LOGGER.warn("No key price found for corpse type `{}`. Profit calculation will not be accurate.", corpseLoot.corpseType()); + corpseLoot.markPriceDataIncomplete(); + } + } + } + + @UnmodifiableView + public static List getCurrentProfileRewards() { + return ObjectLists.unmodifiable(INSTANCE.currentProfileRewards); + } + + @UnmodifiableView + 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("☠ Flawed Onyx Gemstone", "FLAWED_ONYX_GEM"); + NAME2ID_MAP.put("☠ Fine Onyx Gemstone", "FINE_ONYX_GEM"); + NAME2ID_MAP.put("☠ Flawless Onyx Gemstone", "FLAWLESS_ONYX_GEM"); + + NAME2ID_MAP.put("☘ Flawed Peridot Gemstone", "FLAWED_PERIDOT_GEM"); + NAME2ID_MAP.put("☘ Fine Peridot Gemstone", "FINE_PERIDOT_GEM"); + NAME2ID_MAP.put("☘ Flawless Peridot Gemstone", "FLAWLESS_PERIDOT_GEM"); + + NAME2ID_MAP.put("☘ Flawed Citrine Gemstone", "FLAWED_CITRINE_GEM"); + NAME2ID_MAP.put("☘ Fine Citrine Gemstone", "FINE_CITRINE_GEM"); + NAME2ID_MAP.put("☘ Flawless Citrine Gemstone", "FLAWLESS_CITRINE_GEM"); + + NAME2ID_MAP.put("α Flawed Aquamarine Gemstone", "FLAWED_AQUAMARINE_GEM"); + NAME2ID_MAP.put("α Fine Aquamarine Gemstone", "FINE_AQUAMARINE_GEM"); + NAME2ID_MAP.put("α Flawless Aquamarine Gemstone", "FLAWLESS_AQUAMARINE_GEM"); + + 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("Enchanted Glacite", "ENCHANTED_GLACITE"); + NAME2ID_MAP.put("Enchanted Umber", "ENCHANTED_UMBER"); + NAME2ID_MAP.put("Enchanted Tungsten", "ENCHANTED_TUNGSTEN"); + + NAME2ID_MAP.put("Refined Umber", "REFINED_UMBER"); + NAME2ID_MAP.put("Refined Tungsten", "REFINED_TUNGSTEN"); + NAME2ID_MAP.put("Refined Mithril", "REFINED_MITHRIL"); + NAME2ID_MAP.put("Refined Titanium", "REFINED_TITANIUM"); + + NAME2ID_MAP.put("Umber Plate", "UMBER_PLATE"); + NAME2ID_MAP.put("Tungsten Plate", "TUNGSTEN_PLATE"); + + NAME2ID_MAP.put("Glacite Amalgamation", "GLACITE_AMALGAMATION"); + NAME2ID_MAP.put("Bejeweled Handle", "BEJEWELED_HANDLE"); + NAME2ID_MAP.put("Umber Key", "UMBER_KEY"); + NAME2ID_MAP.put("Tungsten Key", "TUNGSTEN_KEY"); + NAME2ID_MAP.put("Glacite Jewel", "GLACITE_JEWEL"); + NAME2ID_MAP.put("Suspicious Scrap", "SUSPICIOUS_SCRAP"); + NAME2ID_MAP.put("Ice Cold I", "ENCHANTMENT_ICE_COLD_1"); + NAME2ID_MAP.put("Dwarven O's Metallic Minis", "DWARVEN_OS_METALLIC_MINIS"); + NAME2ID_MAP.put("Shattered Locket", "SHATTERED_PENDANT"); + + NAME2ID_MAP.put("Frostbitten Dye", "DYE_FROSTBITTEN"); + + //These don't have an associated item id or price, but they are in the map regardless so we know what items are not properly mapped and log them accordingly + NAME2ID_MAP.put("Opal Crystal", OPAL_CRYSTAL); + NAME2ID_MAP.put("Onyx Crystal", ONYX_CRYSTAL); + NAME2ID_MAP.put("Aquamarine Crystal", AQUAMARINE_CRYSTAL); + NAME2ID_MAP.put("Peridot Crystal", PERIDOT_CRYSTAL); + NAME2ID_MAP.put("Citrine Crystal", CITRINE_CRYSTAL); + NAME2ID_MAP.put("Ruby Crystal", RUBY_CRYSTAL); + NAME2ID_MAP.put("Jasper Crystal", JASPER_CRYSTAL); + NAME2ID_MAP.put("Glacite Powder", GLACITE_POWDER); + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/Reward.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/Reward.java new file mode 100644 index 0000000000..aed26e11d7 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/Reward.java @@ -0,0 +1,46 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +public final class Reward { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("amount").forGetter(Reward::amount), + Codec.STRING.fieldOf("itemId").forGetter(Reward::itemId), + Codec.DOUBLE.fieldOf("pricePerUnit").forGetter(Reward::pricePerUnit) + ).apply(instance, Reward::new)); + + private final String itemId; + private int amount; + private double pricePerUnit; + + public Reward(int amount, String itemId, double pricePerUnit) { + this.amount = amount; + this.itemId = itemId; + this.pricePerUnit = pricePerUnit; + } + + public Reward(int amount, String itemId) { + this(amount, itemId, 0); + } + + public int amount() { + return amount; + } + + public void amount(int amount) { + this.amount = amount; + } + + public String itemId() { + return itemId; + } + + public double pricePerUnit() { + return pricePerUnit; + } + + public void pricePerUnit(double pricePerUnit) { + this.pricePerUnit = pricePerUnit; + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/RewardList.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/RewardList.java new file mode 100644 index 0000000000..6505b32549 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/profittrackers/corpse/RewardList.java @@ -0,0 +1,256 @@ +package de.hysky.skyblocker.skyblock.dwarven.profittrackers.corpse; + +import de.hysky.skyblocker.skyblock.dwarven.CorpseType; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import it.unimi.dsi.fastutil.objects.ObjectIterator; +import it.unimi.dsi.fastutil.objects.Reference2IntArrayMap; +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.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.util.Comparator; +import java.util.List; + +// This class is a copy of CorpseList because it's a very similar widget, but the way entries are added is different to achieve a different layout. +// The main difference between this class and that class is the constructor and the constructors of MultiEntry. +// Sure, you could reuse some of the code if you really wanted to, but it's honestly not worth it for 2 classes. +public class RewardList extends ElementListWidget { + private static final Logger LOGGER = LoggerFactory.getLogger(RewardList.class); + private static final int BORDER_COLOR = 0xFF6C7086; + private static final int INNER_MARGIN = 2; + + public RewardList(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 SingleEntry(Text.translatable("skyblocker.corpseTracker.emptyHistory").formatted(Formatting.RED), false)); + return; + } + + List rewards = lootList.stream() + .flatMap(loot -> loot.rewards().stream()) + .collect(ObjectArrayList::new, + (list, entry) -> { + if (list.stream().anyMatch(reward -> reward.itemId().equals(entry.itemId()))) { + list.stream().filter(reward -> reward.itemId().equals(entry.itemId())).findFirst().ifPresent(reward -> reward.amount(reward.amount() + entry.amount())); + } else { + list.add(new Reward(entry.amount(), entry.itemId(), entry.pricePerUnit())); // Add a clone of the entry so we don't modify the original + } + }, ObjectArrayList::addAll); + // Sorts in-place + rewards.sort(Comparator.comparingInt(RewardList::comparePriority).thenComparing(Reward::itemId)); + + Reference2IntArrayMap keyAmounts = lootList.stream() + .collect(Reference2IntArrayMap::new, + (map, loot) -> map.mergeInt(loot.corpseType(), 1, Integer::sum), + Reference2IntArrayMap::putAll); + + double profit = lootList.stream().mapToDouble(CorpseLoot::profit).sum(); + + for (Reward reward : rewards) { + Text itemName = CorpseList.getItemName(reward.itemId()); + if (CorpseProfitTracker.PRICELESS_ITEMS.contains(reward.itemId())) { + addEntry(new MultiEntry(itemName, reward.amount())); + } else { + addEntry(new MultiEntry(itemName, reward.amount(), reward.pricePerUnit())); + } + } + addEntry(new SingleEntry(Text.empty())); // Just an empty line to separate the items from the keys + for (var entry : keyAmounts.reference2IntEntrySet()) { + addEntry(new MultiEntry(entry.getKey(), entry.getIntValue())); + } + addEntry(new SingleEntry(Text.empty())); // Just an empty line to separate the keys from the total profit + addEntry(new MultiEntry(profit)); + } + + private static int comparePriority(Reward reward) { + // Lists according to the order in which they appear in the NAME2ID map + ObjectIterator ids = CorpseProfitTracker.getName2IdMap().values().iterator(); + int i = 0; + while (ids.hasNext()) { + if (ids.next().equals(reward.itemId())) return i; + i++; + } + LOGGER.warn("Item ID `{}` not found in NAME2ID map", reward.itemId()); + return Integer.MAX_VALUE; + } + + 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); + } + } + } + + 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 + private static class EmptyEntry extends AbstractEntry { + private EmptyEntry() { + children = List.of(); + } + } + + // For a single line of text, allows for a border to be drawn or not + private static class SingleEntry extends AbstractEntry { + private boolean drawBorder = true; + + private SingleEntry(Text text) { + children = List.of(new TextWidget(text, MinecraftClient.getInstance().textRenderer).alignCenter()); + } + + private 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); + } + } + } + + // Represents a multi-column line of entry, with fixed width columns + private static class MultiEntry extends AbstractEntry { + protected @Nullable TextWidget itemName; + protected @Nullable TextWidget amount; + protected @Nullable TextWidget totalPrice; + protected @Nullable TextWidget pricePerUnit = null; + + // For the items + private 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); + if (amount > 1) { // Only show the price per unit if there's more than 1 item, otherwise it's equal to the total price anyway and is redundant. + 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); + } else children = List.of(this.itemName, this.amount, this.totalPrice); + } + + // For the items + private 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 + private 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 + private MultiEntry(CorpseType corpseType, int amount) { + this.itemName = new TextWidget(Text.literal(WordUtils.capitalizeFully(corpseType.name()) + " Corpse Key Cost").formatted(corpseType.color), MinecraftClient.getInstance().textRenderer).alignLeft(); + this.amount = new TextWidget(Text.literal("x" + amount).formatted(Formatting.AQUA), MinecraftClient.getInstance().textRenderer).alignCenter(); + double pricePerKey = corpseType.getKeyPrice(); + // Gotta make do with weird formatting until we have actual formatters + String priceString = (pricePerKey > 0 ? "-" + NumberFormat.getInstance().format(pricePerKey * amount) : 0) + " Coins"; + Text priceText = Text.literal(priceString).formatted(pricePerKey > 0 ? Formatting.RED : Formatting.GOLD); // We're inverting the price here so positive price is red + this.totalPrice = new TextWidget(priceText, MinecraftClient.getInstance().textRenderer); + if (amount > 1) { + this.pricePerUnit = new TextWidget(Text.literal(NumberFormat.getInstance().format(pricePerKey) + " each").formatted(Formatting.GRAY), MinecraftClient.getInstance().textRenderer); + children = List.of(this.itemName, this.amount, this.totalPrice, this.pricePerUnit); + } else 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/resources/assets/skyblocker/lang/en_us.json b/src/main/resources/assets/skyblocker/lang/en_us.json index 6e8b66c5c2..c3627f3c0e 100644 --- a/src/main/resources/assets/skyblocker/lang/en_us.json +++ b/src/main/resources/assets/skyblocker/lang/en_us.json @@ -576,8 +576,10 @@ "skyblocker.config.mining.crystalHollows.chestHighlighter.@Tooltip": "Highlight found treasure chests and lock pick locations when powder mining.", "skyblocker.config.mining.crystalHollows.chestHighlighter.color": "Chest Highlight Color", "skyblocker.config.mining.crystalHollows.chestHighlighter.color.@Tooltip": "What color the treasure chests / lock pick should be highlighted.", - "skyblocker.config.mining.crystalHollows.powderTrackerFilter": "Powder Tracker Shown Items", - "skyblocker.config.mining.crystalHollows.powderTrackerFilter.@Tooltip": "Choose which items to show & include in the profit calculation for the powder tracker.", + "skyblocker.config.mining.crystalHollows.enablePowderTracker": "Enable Powder Mining Tracker", + "skyblocker.config.mining.crystalHollows.enablePowderTracker.@Tooltip": "Tracks the treasure chest rewards you get from powder mining in the Crystal Hollows.", + "skyblocker.config.mining.crystalHollows.powderTrackerFilter": "Powder Mining Tracker Shown Items", + "skyblocker.config.mining.crystalHollows.powderTrackerFilter.@Tooltip": "Choose which items to show & include in the profit calculation for the powder mining tracker.", "skyblocker.config.mining.crystalHollows.powderTrackerFilter.screenTitle": "Shown Items", "skyblocker.config.mining.crystalsHud": "Crystal Hollows Map", @@ -647,6 +649,8 @@ "skyblocker.config.mining.glacite.enableParsingChatCorpseFinder.@Tooltip": "If enabled, will listen to chat and process corpses shared by other players. Should be compatible with other mods.", "skyblocker.config.mining.glacite.autoShareCorpses": "Automatically Share Found Corpses In Chat", "skyblocker.config.mining.glacite.autoShareCorpses.@Tooltip": "Sends a message in party chat with the location of the found corpse when you find one.", + "skyblocker.config.mining.glacite.enableCorpseProfitTracker": "Enable Corpse Profit Tracker", + "skyblocker.config.mining.glacite.enableCorpseProfitTracker.@Tooltip": "Tracks the profit and the loot you get from corpses. Shows the profit in chat when looting a corpse, and has a command to see your history of corpse loot in /skyblocker rewardTrackers corpse list", "skyblocker.config.misc": "Misc", @@ -1274,6 +1278,23 @@ "skyblocker.crimson.kuudra.lowArrowPoison": "Low on Arrow Poison!", "skyblocker.crimson.kuudra.danger": "DANGER!", + "skyblocker.corpseTracker.screenTitle": "Corpse Loot Tracker", + "skyblocker.corpseTracker.historyView": "Switch to history view", + "skyblocker.corpseTracker.summaryView": "Switch to summary view", + "skyblocker.corpseTracker.totalProfit": "Total Profit: %s%s", + "skyblocker.corpseTracker.corpseProfit": "Corpse Profit: %s", + "skyblocker.corpseTracker.incompletePriceData": " (Incomplete Price Data)", + "skyblocker.corpseTracker.hoverText": "Click to open corpse loot history", + "skyblocker.corpseTracker.somethingWentWrong": "Something went wrong with corpse profit calculation. Check logs for more information.", + "skyblocker.corpseTracker.emptyHistory": "Your corpse history list is empty :(", + "skyblocker.corpseTracker.historyReset": "Corpse profit history has been reset for the current profile.", + + "skyblocker.powderTracker.historyReset": "Powder mining tracker has been reset for the current profile.", + "skyblocker.powderTracker.profit": "Profit: %s Coins", + "skyblocker.powderTracker.emptyHistory": "Your powder mining rewards list is empty :(", + "skyblocker.powderTracker.rewardsFilteredOut": "Your powder mining rewards list is empty after filtering :(", + + "skyblocker.waypoints.ordered.import.skyblocker.success": "Successfully imported waypoints from the Skyblocker Ordered Waypoints format!", "skyblocker.waypoints.ordered.import.skyblocker.unknownFormatHeader": "§cThis import code doesn't look like its in the Skyblocker Ordered Waypoints format, double-check your clipboard to see if everything is correct.", "skyblocker.waypoints.ordered.import.skyblocker.fail": "§cFailed to import waypoints from the Skyblocker Ordered Waypoints format. Make sure to have the waypoint data copied to your clipboard!",