aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/de/hysky/skyblocker/config/categories/MiningCategory.java9
-rw-r--r--src/main/java/de/hysky/skyblocker/config/configs/MiningConfig.java11
-rw-r--r--src/main/java/de/hysky/skyblocker/config/screens/powdertracker/ItemTickList.java79
-rw-r--r--src/main/java/de/hysky/skyblocker/config/screens/powdertracker/PowderFilterConfigScreen.java74
-rw-r--r--src/main/java/de/hysky/skyblocker/events/ChatEvents.java46
-rw-r--r--src/main/java/de/hysky/skyblocker/events/ItemPriceUpdateEvent.java21
-rw-r--r--src/main/java/de/hysky/skyblocker/events/SkyblockEvents.java128
-rw-r--r--src/main/java/de/hysky/skyblocker/mixins/MessageHandlerMixin.java19
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dwarven/PowderMiningTracker.java365
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/item/ItemPrice.java32
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/item/tooltip/ItemTooltip.java2
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Utils.java5
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
+ 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_GE