From 2d63e6088aa588556b588070e826ce6c0ec1492d Mon Sep 17 00:00:00 2001 From: SpaceMonkeyy86 Date: Sat, 14 Jun 2025 14:17:57 +0000 Subject: One flow waterboard solver (#1283) * Port Desco19's waterboard solver to Skyblocker * Many waterboard tweaks * Update watertimes.json to newer version and fix leftover water check * Visualize water paths on board * General improvements * Preview lever effects * Fix preview hitboxes being too small in rooms with an entrance from below * Refactor into multiple files and add config options * Benchmark all solutions and improve two of them * Show indicator line from next lever to the lever after that * Optimize many of the slower solutions * Add marks support for easier solution iteration * Tweak comments * Clean up one flow waterboard solver * Add suggested comments * Add lever type argument type and debug command * Verify json file * Make commands debug only and make feedback translatable * Update VerifyJsonTest * Make color codes less scuffed --------- Co-authored-by: Kevinthegreat <92656833+kevinthegreat1@users.noreply.github.com> --- .../config/categories/DungeonsCategory.java | 20 +- .../skyblocker/config/configs/DungeonsConfig.java | 11 +- .../skyblock/dungeon/puzzle/DungeonPuzzle.java | 22 +- .../skyblock/dungeon/puzzle/waterboard/Cell.java | 51 -- .../skyblock/dungeon/puzzle/waterboard/Switch.java | 39 -- .../dungeon/puzzle/waterboard/Waterboard.java | 547 ++++---------------- .../puzzle/waterboard/WaterboardOneFlow.java | 554 +++++++++++++++++++++ .../puzzle/waterboard/WaterboardPreviewer.java | 226 +++++++++ .../skyblock/dungeon/secrets/DungeonMapUtils.java | 18 + .../skyblocker/skyblock/dungeon/secrets/Room.java | 15 + 10 files changed, 953 insertions(+), 550 deletions(-) delete mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Cell.java delete mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Switch.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardOneFlow.java create mode 100644 src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardPreviewer.java (limited to 'src/main/java') diff --git a/src/main/java/de/hysky/skyblocker/config/categories/DungeonsCategory.java b/src/main/java/de/hysky/skyblocker/config/categories/DungeonsCategory.java index 200745bc..9be3165a 100644 --- a/src/main/java/de/hysky/skyblocker/config/categories/DungeonsCategory.java +++ b/src/main/java/de/hysky/skyblocker/config/categories/DungeonsCategory.java @@ -155,11 +155,25 @@ public class DungeonsCategory { .option(Option.createBuilder() .name(Text.translatable("skyblocker.config.dungeons.puzzle.solveWaterboard")) .description(OptionDescription.of(Text.translatable("skyblocker.config.dungeons.puzzle.solveWaterboard.@Tooltip"))) - .binding(defaults.dungeons.puzzleSolvers.solveWaterboard, - () -> config.dungeons.puzzleSolvers.solveWaterboard, - newValue -> config.dungeons.puzzleSolvers.solveWaterboard = newValue) + .binding(defaults.dungeons.puzzleSolvers.waterboardOneFlow, + () -> config.dungeons.puzzleSolvers.waterboardOneFlow, + newValue -> config.dungeons.puzzleSolvers.waterboardOneFlow = newValue) .controller(ConfigUtils::createBooleanController) .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.dungeons.puzzle.previewWaterPath")) + .binding(defaults.dungeons.puzzleSolvers.previewWaterPath, + () -> config.dungeons.puzzleSolvers.previewWaterPath, + newValue -> config.dungeons.puzzleSolvers.previewWaterPath = newValue) + .controller(ConfigUtils::createBooleanController) + .build()) + .option(Option.createBuilder() + .name(Text.translatable("skyblocker.config.dungeons.puzzle.previewLeverEffects")) + .binding(defaults.dungeons.puzzleSolvers.previewLeverEffects, + () -> config.dungeons.puzzleSolvers.previewLeverEffects, + newValue -> config.dungeons.puzzleSolvers.previewLeverEffects = newValue) + .controller(ConfigUtils::createBooleanController) + .build()) .option(Option.createBuilder() .name(Text.translatable("skyblocker.config.dungeons.puzzle.blazeSolver")) .description(OptionDescription.of(Text.translatable("skyblocker.config.dungeons.puzzle.blazeSolver.@Tooltip"))) diff --git a/src/main/java/de/hysky/skyblocker/config/configs/DungeonsConfig.java b/src/main/java/de/hysky/skyblocker/config/configs/DungeonsConfig.java index 8b80f5ac..2b605bcc 100644 --- a/src/main/java/de/hysky/skyblocker/config/configs/DungeonsConfig.java +++ b/src/main/java/de/hysky/skyblocker/config/configs/DungeonsConfig.java @@ -96,8 +96,17 @@ public class DungeonsConfig { @SerialEntry public boolean creeperSolver = true; + @Deprecated + public transient boolean solveWaterboard = true; + @SerialEntry - public boolean solveWaterboard = true; + public boolean waterboardOneFlow = true; + + @SerialEntry + public boolean previewWaterPath = true; + + @SerialEntry + public boolean previewLeverEffects = true; @SerialEntry public boolean blazeSolver = true; diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/DungeonPuzzle.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/DungeonPuzzle.java index 42034d9f..c0dddb88 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/DungeonPuzzle.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/DungeonPuzzle.java @@ -36,17 +36,17 @@ public abstract class DungeonPuzzle implements Tickable, Renderable, Resettable shouldSolve = true; } }); - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("puzzle").then(literal(puzzleName).then(literal("solve").executes(context -> { - Room currentRoom = DungeonManager.getCurrentRoom(); - if (currentRoom != null) { - reset(); - currentRoom.addSubProcess(this); - context.getSource().sendFeedback(Constants.PREFIX.get().append("§aSolving " + puzzleName + " puzzle in the current room.")); - } else { - context.getSource().sendError(Constants.PREFIX.get().append("§cCurrent room is null.")); - } - return Command.SINGLE_SUCCESS; - }))))))); + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("puzzle").then(literal(puzzleName).then(literal("solve").executes(context -> { + Room currentRoom = DungeonManager.getCurrentRoom(); + if (currentRoom != null) { + reset(); + currentRoom.addSubProcess(this); + context.getSource().sendFeedback(Constants.PREFIX.get().append("§aSolving " + puzzleName + " puzzle in the current room.")); + } else { + context.getSource().sendError(Constants.PREFIX.get().append("§cCurrent room is null.")); + } + return Command.SINGLE_SUCCESS; + }))))))); ClientPlayConnectionEvents.JOIN.register(this); } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Cell.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Cell.java deleted file mode 100644 index 0279fed8..00000000 --- a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Cell.java +++ /dev/null @@ -1,51 +0,0 @@ -package de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard; - -public class Cell { - public static final Cell BLOCK = new Cell(Type.BLOCK); - public static final Cell EMPTY = new Cell(Type.EMPTY); - public final Type type; - - private Cell(Type type) { - this.type = type; - } - - public boolean isOpen() { - return type == Type.EMPTY; - } - - public static class SwitchCell extends Cell { - public final int id; - private boolean open; - - public SwitchCell(int id) { - super(Type.SWITCH); - this.id = id; - } - - public static SwitchCell ofOpened(int id) { - SwitchCell switchCell = new SwitchCell(id); - switchCell.open = true; - return switchCell; - } - - @Override - public boolean equals(Object obj) { - return super.equals(obj) || obj instanceof SwitchCell switchCell && id == switchCell.id && open == switchCell.open; - } - - @Override - public boolean isOpen() { - return open; - } - - public void toggle() { - open = !open; - } - } - - public enum Type { - BLOCK, - EMPTY, - SWITCH - } -} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Switch.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Switch.java deleted file mode 100644 index bb8da61d..00000000 --- a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Switch.java +++ /dev/null @@ -1,39 +0,0 @@ -package de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard; - -import org.jetbrains.annotations.NotNull; - -import java.util.AbstractCollection; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - -public class Switch extends AbstractCollection { - public final int id; - public final List cells = new ArrayList<>(); - - public Switch(int id) { - this.id = id; - } - - @Override - @NotNull - public Iterator iterator() { - return cells.iterator(); - } - - @Override - public int size() { - return cells.size(); - } - - @Override - public boolean add(Cell.SwitchCell cell) { - return cells.add(cell); - } - - public void toggle() { - for (Cell.SwitchCell cell : cells) { - cell.toggle(); - } - } -} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Waterboard.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Waterboard.java index 708a86ee..a89918f4 100644 --- a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Waterboard.java +++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/Waterboard.java @@ -1,453 +1,110 @@ package de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard; -import com.google.common.collect.Multimap; -import com.google.common.collect.MultimapBuilder; -import com.mojang.brigadier.Command; -import com.mojang.brigadier.arguments.IntegerArgumentType; -import de.hysky.skyblocker.SkyblockerMod; -import de.hysky.skyblocker.annotations.Init; -import de.hysky.skyblocker.config.SkyblockerConfigManager; -import de.hysky.skyblocker.debug.Debug; -import de.hysky.skyblocker.skyblock.dungeon.puzzle.DungeonPuzzle; -import de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard.Cell.SwitchCell; -import de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonManager; -import de.hysky.skyblocker.skyblock.dungeon.secrets.Room; -import de.hysky.skyblocker.utils.ColorUtils; -import de.hysky.skyblocker.utils.Constants; -import de.hysky.skyblocker.utils.render.RenderHelper; -import de.hysky.skyblocker.utils.scheduler.Scheduler; -import de.hysky.skyblocker.utils.waypoint.Waypoint; -import it.unimi.dsi.fastutil.ints.IntArrayList; -import it.unimi.dsi.fastutil.ints.IntList; -import it.unimi.dsi.fastutil.objects.Object2IntMap; -import it.unimi.dsi.fastutil.objects.Object2IntMaps; -import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; -import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; -import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; -import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.serialization.Codec; import net.minecraft.block.Block; -import net.minecraft.block.BlockState; import net.minecraft.block.Blocks; -import net.minecraft.block.LeverBlock; -import net.minecraft.client.MinecraftClient; -import net.minecraft.entity.player.PlayerEntity; -import net.minecraft.fluid.Fluids; -import net.minecraft.fluid.WaterFluid; -import net.minecraft.util.ActionResult; +import net.minecraft.command.argument.EnumArgumentType; import net.minecraft.util.DyeColor; -import net.minecraft.util.Hand; -import net.minecraft.util.hit.BlockHitResult; -import net.minecraft.util.hit.HitResult; +import net.minecraft.util.StringIdentifiable; import net.minecraft.util.math.BlockPos; -import net.minecraft.util.math.Direction; -import net.minecraft.util.math.Vec3d; -import net.minecraft.world.World; -import org.joml.Vector2i; -import org.joml.Vector2ic; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; -import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; - -public class Waterboard extends DungeonPuzzle { - private static final Logger LOGGER = LoggerFactory.getLogger(Waterboard.class); - public static final Waterboard INSTANCE = new Waterboard(); - private static final Object2IntMap SWITCH_BLOCKS = Object2IntMaps.unmodifiable(new Object2IntOpenHashMap<>(Map.of( - Blocks.COAL_BLOCK, 1, - Blocks.GOLD_BLOCK, 2, - Blocks.QUARTZ_BLOCK, 3, - Blocks.DIAMOND_BLOCK, 4, - Blocks.EMERALD_BLOCK, 5, - Blocks.TERRACOTTA, 6 - ))); - private static final BlockPos[] SWITCH_POSITIONS = new BlockPos[]{ - new BlockPos(20, 61, 10), - new BlockPos(20, 61, 15), - new BlockPos(20, 61, 20), - new BlockPos(10, 61, 20), - new BlockPos(10, 61, 15), - new BlockPos(10, 61, 10) - }; - public static final BlockPos WATER_LEVER = new BlockPos(15, 60, 5); - private static final float[] LIME_COLOR_COMPONENTS = ColorUtils.getFloatComponents(DyeColor.LIME); - - private CompletableFuture solve; - private final Cell[][] cells = new Cell[19][19]; - private final Switch[] switches = new Switch[]{new Switch(0), new Switch(1), new Switch(2), new Switch(3), new Switch(4), new Switch(5)}; - private int doors = 0; - private final Result[] results = new Result[64]; - private int currentCombination; - private final IntList bestCombinations = new IntArrayList(); - private final Waypoint[] waypoints = new Waypoint[7]; - /** - * Used to check the water lever state since the block state does not update immediately after the lever is toggled. - */ - private boolean bestCombinationsUpdated; - - private Waterboard() { - super("waterboard", "water-puzzle"); - } - - @Init - public static void init() { - UseBlockCallback.EVENT.register(INSTANCE::onUseBlock); - if (Debug.debugEnabled()) { - ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("puzzle").then(literal(INSTANCE.puzzleName) - .then(literal("printBoard").executes(context -> { - context.getSource().sendFeedback(Constants.PREFIX.get().append(boardToString(INSTANCE.cells))); - return Command.SINGLE_SUCCESS; - })).then(literal("printDoors").executes(context -> { - context.getSource().sendFeedback(Constants.PREFIX.get().append(Integer.toBinaryString(INSTANCE.doors))); - return Command.SINGLE_SUCCESS; - })).then(literal("printSimulationResults").then(argument("combination", IntegerArgumentType.integer(0, 63)).executes(context -> { - context.getSource().sendFeedback(Constants.PREFIX.get().append(INSTANCE.results[IntegerArgumentType.getInteger(context, "combination")].toString())); - return Command.SINGLE_SUCCESS; - }))).then(literal("printCurrentCombination").executes(context -> { - context.getSource().sendFeedback(Constants.PREFIX.get().append(Integer.toBinaryString(INSTANCE.currentCombination))); - return Command.SINGLE_SUCCESS; - })).then(literal("printBestCombination").executes(context -> { - context.getSource().sendFeedback(Constants.PREFIX.get().append(INSTANCE.bestCombinations.toString())); - return Command.SINGLE_SUCCESS; - })) - ))))); - } - } - - private static String boardToString(Cell[][] cells) { - StringBuilder sb = new StringBuilder(); - for (Cell[] row : cells) { - sb.append("\n"); - for (Cell cell : row) { - switch (cell) { - case SwitchCell switchCell -> sb.append(switchCell.id); - case Cell c when c.type == Cell.Type.BLOCK -> sb.append('#'); - case Cell c when c.type == Cell.Type.EMPTY -> sb.append('.'); - - case null, default -> sb.append('?'); - } - } - } - return sb.toString(); - } - - @Override - public void tick(MinecraftClient client) { - if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.solveWaterboard || client.world == null || !DungeonManager.isCurrentRoomMatched() || solve != null && !solve.isDone()) { - return; - } - Room room = DungeonManager.getCurrentRoom(); - solve = CompletableFuture.runAsync(() -> { - Changed changed = updateBoard(client.world, room); - if (changed == Changed.NONE) { - return; - } - if (results[0] == null) { - updateSwitches(); - simulateCombinations(); - clearSwitches(); - } - if (bestCombinations.isEmpty() || changed.doorChanged()) { - findBestCombinations(); - bestCombinationsUpdated = true; - } - }).exceptionally(e -> { - LOGGER.error("[Skyblocker Waterboard] Encountered an unknown exception while solving waterboard.", e); - return null; - }); - if (waypoints[0] == null) { - for (int i = 0; i < 6; i++) { - waypoints[i] = new Waypoint(room.relativeToActual(SWITCH_POSITIONS[i]), Waypoint.Type.HIGHLIGHT, LIME_COLOR_COMPONENTS); - } - waypoints[6] = new Waypoint(room.relativeToActual(WATER_LEVER), Waypoint.Type.HIGHLIGHT, LIME_COLOR_COMPONENTS); - waypoints[6].setFound(); - } - } - - private Changed updateBoard(World world, Room room) { - // Parse the waterboard. - BlockPos.Mutable pos = new BlockPos.Mutable(24, 78, 26); - Changed changed = Changed.NONE; - for (int row = 0; row < cells.length; pos.move(cells[row].length, -1, 0), row++) { - for (int col = 0; col < cells[row].length; pos.move(Direction.WEST), col++) { - Cell cell = parseBlock(world, room, pos); - if (!cell.equals(cells[row][col])) { - cells[row][col] = cell; - changed = changed.onCellChanged(); - } - } - } - - // Parse door states. - pos.set(15, 57, 15); - int prevDoors = doors; - doors = 0; - for (int i = 0; i < 5; pos.move(Direction.SOUTH), i++) { - doors |= world.getBlockState(room.relativeToActual(pos)).isAir() ? 1 << i : 0; - } - if (doors != prevDoors) { - changed = changed.onDoorChanged(); - } - - // Parse current combination of switches based on the levers. - currentCombination = 0; - for (int i = 0; i < 6; i++) { - currentCombination |= getSwitchState(world, room, i); - } - - return changed; - } - - private Cell parseBlock(World world, Room room, BlockPos.Mutable pos) { - // Check if the block is a switch. - BlockState state = world.getBlockState(room.relativeToActual(pos)); - int switch_ = SWITCH_BLOCKS.getInt(state.getBlock()); - if (switch_-- > 0) { - return new SwitchCell(switch_); - } - // Check if the block is an opened switch by checking the block behind it. - int switchBehind = SWITCH_BLOCKS.getInt(world.getBlockState(room.relativeToActual(pos.move(Direction.SOUTH))).getBlock()); - pos.move(Direction.NORTH); - if (switchBehind-- > 0) { - return SwitchCell.ofOpened(switchBehind); - } - - // Check if the block is empty otherwise the block is a wall. - return state.isAir() || state.isOf(Blocks.WATER) ? Cell.EMPTY : Cell.BLOCK; - } - - private static int getSwitchState(World world, Room room, int i) { - BlockState state = world.getBlockState(room.relativeToActual(SWITCH_POSITIONS[i])); - return state.contains(LeverBlock.POWERED) && state.get(LeverBlock.POWERED) ? 1 << i : 0; - } - - private void updateSwitches() { - clearSwitches(); - for (Cell[] row : cells) { - for (Cell cell : row) { - if (cell instanceof SwitchCell switchCell) { - switches[switchCell.id].add(switchCell); - } - } - } - } - - private void simulateCombinations() { - for (int combination = 0; combination < (1 << 6); combination++) { - for (int switchIndex = 0; switchIndex < 6; switchIndex++) { - if ((combination & (1 << switchIndex)) != 0) { - switches[switchIndex].toggle(); - } - } - results[combination] = simulateCombination(); - for (int switchIndex = 0; switchIndex < 6; switchIndex++) { - if ((combination & (1 << switchIndex)) != 0) { - switches[switchIndex].toggle(); - } - } - } - } - - private Result simulateCombination() { - List waters = new ArrayList<>(); - waters.add(new Vector2i(9, 0)); - Result result = new Result(); - while (!waters.isEmpty()) { - List newWaters = new ArrayList<>(); - for (Iterator watersIt = waters.iterator(); watersIt.hasNext(); ) { - Vector2i water = watersIt.next(); - // Check if the water has reached a door. - if (water.y == 18) { - switch (water.x) { - case 0 -> result.reachedDoors |= 1 << 4; - case 4 -> result.reachedDoors |= 1 << 3; - case 9 -> result.reachedDoors |= 1 << 2; - case 14 -> result.reachedDoors |= 1 << 1; - case 18 -> result.reachedDoors |= 1; - } - watersIt.remove(); - continue; - } - // Check if the water can flow down. - if (water.y < 18 && cells[water.y + 1][water.x].isOpen()) { - result.putPath(water, 0); - water.add(0, 1); - continue; - } - - // Get the offset to the first block on the left and the right that can flow down. - int leftFlowDownOffset = findFlowDown(water, false); - int rightFlowDownOffset = findFlowDown(water, true); - // Check if left down is in range and is closer than right down. - // Note 1: The yarn name "getFlowSpeed" is incorrect as it actually returns the maximum distance that water will check for a hole to flow towards. - // Note 2: Skyblock's maximum offset is 5 instead of 4 for some reason. - if (-leftFlowDownOffset <= ((WaterFluid) Fluids.WATER).getMaxFlowDistance(null) + 1 && -leftFlowDownOffset < rightFlowDownOffset) { - result.putPath(water, leftFlowDownOffset); - water.add(leftFlowDownOffset, 1); - continue; - } - // Check if right down is in range and closer than left down. - if (rightFlowDownOffset <= ((WaterFluid) Fluids.WATER).getMaxFlowDistance(null) + 1 && rightFlowDownOffset < -leftFlowDownOffset) { - result.putPath(water, rightFlowDownOffset); - water.add(rightFlowDownOffset, 1); - continue; - } - - // Else flow to both sides if in range. - if (leftFlowDownOffset > Integer.MIN_VALUE + 1) { - result.putPath(water, leftFlowDownOffset); - newWaters.add(new Vector2i(water).add(leftFlowDownOffset, 1)); - } - if (rightFlowDownOffset < Integer.MAX_VALUE) { - result.putPath(water, rightFlowDownOffset); - newWaters.add(new Vector2i(water).add(rightFlowDownOffset, 1)); - } - watersIt.remove(); - } - waters.addAll(newWaters); - } - return result; - } - - /** - * Finds the first block on the left that can flow down. - */ - private int findFlowDown(Vector2i water, boolean direction) { - for (int i = 0; water.x + i >= 0 && water.x + i < 19 && i > -8 && i < 8 && cells[water.y][water.x + i].isOpen(); i += direction ? 1 : -1) { - if (cells[water.y + 1][water.x + i].isOpen()) { - return i; - } - } - return direction ? Integer.MAX_VALUE : Integer.MIN_VALUE + 1; - } - - private void findBestCombinations() { - bestCombinations.clear(); - for (int combination = 0, bestScore = 0; combination < (1 << 6); combination++) { - int newScore = Integer.bitCount(results[combination].reachedDoors ^ doors); - if (newScore >= bestScore) { - if (newScore > bestScore) { - bestCombinations.clear(); - bestScore = newScore; - } - bestCombinations.add(combination); - } - } - } - - @Override - public void render(WorldRenderContext context) { - if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.solveWaterboard || !DungeonManager.isCurrentRoomMatched()) return; - Room room = DungeonManager.getCurrentRoom(); - - // Render the best combination. - @SuppressWarnings("resource") - BlockState state = context.world().getBlockState(room.relativeToActual(WATER_LEVER)); - // bestCombinationsUpdated is needed because bestCombinations does not update immediately after the lever is turned off. - if (waypoints[0] != null && bestCombinationsUpdated && state.contains(LeverBlock.POWERED) && !state.get(LeverBlock.POWERED)) { - bestCombinations.intStream().mapToObj(bestCombination -> currentCombination ^ bestCombination).min(Comparator.comparingInt(Integer::bitCount)).ifPresent(bestDifference -> { - for (int i = 0; i < 6; i++) { - if ((bestDifference & 1 << i) != 0) { - waypoints[i].render(context); - } - } - if (bestDifference == 0 && !waypoints[6].shouldRender()) { - waypoints[6].setMissing(); - } - }); - } - if (waypoints[6] != null && waypoints[6].shouldRender()) { - waypoints[6].render(context); - } - - // Render the current path of the water. - BlockPos.Mutable pos = new BlockPos.Mutable(15, 79, 26); - RenderHelper.renderLinesFromPoints(context, new Vec3d[]{Vec3d.ofCenter(room.relativeToActual(pos)), Vec3d.ofCenter(room.relativeToActual(pos.move(Direction.DOWN)))}, LIME_COLOR_COMPONENTS, 1f, 5f, true); - Result currentResult = results[currentCombination]; - if (currentResult != null) { - for (Map.Entry entry : currentResult.path.entries()) { - Vec3d start = Vec3d.ofCenter(room.relativeToActual(pos.set(24 - entry.getKey().x(), 78 - entry.getKey().y(), 26))); - Vec3d middle = Vec3d.ofCenter(room.relativeToActual(pos.move(Direction.WEST, entry.getValue()))); - Vec3d end = Vec3d.ofCenter(room.relativeToActual(pos.move(Direction.DOWN))); - RenderHelper.renderLinesFromPoints(context, new Vec3d[]{start, middle}, LIME_COLOR_COMPONENTS, 1f, 5f, true); - RenderHelper.renderLinesFromPoints(context, new Vec3d[]{middle, end}, LIME_COLOR_COMPONENTS, 1f, 5f, true); - } - } - } - - private ActionResult onUseBlock(PlayerEntity player, World world, Hand hand, BlockHitResult blockHitResult) { - BlockState state = world.getBlockState(blockHitResult.getBlockPos()); - if (SkyblockerConfigManager.get().dungeons.puzzleSolvers.solveWaterboard && blockHitResult.getType() == HitResult.Type.BLOCK && waypoints[6] != null && DungeonManager.isCurrentRoomMatched() && blockHitResult.getBlockPos().equals(DungeonManager.getCurrentRoom().relativeToActual(WATER_LEVER)) && state.contains(LeverBlock.POWERED)) { - if (!state.get(LeverBlock.POWERED)) { - bestCombinationsUpdated = false; - Scheduler.INSTANCE.schedule(() -> waypoints[6].setMissing(), 50); - } - waypoints[6].setFound(); - } - return ActionResult.PASS; - } - - @Override - public void reset() { - super.reset(); - solve = null; - for (Cell[] row : cells) { - Arrays.fill(row, null); - } - clearSwitches(); - doors = 0; - Arrays.fill(results, null); - currentCombination = 0; - bestCombinations.clear(); - Arrays.fill(waypoints, null); - } - - public void clearSwitches() { - for (Switch switch_ : switches) { - switch_.clear(); - } - } - - private enum Changed { - NONE, CELL, DOOR, BOTH; - - private boolean cellChanged() { - return this == CELL || this == BOTH; - } - - private boolean doorChanged() { - return this == DOOR || this == BOTH; - } - - private Changed onCellChanged() { - return switch (this) { - case NONE, CELL -> Changed.CELL; - case DOOR, BOTH -> Changed.BOTH; - }; - } - - private Changed onDoorChanged() { - return switch (this) { - case NONE, DOOR -> Changed.DOOR; - case CELL, BOTH -> Changed.BOTH; - }; - } - } - - public static class Result { - private int reachedDoors; - private final Multimap path = MultimapBuilder.hashKeys().arrayListValues().build(); - - public boolean putPath(Vector2i water, int offset) { - return path.put(new Vector2i(water), offset); - } - - @Override - public String toString() { - return "Result[reachedDoors=" + Integer.toBinaryString(reachedDoors) + ", path=" + path + ']'; - } - } +public class Waterboard { + public static final int BOARD_MIN_X = 6; + public static final int BOARD_MAX_X = 24; + public static final int BOARD_MIN_Y = 58; + public static final int BOARD_MAX_Y = 81; + public static final int BOARD_Z = 26; + // The top center of the grid, between the first two toggleable blocks + public static final BlockPos WATER_ENTRANCE_POSITION = new BlockPos(15, 78, 26); + + public enum LeverType implements StringIdentifiable { + COAL(Blocks.COAL_BLOCK, new BlockPos(20, 61, 10), DyeColor.RED, new BlockPos[]{ + new BlockPos(0, -2, 0), new BlockPos(2, -1, 1), + null, new BlockPos(5, -1, 0) + }), + GOLD(Blocks.GOLD_BLOCK, new BlockPos(20, 61, 15), DyeColor.YELLOW, new BlockPos[]{ + new BlockPos(1, -1, 0), new BlockPos(3, -2, 0), + new BlockPos(-4, -1, 1), new BlockPos(1, 0, 0) + }), + QUARTZ(Blocks.QUARTZ_BLOCK, new BlockPos(20, 61, 20), DyeColor.LIGHT_GRAY, new BlockPos[]{ + new BlockPos(1, -4, 1), new BlockPos(-1, 0, 0), + new BlockPos(1, 0, 0), new BlockPos(-1, 0, 1) + }), + DIAMOND(Blocks.DIAMOND_BLOCK, new BlockPos(10, 61, 20), DyeColor.CYAN, new BlockPos[]{ + new BlockPos(0, -5, 1), new BlockPos(-2, -1, 0), + new BlockPos(-1, 0, 1), new BlockPos(-3, -4, 1) + }), + EMERALD(Blocks.EMERALD_BLOCK, new BlockPos(10, 61, 15), DyeColor.LIME, new BlockPos[]{ + new BlockPos(-1, -10, 1), new BlockPos(1, 0, 1), + new BlockPos(-6, 0, 0), new BlockPos(1, -4, 0) + }), + TERRACOTTA(Blocks.TERRACOTTA, new BlockPos(10, 61, 10), DyeColor.ORANGE, new BlockPos[]{ + new BlockPos(-1, -1, 1), new BlockPos(0, -3, 1), + null, new BlockPos(-4, -5, 1) + }), + WATER(Blocks.LAVA, new BlockPos(15, 60, 5), DyeColor.LIGHT_BLUE, null); + + private static final Codec CODEC = StringIdentifiable.createCodec(LeverType::values); + + public final Block block; + public final BlockPos leverPos; + public final DyeColor color; + // Holds positions where the corresponding block is present in the initial state of each variant, offset from the water entrance position + // This is more reliable at detecting if the lever is active than looking at the lever's block state + public final BlockPos[] initialPositions; + + LeverType(Block block, BlockPos leverPos, DyeColor color, BlockPos[] initialPositions) { + this.block = block; + this.leverPos = leverPos; + this.color = color; + this.initialPositions = initialPositions; + } + + public static LeverType fromName(String name) { + for (LeverType leverType : LeverType.values()) { + if (leverType.name().equalsIgnoreCase(name)) { + return leverType; + } + } + return null; + } + + public static LeverType fromBlock(Block block) { + for (LeverType leverType : LeverType.values()) { + if (leverType.block == block) { + return leverType; + } + } + return null; + } + + public static LeverType fromPos(BlockPos leverPos) { + for (LeverType leverType : LeverType.values()) { + if (leverPos.equals(leverType.leverPos)) { + return leverType; + } + } + return null; + } + + @Override + public String asString() { + return name().toLowerCase(); + } + + public static class LeverTypeArgumentType extends EnumArgumentType { + private LeverTypeArgumentType() { + super(CODEC, LeverType::values); + } + + public static LeverTypeArgumentType leverType() { + return new LeverTypeArgumentType(); + } + + public static LeverType getLeverType(CommandContext context, String name) { + return context.getArgument(name, LeverType.class); + } + } + } } diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardOneFlow.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardOneFlow.java new file mode 100644 index 00000000..014d0f8b --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardOneFlow.java @@ -0,0 +1,554 @@ +package de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.StringArgumentType; +import de.hysky.skyblocker.SkyblockerMod; +import de.hysky.skyblocker.annotations.Init; +import de.hysky.skyblocker.config.SkyblockerConfigManager; +import de.hysky.skyblocker.debug.Debug; +import de.hysky.skyblocker.skyblock.dungeon.puzzle.DungeonPuzzle; +import de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonManager; +import de.hysky.skyblocker.skyblock.dungeon.secrets.Room; +import de.hysky.skyblocker.utils.ColorUtils; +import de.hysky.skyblocker.utils.Constants; +import de.hysky.skyblocker.utils.render.RenderHelper; +import it.unimi.dsi.fastutil.doubles.DoubleArrayList; +import it.unimi.dsi.fastutil.doubles.DoubleList; +import it.unimi.dsi.fastutil.objects.ObjectDoublePair; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.minecraft.block.BlockState; +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.player.PlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.*; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.HitResult; +import net.minecraft.util.math.*; +import net.minecraft.world.World; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import static de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard.Waterboard.*; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +/* +Benchmark times for solutions in watertimes.json (for anyone trying to improve the solutions) +Time starts when the water lever is turned on and stops when the last door opens, use the toggleTimer command + +--- Variant 1 --- +012: 11.85s +013: 14.24s +014: 14.35s +023: 14.16s +024: 10.81s +034: 16.11s +123: 12.36s +124: 11.94s +134: 12.01s +234: 11.29s + +--- Variant 2 --- +012: 12.26s +013: 13.79s +014: 14.51s +023: 14.44s +024: 12.66s +034: 11.91s +123: 12.89s +124: 13.26s +134: 12.69s +234: 12.50s + +--- Variant 3 --- +012: 12.45s +013: 12.86s +014: 11.66s +023: 13.04s +024: 11.96s +034: 13.71s +123: 13.66s +124: 10.94s +134: 12.30s +234: 12.65s + +--- Variant 4 --- +012: 13.29s +013: 12.15s +014: 12.25s +023: 11.10s +024: 13.11s +034: 16.59s +123: 11.20s +124: 13.61s +134: 14.21s +234: 13.94s +*/ + +public class WaterboardOneFlow extends DungeonPuzzle { + private static final Logger LOGGER = LoggerFactory.getLogger(WaterboardOneFlow.class); + public static final WaterboardOneFlow INSTANCE = new WaterboardOneFlow(); + private static final Identifier WATER_TIMES = Identifier.of(SkyblockerMod.NAMESPACE, "dungeons/watertimes.json"); + private static final Text WAIT_TEXT = Text.literal("WAIT").formatted(Formatting.RED, Formatting.BOLD); + private static final Text CLICK_TEXT = Text.literal("CLICK").formatted(Formatting.GREEN, Formatting.BOLD); + private static JsonObject SOLUTIONS; + + private boolean timerEnabled; + private final List marks = new ArrayList<>(); + private ClientWorld world; + private Room room; + private ClientPlayerEntity player; + + private int variant; + private String doors; + private String initialDoors; + private EnumMap solution; + private boolean finished; + private long waterStartMillis; + private CompletableFuture solve; + + private WaterboardOneFlow() { + super("waterboard", "water-puzzle"); + } + + @Init + public static void init() { + ClientLifecycleEvents.CLIENT_STARTED.register(WaterboardOneFlow::loadSolutions); + UseBlockCallback.EVENT.register(INSTANCE::onUseBlock); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("puzzle").then(literal(INSTANCE.puzzleName) + .then(literal("reset").executes(context -> { + INSTANCE.softReset(); + return Command.SINGLE_SUCCESS; + })) + ))))); + + if (Debug.debugEnabled()) { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(literal(SkyblockerMod.NAMESPACE).then(literal("dungeons").then(literal("puzzle").then(literal(INSTANCE.puzzleName) + .then(literal("setDoors").then(argument("combination", StringArgumentType.string()).executes(context -> { + String doorCombination = StringArgumentType.getString(context, "combination"); + if (SOLUTIONS.get("1").getAsJsonObject().keySet().contains(doorCombination)) { + INSTANCE.softReset(); + INSTANCE.doors = doorCombination; + } else { + context.getSource().sendError(Constants.PREFIX.get().append("Door combination must be three increasing digits between 0 and 4")); + } + return Command.SINGLE_SUCCESS; + }))) + .then(literal("toggleTimer").executes((context) -> { + INSTANCE.timerEnabled = !INSTANCE.timerEnabled; + context.getSource().sendFeedback(Constants.PREFIX.get().append( + INSTANCE.timerEnabled ? "Timer enabled." : "Timer disabled.")); + return Command.SINGLE_SUCCESS; + })) + .then(literal("modifyLever").then(argument("leverType", LeverType.LeverTypeArgumentType.leverType()).then(argument("times", StringArgumentType.greedyString()).executes((context) -> { + LeverType leverType = LeverType.LeverTypeArgumentType.getLeverType(context, "leverType"); + if (leverType == null) { + context.getSource().sendError(Constants.PREFIX.get().append("Invalid lever type")); + } else if (INSTANCE.solution == null) { + context.getSource().sendError(Constants.PREFIX.get().append("No existing solution")); + } else { + try { + DoubleList times = new DoubleArrayList(); + for (String time : StringArgumentType.getString(context, "times").split(" ")) { + times.add(Double.parseDouble(time)); + } + INSTANCE.solution.put(leverType, times); + } catch (NumberFormatException e) { + context.getSource().sendError(Constants.PREFIX.get().append("Times must be valid numbers or decimals")); + } + } + return Command.SINGLE_SUCCESS; + })))) + .then(literal("addMark").executes((context) -> { + if (INSTANCE.world == null || INSTANCE.room == null || INSTANCE.player == null) { + context.getSource().sendError(Constants.PREFIX.get().append("Solver not active")); + return Command.SINGLE_SUCCESS; + } + + Vec3d camera = INSTANCE.room.actualToRelative(INSTANCE.player.getEyePos()); + Vec3d look = INSTANCE.room.actualToRelative(INSTANCE.player.getEyePos() + .add(INSTANCE.player.getRotationVector())).subtract(camera); + double t = (BOARD_Z + 0.5 - camera.getZ()) / look.getZ(); + Vec3d vec = camera.add(look.multiply(t)); + double x = MathHelper.floor(vec.x); + double y = MathHelper.floor(vec.y); + double z = MathHelper.floor(vec.z); + + if (x < BOARD_MIN_X || x > BOARD_MAX_X || y < BOARD_MIN_Y || y > BOARD_MAX_Y || z != BOARD_Z) { + context.getSource().sendError(Constants.PREFIX.get().append("Mark is not inside the board")); + return Command.SINGLE_SUCCESS; + } + BlockPos pos = BlockPos.ofFloored(INSTANCE.room.relativeToActual(vec)); + + if (!INSTANCE.world.getBlockState(pos).isAir()) { + context.getSource().sendError(Constants.PREFIX.get().append("Marks can only be placed on air")); + return Command.SINGLE_SUCCESS; + } + + for (Mark mark : INSTANCE.marks) { + if (mark.pos.equals(pos)) { + context.getSource().sendError(Constants.PREFIX.get().append("There is already a mark at that position")); + return Command.SINGLE_SUCCESS; + } + } + + INSTANCE.marks.add(new Mark(INSTANCE.marks.size() + 1, pos)); + return Command.SINGLE_SUCCESS; + })) + .then(literal("clearMarks").executes((context) -> { + INSTANCE.marks.clear(); + return Command.SINGLE_SUCCESS; + })) + ))))); + } + } + + private static void loadSolutions(MinecraftClient client) { + try (BufferedReader reader = client.getResourceManager().openAsReader(WATER_TIMES)) { + SOLUTIONS = JsonParser.parseReader(reader).getAsJsonObject(); + } catch (Exception e) { + LOGGER.error("[Skyblocker Waterboard] Failed to load solutions json", e); + } + } + + @Override + public void tick(MinecraftClient client) { + if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.waterboardOneFlow || + !shouldSolve() || + client.world == null || + client.player == null || + !DungeonManager.isCurrentRoomMatched()) { + return; + } + + world = client.world; + room = DungeonManager.getCurrentRoom(); + player = client.player; + + if (solution == null && !finished && solve == null) { + solve = CompletableFuture.runAsync(this::solvePuzzle).exceptionally(e -> { + LOGGER.error("[Skyblocker Waterboard] Encountered an unknown exception while solving waterboard.", e); + finished = true; + return null; + }); + } + + if (!finished && isPuzzleSolved()) { + finished = true; + if (timerEnabled) { + double elapsed = (System.currentTimeMillis() - waterStartMillis) / 1000.0; + player.sendMessage(Constants.PREFIX.get().append("Puzzle solved in ") + .append(Text.literal(String.format("%.2f", elapsed)).formatted(Formatting.GREEN)) + .append(Formatting.RESET.toString()).append(" seconds."), false); + } + } + + if (waterStartMillis > 0) { + for (Mark mark : marks) { + if (!mark.reached && world.getBlockState(mark.pos).isOf(Blocks.WATER)) { + mark.reached = true; + double elapsed = (System.currentTimeMillis() - waterStartMillis) / 1000.0; + player.sendMessage(Constants.PREFIX.get().append(String.format("Mark %d reached in ", mark.index)) + .append(Text.literal(String.format("%.2f", elapsed)).formatted(Formatting.GREEN)) + .append(Formatting.RESET.toString()).append(" seconds."), false); + } + } + } + } + + private void solvePuzzle() { + variant = findVariant(); + if (variant == 0) { + finished = true; + return; + } + + initialDoors = findDoors(); + if (doors == null) { + // Assume we want to open all doors, unless manually testing a different solution + doors = initialDoors; + if (doors.isEmpty()) { + solution = makeEmptySolution(); + finished = true; + return; + } else if (doors.length() != 3) { + player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.dungeons.puzzle.waterboard.invalidDoors")), false); + finished = true; + return; + } + } + + if (!checkWater()) { + player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.dungeons.puzzle.waterboard.waterFound")), false); + finished = true; + return; + } + + if (!finished) { + // Solutions are precalculated according to board variant and initial door combination (in watertimes.json) + JsonObject data = SOLUTIONS.get(String.valueOf(variant)).getAsJsonObject().get(doors).getAsJsonObject(); + solution = setupSolution(data); + } + } + + private EnumMap makeEmptySolution() { + EnumMap solution = new EnumMap<>(LeverType.class); + for (LeverType leverType : LeverType.values()) { + solution.put(leverType, new DoubleArrayList()); + } + return solution; + } + + private int findVariant() { + // The waterboard only has four possible layouts, each with a unique pair of + // toggleable blocks at the entrance. They are a block lower on the first layout. + + Set firstSwitches = new HashSet<>(); + Box firstSwitchBlocks = Box.enclosing(room.relativeToActual(WATER_ENTRANCE_POSITION.add(-1, -1, 0)), + room.relativeToActual(WATER_ENTRANCE_POSITION.add(1, 0, 1))); + for (BlockState state : world.getStatesInBox(firstSwitchBlocks).toList()) { + LeverType leverType = LeverType.fromBlock(state.getBlock()); + if (leverType != null) { + firstSwitches.add(leverType); + } + } + + if (firstSwitches.contains(LeverType.GOLD) && firstSwitches.contains(LeverType.TERRACOTTA)) { + return 1; + } else if (firstSwitches.contains(LeverType.EMERALD) && firstSwitches.contains(LeverType.QUARTZ)) { + return 2; + } else if (firstSwitches.contains(LeverType.QUARTZ) && firstSwitches.contains(LeverType.DIAMOND)) { + return 3; + } else if (firstSwitches.contains(LeverType.GOLD) && firstSwitches.contains(LeverType.QUARTZ)) { + return 4; + } + + LOGGER.error("[Skyblocker Waterboard] Unknown waterboard layout. Detected switches: [{}]", + String.join(", ", firstSwitches.stream().map(LeverType::asString).toList())); + + return 0; + } + + private String findDoors() { + // Determine which doors are closed + StringBuilder doorBuilder = new StringBuilder(); + BlockPos.Mutable doorPos = new BlockPos.Mutable(15, 57, 19); + for (int i = 0; i < 5; i++) { + if (!world.getBlockState(room.relativeToActual(doorPos)).isAir()) { + doorBuilder.append(i); + } + doorPos.move(Direction.NORTH); + } + return doorBuilder.toString(); + } + + private boolean checkWater() { + // Make sure there is no water currently in the board + for (int x = BOARD_MIN_X; x <= BOARD_MAX_X; x++) { + for (int y = BOARD_MIN_Y; y <= BOARD_MAX_Y; y++) { + BlockPos pos = room.relativeToActual(new BlockPos(x, y, BOARD_Z)); + BlockState state = world.getBlockState(pos); + if (state.isOf(Blocks.WATER)) { + return false; + } + } + } + return true; + } + + private EnumMap setupSolution(JsonObject data) { + EnumMap solution = makeEmptySolution(); + for (Map.Entry entry : data.entrySet()) { + LeverType leverType = LeverType.fromName(entry.getKey()); + if (leverType != null) { + DoubleList times = new DoubleArrayList(); + for (JsonElement element : entry.getValue().getAsJsonArray()) { + times.add(element.getAsDouble()); + } + solution.put(leverType, times); + } + } + + // If the solver was reset after using some levers, make sure they are flipped back to the correct positions + for (LeverType leverType : LeverType.values()) { + DoubleList times = solution.get(leverType); + if (leverType != LeverType.WATER && isLeverActive(leverType)) { + if (times.isEmpty() || times.getFirst() != 0.0) { + times.addFirst(0.0); + } else { + times.removeFirst(); + } + } + } + + return solution; + } + + private boolean isLeverActive(LeverType leverType) { + BlockPos offset = leverType.initialPositions[variant - 1]; + if (offset == null) { + return false; + } + return !world.getBlockState(room.relativeToActual(WATER_ENTRANCE_POSITION.add(offset))).isOf(leverType.block); + } + + private boolean isPuzzleSolved() { + if (doors == null || initialDoors == null || waterStartMillis == 0) { + return false; + } + String currentDoors = findDoors(); + for (int i = 0; i < 5; i++) { + String s = String.valueOf(i); + // If the door was toggled, initialDoors... == currentDoors... will be false. + // If the door should have been toggled, doors... will be true. + // If the puzzle is solved, doors will only be toggled when they should be, + // so the two will not be equal. If they are equal, it means the puzzle isn't solved. + if (initialDoors.contains(s) == currentDoors.contains(s) == doors.contains(s)) { + return false; + } + } + return true; + } + + @Override + public void render(WorldRenderContext context) { + if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.waterboardOneFlow || + world == null || room == null || player == null) return; + + try { + for (Mark mark : marks) { + float[] components = ColorUtils.getFloatComponents(mark.reached ? DyeColor.LIME : DyeColor.WHITE); + RenderHelper.renderFilled(context, mark.pos, components, 0.5f, true); + RenderHelper.renderText(context, Text.of(String.format("Mark %d", mark.index)), + mark.pos.toCenterPos().offset(Direction.UP, 0.2), true); + } + + if (solution != null) { + List> sortedTimes = solution.entrySet().stream() + .flatMap((entry) -> entry.getValue().doubleStream().mapToObj((time) -> ObjectDoublePair.of(entry.getKey(), time))) + // Sort by next use time, then by lever type + .sorted(Comparator + .>comparingDouble(p -> p.rightDouble() + (p.left() == LeverType.WATER ? 0.001 : 0.0)) + .thenComparingInt(p -> p.left().ordinal()) + ).toList(); + LeverType nextLever = sortedTimes.isEmpty() ? null : sortedTimes.getFirst().left(); + LeverType nextNextLever = sortedTimes.size() < 2 ? null : sortedTimes.get(1).left(); + + if (nextLever != null) { + RenderHelper.renderLineFromCursor(context, + room.relativeToActual(nextLever.leverPos).toCenterPos(), + ColorUtils.getFloatComponents(DyeColor.LIME), 1f, 2f); + if (nextNextLever != null) { + RenderHelper.renderLinesFromPoints(context, new Vec3d[]{ + room.relativeToActual(nextLever.leverPos).toCenterPos(), + room.relativeToActual(nextNextLever.leverPos).toCenterPos() + }, ColorUtils.getFloatComponents(DyeColor.WHITE), 0.5f, 1f, true); + } + } + + renderLeverText(context, nextLever); + } + } catch (Exception e) { + LOGGER.error("[Skyblocker Waterboard] Error while rendering one flow", e); + } + } + + private void renderLeverText(WorldRenderContext context, LeverType nextLever) { + for (Map.Entry leverData : solution.entrySet()) { + LeverType lever = leverData.getKey(); + for (int i = 0; i < leverData.getValue().size(); i++) { + double nextTime = leverData.getValue().getDouble(i); + long remainingTime = waterStartMillis + (long)(nextTime * 1000) - System.currentTimeMillis(); + Text text; + + if (lever == LeverType.WATER && nextTime == 0.0 && nextLever != LeverType.WATER) { + // Solutions assume levers with a time of 0.0 are used before the water lever + text = WAIT_TEXT; + } else if (waterStartMillis == 0 && nextTime == 0.0 || waterStartMillis > 0 && remainingTime <= 0.0) { + text = CLICK_TEXT; + } else { + double timeToShow = waterStartMillis == 0 ? nextTime : remainingTime / 1000.0; + text = Text.literal(String.format("%.2f", timeToShow)).formatted(Formatting.YELLOW); + } + + RenderHelper.renderText(context, text, + room.relativeToActual(lever.leverPos).toCenterPos() + .offset(Direction.UP, 0.5 * (i + 1)), true); + } + } + } + + private ActionResult onUseBlock(PlayerEntity player, World world, Hand hand, BlockHitResult blockHitResult) { + try { + if (SkyblockerConfigManager.get().dungeons.puzzleSolvers.waterboardOneFlow && + solution != null && blockHitResult.getType() == HitResult.Type.BLOCK) { + LeverType leverType = LeverType.fromPos(room.actualToRelative(blockHitResult.getBlockPos())); + if (leverType != null) { + List times = solution.get(leverType); + if (waterStartMillis == 0 && leverType != LeverType.WATER && (times.isEmpty() || times.getFirst() != 0.0)) { + // If the incorrect lever was used and the water hasn't started yet, tell the player to move it back + times.addFirst(0.0); + } else { + if (!times.isEmpty()) { + times.removeFirst(); + } + if (waterStartMillis == 0 && leverType == LeverType.WATER) { + waterStartMillis = System.currentTimeMillis(); + } + } + } + } + } catch (Exception e) { + LOGGER.error("[Skyblocker Waterboard] Exception in onUseBlock", e); + } + return ActionResult.PASS; + } + + @Override + public void reset() { + super.reset(); + softReset(); + } + + private void softReset() { + // In most cases we want the solver to remain active after resetting, so don't call super.reset() + solve = null; + variant = 0; + doors = null; + initialDoors = null; + solution = null; + finished = false; + waterStartMillis = 0; + for (Mark mark : marks) { + mark.reached = false; + } + } + + // Can be added to the board to time how long it takes the water to reach certain locations. + // Use the addMarks command while looking at the spot where the mark should go. + private static class Mark { + private final int index; + private final BlockPos pos; + private boolean reached; + + private Mark(int index, BlockPos pos) { + this.index = index; + this.pos = pos; + this.reached = false; + } + } +} diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardPreviewer.java b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardPreviewer.java new file mode 100644 index 00000000..3cc82434 --- /dev/null +++ b/src/main/java/de/hysky/skyblocker/skyblock/dungeon/puzzle/waterboard/WaterboardPreviewer.java @@ -0,0 +1,226 @@ +package de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard; + +import de.hysky.skyblocker.annotations.Init; +import de.hysky.skyblocker.config.SkyblockerConfigManager; +import de.hysky.skyblocker.skyblock.dungeon.puzzle.DungeonPuzzle; +import de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonManager; +import de.hysky.skyblocker.skyblock.dungeon.secrets.Room; +import de.hysky.skyblocker.utils.ColorUtils; +import de.hysky.skyblocker.utils.render.RenderHelper; +import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext; +import net.minecraft.block.BlockState; +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.util.Pair; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.BlockView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static de.hysky.skyblocker.skyblock.dungeon.puzzle.waterboard.Waterboard.*; + +public class WaterboardPreviewer extends DungeonPuzzle { + private static final Logger LOGGER = LoggerFactory.getLogger(WaterboardPreviewer.class); + public static final WaterboardPreviewer INSTANCE = new WaterboardPreviewer(); + + private LeverType prospective; + private ClientWorld world; + private Room room; + private ClientPlayerEntity player; + + private WaterboardPreviewer() { + super("waterboard", "water-puzzle"); + } + + @Init + public static void init() {} + + @Override + public void tick(MinecraftClient client) {} + + @Override + public void render(WorldRenderContext context) { + if (!shouldSolve() || MinecraftClient.getInstance().world == null || MinecraftClient.getInstance().player == null || !DungeonManager.isCurrentRoomMatched()) { + return; + } + + world = MinecraftClient.getInstance().world; + room = DungeonManager.getCurrentRoom(); + player = MinecraftClient.getInstance().player; + + try { + findProspective(); + renderWaterPath(context); + renderProspectiveChanges(context); + } catch (Exception e) { + LOGGER.error("[Skyblocker Waterboard] Error while rendering previews", e); + } + } + + private void renderWaterPath(WorldRenderContext context) { + if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.previewWaterPath) { + return; + } + + // Calculate and render path of water through the board + // If there is a prospective lever, instead find the path for if that lever was used + List> waterPath = new ArrayList<>(); + waterPath.add(new Pair<>(WATER_ENTRANCE_POSITION.up(5), WATER_ENTRANCE_POSITION.up(3))); + findWaterPathVertical(WATER_ENTRANCE_POSITION.up(3), waterPath); + + for (Pair pair : waterPath) { + Vec3d head = room.relativeToActual(pair.getLeft()).toCenterPos(); + Vec3d tail = room.relativeToActual(pair.getRight()).toCenterPos(); + + List lines = new ArrayList<>(); + if (prospective == null) { + lines.add(new Vec3d[]{head, tail}); + } else { + Vec3d forward = tail.subtract(head).normalize(); + double distance = head.distanceTo(tail); + for (int i = 0; i < distance; i++) { + lines.add(new Vec3d[]{head, head.add(forward.multiply(0.3))}); + lines.add(new Vec3d[]{head.add(forward.multiply(0.7)), head.add(forward)}); + head = head.add(forward); + } + } + + for (Vec3d[] line : lines) { + RenderHelper.renderLinesFromPoints(context, line, + ColorUtils.getFloatComponents(LeverType.WATER.color), 1f, 3f, true); + } + } + } + + private void findWaterPathVertical(BlockPos root, List> waterPath) { + if (isWaterPassable(root.down())) { + BlockPos.Mutable tail = new BlockPos.Mutable().set(root.down()); + while (isWaterPassable(tail.down())) { + tail.move(Direction.DOWN); + } + waterPath.add(new Pair<>(root, new BlockPos(tail))); + findWaterPathHorizontal(tail, waterPath); + } + } + + private void findWaterPathHorizontal(BlockPos root, List> waterPath) { + if (!isWaterPassable(root.down())) { + BlockPos.Mutable left = new BlockPos.Mutable().set(root); + int leftSteps = 0; + while (isWaterPassable(left.east()) && !isWaterPassable(left.down()) && leftSteps < 7) { + left.move(Direction.EAST); + leftSteps++; + } + BlockPos.Mutable right = new BlockPos.Mutable().set(root); + int rightSteps = 0; + while (isWaterPassable(right.west()) && !isWaterPassable(right.down()) && rightSteps < 7) { + right.move(Direction.WEST); + rightSteps++; + } + + // If one side has an air block closer to the source than the other side, the water will only flow in that direction. + // Skyblock only looks up to 5 blocks away when determining if there is an air block. + // If no air is found, the water flows in both directions up to a maximum of 7 blocks away. + if (isWaterPassable(left.down()) && leftSteps <= 5 && (leftSteps < rightSteps || !isWaterPassable(right.down()))) { + waterPath.add(new Pair<>(root, new BlockPos(left))); + findWaterPathVertical(left, waterPath); + } else if (isWaterPassable(right.down()) && rightSteps <= 5 && (rightSteps < leftSteps || !isWaterPassable(left.down()))) { + waterPath.add(new Pair<>(root, new BlockPos(right))); + findWaterPathVertical(right, waterPath); + } else { + if (leftSteps > 0) { + waterPath.add(new Pair<>(root, new BlockPos(left))); + findWaterPathVertical(left, waterPath); + } + if (rightSteps > 0) { + waterPath.add(new Pair<>(root, new BlockPos(right))); + findWaterPathVertical(right, waterPath); + } + } + } + } + + private boolean isWaterPassable(BlockPos pos) { + if (pos.getX() < BOARD_MIN_X || pos.getX() > BOARD_MAX_X || + pos.getY() < BOARD_MIN_Y || pos.getY() > BOARD_MAX_Y || + pos.getZ() != BOARD_Z) { + return false; + } + BlockState state = world.getBlockState(room.relativeToActual(pos)); + BlockState behindState = world.getBlockState(room.relativeToActual(pos.offset(Direction.SOUTH))); + boolean open = state.isAir() || state.isOf(Blocks.WATER); + if (prospective == null) { + return open; + } else { + return open && !behindState.isOf(prospective.block) || state.isOf(prospective.block); + } + } + + private void findProspective() { + if (!SkyblockerConfigManager.get().dungeons.puzzleSolvers.previewLeverEffects) { + return; + } + + // If the player is looking at a toggleable block in the board, show what would happen if that block was toggled + Vec3d camera = room.actualToRelative(player.getEyePos()); + Vec3d look = room.actualToRelative(player.getEyePos().add(player.getRotationVector())).subtract(camera); + double t1 = (BOARD_Z - 1.5 - camera.getZ()) / look.getZ(); + double t2 = (BOARD_Z + 1.5 - camera.getZ()) / look.getZ(); + Vec3d start = camera.add(look.multiply(t1)); + Vec3d end = camera.add(look.multiply(t2)); + + Direction behind = switch (room.getDirection()) { + case NW -> Direction.SOUTH; + case NE -> Direction.WEST; + case SW -> Direction.EAST; + case SE -> Direction.NORTH; + }; + + prospective = BlockView.raycast(room.relativeToActual(start), room.relativeToActual(end), null, (ctx, pos) -> { + if (room.actualToRelative(pos).getZ() != BOARD_Z) { + return null; +