From a09c6ee7931a0af64f4a5be08830bbccdce4c38c Mon Sep 17 00:00:00 2001 From: Rime <81419447+Emirlol@users.noreply.github.com> Date: Wed, 1 Jan 2025 23:47:57 +0300 Subject: [PATCH] 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 --- .../config/categories/MiningCategory.java | 9 + .../config/configs/MiningConfig.java | 11 + .../screens/powdertracker/ItemTickList.java | 79 ++++ .../PowderFilterConfigScreen.java | 74 ++++ .../hysky/skyblocker/events/ChatEvents.java | 46 +++ .../events/ItemPriceUpdateEvent.java | 21 + .../skyblocker/events/SkyblockEvents.java | 128 +++--- .../mixins/MessageHandlerMixin.java | 19 + .../skyblock/dwarven/PowderMiningTracker.java | 365 ++++++++++++++++++ .../skyblocker/skyblock/item/ItemPrice.java | 32 +- .../skyblock/item/tooltip/ItemTooltip.java | 2 + .../java/de/hysky/skyblocker/utils/Utils.java | 5 + .../assets/skyblocker/lang/en_us.json | 5 + src/main/resources/skyblocker.mixins.json | 1 + 14 files changed, 734 insertions(+), 63 deletions(-) create mode 100644 src/main/java/de/hysky/skyblocker/config/screens/powdertracker/ItemTickList.java create mode 100644 src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java create mode 100644 src/main/java/de/hysky/skyblocker/events/ChatEvents.java create mode 100644 src/main/java/de/hysky/skyblocker/events/ItemPriceUpdateEvent.java create mode 100644 src/main/java/de/hysky/skyblocker/mixins/MessageHandlerMixin.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java diff --git a/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java b/src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java index 343643685b..d12f523a72 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 @@ 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 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()) .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 0eb76f2273..201c5c4961 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 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 static class CrystalHollows { @SerialEntry public Color chestHighlightColor = new Color(0, 0, 255, 128); + + @SerialEntry + public boolean enablePowderTracker = true; + + @SerialEntry + public boolean countNaturalChestsInTracker = true; + + @SerialEntry + public List 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 0000000000..ed67b4561b --- /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 { + private final List filters; + private final List allItems; + + public ItemTickList(MinecraftClient minecraftClient, int width, int height, int y, int entryHeight, List filters, List 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 { + private final List 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 selectableChildren() { + return children; + } + + @Override + public List 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 0000000000..84337d7bd9 --- /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 filters; + private final List allItems; + + public PowderFilterConfigScreen(@Nullable Screen parent, List 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 0000000000..2c50aeb056 --- /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 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 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 0000000000..f641509646 --- /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 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 93622d8265..6add9fb657 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 @@ @Environment(EnvType.CLIENT) public final class SkyblockEvents { - public static final Event JOIN = EventFactory.createArrayBacked(SkyblockJoin.class, callbacks -> () -> { - for (SkyblockEvents.SkyblockJoin callback : callbacks) { - callback.onSkyblockJoin(); - } - }); + public static final Event JOIN = EventFactory.createArrayBacked(SkyblockJoin.class, callbacks -> () -> { + for (SkyblockEvents.SkyblockJoin callback : callbacks) { + callback.onSkyblockJoin(); + } + }); - public static final Event LEAVE = EventFactory.createArrayBacked(SkyblockLeave.class, callbacks -> () -> { - for (SkyblockLeave callback : callbacks) { - callback.onSkyblockLeave(); - } - }); + public static final Event LEAVE = EventFactory.createArrayBacked(SkyblockLeave.class, callbacks -> () -> { + for (SkyblockLeave callback : callbacks) { + callback.onSkyblockLeave(); + } + }); - public static final Event LOCATION_CHANGE = EventFactory.createArrayBacked(SkyblockLocationChange.class, callbacks -> location -> { - for (SkyblockLocationChange callback : callbacks) { - callback.onSkyblockLocationChange(location); - } - }); + public static final Event 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 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 PROFILE_CHANGE = EventFactory.createArrayBacked(ProfileChange.class, callbacks -> (prev, profile) -> { + for (ProfileChange callback : callbacks) { + callback.onSkyblockProfileChange(prev, profile); + } + }); - public static final Event PURSE_CHANGE = EventFactory.createArrayBacked(PurseChange.class, callbacks -> (diff, cause) -> { - for (PurseChange callback : callbacks) { - callback.onPurseChange(diff, cause); - } - }); + /** + *

Called when the player's skyblock profile is first detected via chat messages.

+ *

This is useful for initializing data on features that track data for separate profiles separately.

+ * + * @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 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 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 0000000000..60a73e9995 --- /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 0000000000..5459d9551c --- /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> 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> ALL_REWARDS_CODEC = Codec.unboundedMap(Codec.STRING, REWARDS_CODEC); + 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<>((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> ALL_REWARDS = new Object2ObjectArrayMap<>(); + + /** + *

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

+ *

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

+ * + * @implNote This is a map of item IDs to the amount of that item obtained. + */ + @SuppressWarnings("JavadocReference") + private static Object2IntMap currentProfileRewards = new Object2IntOpenHashMap<>(); + private static boolean insideChestMessage = false; + private static double profit = 0; + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isEnabled() { + return SkyblockerConfigManager.get().mining.crystalHollows.enablePowderTracker; + } + + @Init + public static void init() { + ChatEvents.RECEIVE_STRING.register(PowderMiningTracker::onChatMessage); + HudRenderEvents.AFTER_MAIN_HUD.register(PowderMiningTracker::render); + + ItemPriceUpdateEvent.ON_PRICE_UPDATE.register(() -> { + if (isEnabled()) recalculatePrices(); + }); + + 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 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> set = SHOWN_REWARDS.object2IntEntrySet(); + for (Object2IntMap.Entry entry : set) { + calculateProfitForItem(getItemId(entry.getKey().getString()), entry.getIntValue()); + } + } + + /** + * Resets the shown rewards and profit to 0 and recalculates rewards for the current profile based on the config filter. + */ + public static void recalculateAll() { + SHOWN_REWARDS.clear(); + ObjectSet> set = currentProfileRewards.object2IntEntrySet(); + // The filters are actually item names so that they would look nice and not need a lot of mapping under the screen code + // Here they are converted to item IDs for comparison + List filters = SkyblockerConfigManager.get().mining.crystalHollows.powderTrackerFilter.stream().map(PowderMiningTracker::getItemId).toList(); + for (Object2IntMap.Entry entry : set) { + if (filters.contains(entry.getKey())) continue; + + if (entry.getKey().equals("GEMSTONE_POWDER")) { + SHOWN_REWARDS.put(Text.literal("Gemstone Powder").formatted(Formatting.LIGHT_PURPLE), entry.getIntValue()); + } else { + ItemStack stack = ItemRepository.getItemStack(entry.getKey()); + if (stack == null) { + LOGGER.warn("Item stack for id `{}` is null! This might be caused by failed item repository downloads.", entry.getKey()); + continue; + } + SHOWN_REWARDS.put(stack.getName(), entry.getIntValue()); + } + } + recalculatePrices(); + } + + @Unmodifiable + public static Object2ObjectMap getName2IdMap() { + return Object2ObjectMaps.unmodifiable(NAME2ID_MAP); + } + + 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 + Files.createFile(getRewardFilePath()); + } + Files.writeString(getRewardFilePath(), jsonString); + LOGGER.info("Saved powder mining rewards to file."); + } catch (Exception e) { + LOGGER.error("Failed to save powder mining rewards to file!", e); + } + } + + static { + NAME2ID_MAP.put("Gemstone Powder", "GEMSTONE_POWDER"); // Not an actual item, but since we're using IDs for mapping to colored text we need to have this here + + NAME2ID_MAP.put("❤ Rough Ruby Gemstone", "ROUGH_RUBY_GEM"); + NAME2ID_MAP.put("❤ Flawed Ruby Gemstone", "FLAWED_RUBY_GEM"); + NAME2ID_MAP.put("❤ Fine Ruby Gemstone", "FINE_RUBY_GEM"); + NAME2ID_MAP.put("❤ Flawless Ruby Gemstone", "FLAWLESS_RUBY_GEM"); + + NAME2ID_MAP.put("❈ Rough Amethyst Gemstone", "ROUGH_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Flawed Amethyst Gemstone", "FLAWED_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Fine Amethyst Gemstone", "FINE_AMETHYST_GEM"); + NAME2ID_MAP.put("❈ Flawless Amethyst Gemstone", "FLAWLESS_AMETHYST_GEM"); + + NAME2ID_MAP.put("☘ Rough Jade Gemstone", "ROUGH_JADE_GEM"); + NAME2ID_MAP.put("☘ Flawed Jade Gemstone", "FLAWED_JADE_GEM"); + NAME2ID_MAP.put("☘ Fine Jade Gemstone", "FINE_JADE_GEM"); + NAME2ID_MAP.put("☘ Flawless Jade Gemstone", "FLAWLESS_JADE_GEM"); + + NAME2ID_MAP.put("⸕ Rough Amber Gemstone", "ROUGH_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Flawed Amber Gemstone", "FLAWED_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Fine Amber Gemstone", "FINE_AMBER_GEM"); + NAME2ID_MAP.put("⸕ Flawless Amber Gemstone", "FLAWLESS_AMBER_GEM"); + + NAME2ID_MAP.put("✎ Rough Sapphire Gemstone", "ROUGH_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Flawed Sapphire Gemstone", "FLAWED_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Fine Sapphire Gemstone", "FINE_SAPPHIRE_GEM"); + NAME2ID_MAP.put("✎ Flawless Sapphire Gemstone", "FLAWLESS_SAPPHIRE_GEM"); + + NAME2ID_MAP.put("✧ Rough Topaz Gemstone", "ROUGH_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Flawed Topaz Gemstone", "FLAWED_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Fine Topaz Gemstone", "FINE_TOPAZ_GEM"); + NAME2ID_MAP.put("✧ Flawless Topaz Gemstone", "FLAWLESS_TOPAZ_GEM"); + + NAME2ID_MAP.put("❁ Rough Jasper Gemstone", "ROUGH_JASPER_GEM"); + NAME2ID_MAP.put("❁ Flawed Jasper Gemstone", "FLAWED_JASPER_GEM"); + NAME2ID_MAP.put("❁ Fine Jasper Gemstone", "FINE_JASPER_GEM"); + NAME2ID_MAP.put("❁ Flawless Jasper Gemstone", "FLAWLESS_JASPER_GEM"); + + NAME2ID_MAP.put("Pickonimbus 2000", "PICKONIMBUS"); + NAME2ID_MAP.put("Ascension Rope", "ASCENSION_ROPE"); + NAME2ID_MAP.put("Wishing Compass", "WISHING_COMPASS"); + NAME2ID_MAP.put("Gold Essence", "ESSENCE_GOLD"); + NAME2ID_MAP.put("Diamond Essence", "ESSENCE_DIAMOND"); + NAME2ID_MAP.put("Prehistoric Egg", "PREHISTORIC_EGG"); + NAME2ID_MAP.put("Sludge Juice", "SLUDGE_JUICE"); + NAME2ID_MAP.put("Oil Barrel", "OIL_BARREL"); + NAME2ID_MAP.put("Jungle Heart", "JUNGLE_HEART"); + NAME2ID_MAP.put("Treasurite", "TREASURITE"); + NAME2ID_MAP.put("Yoggie", "YOGGIE"); + + NAME2ID_MAP.put("Goblin Egg", "GOBLIN_EGG"); + NAME2ID_MAP.put("Green Goblin Egg", "GOBLIN_EGG_GREEN"); + NAME2ID_MAP.put("Blue Goblin Egg", "GOBLIN_EGG_BLUE"); + NAME2ID_MAP.put("Red Goblin Egg", "GOBLIN_EGG_RED"); + NAME2ID_MAP.put("Yellow Goblin Egg", "GOBLIN_EGG_YELLOW"); + + NAME2ID_MAP.put("Control Switch", "CONTROL_SWITCH"); + NAME2ID_MAP.put("Electron Transmitter", "ELECTRON_TRANSMITTER"); + NAME2ID_MAP.put("FTX 3070", "FTX_3070"); + NAME2ID_MAP.put("Synthetic Heart", "SYNTHETIC_HEART"); + NAME2ID_MAP.put("Robotron Reflector", "ROBOTRON_REFLECTOR"); + NAME2ID_MAP.put("Superlite Motor", "SUPERLITE_MOTOR"); + } + + @NotNull + private static String getItemId(String itemName) { + return NAME2ID_MAP.getOrDefault(itemName, ""); + } + + private static Path getRewardFilePath() { + return SkyblockerMod.CONFIG_DIR.resolve("reward-trackers/powder-mining.json"); + } + + private static String getCombinedId(String profileUuid) { + return Utils.getUndashedUuid() + "+" + profileUuid; + } + + private static void render(DrawContext context, RenderTickCounter tickCounter) { + if (Utils.getLocation() != Location.CRYSTAL_HOLLOWS || !isEnabled()) return; + int y = MinecraftClient.getInstance().getWindow().getScaledHeight() / 2 - 100; + var set = SHOWN_REWARDS.object2IntEntrySet(); + for (Object2IntMap.Entry entry : set) { + context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, entry.getKey(), 5, y, 0xFFFFFF); + context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.of(NumberFormat.getInstance().format(entry.getIntValue())), 10 + MinecraftClient.getInstance().textRenderer.getWidth(entry.getKey()), y, 0xFFFFFF); + y += 10; + } + if (!set.isEmpty()) context.drawTextWithShadow(MinecraftClient.getInstance().textRenderer, Text.literal("Gain: " + NumberFormat.getInstance().format(profit) + " coins").formatted(Formatting.GOLD), 5, y + 10, 0xFFFFFF); + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/item/ItemPrice.java b/src/main/java/de/hysky/skyblocker/skyblock/item/ItemPrice.java index 03da43cff2..12d688cbf0 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/item/ItemPrice.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/item/ItemPrice.java @@ -1,6 +1,7 @@ package de.hysky.skyblocker.skyblock.item; import de.hysky.skyblocker.annotations.Init; +import de.hysky.skyblocker.events.ItemPriceUpdateEvent; import de.hysky.skyblocker.skyblock.item.tooltip.ItemTooltip; import de.hysky.skyblocker.skyblock.item.tooltip.info.DataTooltipInfoType; import de.hysky.skyblocker.skyblock.item.tooltip.info.TooltipInfoType; @@ -33,8 +34,21 @@ public class ItemPrice { "key.categories.skyblocker" )); - @Init - public static void init() {} + /** + *

Crucial init method, do not remove.

+ * + *

This is required due to the way keybindings are registered via Fabric api and lazy static initialization.

+ *

+ * Key bindings are required to be registered before {@link net.minecraft.client.MinecraftClient#options MinecraftClient#options} is initialized. + * This is probably due to how fabric adds key binding options to the key binding options screen. + * Since {@link #ITEM_PRICE_LOOKUP} and {@link #ITEM_PRICE_REFRESH} are static fields, they are initialized lazily, which means they are only initialized when the class is accessed for the first time. + * That first time is generally when the player is already in the game and tries to use the key bindings in a handled screen, which is much later than the possible initialization period. + * This causes an {@link IllegalStateException} to be thrown from {@link net.fabricmc.fabric.impl.client.keybinding.KeyBindingRegistryImpl#registerKeyBinding(KeyBinding) KeyBindingRegistryImpl#registerKeybinding} and the game to crash. + *

+ */ + @SuppressWarnings("UnstableApiUsage") //For the javadoc reference. + @Init + public static void init() {} public static void itemPriceLookup(ClientPlayerEntity player, @NotNull Slot slot) { ItemStack stack = slot.getStack(); @@ -69,11 +83,13 @@ public static void refreshItemPrices(ClientPlayerEntity player) { CompletableFuture.allOf(Stream.of(TooltipInfoType.NPC, TooltipInfoType.BAZAAR, TooltipInfoType.LOWEST_BINS, TooltipInfoType.ONE_DAY_AVERAGE, TooltipInfoType.THREE_DAY_AVERAGE) .map(DataTooltipInfoType::downloadIfEnabled) .toArray(CompletableFuture[]::new) - ).thenRun(() -> player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.config.helpers.itemPrice.refreshedItemPrices")), false)) - .exceptionally(e -> { - ItemTooltip.LOGGER.error("[Skyblocker Item Price] Failed to refresh item prices", e); - player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.config.helpers.itemPrice.itemPriceRefreshFailed")), false); - return null; - }); + ).thenRun(() -> { + ItemPriceUpdateEvent.ON_PRICE_UPDATE.invoker().onPriceUpdate(); + player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.config.helpers.itemPrice.refreshedItemPrices")), false); + }).exceptionally(e -> { + ItemTooltip.LOGGER.error("[Skyblocker Item Price] Failed to refresh item prices", e); + player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.config.helpers.itemPrice.itemPriceRefreshFailed")), false); + return null; + }); } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/item/tooltip/ItemTooltip.java b/src/main/java/de/hysky/skyblocker/skyblock/item/tooltip/ItemTooltip.java index c21fd0a14c..fadff943cc 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/item/tooltip/ItemTooltip.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/item/tooltip/ItemTooltip.java @@ -3,6 +3,7 @@ import de.hysky.skyblocker.annotations.Init; import de.hysky.skyblocker.config.SkyblockerConfigManager; import de.hysky.skyblocker.config.configs.GeneralConfig; +import de.hysky.skyblocker.events.ItemPriceUpdateEvent; import de.hysky.skyblocker.skyblock.item.tooltip.adders.CraftPriceTooltip; import de.hysky.skyblocker.skyblock.item.tooltip.info.DataTooltipInfoType; import de.hysky.skyblocker.skyblock.item.tooltip.info.TooltipInfoType; @@ -83,6 +84,7 @@ public static void init() { .map(DataTooltipInfoType.class::cast) .map(DataTooltipInfoType::downloadIfEnabled) .toArray(CompletableFuture[]::new) + ).thenRun(ItemPriceUpdateEvent.ON_PRICE_UPDATE.invoker()::onPriceUpdate ).exceptionally(e -> { LOGGER.error("[Skyblocker] Encountered unknown error while downloading tooltip data", e); return null; diff --git a/src/main/java/de/hysky/skyblocker/utils/Utils.java b/src/main/java/de/hysky/skyblocker/utils/Utils.java index c51a097e92..ac557e80a5 100644 --- a/src/main/java/de/hysky/skyblocker/utils/Utils.java +++ b/src/main/java/de/hysky/skyblocker/utils/Utils.java @@ -98,6 +98,8 @@ public class Utils { @NotNull public static double purse = 0; + private static boolean firstProfileUpdate = true; + /** * @implNote The parent text will always be empty, the actual text content is inside the text's siblings. */ @@ -513,6 +515,9 @@ public static boolean onChatMessage(Text text, boolean overlay) { if (!prevProfileId.equals(profileId)) { SkyblockEvents.PROFILE_CHANGE.invoker().onSkyblockProfileChange(prevProfileId, profileId); + } else if (firstProfileUpdate) { + SkyblockEvents.PROFILE_INIT.invoker().onSkyblockProfileInit(profileId); + firstProfileUpdate = false; } MuseumItemCache.tick(profileId); diff --git a/src/main/resources/assets/skyblocker/lang/en_us.json b/src/main/resources/assets/skyblocker/lang/en_us.json index 0fa2d2a5dd..2a13cb35ce 100644 --- a/src/main/resources/assets/skyblocker/lang/en_us.json +++ b/src/main/resources/assets/skyblocker/lang/en_us.json @@ -23,6 +23,8 @@ "text.skyblocker.quit_discard": "Quit & Discard Changes", "text.skyblocker.confirm": "Confirm", "text.skyblocker.config": "Open Config...", + "text.skyblocker.reset": "Reset", + "text.skyblocker.undo": "Undo", "text.skyblocker.source": "Source", "text.skyblocker.website": "Website", "text.skyblocker.translate": "Translate", @@ -570,6 +572,9 @@ "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.powderTrackerFilter.screenTitle": "Shown Items", "skyblocker.config.mining.crystalsHud": "Crystal Hollows Map", "skyblocker.config.mining.crystalsHud.enabled": "Enabled", diff --git a/src/main/resources/skyblocker.mixins.json b/src/main/resources/skyblocker.mixins.json index bf32e11acc..7943eb879c 100644 --- a/src/main/resources/skyblocker.mixins.json +++ b/src/main/resources/skyblocker.mixins.json @@ -27,6 +27,7 @@ "InventoryScreenMixin", "ItemStackMixin", "LeverBlockMixin", + "MessageHandlerMixin", "MinecraftClientMixin", "MouseMixin", "MushroomPlantBlockMixin",