aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/de/hysky/skyblocker/skyblock/dungeon
diff options
context:
space:
mode:
authorAaron <51387595+AzureAaron@users.noreply.github.com>2023-10-09 23:07:23 -0400
committerGitHub <noreply@github.com>2023-10-09 23:07:23 -0400
commita373db64a319c263b2b4c1d07084fa18bd12353b (patch)
tree0f918dd1c05c42616797e47e165e2385f0fca65b /src/main/java/de/hysky/skyblocker/skyblock/dungeon
parent2315b90da8117f28f66348927afdb621ee4fc815 (diff)
parentf346617971b20f6be373411ce5433487cd8d82c2 (diff)
downloadSkyblocker-a373db64a319c263b2b4c1d07084fa18bd12353b.tar.gz
Skyblocker-a373db64a319c263b2b4c1d07084fa18bd12353b.tar.bz2
Skyblocker-a373db64a319c263b2b4c1d07084fa18bd12353b.zip
Merge pull request #348 from LifeIsAParadox/change-packageprefix
me.xrmvizzy -> de.hysky
Diffstat (limited to 'src/main/java/de/hysky/skyblocker/skyblock/dungeon')
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/CroesusHelper.java34
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonBlaze.java152
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonChestProfit.java169
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMap.java61
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMapConfigScreen.java62
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/LividColor.java42
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/OldLever.java40
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/Reparty.java94
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/StarredMobGlow.java56
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/ThreeWeirdos.java39
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/TicTacToe.java136
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/Trivia.java100
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java275
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java451
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/Room.java473
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java142
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/ColorTerminal.java72
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/OrderTerminal.java58
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/StartsWithTerminal.java35
19 files changed, 2491 insertions, 0 deletions
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/CroesusHelper.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/CroesusHelper.java
new file mode 100644
index 00000000..e95b47c9
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/CroesusHelper.java
@@ -0,0 +1,34 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.gui.ColorHighlight;
+import de.hysky.skyblocker.utils.render.gui.ContainerSolver;
+import net.minecraft.item.ItemStack;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class CroesusHelper extends ContainerSolver {
+
+ public CroesusHelper() {
+ super("^Croesus$");
+ }
+
+ @Override
+ protected boolean isEnabled() {
+ return SkyblockerConfigManager.get().locations.dungeons.croesusHelper;
+ }
+
+ @Override
+ protected List<ColorHighlight> getColors(String[] groups, Map<Integer, ItemStack> slots) {
+ List<ColorHighlight> highlights = new ArrayList<>();
+ for (Map.Entry<Integer, ItemStack> entry : slots.entrySet()) {
+ ItemStack stack = entry.getValue();
+ if (stack != null && stack.getNbt() != null && (stack.getNbt().toString().contains("No more Chests to open!") || stack.getNbt().toString().contains("Opened Chest:"))) {
+ highlights.add(ColorHighlight.gray(entry.getKey()));
+ }
+ }
+ return highlights;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonBlaze.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonBlaze.java
new file mode 100644
index 00000000..9f247668
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonBlaze.java
@@ -0,0 +1,152 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import it.unimi.dsi.fastutil.objects.ObjectIntPair;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.client.world.ClientWorld;
+import net.minecraft.entity.decoration.ArmorStandEntity;
+import net.minecraft.predicate.entity.EntityPredicates;
+import net.minecraft.util.math.Box;
+import net.minecraft.util.math.Vec3d;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * This class provides functionality to render outlines around Blaze entities
+ */
+public class DungeonBlaze {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DungeonBlaze.class.getName());
+ private static final float[] GREEN_COLOR_COMPONENTS = {0.0F, 1.0F, 0.0F};
+ private static final float[] WHITE_COLOR_COMPONENTS = {1.0f, 1.0f, 1.0f};
+
+ private static ArmorStandEntity highestBlaze = null;
+ private static ArmorStandEntity lowestBlaze = null;
+ private static ArmorStandEntity nextHighestBlaze = null;
+ private static ArmorStandEntity nextLowestBlaze = null;
+
+ public static void init() {
+ Scheduler.INSTANCE.scheduleCyclic(DungeonBlaze::update, 4);
+ WorldRenderEvents.BEFORE_DEBUG_RENDER.register(DungeonBlaze::blazeRenderer);
+ }
+
+ /**
+ * Updates the state of Blaze entities and triggers the rendering process if necessary.
+ */
+ public static void update() {
+ ClientWorld world = MinecraftClient.getInstance().world;
+ ClientPlayerEntity player = MinecraftClient.getInstance().player;
+ if (world == null || player == null || !Utils.isInDungeons()) return;
+ List<ObjectIntPair<ArmorStandEntity>> blazes = getBlazesInWorld(world, player);
+ sortBlazes(blazes);
+ updateBlazeEntities(blazes);
+ }
+
+ /**
+ * Retrieves Blaze entities in the world and parses their health information.
+ *
+ * @param world The client world to search for Blaze entities.
+ * @return A list of Blaze entities and their associated health.
+ */
+ private static List<ObjectIntPair<ArmorStandEntity>> getBlazesInWorld(ClientWorld world, ClientPlayerEntity player) {
+ List<ObjectIntPair<ArmorStandEntity>> blazes = new ArrayList<>();
+ for (ArmorStandEntity blaze : world.getEntitiesByClass(ArmorStandEntity.class, player.getBoundingBox().expand(500D), EntityPredicates.NOT_MOUNTED)) {
+ String blazeName = blaze.getName().getString();
+ if (blazeName.contains("Blaze") && blazeName.contains("/")) {
+ try {
+ int health = Integer.parseInt(blazeName.substring(blazeName.indexOf("/") + 1, blazeName.length() - 1));
+ blazes.add(ObjectIntPair.of(blaze, health));
+ } catch (NumberFormatException e) {
+ handleException(e);
+ }
+ }
+ }
+ return blazes;
+ }
+
+ /**
+ * Sorts the Blaze entities based on their health values.
+ *
+ * @param blazes The list of Blaze entities to be sorted.
+ */
+ private static void sortBlazes(List<ObjectIntPair<ArmorStandEntity>> blazes) {
+ blazes.sort(Comparator.comparingInt(ObjectIntPair::rightInt));
+ }
+
+ /**
+ * Updates information about Blaze entities based on sorted list.
+ *
+ * @param blazes The sorted list of Blaze entities with associated health values.
+ */
+ private static void updateBlazeEntities(List<ObjectIntPair<ArmorStandEntity>> blazes) {
+ if (!blazes.isEmpty()) {
+ lowestBlaze = blazes.get(0).left();
+ int highestIndex = blazes.size() - 1;
+ highestBlaze = blazes.get(highestIndex).left();
+ if (blazes.size() > 1) {
+ nextLowestBlaze = blazes.get(1).left();
+ nextHighestBlaze = blazes.get(highestIndex - 1).left();
+ }
+ }
+ }
+
+ /**
+ * Renders outlines for Blaze entities based on health and position.
+ *
+ * @param wrc The WorldRenderContext used for rendering.
+ */
+ public static void blazeRenderer(WorldRenderContext wrc) {
+ try {
+ if (highestBlaze != null && lowestBlaze != null && highestBlaze.isAlive() && lowestBlaze.isAlive() && SkyblockerConfigManager.get().locations.dungeons.blazesolver) {
+ if (highestBlaze.getY() < 69) {
+ renderBlazeOutline(highestBlaze, nextHighestBlaze, wrc);
+ }
+ if (lowestBlaze.getY() > 69) {
+ renderBlazeOutline(lowestBlaze, nextLowestBlaze, wrc);
+ }
+ }
+ } catch (Exception e) {
+ handleException(e);
+ }
+ }
+
+ /**
+ * Renders outlines for Blaze entities and connections between them.
+ *
+ * @param blaze The Blaze entity for which to render an outline.
+ * @param nextBlaze The next Blaze entity for connection rendering.
+ * @param wrc The WorldRenderContext used for rendering.
+ */
+ private static void renderBlazeOutline(ArmorStandEntity blaze, ArmorStandEntity nextBlaze, WorldRenderContext wrc) {
+ Box blazeBox = blaze.getBoundingBox().expand(0.3, 0.9, 0.3).offset(0, -1.1, 0);
+ RenderHelper.renderOutline(wrc, blazeBox, GREEN_COLOR_COMPONENTS, 5f);
+
+ if (nextBlaze != null && nextBlaze.isAlive() && nextBlaze != blaze) {
+ Box nextBlazeBox = nextBlaze.getBoundingBox().expand(0.3, 0.9, 0.3).offset(0, -1.1, 0);
+ RenderHelper.renderOutline(wrc, nextBlazeBox, WHITE_COLOR_COMPONENTS, 5f);
+
+ Vec3d blazeCenter = blazeBox.getCenter();
+ Vec3d nextBlazeCenter = nextBlazeBox.getCenter();
+
+ RenderHelper.renderLinesFromPoints(wrc, new Vec3d[]{blazeCenter, nextBlazeCenter}, WHITE_COLOR_COMPONENTS, 1f, 5f);
+ }
+ }
+
+ /**
+ * Handles exceptions by logging and printing stack traces.
+ *
+ * @param e The exception to handle.
+ */
+ private static void handleException(Exception e) {
+ LOGGER.warn("[Skyblocker BlazeRenderer] Encountered an unknown exception", e);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonChestProfit.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonChestProfit.java
new file mode 100644
index 00000000..4e6a5240
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonChestProfit.java
@@ -0,0 +1,169 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import com.google.gson.JsonObject;
+import de.hysky.skyblocker.config.SkyblockerConfig;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.mixin.accessor.ScreenAccessor;
+import de.hysky.skyblocker.skyblock.item.PriceInfoTooltip;
+import de.hysky.skyblocker.utils.Utils;
+import it.unimi.dsi.fastutil.ints.IntBooleanPair;
+import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen;
+import net.minecraft.client.item.TooltipContext;
+import net.minecraft.item.ItemStack;
+import net.minecraft.screen.GenericContainerScreenHandler;
+import net.minecraft.screen.ScreenHandlerType;
+import net.minecraft.screen.slot.Slot;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.DecimalFormat;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class DungeonChestProfit {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DungeonChestProfit.class);
+ private static final Pattern ESSENCE_PATTERN = Pattern.compile("(?<type>[A-Za-z]+) Essence x(?<amount>[0-9]+)");
+ private static final DecimalFormat FORMATTER = new DecimalFormat("#,###");
+
+ public static void init() {
+ ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> ScreenEvents.afterTick(screen).register(screen1 -> {
+ if (Utils.isOnSkyblock() && screen instanceof GenericContainerScreen genericContainerScreen && genericContainerScreen.getScreenHandler().getType() == ScreenHandlerType.GENERIC_9X6) {
+ ((ScreenAccessor) screen).setTitle(getChestProfit(genericContainerScreen.getScreenHandler(), screen.getTitle(), client));
+ }
+ }));
+ }
+
+ public static Text getChestProfit(GenericContainerScreenHandler handler, Text title, MinecraftClient client) {
+ try {
+ if (SkyblockerConfigManager.get().locations.dungeons.dungeonChestProfit.enableProfitCalculator && isDungeonChest(title.getString())) {
+ int profit = 0;
+ boolean hasIncompleteData = false, usedKismet = false;
+ List<Slot> slots = handler.slots.subList(0, handler.getRows() * 9);
+
+ //If the item stack for the "Open Reward Chest" button or the kismet button hasn't been sent to the client yet
+ if (slots.get(31).getStack().isEmpty() || slots.get(50).getStack().isEmpty()) return title;
+
+ for (Slot slot : slots) {
+ ItemStack stack = slot.getStack();
+
+ if (!stack.isEmpty()) {
+ String name = stack.getName().getString();
+ String id = PriceInfoTooltip.getInternalNameFromNBT(stack, false);
+
+ //Regular item price
+ if (id != null) {
+ IntBooleanPair priceData = getItemPrice(id);
+
+ if (!priceData.rightBoolean()) hasIncompleteData = true;
+
+ //Add the item price to the profit
+ profit += priceData.leftInt();
+
+ continue;
+ }
+
+ //Essence price
+ if (name.contains("Essence") && SkyblockerConfigManager.get().locations.dungeons.dungeonChestProfit.includeEssence) {
+ Matcher matcher = ESSENCE_PATTERN.matcher(name);
+
+ if (matcher.matches()) {
+ String type = matcher.group("type");
+ int amount = Integer.parseInt(matcher.group("amount"));
+
+ IntBooleanPair priceData = getItemPrice(("ESSENCE_" + type).toUpperCase());
+
+ if (!priceData.rightBoolean()) hasIncompleteData = true;
+
+ //Add the price of the essence to the profit
+ profit += priceData.leftInt() * amount;
+
+ continue;
+ }
+ }
+
+ //Determine the cost of the chest
+ if (name.contains("Open Reward Chest")) {
+ String foundString = searchLoreFor(stack, client, "Coins");
+
+ //Incase we're searching the free chest
+ if (!StringUtils.isBlank(foundString)) {
+ profit -= Integer.parseInt(foundString.replaceAll("[^0-9]", ""));
+ }
+
+ continue;
+ }
+
+ //Determine if a kismet was used or not
+ if (name.contains("Reroll Chest")) {
+ usedKismet = !StringUtils.isBlank(searchLoreFor(stack, client, "You already rerolled a chest!"));
+ }
+ }
+ }
+
+ if (SkyblockerConfigManager.get().locations.dungeons.dungeonChestProfit.includeKismet && usedKismet) {
+ IntBooleanPair kismetPriceData = getItemPrice("KISMET_FEATHER");
+
+ if (!kismetPriceData.rightBoolean()) hasIncompleteData = true;
+
+ profit -= kismetPriceData.leftInt();
+ }
+
+ return Text.literal(title.getString()).append(getProfitText(profit, hasIncompleteData));
+ }
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker Profit Calculator] Failed to calculate dungeon chest profit! ", e);
+ }
+
+ return title;
+ }
+
+ /**
+ * @return An {@link IntBooleanPair} with the {@code left int} representing the item's price, and the {@code right boolean} indicating if the price
+ * was based on complete data.
+ */
+ private static IntBooleanPair getItemPrice(String id) {
+ JsonObject bazaarPrices = PriceInfoTooltip.getBazaarPrices();
+ JsonObject lbinPrices = PriceInfoTooltip.getLBINPrices();
+
+ if (bazaarPrices == null || lbinPrices == null) return IntBooleanPair.of(0, false);
+
+ if (bazaarPrices.has(id)) {
+ JsonObject item = bazaarPrices.get(id).getAsJsonObject();
+ boolean isPriceNull = item.get("sellPrice").isJsonNull();
+
+ return IntBooleanPair.of(isPriceNull ? 0 : (int) item.get("sellPrice").getAsDouble(), !isPriceNull);
+ }
+
+ if (lbinPrices.has(id)) {
+ return IntBooleanPair.of((int) lbinPrices.get(id).getAsDouble(), true);
+ }
+
+ return IntBooleanPair.of(0, false);
+ }
+
+ /**
+ * Searches for a specific string of characters in the name and lore of an item
+ */
+ private static String searchLoreFor(ItemStack stack, MinecraftClient client, String searchString) {
+ return stack.getTooltip(client.player, TooltipContext.BASIC).stream().map(Text::getString).filter(line -> line.contains(searchString)).findAny().orElse(null);
+ }
+
+ private static boolean isDungeonChest(String name) {
+ return name.equals("Wood Chest") || name.equals("Gold Chest") || name.equals("Diamond Chest") || name.equals("Emerald Chest") || name.equals("Obsidian Chest") || name.equals("Bedrock Chest");
+ }
+
+ private static Text getProfitText(int profit, boolean hasIncompleteData) {
+ SkyblockerConfig.DungeonChestProfit config = SkyblockerConfigManager.get().locations.dungeons.dungeonChestProfit;
+ return getProfitText(profit, hasIncompleteData, config.neutralThreshold, config.neutralColor, config.profitColor, config.lossColor, config.incompleteColor);
+ }
+
+ static Text getProfitText(int profit, boolean hasIncompleteData, int neutralThreshold, Formatting neutralColor, Formatting profitColor, Formatting lossColor, Formatting incompleteColor) {
+ return Text.literal((profit > 0 ? " +" : " ") + FORMATTER.format(profit)).formatted(hasIncompleteData ? incompleteColor : (Math.abs(profit) < neutralThreshold) ? neutralColor : (profit > 0) ? profitColor : lossColor);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMap.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMap.java
new file mode 100644
index 00000000..e1af85ea
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMap.java
@@ -0,0 +1,61 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.render.MapRenderer;
+import net.minecraft.client.render.VertexConsumerProvider;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.item.FilledMapItem;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.map.MapState;
+import net.minecraft.nbt.NbtCompound;
+import net.minecraft.util.Identifier;
+import org.apache.commons.lang3.StringUtils;
+
+public class DungeonMap {
+ private static final Identifier MAP_BACKGROUND = new Identifier("textures/map/map_background.png");
+
+ public static void render(MatrixStack matrices) {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client.player == null || client.world == null) return;
+ ItemStack item = client.player.getInventory().main.get(8);
+ NbtCompound tag = item.getNbt();
+
+ if (tag != null && tag.contains("map")) {
+ String tag2 = tag.asString();
+ tag2 = StringUtils.substringBetween(tag2, "map:", "}");
+ int mapid = Integer.parseInt(tag2);
+ VertexConsumerProvider.Immediate vertices = client.getBufferBuilders().getEffectVertexConsumers();
+ MapRenderer map = client.gameRenderer.getMapRenderer();
+ MapState state = FilledMapItem.getMapState(mapid, client.world);
+ float scaling = SkyblockerConfigManager.get().locations.dungeons.mapScaling;
+ int x = SkyblockerConfigManager.get().locations.dungeons.mapX;
+ int y = SkyblockerConfigManager.get().locations.dungeons.mapY;
+
+ if (state == null) return;
+ matrices.push();
+ matrices.translate(x, y, 0);
+ matrices.scale(scaling, scaling, 0f);
+ map.draw(matrices, vertices, mapid, state, false, 15728880);
+ vertices.draw();
+ matrices.pop();
+ }
+ }
+
+ public static void renderHUDMap(DrawContext context, int x, int y) {
+ float scaling = SkyblockerConfigManager.get().locations.dungeons.mapScaling;
+ int size = (int) (128 * scaling);
+ context.drawTexture(MAP_BACKGROUND, x, y, 0, 0, size, size, size, size);
+ }
+
+ public static void init() {
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(ClientCommandManager.literal("skyblocker")
+ .then(ClientCommandManager.literal("hud")
+ .then(ClientCommandManager.literal("dungeonmap")
+ .executes(Scheduler.queueOpenScreenCommand(DungeonMapConfigScreen::new))))));
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMapConfigScreen.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMapConfigScreen.java
new file mode 100644
index 00000000..145ee2bc
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/DungeonMapConfigScreen.java
@@ -0,0 +1,62 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.text.Text;
+
+import java.awt.*;
+
+public class DungeonMapConfigScreen extends Screen {
+
+ private int hudX = SkyblockerConfigManager.get().locations.dungeons.mapX;
+ private int hudY = SkyblockerConfigManager.get().locations.dungeons.mapY;
+ private final Screen parent;
+
+ protected DungeonMapConfigScreen() {
+ this(null);
+ }
+
+ public DungeonMapConfigScreen(Screen parent) {
+ super(Text.literal("Dungeon Map Config"));
+ this.parent = parent;
+ }
+
+ @Override
+ public void render(DrawContext context, int mouseX, int mouseY, float delta) {
+ super.render(context, mouseX, mouseY, delta);
+ renderBackground(context, mouseX, mouseY, delta);
+ DungeonMap.renderHUDMap(context, hudX, hudY);
+ context.drawCenteredTextWithShadow(textRenderer, "Right Click To Reset Position", width >> 1, height >> 1, Color.GRAY.getRGB());
+ }
+
+ @Override
+ public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
+ float scaling = SkyblockerConfigManager.get().locations.dungeons.mapScaling;
+ int size = (int) (128 * scaling);
+ if (RenderHelper.pointIsInArea(mouseX, mouseY, hudX, hudY, hudX + size, hudY + size) && button == 0) {
+ hudX = (int) Math.max(Math.min(mouseX - (size >> 1), this.width - size), 0);
+ hudY = (int) Math.max(Math.min(mouseY - (size >> 1), this.height - size), 0);
+ }
+ return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
+ }
+
+ @Override
+ public boolean mouseClicked(double mouseX, double mouseY, int button) {
+ if (button == 1) {
+ hudX = 2;
+ hudY = 2;
+ }
+
+ return super.mouseClicked(mouseX, mouseY, button);
+ }
+
+ @Override
+ public void close() {
+ SkyblockerConfigManager.get().locations.dungeons.mapX = hudX;
+ SkyblockerConfigManager.get().locations.dungeons.mapY = hudY;
+ SkyblockerConfigManager.save();
+ this.client.setScreen(parent);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/LividColor.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/LividColor.java
new file mode 100644
index 00000000..762a6e17
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/LividColor.java
@@ -0,0 +1,42 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.scheduler.MessageScheduler;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.util.math.BlockPos;
+
+public class LividColor {
+ private static int tenTicks = 0;
+
+ public static void init() {
+ ClientReceiveMessageEvents.GAME.register((message, overlay) -> {
+ if (SkyblockerConfigManager.get().locations.dungeons.lividColor.enableLividColor && message.getString().equals("[BOSS] Livid: I respect you for making it to here, but I'll be your undoing.")) {
+ tenTicks = 8;
+ }
+ });
+ }
+
+ public static void update() {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (tenTicks != 0) {
+ if (SkyblockerConfigManager.get().locations.dungeons.lividColor.enableLividColor && Utils.isInDungeons() && client.world != null) {
+ if (tenTicks == 1) {
+ MessageScheduler.INSTANCE.sendMessageAfterCooldown(SkyblockerConfigManager.get().locations.dungeons.lividColor.lividColorText.replace("[color]", "red"));
+ tenTicks = 0;
+ return;
+ }
+ String key = client.world.getBlockState(new BlockPos(5, 110, 42)).getBlock().getTranslationKey();
+ if (key.startsWith("block.minecraft.") && key.endsWith("wool") && !key.endsWith("red_wool")) {
+ MessageScheduler.INSTANCE.sendMessageAfterCooldown(SkyblockerConfigManager.get().locations.dungeons.lividColor.lividColorText.replace("[color]", key.substring(16, key.length() - 5)));
+ tenTicks = 0;
+ return;
+ }
+ tenTicks--;
+ } else {
+ tenTicks = 0;
+ }
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/OldLever.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/OldLever.java
new file mode 100644
index 00000000..b9b76c59
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/OldLever.java
@@ -0,0 +1,40 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import net.minecraft.block.Block;
+import net.minecraft.block.enums.BlockFace;
+import net.minecraft.util.math.Direction;
+import net.minecraft.util.shape.VoxelShape;
+
+public class OldLever {
+ protected static final VoxelShape FLOOR_SHAPE = Block.createCuboidShape(4.0D, 0.0D, 4.0D, 12.0D, 10.0D, 12.0D);
+ protected static final VoxelShape NORTH_SHAPE = Block.createCuboidShape(5.0D, 3.0D, 10.0D, 11.0D, 13.0D, 16.0D);
+ protected static final VoxelShape SOUTH_SHAPE = Block.createCuboidShape(5.0D, 3.0D, 0.0D, 11.0D, 13.0D, 6.0D);
+ protected static final VoxelShape EAST_SHAPE = Block.createCuboidShape(0.0D, 3.0D, 5.0D, 6.0D, 13.0D, 11.0D);
+ protected static final VoxelShape WEST_SHAPE = Block.createCuboidShape(10.0D, 3.0D, 5.0D, 16.0D, 13.0D, 11.0D);
+
+ public static VoxelShape getShape(BlockFace face, Direction direction) {
+ if (!SkyblockerConfigManager.get().general.hitbox.oldLeverHitbox)
+ return null;
+
+ if (face == BlockFace.FLOOR) {
+ return FLOOR_SHAPE;
+ } else if (face == BlockFace.WALL) {
+ switch (direction) {
+ case EAST -> {
+ return EAST_SHAPE;
+ }
+ case WEST -> {
+ return WEST_SHAPE;
+ }
+ case SOUTH -> {
+ return SOUTH_SHAPE;
+ }
+ case NORTH -> {
+ return NORTH_SHAPE;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Reparty.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Reparty.java
new file mode 100644
index 00000000..6165ac6a
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Reparty.java
@@ -0,0 +1,94 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.chat.ChatFilterResult;
+import de.hysky.skyblocker.utils.chat.ChatPatternListener;
+import de.hysky.skyblocker.utils.scheduler.MessageScheduler;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.text.Text;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class Reparty extends ChatPatternListener {
+ private static final MinecraftClient client = MinecraftClient.getInstance();
+ public static final Pattern PLAYER = Pattern.compile(" ([a-zA-Z0-9_]{2,16}) ●");
+ private static final int BASE_DELAY = 10;
+
+ private String[] players;
+ private int playersSoFar;
+ private boolean repartying;
+ private String partyLeader;
+
+ public Reparty() {
+ super("^(?:You are not currently in a party\\." +
+ "|Party (?:Membe|Moderato)rs(?: \\(([0-9]+)\\)|:( .*))" +
+ "|([\\[A-z+\\]]* )?(?<disband>.*) has disbanded .*" +
+ "|.*\n([\\[A-z+\\]]* )?(?<invite>.*) has invited you to join their party!" +
+ "\nYou have 60 seconds to accept. Click here to join!\n.*)$");
+
+ this.repartying = false;
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(ClientCommandManager.literal("rp").executes(context -> {
+ if (!Utils.isOnSkyblock() || this.repartying || client.player == null) return 0;
+ this.repartying = true;
+ MessageScheduler.INSTANCE.sendMessageAfterCooldown("/p list");
+ return 0;
+ })));
+ }
+
+ @Override
+ public ChatFilterResult state() {
+ return (SkyblockerConfigManager.get().general.acceptReparty || this.repartying) ? ChatFilterResult.FILTER : ChatFilterResult.PASS;
+ }
+
+ @Override
+ public boolean onMatch(Text message, Matcher matcher) {
+ if (matcher.group(1) != null && repartying) {
+ this.playersSoFar = 0;
+ this.players = new String[Integer.parseInt(matcher.group(1)) - 1];
+ } else if (matcher.group(2) != null && repartying) {
+ Matcher m = PLAYER.matcher(matcher.group(2));
+ while (m.find()) {
+ this.players[playersSoFar++] = m.group(1);
+ }
+ } else if (matcher.group("disband") != null && !matcher.group("disband").equals(client.getSession().getUsername())) {
+ partyLeader = matcher.group("disband");
+ Scheduler.INSTANCE.schedule(() -> partyLeader = null, 61);
+ return false;
+ } else if (matcher.group("invite") != null && matcher.group("invite").equals(partyLeader)) {
+ String command = "/party accept " + partyLeader;
+ sendCommand(command, 0);
+ return false;
+ } else {
+ this.repartying = false;
+ return false;
+ }
+ if (this.playersSoFar == this.players.length) {
+ reparty();
+ }
+ return false;
+ }
+
+ private void reparty() {
+ ClientPlayerEntity playerEntity = client.player;
+ if (playerEntity == null) {
+ this.repartying = false;
+ return;
+ }
+ sendCommand("/p disband", 1);
+ for (int i = 0; i < this.players.length; ++i) {
+ String command = "/p invite " + this.players[i];
+ sendCommand(command, i + 2);
+ }
+ Scheduler.INSTANCE.schedule(() -> this.repartying = false, this.players.length + 2);
+ }
+
+ private void sendCommand(String command, int delay) {
+ MessageScheduler.INSTANCE.queueMessage(command, delay * BASE_DELAY);
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/StarredMobGlow.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/StarredMobGlow.java
new file mode 100644
index 00000000..2072017d
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/StarredMobGlow.java
@@ -0,0 +1,56 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.render.culling.OcclusionCulling;
+import net.minecraft.entity.Entity;
+import net.minecraft.entity.decoration.ArmorStandEntity;
+import net.minecraft.entity.passive.BatEntity;
+import net.minecraft.entity.player.PlayerEntity;
+import net.minecraft.predicate.entity.EntityPredicates;
+import net.minecraft.util.math.Box;
+
+import java.util.List;
+
+public class StarredMobGlow {
+
+ public static boolean shouldMobGlow(Entity entity) {
+ Box box = entity.getBoundingBox();
+
+ if (Utils.isInDungeons() && !entity.isInvisible() && OcclusionCulling.isVisible(box.minX, box.minY, box.minZ, box.maxX, box.maxY, box.maxZ)) {
+ // Minibosses
+ if (entity instanceof PlayerEntity) {
+ switch (entity.getName().getString()) {
+ case "Lost Adventurer", "Shadow Assassin", "Diamond Guy" -> {
+ return true;
+ }
+ }
+ }
+
+ // Regular Mobs
+ if (!(entity instanceof ArmorStandEntity)) {
+ Box searchBox = box.expand(0, 2, 0);
+ List<ArmorStandEntity> armorStands = entity.getWorld().getEntitiesByClass(ArmorStandEntity.class, searchBox, EntityPredicates.NOT_MOUNTED);
+
+ if (!armorStands.isEmpty() && armorStands.get(0).getName().getString().contains("✯")) return true;
+ }
+
+ // Bats
+ return entity instanceof BatEntity;
+ }
+
+ return false;
+ }
+
+ public static int getGlowColor(Entity entity) {
+ if (entity instanceof PlayerEntity) {
+ return switch (entity.getName().getString()) {
+ case "Lost Adventurer" -> 0xfee15c;
+ case "Shadow Assassin" -> 0x5b2cb2;
+ case "Diamond Guy" -> 0x57c2f7;
+ default -> 0xf57738;
+ };
+ }
+
+ return 0xf57738;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/ThreeWeirdos.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/ThreeWeirdos.java
new file mode 100644
index 00000000..e1ab2fa8
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/ThreeWeirdos.java
@@ -0,0 +1,39 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.chat.ChatFilterResult;
+import de.hysky.skyblocker.utils.chat.ChatPatternListener;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.entity.decoration.ArmorStandEntity;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+
+import java.util.regex.Matcher;
+
+public class ThreeWeirdos extends ChatPatternListener {
+ public ThreeWeirdos() {
+ super("^§e\\[NPC] §c([A-Z][a-z]+)§f: (?:The reward is(?: not in my chest!|n't in any of our chests\\.)|My chest (?:doesn't have the reward\\. We are all telling the truth\\.|has the reward and I'm telling the truth!)|At least one of them is lying, and the reward is not in §c§c[A-Z][a-z]+'s §rchest\\!|Both of them are telling the truth\\. Also, §c§c[A-Z][a-z]+ §rhas the reward in their chest\\!)$");
+ }
+
+ @Override
+ public ChatFilterResult state() {
+ return SkyblockerConfigManager.get().locations.dungeons.solveThreeWeirdos ? null : ChatFilterResult.PASS;
+ }
+
+ @Override
+ public boolean onMatch(Text message, Matcher matcher) {
+ MinecraftClient client = MinecraftClient.getInstance();
+ if (client.player == null || client.world == null) return false;
+ client.world.getEntitiesByClass(
+ ArmorStandEntity.class,
+ client.player.getBoundingBox().expand(3),
+ entity -> {
+ Text customName = entity.getCustomName();
+ return customName != null && customName.getString().equals(matcher.group(1));
+ }
+ ).forEach(
+ entity -> entity.setCustomName(Text.of(Formatting.GREEN + matcher.group(1)))
+ );
+ return false;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/TicTacToe.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/TicTacToe.java
new file mode 100644
index 00000000..2d56c8a0
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/TicTacToe.java
@@ -0,0 +1,136 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import de.hysky.skyblocker.utils.tictactoe.TicTacToeUtils;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
+import net.minecraft.block.Block;
+import net.minecraft.block.Blocks;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.client.world.ClientWorld;
+import net.minecraft.entity.decoration.ItemFrameEntity;
+import net.minecraft.item.FilledMapItem;
+import net.minecraft.item.map.MapState;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Box;
+import net.minecraft.util.math.Direction;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * Thanks to Danker for a reference implementation!
+ */
+public class TicTacToe {
+ private static final Logger LOGGER = LoggerFactory.getLogger(TicTacToe.class);
+ private static final float[] RED_COLOR_COMPONENTS = {1.0F, 0.0F, 0.0F};
+ private static Box nextBestMoveToMake = null;
+
+ public static void init() {
+ WorldRenderEvents.BEFORE_DEBUG_RENDER.register(TicTacToe::solutionRenderer);
+ }
+
+ public static void tick() {
+ MinecraftClient client = MinecraftClient.getInstance();
+ ClientWorld world = client.world;
+ ClientPlayerEntity player = client.player;
+
+ nextBestMoveToMake = null;
+
+ if (world == null || player == null || !Utils.isInDungeons()) return;
+
+ //Search within 21 blocks for item frames that contain maps
+ Box searchBox = new Box(player.getX() - 21, player.getY() - 21, player.getZ() - 21, player.getX() + 21, player.getY() + 21, player.getZ() + 21);
+ List<ItemFrameEntity> itemFramesThatHoldMaps = world.getEntitiesByClass(ItemFrameEntity.class, searchBox, ItemFrameEntity::containsMap);
+
+ try {
+ //Only attempt to solve if its the player's turn
+ if (itemFramesThatHoldMaps.size() != 9 && itemFramesThatHoldMaps.size() % 2 == 1) {
+ char[][] board = new char[3][3];
+ BlockPos leftmostRow = null;
+ int sign = 1;
+ char facing = 'X';
+
+ for (ItemFrameEntity itemFrame : itemFramesThatHoldMaps) {
+ MapState mapState = world.getMapState(FilledMapItem.getMapName(itemFrame.getMapId().getAsInt()));
+
+ if (mapState == null) continue;
+
+ int column = 0, row;
+ sign = 1;
+
+ //Find position of the item frame relative to where it is on the tic tac toe board
+ if (itemFrame.getHorizontalFacing() == Direction.SOUTH || itemFrame.getHorizontalFacing() == Direction.WEST) sign = -1;
+ BlockPos itemFramePos = BlockPos.ofFloored(itemFrame.getX(), itemFrame.getY(), itemFrame.getZ());
+
+ for (int i = 2; i >= 0; i--) {
+ int realI = i * sign;
+ BlockPos blockPos = itemFramePos;
+
+ if (itemFrame.getX() % 0.5 == 0) {
+ blockPos = itemFramePos.add(realI, 0, 0);
+ } else if (itemFrame.getZ() % 0.5 == 0) {
+ blockPos = itemFramePos.add(0, 0, realI);
+ facing = 'Z';
+ }
+
+ Block block = world.getBlockState(blockPos).getBlock();
+ if (block == Blocks.AIR || block == Blocks.STONE_BUTTON) {
+ leftmostRow = blockPos;
+ column = i;
+
+ break;
+ }
+ }
+
+ //Determine the row of the item frame
+ if (itemFrame.getY() == 72.5) {
+ row = 0;
+ } else if (itemFrame.getY() == 71.5) {
+ row = 1;
+ } else if (itemFrame.getY() == 70.5) {
+ row = 2;
+ } else {
+ continue;
+ }
+
+
+ //Get the color of the middle pixel of the map which determines whether its X or O
+ int middleColor = mapState.colors[8256] & 255;
+
+ if (middleColor == 114) {
+ board[row][column] = 'X';
+ } else if (middleColor == 33) {
+ board[row][column] = 'O';
+ }
+
+ int bestMove = TicTacToeUtils.getBestMove(board) - 1;
+
+ if (leftmostRow != null) {
+ double drawX = facing == 'X' ? leftmostRow.getX() - sign * (bestMove % 3) : leftmostRow.getX();
+ double drawY = 72 - (double) (bestMove / 3);
+ double drawZ = facing == 'Z' ? leftmostRow.getZ() - sign * (bestMove % 3) : leftmostRow.getZ();
+
+ nextBestMoveToMake = new Box(drawX, drawY, drawZ, drawX + 1, drawY + 1, drawZ + 1);
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker Tic Tac Toe] Encountered an exception while determining a tic tac toe solution!", e);
+ }
+ }
+
+ private static void solutionRenderer(WorldRenderContext context) {
+ try {
+ if (SkyblockerConfigManager.get().locations.dungeons.solveTicTacToe && nextBestMoveToMake != null) {
+ RenderHelper.renderOutline(context, nextBestMoveToMake, RED_COLOR_COMPONENTS, 5);
+ }
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker Tic Tac Toe] Encountered an exception while rendering the tic tac toe solution!", e);
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Trivia.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Trivia.java
new file mode 100644
index 00000000..262d4a4e
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/Trivia.java
@@ -0,0 +1,100 @@
+package de.hysky.skyblocker.skyblock.dungeon;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.skyblock.FairySouls;
+import de.hysky.skyblocker.utils.chat.ChatFilterResult;
+import de.hysky.skyblocker.utils.chat.ChatPatternListener;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+import java.util.regex.Matcher;
+
+public class Trivia extends ChatPatternListener {
+ private static final Map<String, String[]> answers;
+ private List<String> solutions = Collections.emptyList();
+
+ public Trivia() {
+ super("^ +(?:([A-Za-z,' ]*\\?)|§6 ([ⓐⓑⓒ]) §a([a-zA-Z0-9 ]+))$");
+ }
+
+ @Override
+ public ChatFilterResult state() {
+ return SkyblockerConfigManager.get().locations.dungeons.solveTrivia ? ChatFilterResult.FILTER : ChatFilterResult.PASS;
+ }
+
+ @Override
+ public boolean onMatch(Text message, Matcher matcher) {
+ String riddle = matcher.group(3);
+ if (riddle != null) {
+ if (!solutions.contains(riddle)) {
+ ClientPlayerEntity player = MinecraftClient.getInstance().player;
+ if (player != null)
+ MinecraftClient.getInstance().player.sendMessage(Text.of(" " + Formatting.GOLD + matcher.group(2) + Formatting.RED + " " + riddle), false);
+ return player != null;
+ }
+ } else updateSolutions(matcher.group(0));
+ return false;
+ }
+
+ private void updateSolutions(String question) {
+ String trimmedQuestion = question.trim();
+ if (trimmedQuestion.equals("What SkyBlock year is it?")) {
+ long currentTime = System.currentTimeMillis() / 1000L;
+ long diff = currentTime - 1560276000;
+ int year = (int) (diff / 446400 + 1);
+ solutions = Collections.singletonList("Year " + year);
+ } else {
+ solutions = Arrays.asList(answers.get(trimmedQuestion));
+ }
+ }
+
+ static {
+ answers = Collections.synchronizedMap(new HashMap<>());
+ answers.put("What is the status of The Watcher?", new String[]{"Stalker"});
+ answers.put("What is the status of Bonzo?", new String[]{"New Necromancer"});
+ answers.put("What is the status of Scarf?", new String[]{"Apprentice Necromancer"});
+ answers.put("What is the status of The Professor?", new String[]{"Professor"});
+ answers.put("What is the status of Thorn?", new String[]{"Shaman Necromancer"});
+ answers.put("What is the status of Livid?", new String[]{"Master Necromancer"});
+ answers.put("What is the status of Sadan?", new String[]{"Necromancer Lord"});
+ answers.put("What is the status of Maxor?", new String[]{"The Wither Lords"});
+ answers.put("What is the status of Goldor?", new String[]{"The Wither Lords"});
+ answers.put("What is the status of Storm?", new String[]{"The Wither Lords"});
+ answers.put("What is the status of Necron?", new String[]{"The Wither Lords"});
+ answers.put("What is the status of Maxor, Storm, Goldor and Necron?", new String[]{"The Wither Lords"});
+ answers.put("Which brother is on the Spider's Den?", new String[]{"Rick"});
+ answers.put("What is the name of Rick's brother?", new String[]{"Pat"});
+ answers.put("What is the name of the Painter in the Hub?", new String[]{"Marco"});
+ answers.put("What is the name of the person that upgrades pets?", new String[]{"Kat"});
+ answers.put("What is the name of the lady of the Nether?", new String[]{"Elle"});
+ answers.put("Which villager in the Village gives you a Rogue Sword?", new String[]{"Jamie"});
+ answers.put("How many unique minions are there?", new String[]{"59 Minions"});
+ answers.put("Which of these enemies does not spawn in the Spider's Den?", new String[]{"Zombie Spider", "Cave Spider", "Wither Skeleton", "Dashing Spooder", "Broodfather", "Night Spider"});
+ answers.put("Which of these monsters only spawns at night?", new String[]{"Zombie Villager", "Ghast"});
+ answers.put("Which of these is not a dragon in The End?", new String[]{"Zoomer Dragon", "Weak Dragon", "Stonk Dragon", "Holy Dragon", "Boomer Dragon", "Booger Dragon", "Older Dragon", "Elder Dragon", "Stable Dragon", "Professor Dragon"});
+ FairySouls.runAsyncAfterFairySoulsLoad(() -> {
+ answers.put("How many total Fairy Souls are there?", getFairySoulsSizeString(null));
+ answers.put("How many Fairy Souls are there in Spider's Den?", getFairySoulsSizeString("combat_1"));
+ answers.put("How many Fairy Souls are there in The End?", getFairySoulsSizeString("combat_3"));
+ answers.put("How many Fairy Souls are there in The Farming Islands?", getFairySoulsSizeString("farming_1"));
+ answers.put("How many Fairy Souls are there in Crimson Isle?", getFairySoulsSizeString("crimson_isle"));
+ answers.put("How many Fairy Souls are there in The Park?", getFairySoulsSizeString("foraging_1"));
+ answers.put("How many Fairy Souls are there in Jerry's Workshop?", getFairySoulsSizeString("winter"));
+ answers.put("How many Fairy Souls are there in Hub?", getFairySoulsSizeString("hub"));
+ answers.put("How many Fairy Souls are there in The Hub?", getFairySoulsSizeString("hub"));
+ answers.put("How many Fairy Souls are there in Deep Caverns?", getFairySoulsSizeString("mining_2"));
+ answers.put("How many Fairy Souls are there in Gold Mine?", getFairySoulsSizeString("mining_1"));
+ answers.put("How many Fairy Souls are there in Dungeon Hub?", getFairySoulsSizeString("dungeon_hub"));
+ });
+ }
+
+ @NotNull
+ private static String[] getFairySoulsSizeString(@Nullable String location) {
+ return new String[]{"%d Fairy Souls".formatted(FairySouls.getFairySoulsSize(location))};
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java
new file mode 100644
index 00000000..259cc3f3
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonMapUtils.java
@@ -0,0 +1,275 @@
+package de.hysky.skyblocker.skyblock.dungeon.secrets;
+
+import com.google.gson.JsonObject;
+import it.unimi.dsi.fastutil.ints.IntSortedSet;
+import it.unimi.dsi.fastutil.objects.ObjectIntPair;
+import net.minecraft.block.MapColor;
+import net.minecraft.item.map.MapIcon;
+import net.minecraft.item.map.MapState;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.MathHelper;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.util.math.Vec3i;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.RoundingMode;
+import org.joml.Vector2i;
+import org.joml.Vector2ic;
+
+import java.util.*;
+
+public class DungeonMapUtils {
+ public static final byte BLACK_COLOR = MapColor.BLACK.getRenderColorByte(MapColor.Brightness.LOWEST);
+ public static final byte WHITE_COLOR = MapColor.WHITE.getRenderColorByte(MapColor.Brightness.HIGH);
+
+ public static byte getColor(MapState map, @Nullable Vector2ic pos) {
+ return pos == null ? -1 : getColor(map, pos.x(), pos.y());
+ }
+
+ public static byte getColor(MapState map, int x, int z) {
+ if (x < 0 || z < 0 || x >= 128 || z >= 128) {
+ return -1;
+ }
+ return map.colors[x + (z << 7)];
+ }
+
+ public static boolean isEntranceColor(MapState map, int x, int z) {
+ return getColor(map, x, z) == Room.Type.ENTRANCE.color;
+ }
+
+ public static boolean isEntranceColor(MapState map, @Nullable Vector2ic pos) {
+ return getColor(map, pos) == Room.Type.ENTRANCE.color;
+ }
+
+ @Nullable
+ private static Vector2i getMapPlayerPos(MapState map) {
+ for (MapIcon icon : map.getIcons()) {
+ if (icon.type() == MapIcon.Type.FRAME) {
+ return new Vector2i((icon.x() >> 1) + 64, (icon.z() >> 1) + 64);
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ public static ObjectIntPair<Vector2ic> getMapEntrancePosAndRoomSize(@NotNull MapState map) {
+ Vector2ic mapPos = getMapPlayerPos(map);
+ if (mapPos == null) {
+ return null;
+ }
+ Queue<Vector2ic> posToCheck = new ArrayDeque<>();
+ Set<Vector2ic> checked = new HashSet<>();
+ posToCheck.add(mapPos);
+ checked.add(mapPos);
+ while ((mapPos = posToCheck.poll()) != null) {
+ if (isEntranceColor(map, mapPos)) {
+ ObjectIntPair<Vector2ic> mapEntranceAndRoomSizePos = getMapEntrancePosAndRoomSizeAt(map, mapPos);
+ if (mapEntranceAndRoomSizePos.rightInt() > 0) {
+ return mapEntranceAndRoomSizePos;
+ }
+ }
+ Vector2ic pos = new Vector2i(mapPos).sub(10, 0);
+ if (checked.add(pos)) {
+ posToCheck.add(pos);
+ }
+ pos = new Vector2i(mapPos).sub(0, 10);
+ if (checked.add(pos)) {
+ posToCheck.add(pos);
+ }
+ pos = new Vector2i(mapPos).add(10, 0);
+ if (checked.add(pos)) {
+ posToCheck.add(pos);
+ }
+ pos = new Vector2i(mapPos).add(0, 10);
+ if (checked.add(pos)) {
+ posToCheck.add(pos);
+ }
+ }
+ return null;
+ }
+
+ private static ObjectIntPair<Vector2ic> getMapEntrancePosAndRoomSizeAt(MapState map, Vector2ic mapPosImmutable) {
+ Vector2i mapPos = new Vector2i(mapPosImmutable);
+ // noinspection StatementWithEmptyBody
+ while (isEntranceColor(map, mapPos.sub(1, 0))) {
+ }
+ mapPos.add(1, 0);
+ //noinspection StatementWithEmptyBody
+ while (isEntranceColor(map, mapPos.sub(0, 1))) {
+ }
+ return ObjectIntPair.of(mapPos.add(0, 1), getMapRoomSize(map, mapPos));
+ }
+
+ public static int getMapRoomSize(MapState map, Vector2ic mapEntrancePos) {
+ int i = -1;
+ //noinspection StatementWithEmptyBody
+ while (isEntranceColor(map, mapEntrancePos.x() + ++i, mapEntrancePos.y())) {
+ }
+ return i > 5 ? i : 0;
+ }
+
+ /**
+ * Gets the map position of the top left corner of the room the player is in.
+ *
+ * @param map the map
+ * @param mapEntrancePos the map position of the top left corner of the entrance
+ * @param mapRoomSize the size of a room on the map
+ * @return the map position of the top left corner of the room the player is in
+ * @implNote {@code mapPos} is shifted by 2 so room borders are evenly split.
+ * {@code mapPos} is then shifted by {@code offset} to align the top left most room at (0, 0)
+ * so subtracting the modulo will give the top left corner of the room shifted by {@code offset}.
+ * Finally, {@code mapPos} is shifted back by {@code offset} to its intended position.
+ */
+ @Nullable
+ public static Vector2ic getMapRoomPos(MapState map, Vector2ic mapEntrancePos, int mapRoomSize) {
+ int mapRoomSizeWithGap = mapRoomSize + 4;
+ Vector2i mapPos = getMapPlayerPos(map);
+ if (mapPos == null) {
+ return null;
+ }
+ Vector2ic offset = new Vector2i(mapEntrancePos.x() % mapRoomSizeWithGap, mapEntrancePos.y() % mapRoomSizeWithGap);
+ return mapPos.add(2, 2).sub(offset).sub(mapPos.x() % mapRoomSizeWithGap, mapPos.y() % mapRoomSizeWithGap).add(offset);
+ }
+
+ /**
+ * Gets the map position of the top left corner of the room corresponding to the physical position of the northwest corner of a room.
+ *
+ * @param physicalEntrancePos the physical position of the northwest corner of the entrance room
+ * @param mapEntrancePos the map position of the top left corner of the entrance room
+ * @param mapRoomSize the size of a room on the map
+ * @param physicalPos the physical position of the northwest corner of the room
+ * @return the map position of the top left corner of the room corresponding to the physical position of the northwest corner of a room
+ */
+ public static Vector2ic getMapPosFromPhysical(Vector2ic physicalEntrancePos, Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalPos) {
+ return new Vector2i(physicalPos).sub(physicalEntrancePos).div(32).mul(mapRoomSize + 4).add(mapEntrancePos);
+ }
+
+ /**
+ * @see #getPhysicalRoomPos(double, double)
+ */
+ @NotNull
+ public static Vector2ic getPhysicalRoomPos(@NotNull Vec3d pos) {
+ return getPhysicalRoomPos(pos.getX(), pos.getZ());
+ }
+
+ /**
+ * @see #getPhysicalRoomPos(double, double)
+ */
+ @NotNull
+ public static Vector2ic getPhysicalRoomPos(@NotNull Vec3i pos) {
+ return getPhysicalRoomPos(pos.getX(), pos.getZ());
+ }
+
+ /**
+ * Gets the physical position of the northwest corner of the room the given coordinate is in. Hypixel Skyblock Dungeons are aligned to a 32 by 32 blocks grid, allowing corners to be calculated through math.
+ *
+ * @param x the x position of the coordinate to calculate
+ * @param z the z position of the coordinate to calculate
+ * @return the physical position of the northwest corner of the room the player is in
+ * @implNote {@code physicalPos} is shifted by 0.5 so room borders are evenly split.
+ * {@code physicalPos} is further shifted by 8 because Hypixel offset dungeons by 8 blocks in Skyblock 0.12.3.
+ * Subtracting the modulo gives the northwest corner of the room shifted by 8. Finally, {@code physicalPos} is shifted back by 8 to its intended position.
+ */
+ @NotNull
+ public static Vector2ic getPhysicalRoomPos(double x, double z) {
+ Vector2i physicalPos = new Vector2i(x + 8.5, z + 8.5, RoundingMode.TRUNCATE);
+ return physicalPos.sub(MathHelper.floorMod(physicalPos.x(), 32), MathHelper.floorMod(physicalPos.y(), 32)).sub(8, 8);
+ }
+
+ public static Vector2ic[] getPhysicalPosFromMap(Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalEntrancePos, Vector2ic... mapPositions) {
+ for (int i = 0; i < mapPositions.length; i++) {
+ mapPositions[i] = getPhysicalPosFromMap(mapEntrancePos, mapRoomSize, physicalEntrancePos, mapPositions[i]);
+ }
+ return mapPositions;
+ }
+
+ /**
+ * Gets the physical position of the northwest corner of the room corresponding to the map position of the top left corner of a room.
+ *
+ * @param mapEntrancePos the map position of the top left corner of the entrance room
+ * @param mapRoomSize the size of a room on the map
+ * @param physicalEntrancePos the physical position of the northwest corner of the entrance room
+ * @param mapPos the map position of the top left corner of the room
+ * @return the physical position of the northwest corner of the room corresponding to the map position of the top left corner of a room
+ */
+ public static Vector2ic getPhysicalPosFromMap(Vector2ic mapEntrancePos, int mapRoomSize, Vector2ic physicalEntrancePos, Vector2ic mapPos) {
+ return new Vector2i(mapPos).sub(mapEntrancePos).div(mapRoomSize + 4).mul(32).add(physicalEntrancePos);
+ }
+
+ public static Vector2ic getPhysicalCornerPos(Room.Direction direction, IntSortedSet segmentsX, IntSortedSet segmentsY) {
+ return switch (direction) {
+ case NW -> new Vector2i(segmentsX.firstInt(), segmentsY.firstInt());
+ case NE -> new Vector2i(segmentsX.lastInt() + 30, segmentsY.firstInt());
+ case SW -> new Vector2i(segmentsX.firstInt(), segmentsY.lastInt() + 30);
+ case SE -> new Vector2i(segmentsX.lastInt() + 30, segmentsY.lastInt() + 30);
+ };
+ }
+
+ public static BlockPos actualToRelative(Room.Direction direction, Vector2ic physicalCornerPos, BlockPos pos) {
+ return switch (direction) {
+ case NW -> new BlockPos(pos.getX() - physicalCornerPos.x(), pos.getY(), pos.getZ() - physicalCornerPos.y());
+ case NE -> new BlockPos(pos.getZ() - physicalCornerPos.y(), pos.getY(), -pos.getX() + physicalCornerPos.x());
+ case SW -> new BlockPos(-pos.getZ() + physicalCornerPos.y(), pos.getY(), pos.getX() - physicalCornerPos.x());
+ case SE -> new BlockPos(-pos.getX() + physicalCornerPos.x(), pos.getY(), -pos.getZ() + physicalCornerPos.y());
+ };
+ }
+
+ public static BlockPos relativeToActual(Room.Direction direction, Vector2ic physicalCornerPos, JsonObject posJson) {
+ return relativeToActual(direction, physicalCornerPos, new BlockPos(posJson.get("x").getAsInt(), posJson.get("y").getAsInt(), posJson.get("z").getAsInt()));
+ }
+
+ public static BlockPos relativeToActual(Room.Direction direction, Vector2ic physicalCornerPos, BlockPos pos) {
+ return switch (direction) {
+ case NW -> new BlockPos(pos.getX() + physicalCornerPos.x(), pos.getY(), pos.getZ() + physicalCornerPos.y());
+ case NE -> new BlockPos(-pos.getZ() + physicalCornerPos.x(), pos.getY(), pos.getX() + physicalCornerPos.y());
+ case SW -> new BlockPos(pos.getZ() + physicalCornerPos.x(), pos.getY(), -pos.getX() + physicalCornerPos.y());
+ case SE -> new BlockPos(-pos.getX() + physicalCornerPos.x(), pos.getY(), -pos.getZ() + physicalCornerPos.y());
+ };
+ }
+
+ public static Room.Type getRoomType(MapState map, Vector2ic mapPos) {
+ return switch (getColor(map, mapPos)) {
+ case 30 -> Room.Type.ENTRANCE;
+ case 63 -> Room.Type.ROOM;
+ case 66 -> Room.Type.PUZZLE;
+ case 62 -> Room.Type.TRAP;
+ case 74 -> Room.Type.MINIBOSS;
+ case 82 -> Room.Type.FAIRY;
+ case 18 -> Room.Type.BLOOD;
+ case 85 -> Room.Type.UNKNOWN;
+ default -> null;
+ };
+ }
+
+ public static Vector2ic[] getRoomSegments(MapState map, Vector2ic mapPos, int mapRoomSize, byte color) {
+ Set<Vector2ic> segments = new HashSet<>();
+ Queue<Vector2ic> queue = new ArrayDeque<>();
+ segments.add(mapPos);
+ queue.add(mapPos);
+ while (!queue.isEmpty()) {
+ Vector2ic curMapPos = queue.poll();
+ Vector2i newMapPos = new Vector2i();
+ if (getColor(map, newMapPos.set(curMapPos).sub(1, 0)) == color && !segments.contains(newMapPos.sub(mapRoomSize + 3, 0))) {
+ segments.add(newMapPos);
+ queue.add(newMapPos);
+ newMapPos = new Vector2i();
+ }
+ if (getColor(map, newMapPos.set(curMapPos).sub(0, 1)) == color && !segments.contains(newMapPos.sub(0, mapRoomSize + 3))) {
+ segments.add(newMapPos);
+ queue.add(newMapPos);
+ newMapPos = new Vector2i();
+ }
+ if (getColor(map, newMapPos.set(curMapPos).add(mapRoomSize, 0)) == color && !segments.contains(newMapPos.add(4, 0))) {
+ segments.add(newMapPos);
+ queue.add(newMapPos);
+ newMapPos = new Vector2i();
+ }
+ if (getColor(map, newMapPos.set(curMapPos).add(0, mapRoomSize)) == color && !segments.contains(newMapPos.add(0, 4))) {
+ segments.add(newMapPos);
+ queue.add(newMapPos);
+ }
+ }
+ DungeonSecrets.LOGGER.debug("[Skyblocker] Found dungeon room segments: {}", Arrays.toString(segments.toArray()));
+ return segments.toArray(Vector2ic[]::new);
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java
new file mode 100644
index 00000000..c2358689
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/DungeonSecrets.java
@@ -0,0 +1,451 @@
+package de.hysky.skyblocker.skyblock.dungeon.secrets;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.mojang.brigadier.Command;
+import com.mojang.brigadier.arguments.IntegerArgumentType;
+import com.mojang.brigadier.builder.ArgumentBuilder;
+import com.mojang.brigadier.builder.RequiredArgumentBuilder;
+import it.unimi.dsi.fastutil.objects.Object2ByteMap;
+import it.unimi.dsi.fastutil.objects.Object2ByteOpenHashMap;
+import it.unimi.dsi.fastutil.objects.ObjectIntPair;
+import de.hysky.skyblocker.SkyblockerMod;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
+import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
+import net.fabricmc.fabric.api.event.player.UseBlockCallback;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.entity.ItemEntity;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.mob.AmbientEntity;
+import net.minecraft.entity.passive.BatEntity;
+import net.minecraft.item.FilledMapItem;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.item.map.MapState;
+import net.minecraft.resource.Resource;
+import net.minecraft.text.Text;
+import net.minecraft.util.ActionResult;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.hit.BlockHitResult;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.world.World;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.joml.Vector2ic;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.zip.InflaterInputStream;
+
+import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
+import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal;
+
+public class DungeonSecrets {
+ protected static final Logger LOGGER = LoggerFactory.getLogger(DungeonSecrets.class);
+ private static final String DUNGEONS_PATH = "dungeons";
+ /**
+ * Maps the block identifier string to a custom numeric block id used in dungeon rooms data.
+ *
+ * @implNote Not using {@link net.minecraft.registry.Registry#getId(Object) Registry#getId(Block)} and {@link net.minecraft.block.Blocks Blocks} since this is also used by {@link de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonRoomsDFU DungeonRoomsDFU}, which runs outside of Minecraft.
+ */
+ @SuppressWarnings("JavadocReference")
+ protected static final Object2ByteMap<String> NUMERIC_ID = new Object2ByteOpenHashMap<>(Map.ofEntries(
+ Map.entry("minecraft:stone", (byte) 1),
+ Map.entry("minecraft:diorite", (byte) 2),
+ Map.entry("minecraft:polished_diorite", (byte) 3),
+ Map.entry("minecraft:andesite", (byte) 4),
+ Map.entry("minecraft:polished_andesite", (byte) 5),
+ Map.entry("minecraft:grass_block", (byte) 6),
+ Map.entry("minecraft:dirt", (byte) 7),
+ Map.entry("minecraft:coarse_dirt", (byte) 8),
+ Map.entry("minecraft:cobblestone", (byte) 9),
+ Map.entry("minecraft:bedrock", (byte) 10),
+ Map.entry("minecraft:oak_leaves", (byte) 11),
+ Map.entry("minecraft:gray_wool", (byte) 12),
+ Map.entry("minecraft:double_stone_slab", (byte) 13),
+ Map.entry("minecraft:mossy_cobblestone", (byte) 14),
+ Map.entry("minecraft:clay", (byte) 15),
+ Map.entry("minecraft:stone_bricks", (byte) 16),
+ Map.entry("minecraft:mossy_stone_bricks", (byte) 17),
+ Map.entry("minecraft:chiseled_stone_bricks", (byte) 18),
+ Map.entry("minecraft:gray_terracotta", (byte) 19),
+ Map.entry("minecraft:cyan_terracotta", (byte) 20),
+ Map.entry("minecraft:black_terracotta", (byte) 21)
+ ));
+ /**
+ * Block data for dungeon rooms. See {@link de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonRoomsDFU DungeonRoomsDFU} for format details and how it's generated.
+ * All access to this map must check {@link #isRoomsLoaded()} to prevent concurrent modification.
+ */
+ @SuppressWarnings("JavadocReference")
+ protected static final HashMap<String, Map<String, Map<String, int[]>>> ROOMS_DATA = new HashMap<>();
+ @NotNull
+ private static final Map<Vector2ic, Room> rooms = new HashMap<>();
+ private static final Map<String, JsonElement> roomsJson = new HashMap<>();
+ private static final Map<String, JsonElement> waypointsJson = new HashMap<>();
+ @Nullable
+ private static CompletableFuture<Void> roomsLoaded;
+ /**
+ * The map position of the top left corner of the entrance room.
+ */
+ @Nullable
+ private static Vector2ic mapEntrancePos;
+ /**
+ * The size of a room on the map.
+ */
+ private static int mapRoomSize;
+ /**
+ * The physical position of the northwest corner of the entrance room.
+ */
+ @Nullable
+ private static Vector2ic physicalEntrancePos;
+ private static Room currentRoom;
+
+ public static boolean isRoomsLoaded() {
+ return roomsLoaded != null && roomsLoaded.isDone();
+ }
+
+ @SuppressWarnings("unused")
+ public static JsonObject getRoomMetadata(String room) {
+ return roomsJson.get(room).getAsJsonObject();
+ }
+
+ public static JsonArray getRoomWaypoints(String room) {
+ return waypointsJson.get(room).getAsJsonArray();
+ }
+
+ /**
+ * Loads the dungeon secrets asynchronously from {@code /assets/skyblocker/dungeons}.
+ * Use {@link #isRoomsLoaded()} to check for completion of loading.
+ */
+ public static void init() {
+ if (SkyblockerConfigManager.get().locations.dungeons.secretWaypoints.noInitSecretWaypoints) {
+ return;
+ }
+ // Execute with MinecraftClient as executor since we need to wait for MinecraftClient#resourceManager to be set
+ CompletableFuture.runAsync(DungeonSecrets::load, MinecraftClient.getInstance()).exceptionally(e -> {
+ LOGGER.error("[Skyblocker] Failed to load dungeon secrets", e);
+ return null;
+ });
+ Scheduler.INSTANCE.scheduleCyclic(DungeonSecrets::update, 10);
+ WorldRenderEvents.AFTER_TRANSLUCENT.register(DungeonSecrets::render);
+ ClientReceiveMessageEvents.GAME.register(DungeonSecrets::onChatMessage);
+ ClientReceiveMessageEvents.GAME_CANCELED.register(DungeonSecrets::onChatMessage);
+ UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> onUseBlock(world, hitResult));
+ ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("secrets")
+ .then(literal("markAsFound").then(markSecretsCommand(true)))
+ .then(literal("markAsMissing").then(markSecretsCommand(false)))))));
+ ClientPlayConnectionEvents.JOIN.register(((handler, sender, client) -> reset()));
+ }
+
+ private static void load() {
+ long startTime = System.currentTimeMillis();
+ List<CompletableFuture<Void>> dungeonFutures = new ArrayList<>();
+ for (Map.Entry<Identifier, Resource> resourceEntry : MinecraftClient.getInstance().getResourceManager().findResources(DUNGEONS_PATH, id -> id.getPath().endsWith(".skeleton")).entrySet()) {
+ String[] path = resourceEntry.getKey().getPath().split("/");
+ if (path.length != 4) {
+ LOGGER.error("[Skyblocker] Failed to load dungeon secrets, invalid resource identifier {}", resourceEntry.getKey());
+ break;
+ }
+ String dungeon = path[1];
+ String roomShape = path[2];
+ String room = path[3].substring(0, path[3].length() - ".skeleton".length());
+ ROOMS_DATA.computeIfAbsent(dungeon, dungeonKey -> new HashMap<>());
+ ROOMS_DATA.get(dungeon).computeIfAbsent(roomShape, roomShapeKey -> new HashMap<>());
+ dungeonFutures.add(CompletableFuture.supplyAsync(() -> readRoom(resourceEntry.getValue())).thenAcceptAsync(rooms -> {
+ Map<String, int[]> roomsMap = ROOMS_DATA.get(dungeon).get(roomShape);
+ synchronized (roomsMap) {
+ roomsMap.put(room, rooms);
+ }
+ LOGGER.debug("[Skyblocker] Loaded dungeon secrets dungeon {} room shape {} room {}", dungeon, roomShape, room);
+ }).exceptionally(e -> {
+ LOGGER.error("[Skyblocker] Failed to load dungeon secrets dungeon {} room shape {} room {}", dungeon, roomShape, room, e);
+ return null;
+ }));
+ }
+ dungeonFutures.add(CompletableFuture.runAsync(() -> {
+ try (BufferedReader roomsReader = MinecraftClient.getInstance().getResourceManager().openAsReader(new Identifier(SkyblockerMod.NAMESPACE, "dungeons/dungeonrooms.json")); BufferedReader waypointsReader = MinecraftClient.getInstance().getResourceManager().openAsReader(new Identifier(SkyblockerMod.NAMESPACE, "dungeons/secretlocations.json"))) {
+ loadJson(roomsReader, roomsJson);
+ loadJson(waypointsReader, waypointsJson);
+ LOGGER.debug("[Skyblocker] Loaded dungeon secrets json");
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker] Failed to load dungeon secrets json", e);
+ }
+ }));
+ roomsLoaded = CompletableFuture.allOf(dungeonFutures.toArray(CompletableFuture[]::new)).thenRun(() -> LOGGER.info("[Skyblocker] Loaded dungeon secrets for {} dungeon(s), {} room shapes, and {} rooms total in {} ms", ROOMS_DATA.size(), ROOMS_DATA.values().stream().mapToInt(Map::size).sum(), ROOMS_DATA.values().stream().map(Map::values).flatMap(Collection::stream).mapToInt(Map::size).sum(), System.currentTimeMillis() - startTime)).exceptionally(e -> {
+ LOGGER.error("[Skyblocker] Failed to load dungeon secrets", e);
+ return null;
+ });
+ LOGGER.info("[Skyblocker] Started loading dungeon secrets in (blocked main thread for) {} ms", System.currentTimeMillis() - startTime);
+ }
+
+ private static int[] readRoom(Resource resource) throws RuntimeException {
+ try (ObjectInputStream in = new ObjectInputStream(new InflaterInputStream(resource.getInputStream()))) {
+ return (int[]) in.readObject();
+ } catch (IOException | ClassNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Loads the json from the given {@link BufferedReader} into the given {@link Map}.
+ * @param reader the reader to read the json from
+ * @param map the map to load into
+ */
+ private static void loadJson(BufferedReader reader, Map<String, JsonElement> map) {
+ SkyblockerMod.GSON.fromJson(reader, JsonObject.class).asMap().forEach((room, jsonElement) -> map.put(room.toLowerCase().replaceAll(" ", "-"), jsonElement));
+ }
+
+ private static ArgumentBuilder<FabricClientCommandSource, RequiredArgumentBuilder<FabricClientCommandSource, Integer>> markSecretsCommand(boolean found) {
+ return argument("secret", IntegerArgumentType.integer()).executes(context -> {
+ int secretIndex = IntegerArgumentType.getInteger(context, "secret");
+ if (markSecrets(secretIndex, found)) {
+ context.getSource().sendFeedback(Text.translatable(found ? "skyblocker.dungeons.secrets.markSecretFound" : "skyblocker.dungeons.secrets.markSecretMissing", secretIndex));
+ } else {
+ context.getSource().sendError(Text.translatable(found ? "skyblocker.dungeons.secrets.markSecretFoundUnable" : "skyblocker.dungeons.secrets.markSecretMissingUnable", secretIndex));
+ }
+ return Command.SINGLE_SUCCESS;
+ });
+ }
+
+ /**
+ * Updates the dungeon. The general idea is similar to the Dungeon Rooms Mod.
+ * <p></p>
+ * When entering a new dungeon, this method:
+ * <ul>
+ * <li> Gets the physical northwest corner position of the entrance room and saves it in {@link #physicalEntrancePos}. </li>
+ * <li> Do nothing until the dungeon map exists. </li>
+ * <li> Gets the upper left corner of entrance room on the map and saves it in {@link #mapEntrancePos}. </li>
+ * <li> Gets the size of a room on the map in pixels and saves it in {@link #mapRoomSize}. </li>
+ * <li> Creates a new {@link Room} with {@link Room.Type} {@link Room.Type.ENTRANCE ENTRANCE} and sets {@link #currentRoom}. </li>
+ * </ul>
+ * When processing an existing dungeon, this method:
+ * <ul>
+ * <li> Calculates the physical northwest corner and upper left corner on the map of the room the player is currently in. </li>
+ * <li> Gets the room type based on the map color. </li>
+ * <li> If the room has not been created (when the physical northwest corner is not in {@link #rooms}):</li>
+ * <ul>
+ * <li> If the room type is {@link Room.Type.ROOM}, gets the northwest corner of all connected room segments with {@link DungeonMapUtils#getRoomSegments(MapState, Vector2ic, int, byte)}. (For example, a 1x2 room has two room segments.) </li>
+ * <li> Create a new room. </li>
+ * </ul>
+ * <li> Sets {@link #currentRoom} to the current room, either created from the previous step or from {@link #rooms}. </li>
+ * <li> Calls {@link Room#update()} on {@link #currentRoom}. </li>
+ * </ul>
+ */
+ @SuppressWarnings("JavadocReference")
+ private static void update() {
+ if (!SkyblockerConfigManager.get().locations.dungeons.secretWaypoints.enableSecretWaypoints) {
+ return;
+ }
+ if (!Utils.isInDungeons()) {
+ if (mapEntrancePos != null) {
+ reset();
+ }
+ return;
+ }
+ MinecraftClient client = MinecraftClient.getInstance();
+ ClientPlayerEntity player = client.player;
+ if (player == null || client.world == null) {
+ return;
+ }
+ if (physicalEntrancePos == null) {
+ Vec3d playerPos = player.getPos();
+ physicalEntrancePos = DungeonMapUtils.getPhysicalRoomPos(playerPos);
+ currentRoom = newRoom(Room.Type.ENTRANCE, physicalEntrancePos);
+ }
+ ItemStack stack = player.getInventory().main.get(8);
+ if (!stack.isOf(Items.FILLED_MAP)) {
+ return;
+ }
+ MapState map = FilledMapItem.getMapState(FilledMapItem.getMapId(stack), client.world);
+ if (map == null) {
+ return;
+ }
+ if (mapEntrancePos == null || mapRoomSize == 0) {
+ ObjectIntPair<Vector2ic> mapEntrancePosAndSize = DungeonMapUtils.getMapEntrancePosAndRoomSize(map);
+ if (mapEntrancePosAndSize == null) {
+ return;
+ }
+ mapEntrancePos = mapEntrancePosAndSize.left();
+ mapRoomSize = mapEntrancePosAndSize.rightInt();
+ LOGGER.info("[Skyblocker] Started dungeon with map room size {}, map entrance pos {}, player pos {}, and physical entrance pos {}", mapRoomSize, mapEntrancePos, client.player.getPos(), physicalEntrancePos);
+ }
+
+ Vector2ic physicalPos = DungeonMapUtils.getPhysicalRoomPos(client.player.getPos());
+ Vector2ic mapPos = DungeonMapUtils.getMapPosFromPhysical(physicalEntrancePos, mapEntrancePos, mapRoomSize, physicalPos);
+ Room room = rooms.get(physicalPos);
+ if (room == null) {
+ Room.Type type = DungeonMapUtils.getRoomType(map, mapPos);
+ if (type == null || type == Room.Type.UNKNOWN) {
+ return;
+ }
+ switch (type) {
+ case ENTRANCE, PUZZLE, TRAP, MINIBOSS, FAIRY, BLOOD -> room = newRoom(type, physicalPos);
+ case ROOM -> room = newRoom(type, DungeonMapUtils.getPhysicalPosFromMap(mapEntrancePos, mapRoomSize, physicalEntrancePos, DungeonMapUtils.getRoomSegments(map, mapPos, mapRoomSize, type.color)));
+ }
+ }
+ if (room != null && currentRoom != room) {
+ currentRoom = room;
+ }
+ currentRoom.update();
+ }
+
+ /**
+ * Creates a new room with the given type and physical positions,
+ * adds the room to {@link #rooms}, and sets {@link #currentRoom} to the new room.
+ *
+ * @param type the type of room to create
+ * @param physicalPositions the physical positions of the room
+ */
+ @Nullable
+ private static Room newRoom(Room.Type type, Vector2ic... physicalPositions) {
+ try {
+ Room newRoom = new Room(type, physicalPositions);
+ for (Vector2ic physicalPos : physicalPositions) {
+ rooms.put(physicalPos, newRoom);
+ }
+ return newRoom;
+ } catch (IllegalArgumentException e) {
+ LOGGER.error("[Skyblocker] Failed to create room", e);
+ }
+ return null;
+ }
+
+ /**
+ * Renders the secret waypoints in {@link #currentRoom} if {@link #isCurrentRoomMatched()}.
+ */
+ private static void render(WorldRenderContext context) {
+ if (isCurrentRoomMatched()) {
+ currentRoom.render(context);
+ }
+ }
+
+ /**
+ * Calls {@link Room#onChatMessage(String)} on {@link #currentRoom} if the message is an overlay message and {@link #isCurrentRoomMatched()}.
+ * Used to detect when all secrets in a room are found.
+ */
+ private static void onChatMessage(Text text, boolean overlay) {
+ if (overlay && isCurrentRoomMatched()) {
+ currentRoom.onChatMessage(text.getString());
+ }
+ }
+
+ /**
+ * Calls {@link Room#onUseBlock(World, BlockHitResult)} on {@link #currentRoom} if {@link #isCurrentRoomMatched()}.
+ * Used to detect finding {@link SecretWaypoint.Category.CHEST} and {@link SecretWaypoint.Category.WITHER} secrets.
+ *
+ * @return {@link ActionResult#PASS}
+ */
+ @SuppressWarnings("JavadocReference")
+ private static ActionResult onUseBlock(World world, BlockHitResult hitResult) {
+ if (isCurrentRoomMatched()) {
+ currentRoom.onUseBlock(world, hitResult);
+ }
+ return ActionResult.PASS;
+ }
+
+ /**
+ * Calls {@link Room#onItemPickup(ItemEntity, LivingEntity)} on the room the {@code collector} is in if that room {@link #isRoomMatched(Room)}.
+ * Used to detect finding {@link SecretWaypoint.Category.ITEM} secrets.
+ * If the collector is the player, {@link #currentRoom} is used as an optimization.
+ */
+ @SuppressWarnings("JavadocReference")
+ public static void onItemPickup(ItemEntity itemEntity, LivingEntity collector, boolean isPlayer) {
+ if (isPlayer) {
+ if (isCurrentRoomMatched()) {
+ currentRoom.onItemPickup(itemEntity, collector);
+ }
+ } else {
+ Room room = getRoomAtPhysical(collector.getPos());
+ if (isRoomMatched(room)) {
+ room.onItemPickup(itemEntity, collector);
+ }
+ }
+ }
+
+ /**
+ * Calls {@link Room#onBatRemoved(BatEntity)} on the room the {@code bat} is in if that room {@link #isRoomMatched(Room)}.
+ * Used to detect finding {@link SecretWaypoint.Category.BAT} secrets.
+ */
+ @SuppressWarnings("JavadocReference")
+ public static void onBatRemoved(AmbientEntity bat) {
+ Room room = getRoomAtPhysical(bat.getPos());
+ if (isRoomMatched(room)) {
+ room.onBatRemoved(bat);
+ }
+ }
+
+ public static boolean markSecrets(int secretIndex, boolean found) {
+ if (isCurrentRoomMatched()) {
+ return currentRoom.markSecrets(secretIndex, found);
+ }
+ return false;
+ }
+
+ /**
+ * Gets the room at the given physical position.
+ *
+ * @param pos the physical position
+ * @return the room at the given physical position, or null if there is no room at the given physical position
+ * @see #rooms
+ * @see DungeonMapUtils#getPhysicalRoomPos(Vec3d)
+ */
+ @Nullable
+ private static Room getRoomAtPhysical(Vec3d pos) {
+ return rooms.get(DungeonMapUtils.getPhysicalRoomPos(pos));
+ }
+
+ /**
+ * Calls {@link #isRoomMatched(Room)} on {@link #currentRoom}.
+ *
+ * @return {@code true} if {@link #currentRoom} is not null and {@link #isRoomMatched(Room)}
+ */
+ private static boolean isCurrentRoomMatched() {
+ return isRoomMatched(currentRoom);
+ }
+
+ /**
+ * Calls {@link #shouldProcess()} and {@link Room#isMatched()} on the given room.
+ *
+ * @param room the room to check
+ * @return {@code true} if {@link #shouldProcess()}, the given room is not null, and {@link Room#isMatched()} on the given room
+ */
+ @Contract("null -> false")
+ private static boolean isRoomMatched(@Nullable Room room) {
+ return shouldProcess() && room != null && room.isMatched();
+ }
+
+ /**
+ * Checks if the player is in a dungeon and {@link de.hysky.skyblocker.config.SkyblockerConfig.Dungeons#secretWaypoints Secret Waypoints} is enabled.
+ *
+ * @return whether dungeon secrets should be processed
+ */
+ private static boolean shouldProcess() {
+ return SkyblockerConfigManager.get().locations.dungeons.secretWaypoints.enableSecretWaypoints && Utils.isInDungeons();
+ }
+
+ /**
+ * Resets fields when leaving a dungeon.
+ */
+ private static void reset() {
+ mapEntrancePos = null;
+ mapRoomSize = 0;
+ physicalEntrancePos = null;
+ rooms.clear();
+ currentRoom = null;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/Room.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/Room.java
new file mode 100644
index 00000000..dd7dc91e
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/Room.java
@@ -0,0 +1,473 @@
+package de.hysky.skyblocker.skyblock.dungeon.secrets;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import it.unimi.dsi.fastutil.ints.IntRBTreeSet;
+import it.unimi.dsi.fastutil.ints.IntSortedSet;
+import it.unimi.dsi.fastutil.ints.IntSortedSets;
+import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.fabricmc.fabric.api.util.TriState;
+import net.minecraft.block.BlockState;
+import net.minecraft.block.Blocks;
+import net.minecraft.block.MapColor;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.network.ClientPlayerEntity;
+import net.minecraft.client.world.ClientWorld;
+import net.minecraft.entity.ItemEntity;
+import net.minecraft.entity.LivingEntity;
+import net.minecraft.entity.mob.AmbientEntity;
+import net.minecraft.registry.Registries;
+import net.minecraft.util.hit.BlockHitResult;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.MathHelper;
+import net.minecraft.world.World;
+import org.apache.commons.lang3.tuple.MutableTriple;
+import org.apache.commons.lang3.tuple.Triple;
+import org.jetbrains.annotations.NotNull;
+import org.joml.Vector2i;
+import org.joml.Vector2ic;
+
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class Room {
+ private static final Pattern SECRETS = Pattern.compile("§7(\\d{1,2})/(\\d{1,2}) Secrets");
+ @NotNull
+ private final Type type;
+ @NotNull
+ private final Set<Vector2ic> segments;
+ /**
+ * The shape of the room. See {@link #getShape(IntSortedSet, IntSortedSet)}.
+ */
+ @NotNull
+ private final Shape shape;
+ /**
+ * The room data containing all rooms for a specific dungeon and {@link #shape}.
+ */
+ private Map<String, int[]> roomsData;
+ /**
+ * Contains all possible dungeon rooms for this room. The list is gradually shrunk by checking blocks until only one room is left.
+ */
+ private List<MutableTriple<Direction, Vector2ic, List<String>>> possibleRooms;
+ /**
+ * Contains all blocks that have been checked to prevent checking the same block multiple times.
+ */
+ private Set<BlockPos> checkedBlocks = new HashSet<>();
+ /**
+ * The task that is used to check blocks. This is used to ensure only one such task can run at a time.
+ */
+ private CompletableFuture<Void> findRoom;
+ private int doubleCheckBlocks;
+ /**
+ * Represents the matching state of the room with the following possible values:
+ * <li>{@link TriState#DEFAULT} means that the room has not been checked, is being processed, or does not {@link Type#needsScanning() need to be processed}.
+ * <li>{@link TriState#FALSE} means that the room has been checked and there is no match.
+ * <li>{@link TriState#TRUE} means that the room has been checked and there is a match.
+ */
+ private TriState matched = TriState.DEFAULT;
+ private Table<Integer, BlockPos, SecretWaypoint> secretWaypoints;
+
+ public Room(@NotNull Type type, @NotNull Vector2ic... physicalPositions) {
+ this.type = type;
+ segments = Set.of(physicalPositions);
+ IntSortedSet segmentsX = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::x).toArray()));
+ IntSortedSet segmentsY = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::y).toArray()));
+ shape = getShape(segmentsX, segmentsY);
+ roomsData = DungeonSecrets.ROOMS_DATA.getOrDefault("catacombs", Collections.emptyMap()).getOrDefault(shape.shape.toLowerCase(), Collections.emptyMap());
+ possibleRooms = getPossibleRooms(segmentsX, segmentsY);
+ }
+
+ @NotNull
+ public Type getType() {
+ return type;
+ }
+
+ public boolean isMatched() {
+ return matched == TriState.TRUE;
+ }
+
+ @Override
+ public String toString() {
+ return "Room{type=" + type + ", shape=" + shape + ", matched=" + matched + ", segments=" + Arrays.toString(segments.toArray()) + "}";
+ }
+
+ @NotNull
+ private Shape getShape(IntSortedSet segmentsX, IntSortedSet segmentsY) {
+ return switch (segments.size()) {
+ case 1 -> Shape.ONE_BY_ONE;
+ case 2 -> Shape.ONE_BY_TWO;
+ case 3 -> segmentsX.size() == 2 && segmentsY.size() == 2 ? Shape.L_SHAPE : Shape.ONE_BY_THREE;
+ case 4 -> segmentsX.size() == 2 && segmentsY.size() == 2 ? Shape.TWO_BY_TWO : Shape.ONE_BY_FOUR;
+ default -> throw new IllegalArgumentException("There are no matching room shapes with this set of physical positions: " + Arrays.toString(segments.toArray()));
+ };
+ }
+
+ private List<MutableTriple<Direction, Vector2ic, List<String>>> getPossibleRooms(IntSortedSet segmentsX, IntSortedSet segmentsY) {
+ List<String> possibleDirectionRooms = new ArrayList<>(roomsData.keySet());
+ List<MutableTriple<Direction, Vector2ic, List<String>>> possibleRooms = new ArrayList<>();
+ for (Direction direction : getPossibleDirections(segmentsX, segmentsY)) {
+ possibleRooms.add(MutableTriple.of(direction, DungeonMapUtils.getPhysicalCornerPos(direction, segmentsX, segmentsY), possibleDirectionRooms));
+ }
+ return possibleRooms;
+ }
+
+ @NotNull
+ private Direction[] getPossibleDirections(IntSortedSet segmentsX, IntSortedSet segmentsY) {
+ return switch (shape) {
+ case ONE_BY_ONE, TWO_BY_TWO -> Direction.values();
+ case ONE_BY_TWO, ONE_BY_THREE, ONE_BY_FOUR -> {
+ if (segmentsX.size() > 1 && segmentsY.size() == 1) {
+ yield new Direction[]{Direction.NW, Direction.SE};
+ } else if (segmentsX.size() == 1 && segmentsY.size() > 1) {
+ yield new Direction[]{Direction.NE, Direction.SW};
+ }
+ throw new IllegalArgumentException("Shape " + shape.shape + " does not match segments: " + Arrays.toString(segments.toArray()));
+ }
+ case L_SHAPE -> {
+ if (!segments.contains(new Vector2i(segmentsX.firstInt(), segmentsY.firstInt()))) {
+ yield new Direction[]{Direction.SW};
+ } else if (!segments.contains(new Vector2i(segmentsX.firstInt(), segmentsY.lastInt()))) {
+ yield new Direction[]{Direction.SE};
+ } else if (!segments.contains(new Vector2i(segmentsX.lastInt(), segmentsY.firstInt()))) {
+ yield new Direction[]{Direction.NW};
+ } else if (!segments.contains(new Vector2i(segmentsX.lastInt(), segmentsY.lastInt()))) {
+ yield new Direction[]{Direction.NE};
+ }
+ throw new IllegalArgumentException("Shape " + shape.shape + " does not match segments: " + Arrays.toString(segments.toArray()));
+ }
+ };
+ }
+
+ /**
+ * Updates the room.
+ * <p></p>
+ * This method returns immediately if any of the following conditions are met:
+ * <ul>
+ * <li> The room does not need to be scanned and matched. (When the room is not of type {@link Type.ROOM}, {@link Type.PUZZLE}, or {@link Type.TRAP}. See {@link Type#needsScanning()}) </li>
+ * <li> The room has been matched or failed to match and is on cooldown. See {@link #matched}. </li>
+ * <li> {@link #findRoom The previous update} has not completed. </li>
+ * </ul>
+ * Then this method tries to match this room through:
+ * <ul>
+ * <li> Iterate over a 11 by 11 by 11 box around the player. </li>
+ * <li> Check it the block is part of this room and not part of a doorway. See {@link #segments} and {@link #notInDoorway(BlockPos)}. </li>
+ * <li> Checks if the position has been checked and adds it to {@link #checkedBlocks}. </li>
+ * <li> Calls {@link #checkBlock(ClientWorld, BlockPos)} </li>
+ * </ul>
+ */
+ @SuppressWarnings("JavadocReference")
+ protected void update() {
+ // Logical AND has higher precedence than logical OR
+ if (!type.needsScanning() || matched != TriState.DEFAULT || !DungeonSecrets.isRoomsLoaded() || findRoom != null && !findRoom.isDone()) {
+ return;
+ }
+ MinecraftClient client = MinecraftClient.getInstance();
+ ClientPlayerEntity player = client.player;
+ ClientWorld world = client.world;
+ if (player == null || world == null) {
+ return;
+ }
+ findRoom = CompletableFuture.runAsync(() -> {
+ for (BlockPos pos : BlockPos.iterate(player.getBlockPos().add(-5, -5, -5), player.getBlockPos().add(5, 5, 5))) {
+ if (segments.contains(DungeonMapUtils.getPhysicalRoomPos(pos)) && notInDoorway(pos) && checkedBlocks.add(pos) && checkBlock(world, pos)) {
+ break;
+ }
+ }
+ });
+ }
+
+ private static boolean notInDoorway(BlockPos pos) {
+ if (pos.getY() < 66 || pos.getY() > 73) {
+ return true;
+ }
+ int x = MathHelper.floorMod(pos.getX() - 8, 32);
+ int z = MathHelper.floorMod(pos.getZ() - 8, 32);
+ return (x < 13 || x > 17 || z > 2 && z < 28) && (z < 13 || z > 17 || x > 2 && x < 28);
+ }
+
+ /**
+ * Filters out dungeon rooms which does not contain the block at the given position.
+ * <p></p>
+ * This method:
+ * <ul>
+ * <li> Checks if the block type is included in the dungeon rooms data. See {@link DungeonSecrets#NUMERIC_ID}. </li>
+ * <li> For each possible direction: </li>
+ * <ul>
+ * <li> Rotate and convert the position to a relative position. See {@link DungeonMapUtils#actualToRelative(Direction, Vector2ic, BlockPos)}. </li>
+ * <li> Encode the block based on the relative position and the custom numeric block id. See {@link #posIdToInt(BlockPos, byte)}. </li>
+ * <li> For each possible room in the current direction: </li>
+ * <ul>
+ * <li> Check if {@link #roomsData} contains the encoded block. </li>
+ * <li> If so, add the room to the new list of possible rooms for this direction. </li>
+ * </ul>
+ * <li> Replace the old possible room list for the current direction with the new one. </li>
+ * </ul>
+ * <li> If there are no matching rooms left: </li>
+ * <ul>
+ * <li> Terminate matching by setting {@link #matched} to {@link TriState#FALSE}. </li>
+ * <li> Schedule another matching attempt in 50 ticks (2.5 seconds). </li>
+ * <li> Reset {@link #possibleRooms} and {@link #checkedBlocks} with {@link #reset()}. </li>
+ * <li> Return {@code true} </li>
+ * </ul>
+ * <li> If there are exactly one room matching: </li>
+ * <ul>
+ * <li> Call {@link #roomMatched(String, Direction, Vector2ic)}. </li>
+ * <li> Discard the no longer needed fields to save memory. </li>
+ * <li> Return {@code true} </li>
+ * </ul>
+ * <li> Return {@code false} </li>
+ * </ul>
+ *
+ * @param world the world to get the block from
+ * @param pos the position of the block to check
+ * @return whether room matching should end. Either a match is found or there are no valid rooms left
+ */
+ private boolean checkBlock(ClientWorld world, BlockPos pos) {
+ byte id = DungeonSecrets.NUMERIC_ID.getByte(Registries.BLOCK.getId(world.getBlockState(pos).getBlock()).toString());
+ if (id == 0) {
+ return false;
+ }
+ for (MutableTriple<Direction, Vector2ic, List<String>> directionRooms : possibleRooms) {
+ int block = posIdToInt(DungeonMapUtils.actualToRelative(directionRooms.getLeft(), directionRooms.getMiddle(), pos), id);
+ List<String> possibleDirectionRooms = new ArrayList<>();
+ for (String room : directionRooms.getRight()) {
+ if (Arrays.binarySearch(roomsData.get(room), block) >= 0) {
+ possibleDirectionRooms.add(room);
+ }
+ }
+ directionRooms.setRight(possibleDirectionRooms);
+ }
+
+ int matchingRoomsSize = possibleRooms.stream().map(Triple::getRight).mapToInt(Collection::size).sum();
+ if (matchingRoomsSize == 0) {
+ // If no rooms match, reset the fields and scan again after 50 ticks.
+ matched = TriState.FALSE;
+ DungeonSecrets.LOGGER.warn("[Skyblocker] No dungeon room matches after checking {} block(s)", checkedBlocks.size());
+ Scheduler.INSTANCE.schedule(() -> matched = TriState.DEFAULT, 50);
+ reset();
+ return true;
+ } else if (matchingRoomsSize == 1 && ++doubleCheckBlocks >= 10) {
+ // If one room matches, load the secrets for that room and discard the no longer needed fields.
+ for (Triple<Direction, Vector2ic, List<String>> directionRooms : possibleRooms) {
+ if (directionRooms.getRight().size() == 1) {
+ roomMatched(directionRooms.getRight().get(0), directionRooms.getLeft(), directionRooms.getMiddle());
+ discard();
+ return true;
+ }
+ }
+ return false; // This should never happen, we just checked that there is one possible room, and the return true in the loop should activate
+ } else {
+ DungeonSecrets.LOGGER.debug("[Skyblocker] {} room(s) remaining after checking {} block(s)", matchingRoomsSize, checkedBlocks.size());
+ return false;
+ }
+ }
+
+ /**
+ * Encodes a {@link BlockPos} and the custom numeric block id into an integer.
+ *
+ * @param pos the position of the block
+ * @param id the custom numeric block id
+ * @return the encoded integer
+ */
+ private int posIdToInt(BlockPos pos, byte id) {
+ return pos.getX() << 24 | pos.getY() << 16 | pos.getZ() << 8 | id;
+ }
+
+ /**
+ * Loads the secret waypoints for the room from {@link DungeonSecrets#waypointsJson} once it has been matched
+ * and sets {@link #matched} to {@link TriState#TRUE}.
+ *
+ * @param directionRooms the direction, position, and name of the room
+ */
+ @SuppressWarnings("JavadocReference")
+ private void roomMatched(String name, Direction direction, Vector2ic physicalCornerPos) {
+ Table<Integer, BlockPos, SecretWaypoint> secretWaypointsMutable = HashBasedTable.create();
+ for (JsonElement waypointElement : DungeonSecrets.getRoomWaypoints(name)) {
+ JsonObject waypoint = waypointElement.getAsJsonObject();
+ String secretName = waypoint.get("secretName").getAsString();
+ int secretIndex = Integer.parseInt(secretName.substring(0, Character.isDigit(secretName.charAt(1)) ? 2 : 1));
+ BlockPos pos = DungeonMapUtils.relativeToActual(direction, physicalCornerPos, waypoint);
+ secretWaypointsMutable.put(secretIndex, pos, new SecretWaypoint(secretIndex, waypoint, secretName, pos));
+ }
+ secretWaypoints = ImmutableTable.copyOf(secretWaypointsMutable);
+ matched = TriState.TRUE;
+ DungeonSecrets.LOGGER.info("[Skyblocker] Room {} matched after checking {} block(s)", name, checkedBlocks.size());
+ }
+
+ /**
+ * Resets fields for another round of matching after room matching fails.
+ */
+ private void reset() {
+ IntSortedSet segmentsX = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::x).toArray()));
+ IntSortedSet segmentsY = IntSortedSets.unmodifiable(new IntRBTreeSet(segments.stream().mapToInt(Vector2ic::y).toArray()));
+ possibleRooms = getPossibleRooms(segmentsX, segmentsY);
+ checkedBlocks = new HashSet<>();
+ doubleCheckBlocks = 0;
+ }
+
+ /**
+ * Discards fields after room matching completes when a room is found.
+ * These fields are no longer needed and are discarded to save memory.
+ */
+ private void discard() {
+ roomsData = null;
+ possibleRooms = null;
+ checkedBlocks = null;
+ doubleCheckBlocks = 0;
+ }
+
+ /**
+ * Calls {@link SecretWaypoint#render(WorldRenderContext)} on {@link #secretWaypoints all secret waypoints}.
+ */
+ protected void render(WorldRenderContext context) {
+ for (SecretWaypoint secretWaypoint : secretWaypoints.values()) {
+ if (secretWaypoint.shouldRender()) {
+ secretWaypoint.render(context);
+ }
+ }
+ }
+
+ /**
+ * Sets all secrets as found if {@link #isAllSecretsFound(String)}.
+ */
+ protected void onChatMessage(String message) {
+ if (isAllSecretsFound(message)) {
+ secretWaypoints.values().forEach(SecretWaypoint::setFound);
+ }
+ }
+
+ /**
+ * Checks if the number of found secrets is equals or greater than the total number of secrets in the room.
+ *
+ * @param message the message to check in
+ * @return whether the number of found secrets is equals or greater than the total number of secrets in the room
+ */
+ protected static boolean isAllSecretsFound(String message) {
+ Matcher matcher = SECRETS.matcher(message);
+ if (matcher.find()) {
+ return Integer.parseInt(matcher.group(1)) >= Integer.parseInt(matcher.group(2));
+ }
+ return false;
+ }
+
+ /**
+ * Marks the secret at the interaction position as found when the player interacts with a chest or a player head,
+ * if there is a secret at the interaction position.
+ *
+ * @param world the world to get the block from
+ * @param hitResult the block being interacted with
+ * @see #onSecretFound(SecretWaypoint, String, Object...)
+ */
+ protected void onUseBlock(World world, BlockHitResult hitResult) {
+ BlockState state = world.getBlockState(hitResult.getBlockPos());
+ if (state.isOf(Blocks.CHEST) || state.isOf(Blocks.PLAYER_HEAD) || state.isOf(Blocks.PLAYER_WALL_HEAD)) {
+ secretWaypoints.column(hitResult.getBlockPos()).values().stream().filter(SecretWaypoint::needsInteraction).findAny()
+ .ifPresent(secretWaypoint -> onSecretFound(secretWaypoint, "[Skyblocker] Detected {} interaction, setting secret #{} as found", secretWaypoint.category, secretWaypoint.secretIndex));
+ } else if (state.isOf(Blocks.LEVER)) {
+ secretWaypoints.column(hitResult.getBlockPos()).values().stream().filter(SecretWaypoint::isLever).forEach(SecretWaypoint::setFound);
+ }
+ }
+
+ /**
+ * Marks the closest secret that requires item pickup no greater than 6 blocks away as found when the player picks up a secret item.
+ *
+ * @param itemEntity the item entity being picked up
+ * @param collector the collector of the item
+ * @see #onSecretFound(SecretWaypoint, String, Object...)
+ */
+ protected void onItemPickup(ItemEntity itemEntity, LivingEntity collector) {
+ if (SecretWaypoint.SECRET_ITEMS.stream().noneMatch(itemEntity.getStack().getName().getString()::contains)) {
+ return;
+ }
+ secretWaypoints.values().stream().filter(SecretWaypoint::needsItemPickup).min(Comparator.comparingDouble(SecretWaypoint.getSquaredDistanceToFunction(collector))).filter(SecretWaypoint.getRangePredicate(collector))
+ .ifPresent(secretWaypoint -> onSecretFound(secretWaypoint, "[Skyblocker] Detected {} picked up a {} from a {} secret, setting secret #{} as found", collector.getName().getString(), itemEntity.getName().getString(), secretWaypoint.category, secretWaypoint.secretIndex));
+ }
+
+ /**
+ * Marks the closest bat secret as found when a bat is killed.
+ *
+ * @param bat the bat being killed
+ * @see #onSecretFound(SecretWaypoint, String, Object...)
+ */
+ protected void onBatRemoved(AmbientEntity bat) {
+ secretWaypoints.values().stream().filter(SecretWaypoint::isBat).min(Comparator.comparingDouble(SecretWaypoint.getSquaredDistanceToFunction(bat)))
+ .ifPresent(secretWaypoint -> onSecretFound(secretWaypoint, "[Skyblocker] Detected {} killed for a {} secret, setting secret #{} as found", bat.getName().getString(), secretWaypoint.category, secretWaypoint.secretIndex));
+ }
+
+ /**
+ * Marks all secret waypoints with the same index as the given {@link SecretWaypoint} as found.
+ *
+ * @param secretWaypoint the secret waypoint to read the index from.
+ * @param msg the message to log
+ * @param args the args for the {@link org.slf4j.Logger#info(String, Object...) Logger#info(String, Object...)} call
+ */
+ private void onSecretFound(SecretWaypoint secretWaypoint, String msg, Object... args) {
+ secretWaypoints.row(secretWaypoint.secretIndex).values().forEach(SecretWaypoint::setFound);
+ DungeonSecrets.LOGGER.info(msg, args);
+ }
+
+ protected boolean markSecrets(int secretIndex, boolean found) {
+ Map<BlockPos, SecretWaypoint> secret = secretWaypoints.row(secretIndex);
+ if (secret.isEmpty()) {
+ return false;
+ } else {
+ secret.values().forEach(found ? SecretWaypoint::setFound : SecretWaypoint::setMissing);
+ return true;
+ }
+ }
+
+ public enum Type {
+ ENTRANCE(MapColor.DARK_GREEN.getRenderColorByte(MapColor.Brightness.HIGH)),
+ ROOM(MapColor.ORANGE.getRenderColorByte(MapColor.Brightness.LOWEST)),
+ PUZZLE(MapColor.MAGENTA.getRenderColorByte(MapColor.Brightness.HIGH)),
+ TRAP(MapColor.ORANGE.getRenderColorByte(MapColor.Brightness.HIGH)),
+ MINIBOSS(MapColor.YELLOW.getRenderColorByte(MapColor.Brightness.HIGH)),
+ FAIRY(MapColor.PINK.getRenderColorByte(MapColor.Brightness.HIGH)),
+ BLOOD(MapColor.BRIGHT_RED.getRenderColorByte(MapColor.Brightness.HIGH)),
+ UNKNOWN(MapColor.GRAY.getRenderColorByte(MapColor.Brightness.NORMAL));
+ final byte color;
+
+ Type(byte color) {
+ this.color = color;
+ }
+
+ /**
+ * @return whether this room type has secrets and needs to be scanned and matched.
+ */
+ private boolean needsScanning() {
+ return switch (this) {
+ case ROOM, PUZZLE, TRAP -> true;
+ default -> false;
+ };
+ }
+ }
+
+ private enum Shape {
+ ONE_BY_ONE("1x1"),
+ ONE_BY_TWO("1x2"),
+ ONE_BY_THREE("1x3"),
+ ONE_BY_FOUR("1x4"),
+ L_SHAPE("L-shape"),
+ TWO_BY_TWO("2x2");
+ final String shape;
+
+ Shape(String shape) {
+ this.shape = shape;
+ }
+
+ @Override
+ public String toString() {
+ return shape;
+ }
+ }
+
+ public enum Direction {
+ NW, NE, SW, SE
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java
new file mode 100644
index 00000000..d2a31ea3
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/secrets/SecretWaypoint.java
@@ -0,0 +1,142 @@
+package de.hysky.skyblocker.skyblock.dungeon.secrets;
+
+import com.google.gson.JsonObject;
+
+import de.hysky.skyblocker.config.SkyblockerConfig;
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.entity.Entity;
+import net.minecraft.text.Text;
+import net.minecraft.util.Formatting;
+import net.minecraft.util.math.BlockPos;
+import net.minecraft.util.math.Vec3d;
+
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.function.ToDoubleFunction;
+
+public class SecretWaypoint {
+ static final List<String> SECRET_ITEMS = List.of("Decoy", "Defuse Kit", "Dungeon Chest Key", "Healing VIII", "Inflatable Jerry", "Spirit Leap", "Training Weights", "Trap", "Treasure Talisman");
+ final int secretIndex;
+ final Category category;
+ private final Text name;
+ private final BlockPos pos;
+ private final Vec3d centerPos;
+ private boolean missing;
+
+ SecretWaypoint(int secretIndex, JsonObject waypoint, String name, BlockPos pos) {
+ this.secretIndex = secretIndex;
+ this.category = Category.get(waypoint);
+ this.name = Text.of(name);
+ this.pos = pos;
+ this.centerPos = pos.toCenterPos();
+ this.missing = true;
+ }
+
+ static ToDoubleFunction<SecretWaypoint> getSquaredDistanceToFunction(Entity entity) {
+ return secretWaypoint -> entity.squaredDistanceTo(secretWaypoint.centerPos);
+ }
+
+ static Predicate<SecretWaypoint> getRangePredicate(Entity entity) {
+ return secretWaypoint -> entity.squaredDistanceTo(secretWaypoint.centerPos) <= 36D;
+ }
+
+ boolean shouldRender() {
+ return category.isEnabled() && missing;
+ }
+
+ boolean needsInteraction() {
+ return category.needsInteraction();
+ }
+
+ boolean isLever() {
+ return category.isLever();
+ }
+
+ boolean needsItemPickup() {
+ return category.needsItemPickup();
+ }
+
+ boolean isBat() {
+ return category.isBat();
+ }
+
+ void setFound() {
+ this.missing = false;
+ }
+
+ void setMissing() {
+ this.missing = true;
+ }
+
+ /**
+ * Renders the secret waypoint, including a filled cube, a beacon beam, the name, and the distance from the player.
+ */
+ void render(WorldRenderContext context) {
+ RenderHelper.renderFilledThroughWallsWithBeaconBeam(context, pos, category.colorComponents, 0.5F);
+ Vec3d posUp = centerPos.add(0, 1, 0);
+ RenderHelper.renderText(context, name, posUp, true);
+ double distance = context.camera().getPos().distanceTo(centerPos);
+ RenderHelper.renderText(context, Text.literal(Math.round(distance) + "m").formatted(Formatting.YELLOW), posUp, 1, MinecraftClient.getInstance().textRenderer.fontHeight + 1, true);
+ }
+
+ enum Category {
+ ENTRANCE(secretWaypoints -> secretWaypoints.enableEntranceWaypoints, 0, 255, 0),
+ SUPERBOOM(secretWaypoints -> secretWaypoints.enableSuperboomWaypoints, 255, 0, 0),
+ CHEST(secretWaypoints -> secretWaypoints.enableChestWaypoints, 2, 213, 250),
+ ITEM(secretWaypoints -> secretWaypoints.enableItemWaypoints, 2, 64, 250),
+ BAT(secretWaypoints -> secretWaypoints.enableBatWaypoints, 142, 66, 0),
+ WITHER(secretWaypoints -> secretWaypoints.enableWitherWaypoints, 30, 30, 30),
+ LEVER(secretWaypoints -> secretWaypoints.enableLeverWaypoints, 250, 217, 2),
+ FAIRYSOUL(secretWaypoints -> secretWaypoints.enableFairySoulWaypoints, 255, 85, 255),
+ STONK(secretWaypoints -> secretWaypoints.enableStonkWaypoints, 146, 52, 235),
+ DEFAULT(secretWaypoints -> secretWaypoints.enableDefaultWaypoints, 190, 255, 252);
+ private final Predicate<SkyblockerConfig.SecretWaypoints> enabledPredicate;
+ private final float[] colorComponents;
+
+ Category(Predicate<SkyblockerConfig.SecretWaypoints> enabledPredicate, int... intColorComponents) {
+ this.enabledPredicate = enabledPredicate;
+ colorComponents = new float[intColorComponents.length];
+ for (int i = 0; i < intColorComponents.length; i++) {
+ colorComponents[i] = intColorComponents[i] / 255F;
+ }
+ }
+
+ private static Category get(JsonObject categoryJson) {
+ return switch (categoryJson.get("category").getAsString()) {
+ case "entrance" -> Category.ENTRANCE;
+ case "superboom" -> Category.SUPERBOOM;
+ case "chest" -> Category.CHEST;
+ case "item" -> Category.ITEM;
+ case "bat" -> Category.BAT;
+ case "wither" -> Category.WITHER;
+ case "lever" -> Category.LEVER;
+ case "fairysoul" -> Category.FAIRYSOUL;
+ case "stonk" -> Category.STONK;
+ default -> Category.DEFAULT;
+ };
+ }
+
+ boolean needsInteraction() {
+ return this == CHEST || this == WITHER;
+ }
+
+ boolean isLever() {
+ return this == LEVER;
+ }
+
+ boolean needsItemPickup() {
+ return this == ITEM;
+ }
+
+ boolean isBat() {
+ return this == BAT;
+ }
+
+ boolean isEnabled() {
+ return enabledPredicate.test(SkyblockerConfigManager.get().locations.dungeons.secretWaypoints);
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/ColorTerminal.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/ColorTerminal.java
new file mode 100644
index 00000000..6e9eb02d
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/ColorTerminal.java
@@ -0,0 +1,72 @@
+package de.hysky.skyblocker.skyblock.dungeon.terminal;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.gui.ColorHighlight;
+import de.hysky.skyblocker.utils.render.gui.ContainerSolver;
+import net.minecraft.item.Item;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+import net.minecraft.registry.Registries;
+import net.minecraft.util.DyeColor;
+import net.minecraft.util.Identifier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.*;
+
+
+public class ColorTerminal extends ContainerSolver {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ColorTerminal.class.getName());
+ private static final Map<String, DyeColor> colorFromName;
+ private DyeColor targetColor;
+ private static final Map<Item, DyeColor> itemColor;
+
+ public ColorTerminal() {
+ super("^Select all the ([A-Z ]+) items!$");
+ }
+
+ @Override
+ protected boolean isEnabled() {
+ targetColor = null;
+ return SkyblockerConfigManager.get().locations.dungeons.terminals.solveColor;
+ }
+
+ @Override
+ protected List<ColorHighlight> getColors(String[] groups, Map<Integer, ItemStack> slots) {
+ trimEdges(slots, 6);
+ List<ColorHighlight> highlights = new ArrayList<>();
+ String colorString = groups[0];
+ if (targetColor == null) {
+ targetColor = colorFromName.get(colorString);
+ if (targetColor == null) {
+ LOGGER.error("[Skyblocker] Couldn't find dye color corresponding to \"" + colorString + "\"");
+ return Collections.emptyList();
+ }
+ }
+ for (Map.Entry<Integer, ItemStack> slot : slots.entrySet()) {
+ ItemStack itemStack = slot.getValue();
+ if (!itemStack.hasEnchantments() && targetColor.equals(itemColor.get(itemStack.getItem()))) {
+ highlights.add(ColorHighlight.green(slot.getKey()));
+ }
+ }
+ return highlights;
+ }
+
+
+ static {
+ colorFromName = new HashMap<>();
+ for (DyeColor color : DyeColor.values())
+ colorFromName.put(color.getName().toUpperCase(Locale.ENGLISH), color);
+ colorFromName.put("SILVER", DyeColor.LIGHT_GRAY);
+ colorFromName.put("LIGHT BLUE", DyeColor.LIGHT_BLUE);
+
+ itemColor = new HashMap<>();
+ for (DyeColor color : DyeColor.values())
+ for (String item : new String[]{"dye", "wool", "stained_glass", "terracotta"})
+ itemColor.put(Registries.ITEM.get(new Identifier(color.getName() + '_' + item)), color);
+ itemColor.put(Items.BONE_MEAL, DyeColor.WHITE);
+ itemColor.put(Items.LAPIS_LAZULI, DyeColor.BLUE);
+ itemColor.put(Items.COCOA_BEANS, DyeColor.BROWN);
+ itemColor.put(Items.INK_SAC, DyeColor.BLACK);
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/OrderTerminal.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/OrderTerminal.java
new file mode 100644
index 00000000..b2636373
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/OrderTerminal.java
@@ -0,0 +1,58 @@
+package de.hysky.skyblocker.skyblock.dungeon.terminal;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.gui.ColorHighlight;
+import de.hysky.skyblocker.utils.render.gui.ContainerSolver;
+import net.minecraft.item.ItemStack;
+import net.minecraft.item.Items;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class OrderTerminal extends ContainerSolver {
+ private final int PANES_NUM = 14;
+ private int[] orderedSlots;
+ private int currentNum = Integer.MAX_VALUE;
+
+ public OrderTerminal() {
+ super("^Click in order!$");
+ }
+
+ @Override
+ protected boolean isEnabled() {
+ orderedSlots = null;
+ currentNum = 0;
+ return SkyblockerConfigManager.get().locations.dungeons.terminals.solveOrder;
+ }
+
+ @Override
+ protected List<ColorHighlight> getColors(String[] groups, Map<Integer, ItemStack> slots) {
+ if(orderedSlots == null && !orderSlots(slots))
+ return Collections.emptyList();
+ while(currentNum < PANES_NUM && Items.LIME_STAINED_GLASS_PANE.equals(slots.get(orderedSlots[currentNum]).getItem()))
+ currentNum++;
+ List<ColorHighlight> highlights = new ArrayList<>(3);
+ int last = Integer.min(3, PANES_NUM - currentNum);
+ for(int i = 0; i < last; i++) {
+ highlights.add(new ColorHighlight(orderedSlots[currentNum + i], (224 - 64 * i) << 24 | 64 << 16 | 96 << 8 | 255));
+ }
+ return highlights;
+ }
+
+ public boolean orderSlots(Map<Integer, ItemStack> slots) {
+ trimEdges(slots, 4);
+ orderedSlots = new int[PANES_NUM];
+ for(Map.Entry<Integer, ItemStack> slot : slots.entrySet()) {
+ if(Items.AIR.equals(slot.getValue().getItem())) {
+ orderedSlots = null;
+ return false;
+ }
+ else
+ orderedSlots[slot.getValue().getCount() - 1] = slot.getKey();
+ }
+ currentNum = 0;
+ return true;
+ }
+} \ No newline at end of file
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/StartsWithTerminal.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/StartsWithTerminal.java
new file mode 100644
index 00000000..5f856af2
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/terminal/StartsWithTerminal.java
@@ -0,0 +1,35 @@
+package de.hysky.skyblocker.skyblock.dungeon.terminal;
+
+import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.utils.render.gui.ColorHighlight;
+import de.hysky.skyblocker.utils.render.gui.ContainerSolver;
+import net.minecraft.item.ItemStack;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class StartsWithTerminal extends ContainerSolver {
+ public StartsWithTerminal() {
+ super("^What starts with: '([A-Z])'\\?$");
+ }
+
+ @Override
+ protected boolean isEnabled() {
+ return SkyblockerConfigManager.get().locations.dungeons.terminals.solveStartsWith;
+ }
+
+ @Override
+ protected List<ColorHighlight> getColors(String[] groups, Map<Integer, ItemStack> slots) {
+ trimEdges(slots, 6);
+ String prefix = groups[0];
+ List<ColorHighlight> highlights = new ArrayList<>();
+ for (Map.Entry<Integer, ItemStack> slot : slots.entrySet()) {
+ ItemStack stack = slot.getValue();
+ if (!stack.hasEnchantments() && stack.getName().getString().startsWith(prefix)) {
+ highlights.add(ColorHighlight.green(slot.getKey()));
+ }
+ }
+ return highlights;
+ }
+} \ No newline at end of file