diff --git a/pom.xml b/pom.xml index 38fc361..c32d4dd 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,16 @@ ${java.version} + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + slime + + + io.ebean @@ -88,6 +98,10 @@ + + enginehub + https://maven.enginehub.org/repo/ + codemc-releases https://repo.codemc.io/repository/maven-releases/ @@ -140,6 +154,12 @@ 1.21.10-R0.1-SNAPSHOT provided + + com.sk89q.worldedit + worldedit-bukkit + 7.3.12 + provided + com.github.stefvanschie.inventoryframework IF diff --git a/src/main/java/xyz/soukup/ecoCraftCore/EcoCraftCore.java b/src/main/java/xyz/soukup/ecoCraftCore/EcoCraftCore.java index aa96cc4..2a1749e 100644 --- a/src/main/java/xyz/soukup/ecoCraftCore/EcoCraftCore.java +++ b/src/main/java/xyz/soukup/ecoCraftCore/EcoCraftCore.java @@ -88,9 +88,12 @@ public final class EcoCraftCore extends JavaPlugin { prepareSlimeWorldsSaver(); }catch (IOException e) { - MineWorldManager.init(); + e.printStackTrace(); + getLogger().severe("Failed to save island templates."); } + MineWorldManager.init(); + this.getServer().getScheduler().runTaskTimer(this, /* Lambda: */ task -> { VirtualChest.saveCache(); } , 6000, 6000); diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineCommand.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineCommand.java index 25cd2f4..f55c8ba 100644 --- a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineCommand.java +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineCommand.java @@ -6,6 +6,7 @@ import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; import org.bukkit.Location; import org.bukkit.World; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import xyz.soukup.ecoCraftCore.messages.Messages; @@ -26,29 +27,34 @@ public class MineCommand { } private static int regenerateMines(CommandContext context) { + CommandSender sender = context.getSource().getSender(); World world = MineWorldManager.getWorld(); if (world == null) { - Messages.send(context.getSource().getSender(), "mine.error.no-world"); + Messages.send(sender, "mine.error.no-world"); return 0; } - Messages.send(context.getSource().getSender(), "mine.regenerating"); + Messages.send(sender, "mine.regenerating"); - // Teleport all players out of the mine world first - for (Player p : world.getPlayers()) { - p.teleport(p.getServer().getWorlds().getFirst().getSpawnLocation()); - Messages.send(p, "mine.teleported-out"); + for (Player player : world.getPlayers()) { + player.teleport(player.getServer().getWorlds().getFirst().getSpawnLocation()); + Messages.send(player, "mine.teleported-out"); } - // Delete old world, create fresh one, then generate - MineWorldManager.recreateWorld(newWorld -> MineManager.regenerate(newWorld)); + MineWorldManager.recreateWorld(newWorld -> { + boolean success = MineManager.regenerate(newWorld); + if (success) { + Messages.send(sender, "mine.regenerate-complete"); + } else { + Messages.send(sender, "mine.error.regenerate-failed"); + } + }); return 1; } private static int teleportToMines(CommandContext context) { Player player = (Player) context.getSource().getSender(); - World world = MineWorldManager.getWorld(); if (world == null) { @@ -57,9 +63,13 @@ public class MineCommand { } Location spawn = MineWorldManager.getSpawnLocation(); + if (spawn == null) { + Messages.send(player, "mine.error.no-world"); + return 0; + } + player.teleport(spawn); Messages.send(player, "mine.teleporting"); return 1; } } - diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineManager.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineManager.java index 614575d..168cc97 100644 --- a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineManager.java +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineManager.java @@ -1,696 +1,62 @@ package xyz.soukup.ecoCraftCore.mines; -import org.bukkit.Bukkit; -import org.bukkit.Material; +import org.bukkit.Location; import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.BlockFace; - -import java.util.*; +import xyz.soukup.ecoCraftCore.mines.generation.MineGenerationConfig; +import xyz.soukup.ecoCraftCore.mines.generation.MineGenerator; +import xyz.soukup.ecoCraftCore.mines.generation.MineRoomLibrary; import static xyz.soukup.ecoCraftCore.EcoCraftCore.plugin; -public class MineManager { - - private static final int TUNNEL_RADIUS = 5; // 11x11 = radius 5 from center - private static final int MIN_BRANCH_SIZE = 30; - private static final int FIRST_BRANCH_MIN_SIZE = 20; - private static final int FIRST_BRANCH_SPLIT_DELAY = 12; - private static final int MAX_BRANCH_SIZE = 80; - private static final double BASE_SPLIT_INCREMENT = 0.004; - private static final double SPLIT_DECAY_PER_BRANCH = 0.0004; - private static final int BLOCKS_PER_TICK = 800; - - // Transition zone: how many blocks before/after a border we blend - private static final int TRANSITION_RANGE = 8; - - private static final Random random = new Random(); - - private static final BlockFace[] DIRECTIONS = { - BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST, BlockFace.DOWN, BlockFace.UP - }; - - // --- Biome definitions --- - - public enum MineBiome { - NORMAL, DEEP, SUPER_DEEP - } - - // Fill progression per biome - private static final Material[] NORMAL_FILLS = {Material.STONE, Material.ANDESITE, Material.COBBLESTONE, Material.TUFF}; - private static final int[] NORMAL_THRESHOLDS = {0, 30, 60, 90}; - - private static final Material[] DEEP_FILLS = {Material.DEEPSLATE, Material.COBBLED_DEEPSLATE, Material.CRACKED_DEEPSLATE_TILES}; - private static final int[] DEEP_THRESHOLDS = {0, 40, 80}; - - private static final Material[] SUPER_DEEP_FILLS = {Material.SMOOTH_BASALT, Material.BLACKSTONE, Material.CRACKED_POLISHED_BLACKSTONE_BRICKS, Material.GILDED_BLACKSTONE}; - private static final int[] SUPER_DEEP_THRESHOLDS = {0, 40, 80, 120}; - - // Special inline blocks (placed during generation, not ores) - private static final double DIRT_CHANCE = 0.04; - private static final double GRAVEL_CHANCE = 0.05; - private static final double INFESTED_CHANCE = 0.008; - private static final double MUD_CHANCE_SUPER_DEEP = 0.03; - private static final double LAVA_CHANCE_SUPER_DEEP = 0.05; - - // Deep biome lava: starts low and increases through the fills - private static final double LAVA_CHANCE_DEEP_MIN = 0.005; - private static final double LAVA_CHANCE_DEEP_MAX = 0.04; - - // --- Ore tables per fill type (rolled on break) --- - - public record OreEntry(Material material, double weight) {} - - private static final Map ORE_TABLES = new LinkedHashMap<>(); - - static { - ORE_TABLES.put(Material.STONE, new OreEntry[]{ - new OreEntry(Material.COPPER_ORE, 1.0), - }); - ORE_TABLES.put(Material.ANDESITE, new OreEntry[]{ - new OreEntry(Material.COAL_ORE, 1.0), - }); - ORE_TABLES.put(Material.COBBLESTONE, new OreEntry[]{ - new OreEntry(Material.IRON_ORE, 1.0), - }); - ORE_TABLES.put(Material.TUFF, new OreEntry[]{ - new OreEntry(Material.GOLD_ORE, 1.0), - }); - ORE_TABLES.put(Material.DEEPSLATE, new OreEntry[]{ - new OreEntry(Material.DEEPSLATE_EMERALD_ORE, 0.4), - new OreEntry(Material.DEEPSLATE_LAPIS_ORE, 0.6), - }); - ORE_TABLES.put(Material.COBBLED_DEEPSLATE, new OreEntry[]{ - new OreEntry(Material.DEEPSLATE_REDSTONE_ORE, 0.6), - new OreEntry(Material.RAW_COPPER_BLOCK, 0.4), - }); - ORE_TABLES.put(Material.CRACKED_DEEPSLATE_TILES, new OreEntry[]{ - new OreEntry(Material.DEEPSLATE_DIAMOND_ORE, 0.6), - new OreEntry(Material.RAW_IRON_BLOCK, 0.4), - }); - ORE_TABLES.put(Material.SMOOTH_BASALT, new OreEntry[]{ - new OreEntry(Material.COAL_BLOCK, 1.0), - }); - ORE_TABLES.put(Material.BLACKSTONE, new OreEntry[]{ - new OreEntry(Material.RAW_GOLD_BLOCK, 1.0), - }); - ORE_TABLES.put(Material.CRACKED_POLISHED_BLACKSTONE_BRICKS, new OreEntry[]{ - new OreEntry(Material.ANCIENT_DEBRIS, 1.0), - }); - ORE_TABLES.put(Material.GILDED_BLACKSTONE, new OreEntry[]{ - new OreEntry(Material.GOLD_BLOCK, 1.0), - }); - } - - // Ore chance depending on how far this fill is from the detected zone - // Index 0 = current fill, 1 = previous fill (one step lighter), 2 = three fills back, 3+ = any other - private static final double ORE_CHANCE_CURRENT = 0.05; - private static final double ORE_CHANCE_PREVIOUS = 0.10; - private static final double ORE_CHANCE_THREE_BACK = 0.02; - private static final double ORE_CHANCE_OTHER = 0.01; - - // Ordered list of ALL fills from shallowest to deepest (used for distance calculations) - private static final List ALL_FILLS_ORDERED = new ArrayList<>(); - - static { - Collections.addAll(ALL_FILLS_ORDERED, NORMAL_FILLS); - Collections.addAll(ALL_FILLS_ORDERED, DEEP_FILLS); - Collections.addAll(ALL_FILLS_ORDERED, SUPER_DEEP_FILLS); - } - - // --- All known fill materials (for checking in MineWorldManager) --- - - private static final Set ALL_FILL_MATERIALS = new HashSet<>(ALL_FILLS_ORDERED); - - public static boolean isFillMaterial(Material material) { - return ALL_FILL_MATERIALS.contains(material); - } - - // --- Biome Y tracking --- - - private static int normalStartY = 0; - private static int deepStartY = 0; - private static int superDeepStartY = 0; - - // --- Branch data class --- - - private static class Branch { - final BlockFace direction; - final int startX, startY, startZ; - final int distanceFromStart; - final int maxSize; - final boolean isFirst; - final MineBiome biome; - double chanceToSplit; - - int endX, endY, endZ; - - Branch(BlockFace direction, int startX, int startY, int startZ, - int distanceFromStart, int maxSize, boolean isFirst, MineBiome biome) { - this.direction = direction; - this.startX = startX; - this.startY = startY; - this.startZ = startZ; - this.distanceFromStart = distanceFromStart; - this.maxSize = maxSize; - this.isFirst = isFirst; - this.biome = biome; - this.chanceToSplit = 0; - this.endX = startX; - this.endY = startY; - this.endZ = startZ; - } - } - - private static class BlockPlacement { - final int x, y, z; - final Material material; - - BlockPlacement(int x, int y, int z, Material material) { - this.x = x; - this.y = y; - this.z = z; - this.material = material; - } - } - - // ===================== MAIN ENTRY POINT ===================== - - public static void regenerate(World world) { - int startY = world.getMaxHeight() - 11; - int minY = world.getMinHeight() + 5; - int maxY = world.getMaxHeight(); - int worldMinY = world.getMinHeight(); - - normalStartY = startY; - - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - Set fillPositions = new HashSet<>(); - List fillPlacements = new ArrayList<>(); - - // ===== Biome 1: NORMAL ===== - plugin.getLogger().info("[MineGen] === Starting NORMAL biome generation ==="); - List normalBranches = new ArrayList<>(); - int[] branchCount = {0}; - generateBiomeBranches(MineBiome.NORMAL, 0, startY, 0, startY, minY, - fillPlacements, fillPositions, normalBranches, branchCount); - plugin.getLogger().info("[MineGen] NORMAL done: " + fillPlacements.size() + " fill blocks, branches=" + branchCount[0]); - - // Find lowest Y from NORMAL - Branch lowestNormal = findLowestBranch(normalBranches); - int deepX = lowestNormal != null ? lowestNormal.endX : 0; - int deepY = lowestNormal != null ? lowestNormal.endY : startY - 100; - int deepZ = lowestNormal != null ? lowestNormal.endZ : 0; - deepStartY = deepY; - - // ===== Biome 2: DEEP ===== - plugin.getLogger().info("[MineGen] === Starting DEEP biome generation at Y=" + deepY + " ==="); - List deepBranches = new ArrayList<>(); - generateBiomeBranches(MineBiome.DEEP, deepX, deepY, deepZ, deepY, minY, - fillPlacements, fillPositions, deepBranches, branchCount); - plugin.getLogger().info("[MineGen] DEEP done: " + fillPlacements.size() + " total fill blocks, branches=" + branchCount[0]); - - // Find lowest Y from DEEP - Branch lowestDeep = findLowestBranch(deepBranches); - int sdX = lowestDeep != null ? lowestDeep.endX : deepX; - int sdY = lowestDeep != null ? lowestDeep.endY : deepY - 100; - int sdZ = lowestDeep != null ? lowestDeep.endZ : deepZ; - superDeepStartY = sdY; - - // ===== Biome 3: SUPER_DEEP ===== - plugin.getLogger().info("[MineGen] === Starting SUPER_DEEP biome generation at Y=" + sdY + " ==="); - List superDeepBranches = new ArrayList<>(); - generateBiomeBranches(MineBiome.SUPER_DEEP, sdX, sdY, sdZ, sdY, minY, - fillPlacements, fillPositions, superDeepBranches, branchCount); - plugin.getLogger().info("[MineGen] SUPER_DEEP done: " + fillPlacements.size() + " total fill blocks, branches=" + branchCount[0]); - - // ===== Walls ===== - List wallPlacements = new ArrayList<>(); - generateWalls(fillPositions, wallPlacements, startY); - plugin.getLogger().info("[MineGen] Walls done: " + wallPlacements.size() + " bedrock blocks."); - - // ===== Schedule placement on main thread ===== - Bukkit.getScheduler().runTask(plugin, () -> - scheduleBlockPlacements(world, fillPlacements, wallPlacements, startY, worldMinY, maxY)); - }); - } - - // ===================== BIOME BRANCH GENERATION ===================== - - private static void generateBiomeBranches(MineBiome biome, int startX, int startY, int startZ, - int ceilingY, int minY, - List placements, Set fillPositions, - List completedBranches, int[] branchCount) { - Deque queue = new ArrayDeque<>(); - - // Reset branch count for this biome so split chance starts fresh - int[] biomeBranchCount = {0}; - - int firstSize = Math.max(FIRST_BRANCH_MIN_SIZE, MIN_BRANCH_SIZE + random.nextInt(MAX_BRANCH_SIZE - MIN_BRANCH_SIZE)); - Branch initial = new Branch(BlockFace.DOWN, startX, startY, startZ, 0, firstSize, true, biome); - queue.add(initial); - biomeBranchCount[0]++; - branchCount[0]++; - - plugin.getLogger().info("[MineGen] [" + biome + "] Starting at (" + startX + "," + startY + "," + startZ + "), minY=" + minY); - - while (!queue.isEmpty()) { - Branch branch = queue.pollFirst(); - double currentIncrement = Math.max(0, BASE_SPLIT_INCREMENT - (biomeBranchCount[0] * SPLIT_DECAY_PER_BRANCH)); - plugin.getLogger().info("[MineGen] [" + biome + "] Processing branch dir=" + branch.direction - + " start=(" + branch.startX + "," + branch.startY + "," + branch.startZ + ")" - + " dist=" + branch.distanceFromStart + " maxSize=" + branch.maxSize - + " first=" + branch.isFirst - + " queued=" + queue.size() + " fillBlocks=" + fillPositions.size() - + " splitIncrement=" + String.format("%.5f", currentIncrement)); - - generateSingleBranch(branch, queue, placements, fillPositions, ceilingY, minY, biomeBranchCount); - completedBranches.add(branch); - } - - plugin.getLogger().info("[MineGen] [" + biome + "] Complete. Branches=" + completedBranches.size()); - } - - private static void generateSingleBranch(Branch branch, Deque queue, List placements, - Set fillPositions, int ceilingY, int minY, int[] branchCount) { - int cx = branch.startX; - int cy = branch.startY; - int cz = branch.startZ; - - for (int step = 0; step < branch.maxSize; step++) { - int distance = branch.distanceFromStart + step; - - generateCrossSection(cx, cy, cz, branch.direction, distance, branch.biome, placements, fillPositions); - - branch.endX = cx; - branch.endY = cy; - branch.endZ = cz; - - // First branch: skip split logic for the first 12 blocks - if (branch.isFirst && step < FIRST_BRANCH_SPLIT_DELAY) { - cx += branch.direction.getModX(); - cy += branch.direction.getModY(); - cz += branch.direction.getModZ(); - if (cy < minY || cy > ceilingY + 5) break; - continue; - } - - double splitIncrement = Math.max(0, BASE_SPLIT_INCREMENT - (branchCount[0] * SPLIT_DECAY_PER_BRANCH)); - - if (splitIncrement <= 0) { - cx += branch.direction.getModX(); - cy += branch.direction.getModY(); - cz += branch.direction.getModZ(); - if (cy < minY || cy > ceilingY + 5) break; - continue; - } - - branch.chanceToSplit += splitIncrement; - - if (random.nextDouble() < branch.chanceToSplit) { - branch.chanceToSplit = 0; - - int splitCount = 1 + random.nextInt(4); - List available = getAvailableDirections(branch.direction); - Collections.shuffle(available, random); - - int created = 0; - for (BlockFace dir : available) { - if (created >= splitCount) break; - - int branchMaxSize = MIN_BRANCH_SIZE + random.nextInt(MAX_BRANCH_SIZE - MIN_BRANCH_SIZE); - - if (dir == BlockFace.UP && cy + branchMaxSize > ceilingY) continue; - if (dir == BlockFace.DOWN && cy - branchMaxSize < minY) continue; - - queue.add(new Branch(dir, cx, cy, cz, distance, branchMaxSize, false, branch.biome)); - branchCount[0]++; - created++; - } - plugin.getLogger().info("[MineGen] [" + branch.biome + "] Split at step=" + step - + " pos=(" + cx + "," + cy + "," + cz + ") created=" + created - + " total=" + branchCount[0]); - } - - cx += branch.direction.getModX(); - cy += branch.direction.getModY(); - cz += branch.direction.getModZ(); - - if (cy < minY || cy > ceilingY + 5) break; - } - } - - private static Branch findLowestBranch(List branches) { - Branch lowest = null; - for (Branch b : branches) { - if (lowest == null || b.endY < lowest.endY) { - lowest = b; - } - } - return lowest; - } - - private static List getAvailableDirections(BlockFace current) { - List dirs = new ArrayList<>(); - BlockFace opposite = current.getOppositeFace(); - for (BlockFace dir : DIRECTIONS) { - if (dir != opposite && dir != current) { - dirs.add(dir); - } - } - return dirs; - } - - // ===================== CROSS SECTION GENERATION ===================== - - private static void generateCrossSection(int cx, int cy, int cz, BlockFace direction, int distance, - MineBiome biome, List placements, Set fillPositions) { - int[][] offsets = getPerpOffsets(direction); - - for (int a = -TUNNEL_RADIUS; a <= TUNNEL_RADIUS; a++) { - for (int b = -TUNNEL_RADIUS; b <= TUNNEL_RADIUS; b++) { - int bx = cx + offsets[0][0] * a + offsets[1][0] * b; - int by = cy + offsets[0][1] * a + offsets[1][1] * b; - int bz = cz + offsets[0][2] * a + offsets[1][2] * b; - - long key = posKey(bx, by, bz); - if (!fillPositions.add(key)) continue; - - Material mat = pickFillForDistance(distance, biome); - placements.add(new BlockPlacement(bx, by, bz, mat)); - } - } - } - - // ===================== FILL SELECTION (no ores during generation) ===================== - - private static Material pickFillForDistance(int distance, MineBiome biome) { - Material[] fills = getFillsForBiome(biome); - int[] thresholds = getThresholdsForBiome(biome); - - // Determine primary fill - Material primaryFill = fills[0]; - for (int i = thresholds.length - 1; i >= 0; i--) { - if (distance >= thresholds[i]) { - primaryFill = fills[i]; - break; - } - } - - // Check transition zone - Material transitionFill = getTransitionFill(distance, fills, thresholds); - if (transitionFill != null && transitionFill != primaryFill) { - int borderDist = getDistanceToBorder(distance, thresholds); - double blendRatio = 0.5 * (1.0 - (double) Math.abs(borderDist) / TRANSITION_RANGE); - if (random.nextDouble() < blendRatio) { - primaryFill = transitionFill; - } - } - - // Roll for special inline blocks (dirt, gravel, lava, mud, infested) - Material special = rollSpecialBlock(biome, primaryFill, distance); - if (special != null) return special; - - return primaryFill; - } - - private static Material rollSpecialBlock(MineBiome biome, Material fill, int distance) { - double roll = random.nextDouble(); - switch (biome) { - case NORMAL: - if (roll < DIRT_CHANCE) return Material.DIRT; - roll -= DIRT_CHANCE; - if (roll < GRAVEL_CHANCE) return Material.GRAVEL; - roll -= GRAVEL_CHANCE; - // Rare infested variants - if (roll < INFESTED_CHANCE) { - return switch (fill) { - case STONE -> Material.INFESTED_STONE; - case COBBLESTONE -> Material.INFESTED_COBBLESTONE; - default -> null; // andesite, tuff don't have infested variants - }; - } - break; - case DEEP: - // Lava in ALL deep fills, chance increases with distance through the biome - // Deep thresholds max out at 80+ (cracked_deepslate_tiles) - // Total deep distance range is roughly 0-120 - double deepProgress = Math.min(1.0, distance / 120.0); - double lavaChance = LAVA_CHANCE_DEEP_MIN + (LAVA_CHANCE_DEEP_MAX - LAVA_CHANCE_DEEP_MIN) * deepProgress; - if (roll < lavaChance) return Material.LAVA; - break; - case SUPER_DEEP: - if (roll < MUD_CHANCE_SUPER_DEEP) return Material.MUD; - roll -= MUD_CHANCE_SUPER_DEEP; - if (roll < LAVA_CHANCE_SUPER_DEEP) return Material.LAVA; - break; - } - return null; - } - - private static Material getTransitionFill(int distance, Material[] fills, int[] thresholds) { - for (int i = 1; i < thresholds.length; i++) { - int border = thresholds[i]; - int diff = distance - border; - if (Math.abs(diff) <= TRANSITION_RANGE) { - return (diff < 0) ? fills[i] : fills[i - 1]; - } - } - return null; - } - - private static int getDistanceToBorder(int distance, int[] thresholds) { - int closest = Integer.MAX_VALUE; - for (int i = 1; i < thresholds.length; i++) { - int diff = distance - thresholds[i]; - if (Math.abs(diff) < Math.abs(closest)) { - closest = diff; - } - } - return closest; - } - - private static Material[] getFillsForBiome(MineBiome biome) { - return switch (biome) { - case NORMAL -> NORMAL_FILLS; - case DEEP -> DEEP_FILLS; - case SUPER_DEEP -> SUPER_DEEP_FILLS; - }; - } - - private static int[] getThresholdsForBiome(MineBiome biome) { - return switch (biome) { - case NORMAL -> NORMAL_THRESHOLDS; - case DEEP -> DEEP_THRESHOLDS; - case SUPER_DEEP -> SUPER_DEEP_THRESHOLDS; - }; - } - - // ===================== ORE-ON-BREAK SYSTEM (called from MineWorldManager) ===================== - - /** - * Detects the deepest fill material among the 6 surrounding blocks to determine - * which fill zone the player is in. Then rolls for an ore based on the broken block's - * position in the fill sequence relative to the detected zone. - * - * Ore chances: - * 5% for ores from the current (detected) fill - * 10% for ores from the previous fill (one step lighter) - * 2% for ores from three fills back - * 1% for any other fill's ores - * - * Returns null if no ore was rolled. - */ - public static Material rollOreOnBreak(Block brokenBlock, Material brokenType) { - // Detect the deepest fill in the 6 surrounding blocks - Material detectedFill = detectDeepestSurroundingFill(brokenBlock); - if (detectedFill == null) { - // Fallback: use the broken block itself if it's a fill - detectedFill = isFillMaterial(brokenType) ? brokenType : Material.STONE; - } - - int detectedIndex = ALL_FILLS_ORDERED.indexOf(detectedFill); - int brokenIndex = ALL_FILLS_ORDERED.indexOf(brokenType); +public final class MineManager { - // If the broken block isn't a fill material, no ore roll - if (brokenIndex < 0) return null; - - // How many steps back from the detected fill is this broken block? - // detectedIndex is the deepest (highest index), brokenIndex is where we are in the sequence - int stepsBack = detectedIndex - brokenIndex; - - // Determine ore chance based on distance from detected fill - double chance; - if (stepsBack == 0) { - chance = ORE_CHANCE_CURRENT; // 5% - breaking the detected fill itself - } else if (stepsBack == 1) { - chance = ORE_CHANCE_PREVIOUS; // 10% - one step lighter - } else if (stepsBack == 2) { - chance = ORE_CHANCE_THREE_BACK; // 2% - two steps lighter - } else { - chance = ORE_CHANCE_OTHER; // 1% - anything else - } - - // Roll for ore - if (random.nextDouble() >= chance) return null; - - // Pick a random ore from the broken block's ore table - OreEntry[] oreTable = ORE_TABLES.get(brokenType); - if (oreTable == null || oreTable.length == 0) return null; - - return pickWeightedOre(oreTable); + private MineManager() { } - /** - * Looks at the 6 blocks surrounding the broken block and finds the deepest fill material. - */ - private static Material detectDeepestSurroundingFill(Block block) { - Material deepest = null; - int deepestIndex = -1; - - for (BlockFace face : DIRECTIONS) { - Block neighbor = block.getRelative(face); - Material type = neighbor.getType(); + public static boolean regenerate(World world) { + MineGenerationConfig generationConfig = MineGenerationConfig.fromConfiguration(plugin.getConfig(), world); + MineRoomLibrary roomLibrary = MineRoomLibrary.load( + plugin.getDataFolder(), + generationConfig.roomsDirectory(), + plugin.getLogger() + ); - int index = ALL_FILLS_ORDERED.indexOf(type); - if (index > deepestIndex) { - deepestIndex = index; - deepest = type; - } + if (roomLibrary.isEmpty()) { + plugin.getLogger().severe("[MineGen] No valid room prefabs were loaded from: " + + roomLibrary.roomsDirectory().getAbsolutePath()); + return false; } - return deepest; - } + MineGenerator.Result generationResult = new MineGenerator( + world, + generationConfig, + roomLibrary, + plugin.getLogger() + ).generate(); - /** - * Picks a random ore from the table using weights. - */ - private static Material pickWeightedOre(OreEntry[] table) { - double totalWeight = 0; - for (OreEntry e : table) totalWeight += e.weight; - - double roll = random.nextDouble() * totalWeight; - double cumulative = 0; - for (OreEntry e : table) { - cumulative += e.weight; - if (roll < cumulative) return e.material; + if (!generationResult.success()) { + return false; } - return table[table.length - 1].material; - } - // ===================== PERPENDICULAR AXES ===================== + Location spawn = getConfiguredSpawnLocation(world); + world.setSpawnLocation(spawn); - private static int[][] getPerpOffsets(BlockFace dir) { - return switch (dir) { - case UP, DOWN -> new int[][]{{1, 0, 0}, {0, 0, 1}}; - case NORTH, SOUTH -> new int[][]{{1, 0, 0}, {0, 1, 0}}; - case EAST, WEST -> new int[][]{{0, 0, 1}, {0, 1, 0}}; - default -> new int[][]{{1, 0, 0}, {0, 0, 1}}; - }; + plugin.getLogger().info("[MineGen] Mine generated with " + generationResult.roomsPlaced() + " rooms."); + return true; } - // ===================== WALL GENERATION ===================== - - private static void generateWalls(Set fillPositions, List wallPlacements, int startY) { - Set wallPositions = new HashSet<>(); - plugin.getLogger().info("[MineGen] Generating bedrock walls for " + fillPositions.size() + " fill positions..."); - - for (long key : fillPositions) { - int x = decodeX(key); - int y = decodeY(key); - int z = decodeZ(key); - - for (BlockFace face : DIRECTIONS) { - int nx = x + face.getModX(); - int ny = y + face.getModY(); - int nz = z + face.getModZ(); - - long neighborKey = posKey(nx, ny, nz); - - if (!fillPositions.contains(neighborKey)) { - if (ny > startY) continue; - - if (wallPositions.add(neighborKey)) { - wallPlacements.add(new BlockPlacement(nx, ny, nz, Material.BEDROCK)); - } - } - } - } - } - - // ===================== BLOCK PLACEMENT SCHEDULING ===================== - - private static void scheduleBlockPlacements(World world, List fillPlacements, - List wallPlacements, - int startY, int minY, int maxY) { - // Three-phase placement: 1) Dirt placeholder 2) Bedrock walls 3) Actual fill - List allPlacements = new ArrayList<>(); + public static Location getConfiguredSpawnLocation(World world) { + Location fallback = new Location(world, 5.5, world.getMaxHeight() - 9, 5.5, 0f, 0f); - // Phase A: Dirt placeholders at all fill positions - List dirtPlaceholders = new ArrayList<>(fillPlacements.size()); - for (BlockPlacement bp : fillPlacements) { - dirtPlaceholders.add(new BlockPlacement(bp.x, bp.y, bp.z, Material.DIRT)); + if (!plugin.getConfig().isConfigurationSection("mine.spawn")) { + return fallback; } - allPlacements.addAll(dirtPlaceholders); - - // Phase B: Bedrock walls - allPlacements.addAll(wallPlacements); - - // Phase C: Actual fill (overwrites dirt) - allPlacements.addAll(fillPlacements); - - // Air blocks above the entrance opening - for (int a = -TUNNEL_RADIUS; a <= TUNNEL_RADIUS; a++) { - for (int b = -TUNNEL_RADIUS; b <= TUNNEL_RADIUS; b++) { - allPlacements.add(new BlockPlacement(a, startY + 1, b, Material.AIR)); - } - } - - int totalBlocks = allPlacements.size(); - int tickDelay = 0; - - plugin.getLogger().info("[MineGen] Scheduling " + totalBlocks + " block placements (" - + dirtPlaceholders.size() + " placeholder + " + wallPlacements.size() + " walls + " - + fillPlacements.size() + " fill) in batches of " + BLOCKS_PER_TICK); - - for (int i = 0; i < totalBlocks; i += BLOCKS_PER_TICK) { - int from = i; - int to = Math.min(i + BLOCKS_PER_TICK, totalBlocks); - - Bukkit.getScheduler().runTaskLater(plugin, () -> { - for (int j = from; j < to; j++) { - BlockPlacement bp = allPlacements.get(j); - if (bp.y < minY || bp.y >= maxY) continue; - world.getBlockAt(bp.x, bp.y, bp.z).setType(bp.material, false); - } - }, tickDelay); - - tickDelay++; - } - - int finalDelay = tickDelay; - Bukkit.getScheduler().runTaskLater(plugin, - () -> plugin.getLogger().info("Mine regeneration complete. " + totalBlocks + " blocks placed."), - finalDelay + 2); - } - - // ===================== POSITION ENCODING/DECODING ===================== - - private static long posKey(int x, int y, int z) { - return ((long) (x + 30000000) & 0x3FFFFFF) - | (((long) (y + 2048) & 0xFFF) << 26) - | (((long) (z + 30000000) & 0x3FFFFFF) << 38); - } - - private static int decodeX(long key) { - return (int) (key & 0x3FFFFFF) - 30000000; - } - - private static int decodeY(long key) { - return (int) ((key >> 26) & 0xFFF) - 2048; - } - private static int decodeZ(long key) { - return (int) ((key >> 38) & 0x3FFFFFF) - 30000000; + double x = plugin.getConfig().getDouble("mine.spawn.x", fallback.getX()); + double y = plugin.getConfig().getDouble("mine.spawn.y", fallback.getY()); + double z = plugin.getConfig().getDouble("mine.spawn.z", fallback.getZ()); + float yaw = (float) plugin.getConfig().getDouble("mine.spawn.yaw", 0.0); + float pitch = (float) plugin.getConfig().getDouble("mine.spawn.pitch", 0.0); + return new Location(world, x, y, z, yaw, pitch); } } diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineWorldManager.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineWorldManager.java index 1a2d8d8..f2d5a94 100644 --- a/src/main/java/xyz/soukup/ecoCraftCore/mines/MineWorldManager.java +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/MineWorldManager.java @@ -4,7 +4,11 @@ import com.infernalsuite.asp.api.AdvancedSlimePaperAPI; import com.infernalsuite.asp.api.world.SlimeWorld; import com.infernalsuite.asp.api.world.properties.SlimeProperties; import com.infernalsuite.asp.api.world.properties.SlimePropertyMap; -import org.bukkit.*; +import org.bukkit.Bukkit; +import org.bukkit.GameRule; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -15,8 +19,6 @@ import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerItemDamageEvent; import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.Damageable; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; import xyz.soukup.ecoCraftCore.database.objects.Island; @@ -39,25 +41,23 @@ public class MineWorldManager implements Listener { try { SlimeWorld slimeWorld = null; - // Try to load existing world if (loader.worldExists(MINE_WORLD_NAME)) { try { slimeWorld = asp.readWorld(loader, MINE_WORLD_NAME, false, new SlimePropertyMap()); - } catch (Exception e) { + } catch (Exception exception) { plugin.getLogger().warning("Mine world data is corrupted, recreating..."); loader.deleteWorld(MINE_WORLD_NAME); } } - // Create fresh world if loading failed or didn't exist if (slimeWorld == null) { - SlimePropertyMap props = new SlimePropertyMap(); - props.setValue(SlimeProperties.ENVIRONMENT, "NORMAL"); + SlimePropertyMap properties = new SlimePropertyMap(); + properties.setValue(SlimeProperties.ENVIRONMENT, "NORMAL"); Island island = new Island("mine", MINE_WORLD_NAME, "Mine World", "Shared mine world", "server", "system", null); island.save(); - slimeWorld = asp.createEmptyWorld(MINE_WORLD_NAME, false, props, loader); + slimeWorld = asp.createEmptyWorld(MINE_WORLD_NAME, false, properties, loader); } SlimeWorld finalWorld = slimeWorld; @@ -70,11 +70,14 @@ public class MineWorldManager implements Listener { mineWorld.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false); mineWorld.setGameRule(GameRule.DO_WEATHER_CYCLE, false); mineWorld.setTime(6000); + if (plugin.getConfig().getBoolean("mine.paste-on-startup", false)) { + MineManager.regenerate(mineWorld); + } plugin.getLogger().info("Mine world loaded successfully."); } }); - } catch (Exception e) { - e.printStackTrace(); + } catch (Exception exception) { + exception.printStackTrace(); plugin.getLogger().severe("Failed to load mine world."); } }); @@ -85,16 +88,12 @@ public class MineWorldManager implements Listener { } public static Location getSpawnLocation() { - if (mineWorld == null) return null; - int startY = mineWorld.getMaxHeight() - 10; - return new Location(mineWorld, 5, startY + 1, 5); + if (mineWorld == null) { + return null; + } + return MineManager.getConfiguredSpawnLocation(mineWorld); } - /** - * Deletes the current mine world and creates a fresh empty one. - * Calls the callback with the new World once it's ready. - * Must be called from the main thread with all players already teleported out. - */ public static void recreateWorld(Consumer callback) { AdvancedSlimePaperAPI asp = AdvancedSlimePaperAPI.instance(); DatabaseIslandLoader loader = new DatabaseIslandLoader(); @@ -113,13 +112,13 @@ public class MineWorldManager implements Listener { plugin.getLogger().info("[MineWorld] Old mine world deleted."); } - SlimePropertyMap props = new SlimePropertyMap(); - props.setValue(SlimeProperties.ENVIRONMENT, "NORMAL"); + SlimePropertyMap properties = new SlimePropertyMap(); + properties.setValue(SlimeProperties.ENVIRONMENT, "NORMAL"); Island island = new Island("mine", MINE_WORLD_NAME, "Mine World", "Shared mine world", "server", "system", null); island.save(); - SlimeWorld slimeWorld = asp.createEmptyWorld(MINE_WORLD_NAME, false, props, loader); + SlimeWorld slimeWorld = asp.createEmptyWorld(MINE_WORLD_NAME, false, properties, loader); Bukkit.getScheduler().runTask(plugin, () -> { asp.loadWorld(slimeWorld, true); @@ -136,8 +135,8 @@ public class MineWorldManager implements Listener { plugin.getLogger().severe("[MineWorld] Failed to load fresh mine world."); } }); - } catch (Exception e) { - e.printStackTrace(); + } catch (Exception exception) { + exception.printStackTrace(); plugin.getLogger().severe("[MineWorld] Failed to recreate mine world."); } }); @@ -151,23 +150,19 @@ public class MineWorldManager implements Listener { return world.equals(mineWorld); } - // Apply invisible mining fatigue when entering mine world @EventHandler public void onWorldChange(PlayerChangedWorldEvent event) { Player player = event.getPlayer(); - // Entering mine world if (isInMineWorld(player)) { applyMiningFatigue(player); } - // Leaving mine world if (isInMineWorld(event.getFrom())) { player.removePotionEffect(PotionEffectType.MINING_FATIGUE); } } - // Re-apply fatigue on join if player is in mine world @EventHandler public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); @@ -176,7 +171,6 @@ public class MineWorldManager implements Listener { } } - // Re-apply fatigue on respawn in mine world @EventHandler public void onDeath(PlayerDeathEvent event) { Bukkit.getScheduler().runTaskLater(plugin, () -> { @@ -191,114 +185,48 @@ public class MineWorldManager implements Listener { player.addPotionEffect(new PotionEffect( PotionEffectType.MINING_FATIGUE, Integer.MAX_VALUE, - 0, // level I (amplifier 0) - true, // ambient - false, // no particles - false // no icon + 0, + true, + false, + false )); } - // Double tool durability damage in mine world @EventHandler public void onItemDamage(PlayerItemDamageEvent event) { - if (!isInMineWorld(event.getPlayer())) return; - if (event.getItem().getType().getMaxDurability() <= 0) return; + if (!isInMineWorld(event.getPlayer())) { + return; + } + if (event.getItem().getType().getMaxDurability() <= 0) { + return; + } event.setDamage(event.getDamage() * 2); } - // Prevent entity explosions from destroying bedrock walls @EventHandler public void onEntityExplode(EntityExplodeEvent event) { - if (!isInMineWorld(event.getEntity().getWorld())) return; + if (!isInMineWorld(event.getEntity().getWorld())) { + return; + } event.blockList().removeIf(block -> block.getType() == Material.BEDROCK); } - // Prevent block explosions from destroying bedrock walls @EventHandler public void onBlockExplode(BlockExplodeEvent event) { - if (!isInMineWorld(event.getBlock().getWorld())) return; + if (!isInMineWorld(event.getBlock().getWorld())) { + return; + } event.blockList().removeIf(block -> block.getType() == Material.BEDROCK); } - // Block break logic: bedrock unbreakable, ore-on-break for ALL fills, mining sequence @EventHandler public void onBlockBreak(BlockBreakEvent event) { - if (!isInMineWorld(event.getPlayer())) return; - - Material type = event.getBlock().getType(); - - // Bedrock is completely unbreakable (mine walls) - if (type == Material.BEDROCK) { - event.setCancelled(true); + if (!isInMineWorld(event.getPlayer())) { return; } - // For any fill material (at any stage of the mining sequence), roll for ore first. - // The ore replaces the block as another mining step — player then breaks the ore normally. - if (MineManager.isFillMaterial(type)) { - Material ore = MineManager.rollOreOnBreak(event.getBlock(), type); - if (ore != null) { - event.setCancelled(true); - event.getBlock().setType(ore); - event.getBlock().getState().update(true); - applyToolDamage(event); - return; - } - } - - // No ore rolled — apply normal mining sequence (deeper fills degrade into lighter fills) - Material nextTier = getNextTier(type); - if (nextTier != null) { + if (event.getBlock().getType() == Material.BEDROCK) { event.setCancelled(true); - event.getBlock().setType(nextTier); - event.getBlock().getState().update(true); - applyToolDamage(event); - return; } - - // Terminal fill (stone) or ores — break normally - } - - /** - * Applies doubled durability damage to the player's tool when we cancel a break event. - * Only applies if the held item actually has durability. - */ - private void applyToolDamage(BlockBreakEvent event) { - ItemStack tool = event.getPlayer().getInventory().getItemInMainHand(); - if (tool.getType().isAir() || tool.getType().getMaxDurability() <= 0) return; - if (!(tool.getItemMeta() instanceof Damageable damageable)) return; - - damageable.setDamage(damageable.getDamage() + 2); // doubled durability - tool.setItemMeta(damageable); - - if (damageable.getDamage() >= tool.getType().getMaxDurability()) { - event.getPlayer().getInventory().setItemInMainHand(null); - event.getPlayer().playSound(event.getPlayer().getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 1.0f); - } - } - - /** - * Mining sequence: deeper fills degrade into lighter fills. - * Returns null when the block should break normally (terminal fill = stone). - * - * Chain: gilded_blackstone -> cracked_polished_blackstone_bricks -> blackstone -> smooth_basalt - * -> cracked_deepslate_tiles -> cobbled_deepslate -> deepslate -> tuff -> cobblestone - * -> andesite -> stone -> null (breaks normally) - */ - private Material getNextTier(Material material) { - return switch (material) { - case GILDED_BLACKSTONE -> Material.CRACKED_POLISHED_BLACKSTONE_BRICKS; - case CRACKED_POLISHED_BLACKSTONE_BRICKS -> Material.BLACKSTONE; - case BLACKSTONE -> Material.SMOOTH_BASALT; - case SMOOTH_BASALT -> Material.CRACKED_DEEPSLATE_TILES; - case CRACKED_DEEPSLATE_TILES -> Material.COBBLED_DEEPSLATE; - case COBBLED_DEEPSLATE -> Material.DEEPSLATE; - case DEEPSLATE -> Material.TUFF; - case TUFF -> Material.COBBLESTONE; - case COBBLESTONE -> Material.ANDESITE; - case ANDESITE -> Material.STONE; - default -> null; // Stone and non-fill blocks break normally - }; } } - diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineBranchState.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineBranchState.java new file mode 100644 index 0000000..feaa26b --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineBranchState.java @@ -0,0 +1,50 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import java.util.Locale; + +public record MineBranchState( + int branchId, + boolean mainPath, + int depth, + String activeNaturalBiome, + int biomeOriginDepth, + int naturalBiomeLength +) { + + public boolean hasActiveNaturalBiome() { + return activeNaturalBiome != null && !activeNaturalBiome.isBlank(); + } + + public MineBranchState withDepth(int newDepth) { + return new MineBranchState( + branchId, + mainPath, + newDepth, + activeNaturalBiome, + biomeOriginDepth, + naturalBiomeLength + ); + } + + public MineBranchState clearNaturalBiome() { + return new MineBranchState( + branchId, + mainPath, + depth, + null, + -1, + 0 + ); + } + + public MineBranchState activateNaturalBiome(String biome, int originDepth, int length) { + return new MineBranchState( + branchId, + mainPath, + depth, + biome == null ? null : biome.toUpperCase(Locale.ROOT), + originDepth, + Math.max(1, length) + ); + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineDirection.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineDirection.java new file mode 100644 index 0000000..2801fea --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineDirection.java @@ -0,0 +1,68 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import java.util.ArrayList; +import java.util.List; + +public enum MineDirection { + FRONT(1 << 0, 0, 0, 1), + BACK(1 << 1, 0, 0, -1), + LEFT(1 << 2, -1, 0, 0), + RIGHT(1 << 3, 1, 0, 0), + TOP(1 << 4, 0, 1, 0), + BOTTOM(1 << 5, 0, -1, 0); + + private static final List HORIZONTAL_DIRECTIONS = List.of(FRONT, BACK, LEFT, RIGHT); + + private final int bitMask; + private final int deltaX; + private final int deltaY; + private final int deltaZ; + + MineDirection(int bitMask, int deltaX, int deltaY, int deltaZ) { + this.bitMask = bitMask; + this.deltaX = deltaX; + this.deltaY = deltaY; + this.deltaZ = deltaZ; + } + + public int bitMask() { + return bitMask; + } + + public int deltaX() { + return deltaX; + } + + public int deltaY() { + return deltaY; + } + + public int deltaZ() { + return deltaZ; + } + + public MineDirection opposite() { + return switch (this) { + case FRONT -> BACK; + case BACK -> FRONT; + case LEFT -> RIGHT; + case RIGHT -> LEFT; + case TOP -> BOTTOM; + case BOTTOM -> TOP; + }; + } + + public static List horizontalDirections() { + return HORIZONTAL_DIRECTIONS; + } + + public static List directionsFromMask(int exitsMask) { + List directions = new ArrayList<>(); + for (MineDirection direction : values()) { + if ((exitsMask & direction.bitMask) != 0) { + directions.add(direction); + } + } + return directions; + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationConfig.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationConfig.java new file mode 100644 index 0000000..4537fc4 --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationConfig.java @@ -0,0 +1,191 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import com.sk89q.worldedit.math.BlockVector3; +import org.bukkit.World; +import org.bukkit.configuration.file.FileConfiguration; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +public record MineGenerationConfig( + String roomsDirectory, + String starterRoomId, + String defaultBiome, + int roomSize, + int maxDepth, + int maxRooms, + boolean ignoreAir, + BlockVector3 origin, + List depthBiomes, + List naturalBiomes +) { + + private static final String BASE_PATH = "mine.generator"; + private static final Pattern ROOM_IDENTIFIER_PATTERN = Pattern.compile("^[A-Z]{2}\\d+$"); + + public static MineGenerationConfig fromConfiguration(FileConfiguration configuration, World world) { + String roomsDirectory = configuration.getString(BASE_PATH + ".rooms-directory", "rooms"); + String starterRoomId = normalizeRoomIdentifier(configuration.getString(BASE_PATH + ".starter-room", "ST15")); + String defaultBiome = normalizeBiome(configuration.getString(BASE_PATH + ".default-biome", "ST"), "ST"); + + int roomSize = Math.max(1, configuration.getInt(BASE_PATH + ".room-size", 16)); + int maxDepth = Math.max(1, configuration.getInt(BASE_PATH + ".max-depth", 64)); + int maxRooms = Math.max(1, configuration.getInt(BASE_PATH + ".max-rooms", 320)); + boolean ignoreAir = configuration.getBoolean(BASE_PATH + ".ignore-air", false); + + int defaultY = world.getMaxHeight() - 10; + BlockVector3 origin = BlockVector3.at( + configuration.getInt(BASE_PATH + ".origin.x", 0), + configuration.getInt(BASE_PATH + ".origin.y", defaultY), + configuration.getInt(BASE_PATH + ".origin.z", 0) + ); + + List depthBiomes = parseDepthBiomes( + configuration.getMapList(BASE_PATH + ".depth-biomes"), + defaultBiome + ); + List naturalBiomes = parseNaturalBiomes( + configuration.getMapList(BASE_PATH + ".natural-biomes") + ); + + return new MineGenerationConfig( + roomsDirectory, + starterRoomId, + defaultBiome, + roomSize, + maxDepth, + maxRooms, + ignoreAir, + origin, + depthBiomes, + naturalBiomes + ); + } + + public String resolveDepthBiome(int depth) { + String biome = defaultBiome; + for (DepthBiomeRule rule : depthBiomes) { + if (depth >= rule.minDepth()) { + biome = rule.biome(); + } else { + break; + } + } + return biome; + } + + private static List parseDepthBiomes(List> entries, String defaultBiome) { + List rules = new ArrayList<>(); + for (Map entry : entries) { + String biome = normalizeBiome(entry.get("biome"), null); + if (biome == null) { + continue; + } + int minDepth = Math.max(0, readInt(entry, "min-depth", 0)); + rules.add(new DepthBiomeRule(biome, minDepth)); + } + + if (rules.isEmpty()) { + rules.add(new DepthBiomeRule(defaultBiome, 0)); + } + + rules.sort(Comparator.comparingInt(DepthBiomeRule::minDepth)); + return List.copyOf(rules); + } + + private static List parseNaturalBiomes(List> entries) { + List rules = new ArrayList<>(); + for (Map entry : entries) { + String biome = normalizeBiome(entry.get("biome"), null); + if (biome == null) { + continue; + } + + double chance = clamp(readDouble(entry, "chance", 0.0), 0.0, 1.0); + if (chance <= 0.0) { + continue; + } + + int length = Math.max(1, readInt(entry, "length", 1)); + rules.add(new NaturalBiomeRule(biome, chance, length)); + } + + return List.copyOf(rules); + } + + private static int readInt(Map entry, String key, int fallback) { + Object value = entry.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String stringValue) { + try { + return Integer.parseInt(stringValue); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + private static double readDouble(Map entry, String key, double fallback) { + Object value = entry.get(key); + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String stringValue) { + try { + return Double.parseDouble(stringValue); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + private static double clamp(double value, double min, double max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + private static String normalizeRoomIdentifier(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return "ST15"; + } + + String normalized = rawValue.trim().toUpperCase(Locale.ROOT); + int extensionIndex = normalized.indexOf('.'); + if (extensionIndex > 0) { + normalized = normalized.substring(0, extensionIndex); + } + + if (!ROOM_IDENTIFIER_PATTERN.matcher(normalized).matches()) { + return "ST15"; + } + + return normalized; + } + + private static String normalizeBiome(Object rawValue, String fallback) { + if (!(rawValue instanceof String biome) || biome.isBlank()) { + return fallback; + } + + return biome.trim().toUpperCase(Locale.ROOT); + } + + public record DepthBiomeRule(String biome, int minDepth) { + } + + public record NaturalBiomeRule(String biome, double chance, int length) { + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationPoint.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationPoint.java new file mode 100644 index 0000000..014c830 --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerationPoint.java @@ -0,0 +1,9 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +public record MineGenerationPoint( + MineGridPosition gridPosition, + MineDirection incomingDirection, + MineDirection travelDirection, + MineBranchState branchState +) { +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerator.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerator.java new file mode 100644 index 0000000..ef9f330 --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGenerator.java @@ -0,0 +1,376 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.WorldEditException; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.function.operation.Operation; +import com.sk89q.worldedit.function.operation.Operations; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.session.ClipboardHolder; +import org.bukkit.World; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class MineGenerator { + + private static final MineGridPosition STARTER_GRID_POSITION = new MineGridPosition(0, 0, 0); + + private final World world; + private final MineGenerationConfig config; + private final MineRoomLibrary roomLibrary; + private final Logger logger; + private final Random random; + + private int nextBranchId = 1; + + public MineGenerator(World world, MineGenerationConfig config, MineRoomLibrary roomLibrary, Logger logger) { + this.world = world; + this.config = config; + this.roomLibrary = roomLibrary; + this.logger = logger; + this.random = ThreadLocalRandom.current(); + } + + public Result generate() { + List starterCandidates = roomLibrary.roomsForIdentifier(config.starterRoomId()); + if (starterCandidates.isEmpty()) { + logger.severe("[MineGen] Starter room '" + config.starterRoomId() + "' was not found in room prefabs."); + return Result.failure(); + } + + MineRoomPrefab starterRoom = chooseWeighted(starterCandidates); + if (starterRoom == null) { + logger.severe("[MineGen] Starter room selection failed."); + return Result.failure(); + } + + if (!hasAllHorizontalExits(starterRoom)) { + logger.severe("[MineGen] Starter room '" + starterRoom.sourceFile().getName() + + "' must contain FRONT, BACK, LEFT and RIGHT exits."); + return Result.failure(); + } + + Map occupiedPositions = new HashMap<>(); + Deque generationQueue = new ArrayDeque<>(); + int roomsPlaced = 0; + + com.sk89q.worldedit.world.World worldEditWorld = BukkitAdapter.adapt(world); + try (EditSession editSession = WorldEdit.getInstance().newEditSession(worldEditWorld)) { + pasteRoom(editSession, starterRoom, STARTER_GRID_POSITION); + occupiedPositions.put(STARTER_GRID_POSITION, starterRoom); + roomsPlaced++; + + for (MineDirection direction : MineDirection.horizontalDirections()) { + MineBranchState branchState = new MineBranchState(nextBranchId++, true, 1, null, -1, 0); + enqueueNextPoint(generationQueue, STARTER_GRID_POSITION, direction, branchState); + } + + while (!generationQueue.isEmpty() && roomsPlaced < config.maxRooms()) { + MineGenerationPoint point = generationQueue.poll(); + MineBranchState branchState = expireNaturalBiomeIfNeeded(point.branchState()); + + if (branchState.depth() > config.maxDepth()) { + continue; + } + if (occupiedPositions.containsKey(point.gridPosition())) { + continue; + } + + MineBranchState placementState = maybeActivateNaturalBiome(branchState); + String resolvedBiome = resolveBiome(placementState); + + MineRoomPrefab selectedRoom = selectRoom( + point, + placementState, + resolvedBiome, + occupiedPositions + ); + if (selectedRoom == null) { + continue; + } + + pasteRoom(editSession, selectedRoom, point.gridPosition()); + occupiedPositions.put(point.gridPosition(), selectedRoom); + roomsPlaced++; + + enqueueFollowingPoints( + generationQueue, + point, + placementState, + selectedRoom + ); + } + } catch (WorldEditException exception) { + logger.log(Level.SEVERE, "[MineGen] Failed to generate mine layout.", exception); + return Result.failure(); + } + + return new Result(true, roomsPlaced); + } + + private String resolveBiome(MineBranchState branchState) { + if (branchState.hasActiveNaturalBiome()) { + return branchState.activeNaturalBiome(); + } + return config.resolveDepthBiome(branchState.depth()); + } + + private MineRoomPrefab selectRoom( + MineGenerationPoint point, + MineBranchState branchState, + String biome, + Map occupiedPositions + ) { + List candidates = collectCandidateRooms( + point, + branchState, + biome, + occupiedPositions, + true + ); + + if (candidates.isEmpty()) { + candidates = collectCandidateRooms( + point, + branchState, + biome, + occupiedPositions, + false + ); + } + + if (candidates.isEmpty()) { + return null; + } + + return chooseWeighted(candidates); + } + + private List collectCandidateRooms( + MineGenerationPoint point, + MineBranchState branchState, + String biome, + Map occupiedPositions, + boolean avoidOccupiedExitTargets + ) { + int blockedExitMask = buildBlockedExitMask(point.gridPosition(), point.incomingDirection(), occupiedPositions); + + List candidates = new ArrayList<>(); + for (MineRoomPrefab room : roomLibrary.allRooms()) { + if (!room.biome().equalsIgnoreCase(biome)) { + continue; + } + if (!room.hasExit(point.incomingDirection())) { + continue; + } + if (!matchesBranchConstraints(room, branchState.mainPath())) { + continue; + } + if (avoidOccupiedExitTargets && (room.exitsMask() & blockedExitMask) != 0) { + continue; + } + candidates.add(room); + } + + return candidates; + } + + private boolean matchesBranchConstraints(MineRoomPrefab room, boolean mainPath) { + int exitCount = room.exitCount(); + if (mainPath) { + return exitCount >= 2; + } + return exitCount == 2; + } + + private int buildBlockedExitMask( + MineGridPosition targetPosition, + MineDirection incomingDirection, + Map occupiedPositions + ) { + int blockedMask = 0; + for (MineDirection direction : MineDirection.values()) { + if (direction == incomingDirection) { + continue; + } + if (occupiedPositions.containsKey(targetPosition.offset(direction))) { + blockedMask |= direction.bitMask(); + } + } + return blockedMask; + } + + private void enqueueFollowingPoints( + Deque generationQueue, + MineGenerationPoint point, + MineBranchState placementState, + MineRoomPrefab room + ) { + List outgoingDirections = room.exits().stream() + .filter(direction -> direction != point.incomingDirection()) + .toList(); + + if (outgoingDirections.isEmpty()) { + return; + } + + MineDirection continuationDirection = chooseContinuationDirection(outgoingDirections, point.travelDirection()); + MineBranchState continuationState = placementState.withDepth(placementState.depth() + 1); + enqueueNextPoint(generationQueue, point.gridPosition(), continuationDirection, continuationState); + + if (!placementState.mainPath()) { + return; + } + + for (MineDirection direction : outgoingDirections) { + if (direction == continuationDirection) { + continue; + } + + MineBranchState childBranchState = new MineBranchState( + nextBranchId++, + false, + placementState.depth() + 1, + placementState.activeNaturalBiome(), + placementState.biomeOriginDepth(), + placementState.naturalBiomeLength() + ); + enqueueNextPoint(generationQueue, point.gridPosition(), direction, childBranchState); + } + } + + private MineDirection chooseContinuationDirection(List outgoingDirections, MineDirection preferredDirection) { + if (outgoingDirections.contains(preferredDirection)) { + return preferredDirection; + } + return outgoingDirections.get(random.nextInt(outgoingDirections.size())); + } + + private MineBranchState expireNaturalBiomeIfNeeded(MineBranchState branchState) { + if (!branchState.hasActiveNaturalBiome()) { + return branchState; + } + + int roomsSpentInBiome = branchState.depth() - branchState.biomeOriginDepth(); + if (roomsSpentInBiome >= branchState.naturalBiomeLength()) { + return branchState.clearNaturalBiome(); + } + return branchState; + } + + private MineBranchState maybeActivateNaturalBiome(MineBranchState branchState) { + if (branchState.hasActiveNaturalBiome()) { + return branchState; + } + if (config.naturalBiomes().isEmpty()) { + return branchState; + } + + double totalChance = 0.0; + for (MineGenerationConfig.NaturalBiomeRule rule : config.naturalBiomes()) { + totalChance += rule.chance(); + } + + if (totalChance <= 0.0) { + return branchState; + } + + if (random.nextDouble() > Math.min(1.0, totalChance)) { + return branchState; + } + + double selectionRoll = random.nextDouble(totalChance); + double cumulativeChance = 0.0; + for (MineGenerationConfig.NaturalBiomeRule rule : config.naturalBiomes()) { + cumulativeChance += rule.chance(); + if (selectionRoll <= cumulativeChance) { + return branchState.activateNaturalBiome(rule.biome(), branchState.depth(), rule.length()); + } + } + + return branchState; + } + + private boolean hasAllHorizontalExits(MineRoomPrefab room) { + for (MineDirection direction : MineDirection.horizontalDirections()) { + if (!room.hasExit(direction)) { + return false; + } + } + return true; + } + + private static void enqueueNextPoint( + Deque generationQueue, + MineGridPosition currentPosition, + MineDirection outgoingDirection, + MineBranchState branchState + ) { + generationQueue.add(new MineGenerationPoint( + currentPosition.offset(outgoingDirection), + outgoingDirection.opposite(), + outgoingDirection, + branchState + )); + } + + private MineRoomPrefab chooseWeighted(List rooms) { + if (rooms.isEmpty()) { + return null; + } + if (rooms.size() == 1) { + return rooms.getFirst(); + } + + int totalWeight = 0; + for (MineRoomPrefab room : rooms) { + totalWeight += Math.max(1, room.weight()); + } + + int roll = random.nextInt(totalWeight); + int runningWeight = 0; + for (MineRoomPrefab room : rooms) { + runningWeight += Math.max(1, room.weight()); + if (roll < runningWeight) { + return room; + } + } + + return rooms.getLast(); + } + + private void pasteRoom(EditSession editSession, MineRoomPrefab room, MineGridPosition gridPosition) throws WorldEditException { + BlockVector3 target = toWorldPosition(gridPosition); + Operation operation = new ClipboardHolder(room.clipboard()) + .createPaste(editSession) + .to(target) + .ignoreAirBlocks(config.ignoreAir()) + .build(); + Operations.complete(operation); + } + + private BlockVector3 toWorldPosition(MineGridPosition gridPosition) { + return config.origin().add( + gridPosition.x() * config.roomSize(), + gridPosition.y() * config.roomSize(), + gridPosition.z() * config.roomSize() + ); + } + + public record Result(boolean success, int roomsPlaced) { + + public static Result failure() { + return new Result(false, 0); + } + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGridPosition.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGridPosition.java new file mode 100644 index 0000000..d993c51 --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineGridPosition.java @@ -0,0 +1,12 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +public record MineGridPosition(int x, int y, int z) { + + public MineGridPosition offset(MineDirection direction) { + return new MineGridPosition( + x + direction.deltaX(), + y + direction.deltaY(), + z + direction.deltaZ() + ); + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomLibrary.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomLibrary.java new file mode 100644 index 0000000..a30eb2c --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomLibrary.java @@ -0,0 +1,181 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormat; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats; +import com.sk89q.worldedit.extent.clipboard.io.ClipboardReader; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class MineRoomLibrary { + + private static final Pattern ROOM_FILE_PATTERN = Pattern.compile("^([A-Za-z]{2})(\\d+)(?:_(\\d+))?$"); + + private final File roomsDirectory; + private final List prefabs; + private final Map> prefabsByIdentifier; + + private MineRoomLibrary( + File roomsDirectory, + List prefabs, + Map> prefabsByIdentifier + ) { + this.roomsDirectory = roomsDirectory; + this.prefabs = prefabs; + this.prefabsByIdentifier = prefabsByIdentifier; + } + + public static MineRoomLibrary load(File dataFolder, String roomsDirectoryName, Logger logger) { + String safeDirectoryName = roomsDirectoryName == null || roomsDirectoryName.isBlank() + ? "rooms" + : roomsDirectoryName; + + File roomsDirectory = new File(dataFolder, safeDirectoryName); + if (!roomsDirectory.exists() && !roomsDirectory.mkdirs()) { + logger.warning("[MineGen] Could not create room prefab directory: " + roomsDirectory.getAbsolutePath()); + } + + File[] roomFiles = roomsDirectory.listFiles(MineRoomLibrary::isSupportedRoomFile); + if (roomFiles == null || roomFiles.length == 0) { + return new MineRoomLibrary(roomsDirectory, List.of(), Map.of()); + } + + List prefabs = new ArrayList<>(); + Map> prefabsByIdentifier = new HashMap<>(); + + for (File roomFile : roomFiles) { + RoomFileMetadata metadata = parseMetadata(roomFile.getName(), logger); + if (metadata == null) { + continue; + } + + ClipboardFormat format = ClipboardFormats.findByFile(roomFile); + if (format == null) { + logger.warning("[MineGen] Unsupported room format: " + roomFile.getName()); + continue; + } + + try ( + FileInputStream inputStream = new FileInputStream(roomFile); + ClipboardReader reader = format.getReader(inputStream) + ) { + Clipboard clipboard = reader.read(); + MineRoomPrefab prefab = new MineRoomPrefab( + metadata.identifier(), + metadata.biome(), + metadata.exitsMask(), + metadata.weight(), + roomFile, + clipboard + ); + prefabs.add(prefab); + prefabsByIdentifier.computeIfAbsent(metadata.identifier(), ignored -> new ArrayList<>()).add(prefab); + } catch (IOException exception) { + logger.warning("[MineGen] Failed to load room prefab " + roomFile.getName() + + ": " + exception.getMessage()); + } + } + + return new MineRoomLibrary( + roomsDirectory, + List.copyOf(prefabs), + prefabsByIdentifier.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> List.copyOf(entry.getValue()) + )) + ); + } + + public File roomsDirectory() { + return roomsDirectory; + } + + public boolean isEmpty() { + return prefabs.isEmpty(); + } + + public List allRooms() { + return prefabs; + } + + public List roomsForIdentifier(String identifier) { + if (identifier == null) { + return List.of(); + } + + return prefabsByIdentifier.getOrDefault(identifier.toUpperCase(Locale.ROOT), List.of()); + } + + private static boolean isSupportedRoomFile(File file) { + if (!file.isFile()) { + return false; + } + + String lowerName = file.getName().toLowerCase(Locale.ROOT); + return lowerName.endsWith(".schematic") || lowerName.endsWith(".schem"); + } + + private static RoomFileMetadata parseMetadata(String fileName, Logger logger) { + int extensionIndex = fileName.lastIndexOf('.'); + if (extensionIndex <= 0) { + logger.warning("[MineGen] Ignoring room prefab '" + fileName + "': missing file extension."); + return null; + } + + String baseName = fileName.substring(0, extensionIndex); + Matcher matcher = ROOM_FILE_PATTERN.matcher(baseName); + if (!matcher.matches()) { + logger.warning("[MineGen] Ignoring room prefab '" + fileName + + "': expected format _."); + return null; + } + + String biome = matcher.group(1).toUpperCase(Locale.ROOT); + int exitsMask; + try { + exitsMask = Integer.parseInt(matcher.group(2)); + } catch (NumberFormatException exception) { + logger.warning("[MineGen] Ignoring room prefab '" + fileName + "': invalid exits value."); + return null; + } + + if (exitsMask <= 0 || exitsMask > 63) { + logger.warning("[MineGen] Ignoring room prefab '" + fileName + + "': exits bitmask must be in range 1..63."); + return null; + } + + int weight = 1; + String weightGroup = matcher.group(3); + if (weightGroup != null) { + try { + weight = Integer.parseInt(weightGroup); + if (weight <= 0) { + logger.warning("[MineGen] Room prefab '" + fileName + "' has non-positive weight, using 1."); + weight = 1; + } + } catch (NumberFormatException exception) { + logger.warning("[MineGen] Room prefab '" + fileName + "' has invalid weight, using 1."); + weight = 1; + } + } + + String identifier = biome + exitsMask; + return new RoomFileMetadata(identifier, biome, exitsMask, weight); + } + + private record RoomFileMetadata(String identifier, String biome, int exitsMask, int weight) { + } +} diff --git a/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomPrefab.java b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomPrefab.java new file mode 100644 index 0000000..28aa4f6 --- /dev/null +++ b/src/main/java/xyz/soukup/ecoCraftCore/mines/generation/MineRoomPrefab.java @@ -0,0 +1,28 @@ +package xyz.soukup.ecoCraftCore.mines.generation; + +import com.sk89q.worldedit.extent.clipboard.Clipboard; + +import java.io.File; +import java.util.List; + +public record MineRoomPrefab( + String identifier, + String biome, + int exitsMask, + int weight, + File sourceFile, + Clipboard clipboard +) { + + public boolean hasExit(MineDirection direction) { + return (exitsMask & direction.bitMask()) != 0; + } + + public int exitCount() { + return Integer.bitCount(exitsMask); + } + + public List exits() { + return MineDirection.directionsFromMask(exitsMask); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3d9ced4..3d01b5f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -11,4 +11,39 @@ database: cache: save-interval: 6000 islands: - spawn: null \ No newline at end of file + spawn: null + +mine: + paste-on-startup: false + spawn: + x: 5.5 + y: 311.0 + z: 5.5 + yaw: 0.0 + pitch: 0.0 + generator: + rooms-directory: "rooms" + starter-room: "ST15" + default-biome: "ST" + room-size: 16 + max-depth: 64 + max-rooms: 320 + ignore-air: false + origin: + x: 0 + y: 310 + z: 0 + depth-biomes: + - biome: "ST" + min-depth: 0 + - biome: "DS" + min-depth: 20 + - biome: "MS" + min-depth: 40 + natural-biomes: + - biome: "DS" + chance: 0.08 + length: 6 + - biome: "MS" + chance: 0.05 + length: 5 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 27f75fc..a711665 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -84,4 +84,5 @@ mine: teleported-out: "Byl jsi teleportován z dolů kvůli regeneraci." error: no-world: "Důlní svět není načtený." + regenerate-failed: "Regenerace dolů selhala. Zkontroluj room prefab soubory a server log."