diff options
| author | Rime <81419447+Emirlol@users.noreply.github.com> | 2025-01-01 23:47:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-01-01 15:47:57 -0500 |
| commit | a09c6ee7931a0af64f4a5be08830bbccdce4c38c (patch) | |
| tree | bd5eda23bec3a6dc7132b10090651ef45a771990 /src/main/java/de | |
| parent | 8d879bd1c95656d39067fdfc9093d89cb7cc7e56 (diff) | |
| download | Skyblocker-a09c6ee7931a0af64f4a5be08830bbccdce4c38c.tar.gz Skyblocker-a09c6ee7931a0af64f4a5be08830bbccdce4c38c.tar.bz2 Skyblocker-a09c6ee7931a0af64f4a5be08830bbccdce4c38c.zip | |
Add powder mining tracker (#1065)
* Add chat events
* Add powder mining tracker
* Add ON_PRICE_UPDATE event and re-calculate profit after prices are updated
* Initial config
* Add config screens for filtering the shown items
* Fix Done button width being only 1 col wide when it's supposed to be 2
* Change to regex because index magic wasn't magic-ing
Regex my beloved
* Read and write from file and fix incorrect param used in recalculateAll
Also a small fix for the regex
* Change literal text to translatable
* Extract `ON_PRICE_UPDATE` event to a separate class under the `events` package
* Simplify switch to use pattern matching
* Extract render and chat message lambdas to methods
* Add PROFILE_INIT event
* Add support for multiple profiles
* Re-add ItemPrice#init with additional documentation
* Prevent rendering profit if there are no rewards
* Format item amounts when rendering
* Add more documentation
Diffstat (limited to 'src/main/java/de')
12 files changed, 728 insertions, 63 deletions
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 34364368..d12f523a 100644 --- a/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java +++ b/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java @@ -3,14 +3,17 @@ package de.hysky.skyblocker.config.categories; import de.hysky.skyblocker.config.ConfigUtils; import de.hysky.skyblocker.config.SkyblockerConfig; 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 dev.isxander.yacl3.api.*; import dev.isxander.yacl3.api.controller.ColorControllerBuilder; import de.hysky.skyblocker.skyblock.tabhud.config.WidgetsConfigurationScreen; import de.hysky.skyblocker.utils.Location; import dev.isxander.yacl3.api.controller.FloatFieldControllerBuilder; import dev.isxander.yacl3.api.controller.IntegerSliderControllerBuilder; +import it.unimi.dsi.fastutil.objects.ObjectImmutableList; import net.minecraft.client.MinecraftClient; import net.minecraft.text.Text; @@ -112,6 +115,12 @@ 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()) .build()) //Crystal Hollows Map 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 0eb76f22..201c5c49 100644 --- a/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java +++ b/src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java @@ -4,6 +4,8 @@ import dev.isxander.yacl3.config.v2.api.SerialEntry; import net.minecraft.client.resource.language.I18n; import java.awt.*; +import java.util.ArrayList; +import java.util.List; public class MiningConfig { @SerialEntry @@ -83,6 +85,15 @@ public class MiningConfig { @SerialEntry public Color chestHighlightColor = new Color(0, 0, 255, 128); + + @SerialEntry + public boolean enablePowderTracker = true; + + @SerialEntry + public boolean countNaturalChestsInTracker = true; + + @SerialEntry + public List<String> powderTrackerFilter = new ArrayList<>(); } public static class CrystalsHud { diff --git a/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/ItemTickList.java b/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/ItemTickList.java new file mode 100644 index 00000000..ed67b456 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/ItemTickList.java @@ -0,0 +1,79 @@ +package de.hysky.skyblocker.config.screens.powdertracker; + +import de.hysky.skyblocker.mixins.accessors.CheckboxWidgetAccessor; +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.CheckboxWidget; +import net.minecraft.client.gui.widget.ElementListWidget; +import net.minecraft.text.Text; + +import java.util.List; + +public class ItemTickList extends ElementListWidget<ItemTickList.ItemTickEntry> { + private final List<String> filters; + private final List<String> allItems; + + public ItemTickList(MinecraftClient minecraftClient, int width, int height, int y, int entryHeight, List<String> filters, List<String> allItems) { + super(minecraftClient, width, height, y, entryHeight); + this.filters = filters; + this.allItems = allItems; + } + + public void clearAndInit() { + clearEntries(); + init(); + } + + public ItemTickList init() { + for (String item : allItems) { + ItemTickEntry entry = new ItemTickEntry( + CheckboxWidget.builder(Text.of(item), client.textRenderer) + .checked(!filters.contains(item)) + .callback((checkbox1, checked) -> { + if (checked) filters.remove(item); + else filters.add(item); + }) + .build() + ); + addEntry(entry); + } + return this; + } + + public static class ItemTickEntry extends ElementListWidget.Entry<ItemTickEntry> { + private final List<CheckboxWidget> children; + + ItemTickEntry(CheckboxWidget checkboxWidget) { + children = List.of(checkboxWidget); + } + + public void setChecked(boolean checked) { + for (CheckboxWidget child : children) { + ((CheckboxWidgetAccessor) child).setChecked(checked); + } + } + + @Override + public void render(DrawContext context, int index, int y, int x, int entryWidth, int entryHeight, int mouseX, int mouseY, boolean hovered, float tickDelta) { + for (CheckboxWidget child : children) { + child.setX(x); + child.setY(y); + child.setWidth(entryWidth); + child.setHeight(entryHeight); + child.render(context, mouseX, mouseY, tickDelta); + } + } + + @Override + public List<? extends Selectable> selectableChildren() { + return children; + } + + @Override + public List<? extends Element> children() { + return children; + } + } +} 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 new file mode 100644 index 00000000..84337d7b --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java @@ -0,0 +1,74 @@ +package de.hysky.skyblocker.config.screens.powdertracker; + +import de.hysky.skyblocker.config.SkyblockerConfigManager; +import de.hysky.skyblocker.skyblock.dwarven.PowderMiningTracker; +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.screen.ScreenTexts; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class PowderFilterConfigScreen extends Screen { + @Nullable + private final Screen parent; + private final List<String> filters; + private final List<String> allItems; + + public PowderFilterConfigScreen(@Nullable Screen parent, List<String> allItems) { + super(Text.of("Powder Mining Tracker Filter Config")); + this.parent = parent; + this.filters = new ArrayList<>(SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter); // Copy the list so we can undo changes when necessary + this.allItems = allItems; + } + + @Override + protected void init() { + addDrawable((context, mouseX, mouseY, delta) -> { + assert client != null; + context.drawCenteredTextWithShadow(client.textRenderer, Text.translatable("skyblocker.config.mining.crystalHollows.powderTrackerFilter.screenTitle").formatted(Formatting.BOLD), width / 2, (32 - client.textRenderer.fontHeight) / 2, 0xFFFFFF); + }); + ItemTickList itemTickList = addDrawableChild(new ItemTickList(MinecraftClient.getInstance(), width, height - 96, 32, 24, filters, allItems).init()); + //Grid code gratuitously stolen from WaypointsScreen. Same goes for the y and heights above. + GridWidget gridWidget = new GridWidget(); + gridWidget.getMainPositioner().marginX(5).marginY(2); + GridWidget.Adder adder = gridWidget.createAdder(2); + + adder.add(ButtonWidget.builder(Text.translatable("text.skyblocker.reset"), button -> { + filters.clear(); + itemTickList.clearAndInit(); + }).build()); + adder.add(ButtonWidget.builder(Text.translatable("text.skyblocker.undo"), button -> { + filters.clear(); + filters.addAll(SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter); + itemTickList.clearAndInit(); + }).build()); + adder.add(ButtonWidget.builder(ScreenTexts.DONE, button -> { + saveFilters(); + close(); + }) + .width((ButtonWidget.DEFAULT_WIDTH * 2) + 10) + .build(), 2); + gridWidget.refreshPositions(); + SimplePositioningWidget.setPos(gridWidget, 0, this.height - 64, this.width, 64); + gridWidget.forEachChild(this::addDrawableChild); + } + + public void saveFilters() { + SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter = filters; + SkyblockerConfigManager.save(); + PowderMiningTracker.recalculateAll(); + } + + @Override + public void close() { + assert client != null; + client.setScreen(parent); + } +} diff --git a/src/main/java/de/hysky/skyblocker/events/ChatEvents.java b/src/main/java/de/hysky/skyblocker/events/ChatEvents.java new file mode 100644 index 00000000..2c50aeb0 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/events/ChatEvents.java @@ -0,0 +1,46 @@ +package de.hysky.skyblocker.events; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Environment(EnvType.CLIENT) +public class ChatEvents { + /** + * This will be called when a game message is received, cancelled or not. + * + * @implNote Not fired when {@code overlay} is {@code true}. See {@link de.hysky.skyblocker.mixins.MessageHandlerMixin#skyblocker$monitorGameMessage(Text, boolean, CallbackInfo) the mixin} for more information. + */ + @SuppressWarnings("JavadocReference") + public static final Event<ChatTextEvent> RECEIVE_TEXT = EventFactory.createArrayBacked(ChatTextEvent.class, listeners -> message -> { + for (ChatTextEvent listener : listeners) { + listener.onMessage(message); + } + }); + + /** + * This will be called when a game message is received, cancelled or not. + * This method is called with the result of {@link Text#getString()} to avoid each listener having to call it. + * + * @implNote Not fired when {@code overlay} is {@code true}. See {@link de.hysky.skyblocker.mixins.MessageHandlerMixin#skyblocker$monitorGameMessage(Text, boolean, CallbackInfo) the mixin} for more information. + */ + @SuppressWarnings("JavadocReference") + public static final Event<ChatStringEvent> RECEIVE_STRING = EventFactory.createArrayBacked(ChatStringEvent.class, listeners -> message -> { + for (ChatStringEvent listener : listeners) { + listener.onMessage(message); + } + }); + + @FunctionalInterface + public interface ChatTextEvent { + void onMessage(Text message); + } + + @FunctionalInterface + public interface ChatStringEvent { + void onMessage(String message); + } +} diff --git a/src/main/java/de/hysky/skyblocker/events/ItemPriceUpdateEvent.java b/src/main/java/de/hysky/skyblocker/events/ItemPriceUpdateEvent.java new file mode 100644 index 00000000..f6415096 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/events/ItemPriceUpdateEvent.java @@ -0,0 +1,21 @@ +package de.hysky.skyblocker.events; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; + +@FunctionalInterface +@Environment(EnvType.CLIENT) +public interface ItemPriceUpdateEvent { + void onPriceUpdate(); + + /** + * An event that is fired when all prices are updated. + */ + Event<ItemPriceUpdateEvent> ON_PRICE_UPDATE = EventFactory.createArrayBacked(ItemPriceUpdateEvent.class, listeners -> () -> { + for (ItemPriceUpdateEvent listener : listeners) { + listener.onPriceUpdate(); + } + }); +} diff --git a/src/main/java/de/hysky/skyblocker/events/SkyblockEvents.java b/src/main/java/de/hysky/skyblocker/events/SkyblockEvents.java index 93622d82..6add9fb6 100644 --- a/src/main/java/de/hysky/skyblocker/events/SkyblockEvents.java +++ b/src/main/java/de/hysky/skyblocker/events/SkyblockEvents.java @@ -9,68 +9,86 @@ import net.fabricmc.fabric.api.event.EventFactory; @Environment(EnvType.CLIENT) public final class SkyblockEvents { - public static final Event<SkyblockJoin> JOIN = EventFactory.createArrayBacked(SkyblockJoin.class, callbacks -> () -> { - for (SkyblockEvents.SkyblockJoin callback : callbacks) { - callback.onSkyblockJoin(); - } - }); + public static final Event<SkyblockJoin> JOIN = EventFactory.createArrayBacked(SkyblockJoin.class, callbacks -> () -> { + for (SkyblockEvents.SkyblockJoin callback : callbacks) { + callback.onSkyblockJoin(); + } + }); - public static final Event<SkyblockLeave> LEAVE = EventFactory.createArrayBacked(SkyblockLeave.class, callbacks -> () -> { - for (SkyblockLeave callback : callbacks) { - callback.onSkyblockLeave(); - } - }); + public static final Event<SkyblockLeave> LEAVE = EventFactory.createArrayBacked(SkyblockLeave.class, callbacks -> () -> { + for (SkyblockLeave callback : callbacks) { + callback.onSkyblockLeave(); + } + }); - public static final Event<SkyblockLocationChange> LOCATION_CHANGE = EventFactory.createArrayBacked(SkyblockLocationChange.class, callbacks -> location -> { - for (SkyblockLocationChange callback : callbacks) { - callback.onSkyblockLocationChange(location); - } - }); + public static final Event<SkyblockLocationChange> LOCATION_CHANGE = EventFactory.createArrayBacked(SkyblockLocationChange.class, callbacks -> location -> { + for (SkyblockLocationChange callback : callbacks) { + callback.onSkyblockLocationChange(location); + } + }); - /** - * Called when the player's Skyblock profile changes. - * - * @implNote This is called upon receiving the chat message for the profile change rather than the exact moment of profile change, so it may be delayed by a few seconds. - */ - public static final Event<ProfileChange> PROFILE_CHANGE = EventFactory.createArrayBacked(ProfileChange.class, callbacks -> (prev, profile) -> { - for (ProfileChange callback : callbacks) { - callback.onSkyblockProfileChange(prev, profile); - } - }); + /** + * Called when the player's Skyblock profile changes. + * + * @implNote This is called upon receiving the chat message for the profile change rather than the exact moment of profile change, so it may be delayed by a few seconds. + */ + public static final Event<ProfileChange> PROFILE_CHANGE = EventFactory.createArrayBacked(ProfileChange.class, callbacks -> (prev, profile) -> { + for (ProfileChange callback : callbacks) { + callback.onSkyblockProfileChange(prev, profile); + } + }); - public static final Event<PurseChange> PURSE_CHANGE = EventFactory.createArrayBacked(PurseChange.class, callbacks -> (diff, cause) -> { - for (PurseChange callback : callbacks) { - callback.onPurseChange(diff, cause); - } - }); + /** + * <p>Called when the player's skyblock profile is first detected via chat messages.</p> + * <p>This is useful for initializing data on features that track data for separate profiles separately.</p> + * + * @implNote This is called upon receiving the chat message for the profile change rather than the exact moment of profile change, so it may be delayed by a few seconds. + */ + public static final Event<ProfileInit> PROFILE_INIT = EventFactory.createArrayBacked(ProfileInit.class, callbacks -> profile -> { + for (ProfileInit callback : callbacks) { + callback.onSkyblockProfileInit(profile); + } + }); - @Environment(EnvType.CLIENT) - @FunctionalInterface - public interface SkyblockJoin { - void onSkyblockJoin(); - } + public static final Event<PurseChange> PURSE_CHANGE = EventFactory.createArrayBacked(PurseChange.class, callbacks -> (diff, cause) -> { + for (PurseChange callback : callbacks) { + callback.onPurseChange(diff, cause); + } + }); - @Environment(EnvType.CLIENT) - @FunctionalInterface - public interface SkyblockLeave { - void onSkyblockLeave(); - } + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface SkyblockJoin { + void onSkyblockJoin(); + } - @Environment(EnvType.CLIENT) - @FunctionalInterface - public interface SkyblockLocationChange { - void onSkyblockLocationChange(Location location); - } + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface SkyblockLeave { + void onSkyblockLeave(); + } - @Environment(EnvType.CLIENT) - @FunctionalInterface - public interface ProfileChange { - void onSkyblockProfileChange(String prevProfileId, String profileId); - } + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface SkyblockLocationChange { + void onSkyblockLocationChange(Location location); + } - @Environment(EnvType.CLIENT) - @FunctionalInterface - public interface PurseChange { - void onPurseChange(double diff, PurseChangeCause cause); - } + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface ProfileChange { + void onSkyblockProfileChange(String prevProfileId, String profileId); + } + + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface ProfileInit { + void onSkyblockProfileInit(String profileId); + } + + @Environment(EnvType.CLIENT) + @FunctionalInterface + public interface PurseChange { + void onPurseChange(double diff, PurseChangeCause cause); + } } diff --git a/src/main/java/de/hysky/skyblocker/mixins/MessageHandlerMixin.java b/src/main/java/de/hysky/skyblocker/mixins/MessageHandlerMixin.java new file mode 100644 index 00000000..60a73e99 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/mixins/MessageHandlerMixin.java @@ -0,0 +1,19 @@ +package de.hysky.skyblocker.mixins; + +import de.hysky.skyblocker.events.ChatEvents; +import net.minecraft.client.network.message.MessageHandler; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(value = MessageHandler.class, priority = 600) //Inject before the default of 1000 so it bypasses fabric's injections +public class MessageHandlerMixin { + @Inject(method = "onGameMessage", at = @At("HEAD")) + private void skyblocker$monitorGameMessage(Text message, boolean overlay, CallbackInfo ci) { + if (overlay) return; //Can add overlay-specific events in the future or incorporate it into the existing events. For now, it's not necessary. + ChatEvents.RECEIVE_TEXT.invoker().onMessage(message); + ChatEvents.RECEIVE_STRING.invoker().onMessage(message.getString()); + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java new file mode 100644 index 00000000..5459d955 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java @@ -0,0 +1,365 @@ +package de.hysky.skyblocker.skyblock.dwarven; + +import com.google.gson.JsonElement; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.codecs.UnboundedMapCodec; +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 it.unimi.dsi.fastutil.doubles.DoubleBooleanPair; +import it.unimi.dsi.fastutil.objects.*; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +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.Files; +import java.nio.file.Path; +import java.text.NumberFormat; +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<Object2IntMap<String>> REWARDS_CODEC = CodecUtils.object2IntMapCodec(Codec.STRING); + // Doesn't matter if the codec outputs a java map instead of a fastutils map, it's only used in #putAll anyway so the contents are copied over + private static final UnboundedMapCodec<String, Object2IntMap<String>> ALL_REWARDS_CODEC = Codec.unboundedMap(Codec.STRING, REWARDS_CODEC); + private static final Object2ObjectArrayMap<String, String> 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<Text> SHOWN_REWARDS = new Object2IntAVLTreeMap<>((o1, o2) -> { + String o1String = o1.getString(); + String o2String = o2.getString(); + int priority1 = comparePriority(o1String); + int priority2 = comparePriority(o2String); + if (priority1 != priority2) return Integer.compare(priority1, priority2); + return o1String.compareTo(o2String); + }); + + /** + * Holds the total reward maps for all accounts and profiles. {@link #currentProfileRewards} is a subset of this map, updated on profile change. + * + * @implNote This is a map from (account uuid + "+" + profile uuid) to itemId/amount map. + */ + private static final Object2ObjectArrayMap<String, Object2IntMap<String>> ALL_REWARDS = new Object2ObjectArrayMap<>(); + + /** + * <p> + * 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. + * </p> + * <p>This is similar to how {@link ChatHud#messages} and {@link ChatHud#visibleMessages} behave.</p> + * + * @implNote This is a map of item IDs to the amount of that item obtained. + */ + @SuppressWarnings("JavadocReference") + private static Object2IntMap<String> 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(); + }); + + ClientLifecycleEvents.CLIENT_STARTED.register(PowderMiningTracker::loadRewards); + ClientLifecycleEvents.CLIENT_STOPPING.register(PowderMiningTracker::saveRewards); + + 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<Text> 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(getCombinedId(profileId), k -> new Object2IntArrayMap<>()); + 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<Object2IntMap.Entry<Text>> set = SHOWN_REWARDS.object2IntEntrySet(); + for (Object2IntMap.Entry<Text> 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<Object2IntMap.Entry<String>> 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<String> filters = SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.stream().map(PowderMiningTracker::getItemId).toList(); + for (Object2IntMap.Entry<String> 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<String, String> getName2IdMap() { + return Object2ObjectMaps.unmodifiable(NAME2ID_MAP); + } + + private static void loadRewards(MinecraftClient client) { + if (Files.notExists(getRewardFilePath())) return; + try { + String jsonString = Files.readString(getRewardFilePath()); + JsonElement json = SkyblockerMod.GSON.fromJson(jsonString, JsonElement.class); + ALL_REWARDS.clear(); + ALL_REWARDS.putAll(ALL_REWARDS_CODEC.decode(JsonOps.INSTANCE, json).getOrThrow().getFirst()); + LOGGER.info("Loaded powder mining rewards from file."); + } catch (Exception e) { + LOGGER.error("Failed to load powder mining rewards from file!", e); + } + } + + private static void saveRewards(MinecraftClient client) { + try { + String jsonString = ALL_REWARDS_CODEC.encodeStart(JsonOps.INSTANCE, ALL_REWARDS).getOrThrow().toString(); + if (Files.notExists(getRewardFilePath())) { + Files.createDirectories(getRewardFilePath().getParent()); // Create all parent directories if they don't exist |
