From 24ab63b9be66cac5ca08e9a1bef114e7f61a5199 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 09:52:48 -0300 Subject: [PATCH 01/15] feat(void): add void death item protection with safe chest and inventory fallback --- specs/void-protection/spec.md | 23 +++ .../marcospaulo/canalhandia/Canalhandia.java | 54 +++++ .../dev/marcospaulo/canalhandia/Module.java | 3 +- .../dev/marcospaulo/canalhandia/Settings.java | 18 ++ .../canalhandia/VoidProtection.java | 185 ++++++++++++++++++ .../canalhandia/VoidProtectionTest.java | 29 +++ 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 specs/void-protection/spec.md create mode 100644 src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java diff --git a/specs/void-protection/spec.md b/specs/void-protection/spec.md new file mode 100644 index 0000000..23123b5 --- /dev/null +++ b/specs/void-protection/spec.md @@ -0,0 +1,23 @@ +# Spec: Proteção de Itens no Vácuo (Void Protection) + +## 1. Problema e Motivação +Quando um jogador morre no vácuo (caindo no The End, Nether ou Overworld profundo), todos os itens e armaduras caem abaixo de Y = -64 e são excluídos pelo motor do Minecraft, impossibilitando qualquer recuperação legítima e gerando frustração. + +## 2. Solução +Implementar o módulo `salvavoid` no plugin Canalhandia: +1. **Detecção:** Identifica mortes por vácuo (`DamageCause.VOID` ou coordenada Y abaixo da altura mínima do mundo). +2. **Busca de Terreno Seguro:** Procura o bloco sólido mais próximo da posição horizontal onde o jogador caiu (raio configurável, padrão 32 blocos). +3. **Opção A (Baú de Resgate):** Se encontrar terreno seguro: + - Cria um baú (ou baú duplo se necessário) sobre o bloco seguro. + - Guarda todos os itens e armaduras do jogador dentro do baú. + - Limpa os drops do evento de morte (para não cair no vácuo). + - Informa ao jogador a localização exata (coordenadas X, Y, Z) do baú no chat. +4. **Opção B (Preservação Direta no Inventário):** Se NÃO houver nenhum bloco seguro por perto (ex: caiu no meio do vácuo infinito do End): + - Preserva o inventário e nível de XP do jogador (`keepInventory = true`, `keepLevel = true`). + - Limpa os drops do evento de morte. + - Envia mensagem confortando o jogador e avisando que os itens foram mantidos no inventário. + +## 3. Critérios de Aceitação +- Módulo integrado ao `/canalhandia modulo salvavoid `. +- Configurações em `Settings.java`: `voidProtectionEnabled`, `voidProtectionRadius`, `voidProtectionKeepXp`. +- Testes unitários cobrindo detecção de morte no vácuo, cálculo de busca e empacotamento de inventário. diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 4598b55..036727c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -837,6 +837,60 @@ public final class Canalhandia extends JavaPlugin implements Listener { }, settings.reactionWindowSeconds() * 20L); } + /** + * Rescues player items when falling into the void. Places a chest on the nearest + * safe ground block, or preserves items directly in the inventory if no solid ground is nearby. + */ + @EventHandler(priority = EventPriority.HIGH) + public void onVoidDeath(PlayerDeathEvent event) { + if (!settings.moduleEnabled(Module.SALVAVOID)) { + return; + } + Player player = event.getEntity(); + EntityDamageEvent damage = player.getLastDamageCause(); + EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause(); + + if (!VoidProtection.isVoidDeath(player.getLocation().getY(), player.getWorld().getMinHeight(), cause)) { + return; + } + + List drops = new ArrayList<>(event.getDrops()); + Location safeLoc = VoidProtection.findSafeChestLocation(player.getWorld(), player.getLocation(), settings.voidProtectionRadius()); + + if (safeLoc != null && !drops.isEmpty() && VoidProtection.rescueToChest(drops, safeLoc)) { + event.getDrops().clear(); + if (settings.voidProtectionKeepXp()) { + event.setKeepLevel(true); + event.setDroppedExp(0); + } + int x = safeLoc.getBlockX(); + int y = safeLoc.getBlockY(); + int z = safeLoc.getBlockZ(); + getServer().getScheduler().runTaskLater(this, () -> { + if (player.isOnline()) { + player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD) + .append(Component.text("Você caiu no vácuo! Seus itens foram guardados em segurança num baú em ", NamedTextColor.YELLOW)) + .append(Component.text(x + ", " + y + ", " + z, NamedTextColor.AQUA, TextDecoration.BOLD)) + .append(Component.text(".", NamedTextColor.YELLOW))); + } + }, 20L); + } else { + // No safe ground found within radius: keep inventory directly + event.setKeepInventory(true); + event.getDrops().clear(); + if (settings.voidProtectionKeepXp()) { + event.setKeepLevel(true); + event.setDroppedExp(0); + } + getServer().getScheduler().runTaskLater(this, () -> { + if (player.isOnline()) { + player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD) + .append(Component.text("Você caiu no vácuo sem terra firme por perto! Seus itens foram mantidos no seu inventário.", NamedTextColor.GREEN))); + } + }, 20L); + } + } + /** * Comic death broadcast + private coordinates, gated by the {@code mortes} * module. Replaces the vanilla translatable death message with a pt-BR diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java index 213620d..498769a 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Module.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -15,7 +15,8 @@ enum Module { RECADOS("recados", "Recados entregues quando o jogador entra"), CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"), IA("ia", "Perguntas para a IA"), - CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"); + CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"), + SALVAVOID("salvavoid", "Proteção de itens no vácuo"); private final String key; private final String label; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index bd5148c..c431ef2 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -597,6 +597,24 @@ final class Settings { set("chunkloader.bluemap", enabled); } + // --- salvavoid ---------------------------------------------------------- + + int voidProtectionRadius() { + return Math.max(1, plugin.getConfig().getInt("salvavoid.raio-busca", 32)); + } + + void voidProtectionRadius(int radius) { + set("salvavoid.raio-busca", Math.max(1, radius)); + } + + boolean voidProtectionKeepXp() { + return plugin.getConfig().getBoolean("salvavoid.preservar-xp", true); + } + + void voidProtectionKeepXp(boolean keep) { + set("salvavoid.preservar-xp", keep); + } + // --- plumbing ----------------------------------------------------------- private void set(String path, Object value) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java new file mode 100644 index 0000000..a70bb14 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java @@ -0,0 +1,185 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Chest; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Rescues player items on void death, either into a safe nearby chest + * on solid ground or directly retained in the player's inventory. + */ +public final class VoidProtection { + + private VoidProtection() { + } + + /** + * Determines whether a death was caused by falling into the void. + */ + public static boolean isVoidDeath(double y, double minHeight, EntityDamageEvent.DamageCause cause) { + if (cause == EntityDamageEvent.DamageCause.VOID) { + return true; + } + return y < minHeight; + } + + /** + * Finds the nearest safe solid block with air space above it within the given horizontal radius. + * Searches in an expanding spiral from the death coordinate. + */ + public static Location findSafeChestLocation(World world, Location deathLoc, int radius) { + if (world == null || deathLoc == null || radius <= 0) { + return null; + } + int centerX = deathLoc.getBlockX(); + int centerZ = deathLoc.getBlockZ(); + int minHeight = world.getMinHeight(); + + Location best = null; + double bestDistSq = Double.MAX_VALUE; + + for (int r = 0; r <= radius; r++) { + for (int dx = -r; dx <= r; dx++) { + for (int dz = -r; dz <= r; dz++) { + if (Math.abs(dx) != r && Math.abs(dz) != r) { + continue; // Only check perimeter of current radius + } + int x = centerX + dx; + int z = centerZ + dz; + + int topY; + try { + topY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); + } catch (Exception e) { + continue; + } + + if (topY <= minHeight) { + continue; + } + + Block ground = world.getBlockAt(x, topY, z); + Block space = world.getBlockAt(x, topY + 1, z); + + if (isSafeGround(ground) && isReplaceable(space)) { + double distSq = (dx * dx) + (dz * dz); + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = space.getLocation(); + if (r == 0) { + return best; + } + } + } + } + } + if (best != null) { + return best; + } + } + return best; + } + + private static boolean isSafeGround(Block block) { + if (block == null) { + return false; + } + Material mat = block.getType(); + if (mat.isAir() || !mat.isSolid()) { + return false; + } + // Avoid placing on hazardous blocks + return mat != Material.LAVA && mat != Material.FIRE && mat != Material.SOUL_FIRE + && mat != Material.CACTUS && mat != Material.MAGMA_BLOCK && mat != Material.SWEET_BERRY_BUSH + && mat != Material.WITHER_ROSE && mat != Material.POWDER_SNOW; + } + + private static boolean isReplaceable(Block block) { + if (block == null) { + return false; + } + Material mat = block.getType(); + return mat.isAir() || mat == Material.SHORT_GRASS || mat == Material.TALL_GRASS + || mat == Material.SNOW || mat == Material.FERN || mat == Material.LARGE_FERN; + } + + /** + * Stores items into a chest (and an adjacent chest if needed) at the target location. + * Returns true if items were successfully stored. + */ + public static boolean rescueToChest(List items, Location chestLoc) { + if (items == null || items.isEmpty() || chestLoc == null) { + return false; + } + try { + Block chestBlock = chestLoc.getBlock(); + chestBlock.setType(Material.CHEST, false); + + BlockState state = chestBlock.getState(); + if (!(state instanceof Chest chest)) { + return false; + } + + Inventory inv = chest.getInventory(); + List remaining = new ArrayList<>(); + + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + var leftover = inv.addItem(item.clone()); + remaining.addAll(leftover.values()); + } + } + + // If items didn't fit in a single chest (27 slots), try expanding to an adjacent double chest + if (!remaining.isEmpty()) { + Block adjacent = findAdjacentSpace(chestLoc); + if (adjacent != null) { + adjacent.setType(Material.CHEST, false); + BlockState adjState = adjacent.getState(); + if (adjState instanceof Chest adjChest) { + Inventory adjInv = adjChest.getInventory(); + for (ItemStack rem : remaining) { + adjInv.addItem(rem); + } + } + } + } + return true; + } catch (Exception e) { + return false; + } + } + + private static Block findAdjacentSpace(Location loc) { + World w = loc.getWorld(); + if (w == null) return null; + int x = loc.getBlockX(); + int y = loc.getBlockY(); + int z = loc.getBlockZ(); + + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] off : offsets) { + Block b = w.getBlockAt(x + off[0], y, z + off[1]); + Block ground = w.getBlockAt(x + off[0], y - 1, z + off[1]); + if (isReplaceable(b) && isSafeGround(ground)) { + return b; + } + } + return null; + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java new file mode 100644 index 0000000..320825d --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java @@ -0,0 +1,29 @@ +package dev.marcospaulo.canalhandia; + +import org.bukkit.event.entity.EntityDamageEvent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class VoidProtectionTest { + + @Test + void identifiesVoidDeathByCause() { + assertTrue(VoidProtection.isVoidDeath(100.0, -64.0, EntityDamageEvent.DamageCause.VOID)); + assertTrue(VoidProtection.isVoidDeath(-70.0, -64.0, EntityDamageEvent.DamageCause.VOID)); + } + + @Test + void identifiesVoidDeathByCoordinatesBelowMinHeight() { + assertTrue(VoidProtection.isVoidDeath(-65.0, -64.0, EntityDamageEvent.DamageCause.FALL)); + assertTrue(VoidProtection.isVoidDeath(-100.0, 0.0, EntityDamageEvent.DamageCause.CUSTOM)); + assertFalse(VoidProtection.isVoidDeath(50.0, -64.0, EntityDamageEvent.DamageCause.FALL)); + assertFalse(VoidProtection.isVoidDeath(10.0, 0.0, EntityDamageEvent.DamageCause.LAVA)); + } + + @Test + void handlesNullWorldOrLocationsGracefully() { + assertNull(VoidProtection.findSafeChestLocation(null, null, 10)); + assertFalse(VoidProtection.rescueToChest(null, null)); + } +} From d93711e5c175696c54d7a8b744bb59aa537f7215 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 10:01:59 -0300 Subject: [PATCH 02/15] fix(chunkloader): deliver anchor directly to inventory on break and protect against accidental interaction --- .../canalhandia/ChunkLoaderListener.java | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index f9c7dde..dcfb959 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -1,7 +1,8 @@ package dev.marcospaulo.canalhandia; -import net.kyori.adventure.text.Component; +import org.bukkit.GameMode; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; import org.bukkit.block.Block; @@ -9,14 +10,18 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockExplodeEvent; import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; import java.util.Iterator; +import java.util.Map; /** * Protects and handles block placement, removal, and explosions for Chunk Anchors. @@ -92,7 +97,7 @@ final class ChunkLoaderListener implements Listener { + (current + 1) + "/" + limitStr + ")."); } - @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onBlockBreak(BlockBreakEvent event) { Block block = event.getBlock(); ChunkLoader loader = plugin.chunkLoaders().byLocation( @@ -118,16 +123,46 @@ final class ChunkLoaderListener implements Listener { plugin.chunkLoaders().remove(loader.id()); event.setDropItems(false); + event.setExpToDrop(0); + + if (player.getGameMode() != GameMode.CREATIVE) { + Map overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1)); + for (ItemStack drop : overflow.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), drop); + } + } - Location loc = block.getLocation(); try { - loc.getWorld().dropItemNaturally(loc, ChunkAnchorItem.create(plugin, 1)); + var loc = block.getLocation().add(0.5, 0.5, 0.5); loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f); } catch (Exception ignored) { } plugin.blueMap().syncChunkLoaders(); - Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida."); + Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida para o seu inventário."); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + Block block = event.getClickedBlock(); + if (block == null || block.getType() != Material.RESPAWN_ANCHOR) { + return; + } + ChunkLoader loader = plugin.chunkLoaders().byLocation( + block.getWorld().getName(), + block.getX(), + block.getY(), + block.getZ() + ); + if (loader != null) { + // Prevent respawn anchor exploding or depleting charges when used as chunk loader + event.setCancelled(true); + Player player = event.getPlayer(); + Msg.ok(player, "Âncora de Chunk #" + loader.id() + " (" + loader.ownerName() + ") está ativa."); + } } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) From c3c4906e1148fddd7cc1ce3eee1b65de377d5b38 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 10:14:18 -0300 Subject: [PATCH 03/15] fix(review): address review comments and add .pr-review.json for pragent --- .pr-review.json | 21 +++ .../canalhandia/ChunkLoaderListener.java | 9 +- .../canalhandia/VoidProtection.java | 127 ++++++++++++------ .../canalhandia/VoidProtectionTest.java | 11 ++ 4 files changed, 125 insertions(+), 43 deletions(-) create mode 100644 .pr-review.json diff --git a/.pr-review.json b/.pr-review.json new file mode 100644 index 0000000..f5f311c --- /dev/null +++ b/.pr-review.json @@ -0,0 +1,21 @@ +{ + "languages": [ + "java", + "yaml", + "markdown" + ], + "focus": [ + "thread-safety", + "paper-chunk-ticketing", + "item-loss-prevention", + "resource-cleanup", + "performance-and-chunk-loading", + "null-safety-and-unit-tests" + ], + "exclude_paths": [ + "target/**", + "*.bak*", + "docs/**" + ], + "instructions": "Canalhandia is a Minecraft Paper 1.21.x server plugin written in modern Java 25. Enforce these core invariants:\n1. Thread Safety: Bukkit API, World, Entity, and Inventory mutations MUST run on the main server thread. Async threads only do pure calculation or async file I/O.\n2. Item & Inventory Safety: Never discard player items. Always handle full-inventory overflow by dropping excess items at the player's location. On multi-step container placement (e.g. chests), roll back blocks if not all items fit.\n3. Lifecycle & Cleanup: All registered chunk tickets, recipes, schedulers, and I/O executors must be cleanly flushed and unloaded in onDisable() and module toggles.\n4. Chunk Loading: Never trigger synchronous chunk generation or loading inside event handlers. Always check world.isChunkLoaded() before querying blocks.\n5. Test Coverage: All domain logic, coordinates math, parsers, and pure helpers must have corresponding JUnit tests in src/test/java." +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index dcfb959..bb2e30f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -125,11 +125,14 @@ final class ChunkLoaderListener implements Listener { event.setDropItems(false); event.setExpToDrop(0); - if (player.getGameMode() != GameMode.CREATIVE) { + if (player.getGameMode() == GameMode.CREATIVE) { + Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada (Modo Criativo)."); + } else { Map overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1)); for (ItemStack drop : overflow.values()) { player.getWorld().dropItemNaturally(player.getLocation(), drop); } + Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida para o seu inventário."); } try { @@ -139,11 +142,13 @@ final class ChunkLoaderListener implements Listener { } plugin.blueMap().syncChunkLoaders(); - Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida para o seu inventário."); } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onPlayerInteract(PlayerInteractEvent event) { + if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { + return; + } if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { return; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java index a70bb14..4cccc36 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java +++ b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java @@ -1,7 +1,5 @@ package dev.marcospaulo.canalhandia; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.Material; @@ -9,15 +7,13 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockState; import org.bukkit.block.Chest; -import org.bukkit.block.DoubleChest; -import org.bukkit.entity.Player; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Map; /** * Rescues player items on void death, either into a safe nearby chest @@ -25,6 +21,8 @@ import java.util.List; */ public final class VoidProtection { + private static final int MAX_CHECKED_COLUMNS = 120; + private VoidProtection() { } @@ -40,7 +38,7 @@ public final class VoidProtection { /** * Finds the nearest safe solid block with air space above it within the given horizontal radius. - * Searches in an expanding spiral from the death coordinate. + * Only checks already-loaded chunks and caps checked columns to prevent server hitches. */ public static Location findSafeChestLocation(World world, Location deathLoc, int radius) { if (world == null || deathLoc == null || radius <= 0) { @@ -49,19 +47,29 @@ public final class VoidProtection { int centerX = deathLoc.getBlockX(); int centerZ = deathLoc.getBlockZ(); int minHeight = world.getMinHeight(); + int checked = 0; - Location best = null; - double bestDistSq = Double.MAX_VALUE; + for (int r = 0; r <= radius; r += (r > 8 ? 2 : 1)) { + Location ringBest = null; + double ringBestDistSq = Double.MAX_VALUE; - for (int r = 0; r <= radius; r++) { - for (int dx = -r; dx <= r; dx++) { - for (int dz = -r; dz <= r; dz++) { - if (Math.abs(dx) != r && Math.abs(dz) != r) { - continue; // Only check perimeter of current radius + for (int dx = -r; dx <= r; dx += (r > 8 ? 2 : 1)) { + for (int dz = -r; dz <= r; dz += (r > 8 ? 2 : 1)) { + if (r > 0 && Math.abs(dx) != r && Math.abs(dz) != r) { + continue; } + if (++checked > MAX_CHECKED_COLUMNS) { + return ringBest; + } + int x = centerX + dx; int z = centerZ + dz; + // Do not load or generate new chunks synchronously on death + if (!world.isChunkLoaded(x >> 4, z >> 4)) { + continue; + } + int topY; try { topY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); @@ -78,24 +86,21 @@ public final class VoidProtection { if (isSafeGround(ground) && isReplaceable(space)) { double distSq = (dx * dx) + (dz * dz); - if (distSq < bestDistSq) { - bestDistSq = distSq; - best = space.getLocation(); - if (r == 0) { - return best; - } + if (distSq < ringBestDistSq) { + ringBestDistSq = distSq; + ringBest = space.getLocation(); } } } } - if (best != null) { - return best; + if (ringBest != null) { + return ringBest; } } - return best; + return null; } - private static boolean isSafeGround(Block block) { + static boolean isSafeGround(Block block) { if (block == null) { return false; } @@ -103,13 +108,12 @@ public final class VoidProtection { if (mat.isAir() || !mat.isSolid()) { return false; } - // Avoid placing on hazardous blocks return mat != Material.LAVA && mat != Material.FIRE && mat != Material.SOUL_FIRE && mat != Material.CACTUS && mat != Material.MAGMA_BLOCK && mat != Material.SWEET_BERRY_BUSH && mat != Material.WITHER_ROSE && mat != Material.POWDER_SNOW; } - private static boolean isReplaceable(Block block) { + static boolean isReplaceable(Block block) { if (block == null) { return false; } @@ -120,18 +124,24 @@ public final class VoidProtection { /** * Stores items into a chest (and an adjacent chest if needed) at the target location. - * Returns true if items were successfully stored. + * All items must be stored without overflow; on any failure, blocks are rolled back + * and false is returned so caller can safely fall back to keepInventory. */ public static boolean rescueToChest(List items, Location chestLoc) { if (items == null || items.isEmpty() || chestLoc == null) { return false; } - try { - Block chestBlock = chestLoc.getBlock(); - chestBlock.setType(Material.CHEST, false); + Block chestBlock = chestLoc.getBlock(); + Material orig1 = chestBlock.getType(); + Block adjacent = null; + Material orig2 = null; + + try { + chestBlock.setType(Material.CHEST, false); BlockState state = chestBlock.getState(); if (!(state instanceof Chest chest)) { + chestBlock.setType(orig1, false); return false; } @@ -140,27 +150,62 @@ public final class VoidProtection { for (ItemStack item : items) { if (item != null && !item.getType().isAir()) { - var leftover = inv.addItem(item.clone()); + Map leftover = inv.addItem(item.clone()); remaining.addAll(leftover.values()); } } - // If items didn't fit in a single chest (27 slots), try expanding to an adjacent double chest if (!remaining.isEmpty()) { - Block adjacent = findAdjacentSpace(chestLoc); - if (adjacent != null) { - adjacent.setType(Material.CHEST, false); - BlockState adjState = adjacent.getState(); - if (adjState instanceof Chest adjChest) { - Inventory adjInv = adjChest.getInventory(); - for (ItemStack rem : remaining) { - adjInv.addItem(rem); - } - } + adjacent = findAdjacentSpace(chestLoc); + if (adjacent == null) { + // Cannot fit all items and no space for second chest -> rollback + inv.clear(); + chestBlock.setType(orig1, false); + return false; + } + + orig2 = adjacent.getType(); + adjacent.setType(Material.CHEST, false); + BlockState adjState = adjacent.getState(); + if (!(adjState instanceof Chest adjChest)) { + inv.clear(); + chestBlock.setType(orig1, false); + adjacent.setType(orig2, false); + return false; + } + + Inventory adjInv = adjChest.getInventory(); + List secondLeftover = new ArrayList<>(); + for (ItemStack rem : remaining) { + Map leftover = adjInv.addItem(rem); + secondLeftover.addAll(leftover.values()); + } + + if (!secondLeftover.isEmpty()) { + // Still overflowed double chest -> rollback everything + inv.clear(); + adjInv.clear(); + chestBlock.setType(orig1, false); + adjacent.setType(orig2, false); + return false; } } + return true; } catch (Exception e) { + try { + if (chestBlock.getType() == Material.CHEST) { + BlockState s = chestBlock.getState(); + if (s instanceof Chest c) c.getInventory().clear(); + chestBlock.setType(orig1, false); + } + if (adjacent != null && adjacent.getType() == Material.CHEST) { + BlockState s2 = adjacent.getState(); + if (s2 instanceof Chest c2) c2.getInventory().clear(); + adjacent.setType(orig2 != null ? orig2 : Material.AIR, false); + } + } catch (Exception ignored) { + } return false; } } diff --git a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java index 320825d..2c0bfdb 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java @@ -1,8 +1,11 @@ package dev.marcospaulo.canalhandia; import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class VoidProtectionTest { @@ -24,6 +27,14 @@ class VoidProtectionTest { @Test void handlesNullWorldOrLocationsGracefully() { assertNull(VoidProtection.findSafeChestLocation(null, null, 10)); + assertNull(VoidProtection.findSafeChestLocation(null, null, 0)); assertFalse(VoidProtection.rescueToChest(null, null)); + assertFalse(VoidProtection.rescueToChest(List.of(), null)); + } + + @Test + void nullBlocksAreNeitherSafeNorReplaceable() { + assertFalse(VoidProtection.isSafeGround(null)); + assertFalse(VoidProtection.isReplaceable(null)); } } From 2c7f5f98ad4f7e75e77a135b3ef02b252fe20c76 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 10:21:11 -0300 Subject: [PATCH 04/15] fix(review): check event.getKeepInventory(), equipment slot hand, and world height bounds --- .../dev/marcospaulo/canalhandia/Canalhandia.java | 2 +- .../canalhandia/ChunkLoaderListener.java | 3 +++ .../dev/marcospaulo/canalhandia/VoidProtection.java | 13 ++++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 036727c..fc49c5b 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -934,7 +934,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { Location loc = player.getLocation(); String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ() + " (" + loc.getWorld().getName() + ")"; - boolean keepInventory = Boolean.TRUE.equals( + boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals( loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY)); pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory)); diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index bb2e30f..a0ccc58 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -149,6 +149,9 @@ final class ChunkLoaderListener implements Listener { if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { return; } + if (event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) { + return; + } if (event.getAction() != Action.RIGHT_CLICK_BLOCK) { return; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java index 4cccc36..6191934 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java +++ b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java @@ -77,12 +77,19 @@ public final class VoidProtection { continue; } - if (topY <= minHeight) { + int maxHeight = world.getMaxHeight(); + if (topY <= minHeight || topY >= maxHeight - 1) { continue; } - Block ground = world.getBlockAt(x, topY, z); - Block space = world.getBlockAt(x, topY + 1, z); + Block ground; + Block space; + try { + ground = world.getBlockAt(x, topY, z); + space = world.getBlockAt(x, topY + 1, z); + } catch (Exception e) { + continue; + } if (isSafeGround(ground) && isReplaceable(space)) { double distSq = (dx * dx) + (dz * dz); From 4261e8b660eb4f5c83224c399a1df66018ff54fc Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 11:16:24 -0300 Subject: [PATCH 05/15] fix(chunkloader): use setChunkForceLoaded for full block/entity/redstone ticking --- src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 8c1a46e..8a40ba3 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -227,6 +227,7 @@ final class ChunkLoaders { try { World w = Bukkit.getWorld(loader.world()); if (w != null) { + w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true); w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); } } catch (Exception ignored) { @@ -241,6 +242,7 @@ final class ChunkLoaders { World w = Bukkit.getWorld(loader.world()); if (w != null) { w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); + w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false); } } catch (Exception ignored) { } From 4b9507b36e388a5ee32f06cad493f40bc6155afd Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 12:53:19 -0300 Subject: [PATCH 06/15] feat(chunkloader): add mob spawning simulation, spawner activation and despawn protection --- .../marcospaulo/canalhandia/Canalhandia.java | 2 + .../canalhandia/CanalhandiaCommand.java | 2 + .../canalhandia/ChunkLoaderListener.java | 19 +++ .../marcospaulo/canalhandia/ChunkLoaders.java | 141 ++++++++++++++++++ .../canalhandia/ChunkLoaderTest.java | 7 + 5 files changed, 171 insertions(+) diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index fc49c5b..9e6d8bc 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -131,6 +131,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml")); if (settings.moduleEnabled(Module.CHUNKLOADER)) { chunkLoaders.loadAllTickets(); + chunkLoaders.startSimulation(); ChunkAnchorItem.registerRecipe(this); } aiBudget = new Budget(settings.aiSpontaneousPerDay(), @@ -192,6 +193,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { poll.hide(); } if (chunkLoaders != null) { + chunkLoaders.stopSimulation(); chunkLoaders.unloadAllTickets(); chunkLoaders.close(); ChunkAnchorItem.unregisterRecipe(this); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 6b18ee5..0ede027 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -570,8 +570,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (module == Module.CHUNKLOADER) { if (on) { plugin.chunkLoaders().loadAllTickets(); + plugin.chunkLoaders().startSimulation(); ChunkAnchorItem.registerRecipe(plugin); } else { + plugin.chunkLoaders().stopSimulation(); plugin.chunkLoaders().unloadAllTickets(); ChunkAnchorItem.unregisterRecipe(plugin); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index a0ccc58..60f92f6 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -5,7 +5,9 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; +import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -16,6 +18,7 @@ import org.bukkit.event.block.BlockExplodeEvent; import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; @@ -211,4 +214,20 @@ final class ChunkLoaderListener implements Listener { } } } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onCreatureSpawn(CreatureSpawnEvent event) { + if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { + return; + } + Location loc = event.getLocation(); + World w = loc.getWorld(); + if (w == null) { + return; + } + ChunkLoader loader = plugin.chunkLoaders().byChunk(w.getName(), loc.getBlockX() >> 4, loc.getBlockZ() >> 4); + if (loader != null && event.getEntity() instanceof Mob mob) { + mob.setRemoveWhenFarAway(false); + } + } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 8a40ba3..37bd90e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -1,12 +1,22 @@ package dev.marcospaulo.canalhandia; import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.CreatureSpawner; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; +import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; import java.io.File; import java.util.ArrayList; @@ -15,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -29,6 +40,7 @@ final class ChunkLoaders { private final File file; private final List loaders = new ArrayList<>(); private final AtomicLong nextId = new AtomicLong(1); + private BukkitTask simulationTask; private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "canalhandia-chunkloaders-io"); @@ -220,6 +232,135 @@ final class ChunkLoaders { } } + void startSimulation() { + if (plugin == null || simulationTask != null) { + return; + } + simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L); + } + + void stopSimulation() { + if (simulationTask != null) { + simulationTask.cancel(); + simulationTask = null; + } + } + + void tickMobSimulation() { + if (plugin == null) { + return; + } + List currentLoaders; + synchronized (loaders) { + currentLoaders = new ArrayList<>(loaders); + } + + for (ChunkLoader loader : currentLoaders) { + try { + World w = Bukkit.getWorld(loader.world()); + if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) { + continue; + } + Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ()); + + // 1. Keep mob spawners (dungeon / blaze / skeleton cages) active + for (BlockState state : chunk.getTileEntities()) { + if (state instanceof CreatureSpawner spawner) { + if (spawner.getRequiredPlayerRange() < 1024) { + spawner.setRequiredPlayerRange(2048); + spawner.update(true, false); + } + } + } + + // 2. Simulate natural mob spawning (dark platforms / slime / nether farms) + long mobCount = 0; + for (Entity entity : chunk.getEntities()) { + if (entity instanceof Mob) { + mobCount++; + } + } + if (mobCount >= 20) { + continue; // Respect chunk mob cap + } + + simulateNaturalSpawning(w, loader, chunk); + } catch (Exception ignored) { + } + } + } + + private void simulateNaturalSpawning(World w, ChunkLoader loader, Chunk chunk) { + int baseX = loader.chunkX() << 4; + int baseZ = loader.chunkZ() << 4; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + + for (int i = 0; i < 2; i++) { + int rx = baseX + rnd.nextInt(16); + int rz = baseZ + rnd.nextInt(16); + int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25))); + + Block ground = w.getBlockAt(rx, topY - 1, rz); + Block space = w.getBlockAt(rx, topY, rz); + Block spaceAbove = w.getBlockAt(rx, topY + 1, rz); + + if (!ground.getType().isSolid() || !space.getType().isAir() || !spaceAbove.getType().isAir()) { + continue; + } + if (ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) { + continue; + } + + int light = space.getLightLevel(); + EntityType toSpawn = pickEntityType(w, space, light); + if (toSpawn == null) { + continue; + } + + try { + Entity spawned = w.spawnEntity(space.getLocation().add(0.5, 0, 0.5), toSpawn, CreatureSpawnEvent.SpawnReason.NATURAL); + if (spawned instanceof Mob mob) { + mob.setRemoveWhenFarAway(false); + } + } catch (Exception ignored) { + } + } + } + + static EntityType pickEntityType(World w, Block space, int light) { + World.Environment env = w.getEnvironment(); + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + + if (env == World.Environment.NETHER) { + if (light > 11) return null; + int roll = rnd.nextInt(100); + if (roll < 45) return EntityType.ZOMBIFIED_PIGLIN; + if (roll < 65) return EntityType.WITHER_SKELETON; + if (roll < 85) return EntityType.BLAZE; + return EntityType.MAGMA_CUBE; + } + + if (env == World.Environment.THE_END) { + return EntityType.ENDERMAN; + } + + boolean isSlimeChunk = space.getChunk().isSlimeChunk() && space.getY() < 40; + if (isSlimeChunk && light <= 7 && rnd.nextInt(3) == 0) { + return EntityType.SLIME; + } + + if (light > 0) { + return null; + } + + int roll = rnd.nextInt(100); + if (roll < 35) return EntityType.ZOMBIE; + if (roll < 65) return EntityType.SKELETON; + if (roll < 85) return EntityType.CREEPER; + if (roll < 95) return EntityType.SPIDER; + return EntityType.WITCH; + } + private void addTicket(ChunkLoader loader) { if (plugin == null) { return; diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index e251b17..4ea4c7e 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -104,4 +104,11 @@ class ChunkLoaderTest { assertNotNull(l2); assertEquals("uuid-ana", l2.ownerUuid()); } + + @Test + void simulationSafelyNoopsWithNullPlugin() { + loaders.startSimulation(); + loaders.tickMobSimulation(); + loaders.stopSimulation(); + } } From 5a6bb9a13dc7d40439f5d69a831c1411649cc59e Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 12:55:38 -0300 Subject: [PATCH 07/15] feat(chunkloader): add /chunkloader limite command and tab completion --- .../canalhandia/CanalhandiaCommand.java | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 0ede027..5e7f3ae 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1822,6 +1822,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "info", "chunk" -> chunkloaderInfo(sender); case "remover", "remove", "del" -> chunkloaderRemove(sender, rest); case "receita", "craft" -> chunkloaderRecipe(sender); + case "limite", "limit" -> chunkloaderLimit(sender, rest); case "admin" -> chunkloaderAdmin(sender, rest); case "dar", "give" -> { if (admin(sender)) { @@ -2040,6 +2041,68 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!"); } + private void chunkloaderLimit(CommandSender sender, String[] args) { + if (args.length == 0) { + int defaultLimit = plugin.settings().chunkLoaderDefaultLimit(); + Msg.header(sender, "Limite de Âncoras de Chunk"); + sender.sendMessage(Component.text(" Limite padrão global: ", NamedTextColor.GRAY) + .append(Component.text(defaultLimit, NamedTextColor.YELLOW))); + if (sender instanceof Player player) { + int myLimit = ChunkLoaders.playerLimit(player, defaultLimit); + String myLimitStr = myLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(myLimit); + int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size(); + sender.sendMessage(Component.text(" Seu limite atual: ", NamedTextColor.GRAY) + .append(Component.text(current + "/" + myLimitStr, NamedTextColor.AQUA))); + } + if (admin(sender)) { + sender.sendMessage(Component.text(" Alterar padrão global: ", NamedTextColor.GRAY) + .append(Component.text("/chunkloader limite padrao ", NamedTextColor.YELLOW))); + sender.sendMessage(Component.text(" Definir por jogador (LuckPerms): ", NamedTextColor.GRAY) + .append(Component.text("/chunkloader limite ", NamedTextColor.YELLOW))); + } + return; + } + + if (!admin(sender)) { + return; + } + + if (args[0].equalsIgnoreCase("padrao") || args[0].equalsIgnoreCase("default")) { + if (args.length < 2) { + Msg.error(sender, "Uso: /chunkloader limite padrao "); + return; + } + try { + int newLimit = Math.max(0, Integer.parseInt(args[1])); + plugin.settings().chunkLoaderDefaultLimit(newLimit); + Msg.ok(sender, "Limite padrão global de Âncoras alterado para " + newLimit + "."); + } catch (NumberFormatException e) { + Msg.error(sender, "Quantidade inválida: " + args[1]); + } + return; + } + + Player target = Bukkit.getPlayerExact(args[0]); + if (target == null) { + Msg.error(sender, "Jogador '" + args[0] + "' não encontrado online."); + return; + } + if (args.length < 2) { + int targetLimit = ChunkLoaders.playerLimit(target, plugin.settings().chunkLoaderDefaultLimit()); + String str = targetLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(targetLimit); + int current = plugin.chunkLoaders().byOwner(target.getUniqueId().toString()).size(); + Msg.ok(sender, "Limite de " + target.getName() + ": " + current + "/" + str + " âncoras ativas."); + return; + } + try { + int newLimit = Math.max(0, Integer.parseInt(args[1])); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit); + Msg.ok(sender, "Limite de " + target.getName() + " definido para " + newLimit + " âncoras."); + } catch (NumberFormatException e) { + Msg.error(sender, "Quantidade inválida: " + args[1]); + } + } + private boolean chunkloaderHelp(CommandSender sender) { Msg.header(sender, "Âncoras de Chunk"); sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA) @@ -2050,6 +2113,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader limite", NamedTextColor.AQUA) + .append(Component.text(" — Mostra ou configura os limites de âncoras", NamedTextColor.GRAY))); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW) .append(Component.text(" — Comandos de administração", NamedTextColor.GRAY))); @@ -2219,7 +2284,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { if (args.length == 1) { - List options = new ArrayList<>(List.of("info", "listar", "remover", "receita")); + List options = new ArrayList<>(List.of("info", "listar", "remover", "receita", "limite")); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { options.addAll(List.of("admin", "dar")); } @@ -2234,6 +2299,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return filter(ids, args[1]); } } + if (args.length == 2 && args[0].equalsIgnoreCase("limite")) { + List opts = new ArrayList<>(List.of("padrao")); + opts.addAll(onlineNames()); + return filter(opts, args[1]); + } if (args.length == 2 && args[0].equalsIgnoreCase("admin")) { return filter(List.of("listar", "remover", "dar"), args[1]); } From ce3b1ebbd9ab3ebdfed3075ef772b024c1e83a42 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 13:13:06 -0300 Subject: [PATCH 08/15] feat(chunkloader): support custom names, anvil placement, and pause/resume --- .../canalhandia/BlueMapBridge.java | 8 +- .../canalhandia/CanalhandiaCommand.java | 128 ++++++++++++++---- .../marcospaulo/canalhandia/ChunkLoader.java | 22 +++ .../canalhandia/ChunkLoaderListener.java | 52 ++++++- .../marcospaulo/canalhandia/ChunkLoaders.java | 92 ++++++++++++- .../canalhandia/ChunkLoaderTest.java | 30 ++++ 6 files changed, 297 insertions(+), 35 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java index 02f4961..2d65cc0 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java +++ b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java @@ -127,9 +127,13 @@ final class BlueMapBridge { if (!mapMatchesWorld(map, loader.world())) { continue; } + String statusHtml = loader.enabled() + ? "Ativo" + : "Pausado"; var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder() - .label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")") - .detail("Âncora de Chunk #" + loader.id() + "
Dono: " + .label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]")) + .detail("" + escape(loader.displayName()) + "
Status: " + + statusHtml + "
Dono: " + escape(loader.ownerName()) + "
Chunk: " + loader.chunkCoords()) .position(loader.x(), loader.y(), loader.z()) .build(); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 5e7f3ae..af32693 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1823,6 +1823,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "remover", "remove", "del" -> chunkloaderRemove(sender, rest); case "receita", "craft" -> chunkloaderRecipe(sender); case "limite", "limit" -> chunkloaderLimit(sender, rest); + case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest); + case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true); + case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false); case "admin" -> chunkloaderAdmin(sender, rest); case "dar", "give" -> { if (admin(sender)) { @@ -1855,13 +1858,30 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { boolean bedrock = isBedrock(sender); for (ChunkLoader loader : mine) { - Component line = Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA) - .append(Component.text(loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE)); + String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]"; + NamedTextColor statusColor = loader.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED; + + Component line = Component.text(" " + loader.displayName() + " ", NamedTextColor.AQUA) + .append(Component.text(loader.place() + " " + loader.chunkCoords() + " ", NamedTextColor.WHITE)) + .append(Component.text(statusText + " ", statusColor)); if (!bedrock) { - line = line.append(Component.text(" [Remover]", NamedTextColor.RED) + if (loader.enabled()) { + line = line.append(Component.text("[Pausar] ", NamedTextColor.YELLOW) + .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( + Component.text("Clique para pausar " + loader.displayName(), NamedTextColor.GRAY))) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand( + "/chunkloader desativar " + loader.id()))); + } else { + line = line.append(Component.text("[Ativar] ", NamedTextColor.GREEN) + .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( + Component.text("Clique para reativar " + loader.displayName(), NamedTextColor.GRAY))) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand( + "/chunkloader ativar " + loader.id()))); + } + line = line.append(Component.text("[Remover]", NamedTextColor.RED) .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( - Component.text("Clique para desativar a âncora #" + loader.id(), NamedTextColor.GRAY))) + Component.text("Clique para recolher " + loader.displayName(), NamedTextColor.GRAY))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( "/chunkloader remover " + loader.id()))); } @@ -1891,9 +1911,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(" em " + world, NamedTextColor.WHITE))); if (here != null) { + String status = here.enabled() ? "Ativa" : "Pausada"; + NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED; sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) - .append(Component.text("Carregada continuamente por Âncora #" + here.id(), NamedTextColor.GREEN)) - .append(Component.text(" (" + here.ownerName() + ")", NamedTextColor.WHITE))); + .append(Component.text(here.displayName() + " (" + status + ")", statusCol)) + .append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE))); } else { sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) .append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY))); @@ -1903,24 +1925,70 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW))); } + private void chunkloaderRename(CommandSender sender, String[] args) { + if (args.length < 2) { + Msg.error(sender, "Uso: /chunkloader nome "); + return; + } + ChunkLoader loader = plugin.chunkLoaders().find(args[0]); + if (loader == null) { + Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'."); + return; + } + boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); + boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin"); + if (!isOwner && !isAdmin) { + Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + "."); + return; + } + String newName = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim(); + if (newName.length() > 32) { + Msg.error(sender, "Nome muito longo (máximo 32 caracteres)."); + return; + } + plugin.chunkLoaders().rename(loader.id(), newName); + plugin.blueMap().syncChunkLoaders(); + Msg.ok(sender, "Âncora #" + loader.id() + " renomeada para '" + newName + "'."); + } + + private void chunkloaderSetEnabled(CommandSender sender, String[] args, boolean enabled) { + if (args.length < 1) { + Msg.error(sender, "Uso: /chunkloader " + (enabled ? "ativar" : "desativar") + " "); + return; + } + ChunkLoader loader = plugin.chunkLoaders().find(args[0]); + if (loader == null) { + Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'."); + return; + } + boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); + boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin"); + if (!isOwner && !isAdmin) { + Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + "."); + return; + } + if (loader.enabled() == enabled) { + Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + "."); + return; + } + plugin.chunkLoaders().setEnabled(loader.id(), enabled); + plugin.blueMap().syncChunkLoaders(); + Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA.")); + } + private void chunkloaderRemove(CommandSender sender, String[] args) { if (args.length < 1) { - Msg.error(sender, "Uso: /chunkloader remover "); + Msg.error(sender, "Uso: /chunkloader remover "); return; } - long id = parseLong(args[0]); - if (id <= 0) { - Msg.error(sender, "ID inválido."); - return; - } - - ChunkLoader loader = plugin.chunkLoaders().byId(id); + ChunkLoader loader = plugin.chunkLoaders().find(args[0]); if (loader == null) { - Msg.error(sender, "Nenhuma Âncora de Chunk encontrada com o ID #" + id); + Msg.error(sender, "Nenhuma Âncora de Chunk encontrada para '" + args[0] + "'."); return; } + long id = loader.id(); boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin"); @@ -1948,7 +2016,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { for (ItemStack drop : overflow.values()) { player.getWorld().dropItemNaturally(player.getLocation(), drop); } - Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e devolvida ao inventário."); + Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e devolvida ao inventário."); } else { try { org.bukkit.World w = Bukkit.getWorld(loader.world()); @@ -1958,7 +2026,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } } catch (Exception ignored) { } - Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e dropada em " + loader.place() + "."); + Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e dropada em " + loader.place() + "."); } } @@ -2108,8 +2176,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA) .append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA) - .append(Component.text(" — Lista suas âncoras ativas e coordenadas", NamedTextColor.GRAY))); - sender.sendMessage(Component.text(" /chunkloader remover ", NamedTextColor.AQUA) + .append(Component.text(" — Lista suas âncoras, nomes, status e coordenadas", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader nome ", NamedTextColor.AQUA) + .append(Component.text(" — Define um nome/rótulo para a âncora", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader ativar ", NamedTextColor.AQUA) + .append(Component.text(" — Reativa o carregamento contínuo da âncora", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader desativar ", NamedTextColor.AQUA) + .append(Component.text(" — Pausa o carregamento sem quebrar o bloco", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader remover ", NamedTextColor.AQUA) .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); @@ -2284,19 +2358,25 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { if (args.length == 1) { - List options = new ArrayList<>(List.of("info", "listar", "remover", "receita", "limite")); + List options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "remover", "receita", "limite")); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { options.addAll(List.of("admin", "dar")); } return filter(options, args[0]); } - if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del"))) { + if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del") + || args[0].equalsIgnoreCase("nome") || args[0].equalsIgnoreCase("renomear") + || args[0].equalsIgnoreCase("ativar") || args[0].equalsIgnoreCase("desativar") + || args[0].equalsIgnoreCase("pausar"))) { if (sender instanceof Player player) { - List ids = new ArrayList<>(); + List identifiers = new ArrayList<>(); for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) { - ids.add(String.valueOf(loader.id())); + identifiers.add(String.valueOf(loader.id())); + if (loader.name() != null && !loader.name().isBlank()) { + identifiers.add(loader.name()); + } } - return filter(ids, args[1]); + return filter(identifiers, args[1]); } } if (args.length == 2 && args[0].equalsIgnoreCase("limite")) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java index 463125c..d80ef1f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java @@ -8,6 +8,8 @@ package dev.marcospaulo.canalhandia; */ record ChunkLoader( long id, + String name, + boolean enabled, String ownerUuid, String ownerName, String world, @@ -18,6 +20,10 @@ record ChunkLoader( int chunkZ, long createdAt) { + ChunkLoader(long id, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) { + this(id, "", true, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + } + String blockCoords() { return x + ", " + y + ", " + z; } @@ -29,4 +35,20 @@ record ChunkLoader( String place() { return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")"); } + + String displayName() { + return (name != null && !name.isBlank()) ? name + " (#" + id + ")" : "#" + id; + } + + String simpleName() { + return (name != null && !name.isBlank()) ? name : "#" + id; + } + + ChunkLoader withName(String newName) { + return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + } + + ChunkLoader withEnabled(boolean newEnabled) { + return new ChunkLoader(id, name, newEnabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index 60f92f6..a4bc90b 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -78,13 +78,27 @@ final class ChunkLoaderListener implements Listener { return; } + String customName = ""; + var meta = event.getItemInHand().getItemMeta(); + if (meta != null && meta.hasDisplayName()) { + try { + String raw = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(meta.displayName()).trim(); + if (!raw.equalsIgnoreCase("Âncora de Chunk") && !raw.isBlank()) { + customName = raw; + } + } catch (Exception ignored) { + } + } + ChunkLoader loader = plugin.chunkLoaders().add( player.getUniqueId().toString(), player.getName(), world, loc.getBlockX(), loc.getBlockY(), - loc.getBlockZ() + loc.getBlockZ(), + customName ); try { @@ -96,7 +110,7 @@ final class ChunkLoaderListener implements Listener { plugin.blueMap().syncChunkLoaders(); String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit); - Msg.ok(player, "Âncora de Chunk ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente (" + Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente (" + (current + 1) + "/" + limitStr + ")."); } @@ -129,13 +143,13 @@ final class ChunkLoaderListener implements Listener { event.setExpToDrop(0); if (player.getGameMode() == GameMode.CREATIVE) { - Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada (Modo Criativo)."); + Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada (Modo Criativo)."); } else { Map overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1)); for (ItemStack drop : overflow.values()) { player.getWorld().dropItemNaturally(player.getLocation(), drop); } - Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida para o seu inventário."); + Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada e recolhida para o seu inventário."); } try { @@ -169,10 +183,36 @@ final class ChunkLoaderListener implements Listener { block.getZ() ); if (loader != null) { - // Prevent respawn anchor exploding or depleting charges when used as chunk loader event.setCancelled(true); Player player = event.getPlayer(); - Msg.ok(player, "Âncora de Chunk #" + loader.id() + " (" + loader.ownerName() + ") está ativa."); + boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); + boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin"); + + if (player.isSneaking() && (isOwner || isAdmin)) { + boolean newState = !loader.enabled(); + plugin.chunkLoaders().setEnabled(loader.id(), newState); + Location l = block.getLocation().add(0.5, 0.5, 0.5); + if (newState) { + try { + l.getWorld().playSound(l, Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.2f); + l.getWorld().spawnParticle(Particle.PORTAL, l, 25, 0.2, 0.2, 0.2, 0.05); + } catch (Exception ignored) { + } + Msg.ok(player, "Âncora " + loader.displayName() + " ATIVADA."); + } else { + try { + l.getWorld().playSound(l, Sound.BLOCK_BEACON_DEACTIVATE, 1.0f, 0.8f); + l.getWorld().spawnParticle(Particle.SMOKE, l, 20, 0.2, 0.2, 0.2, 0.02); + } catch (Exception ignored) { + } + Msg.ok(player, "Âncora " + loader.displayName() + " PAUSADA/DESATIVADA."); + } + plugin.blueMap().syncChunkLoaders(); + return; + } + + String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA"; + Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr + ". (Shift + Clique Direito para alternar)"); } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 37bd90e..52f6cb0 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -69,6 +69,8 @@ final class ChunkLoaders { for (String key : root.getKeys(false)) { try { long id = Long.parseLong(key); + String name = root.getString(key + ".name", ""); + boolean enabled = root.getBoolean(key + ".enabled", true); String ownerUuid = root.getString(key + ".owner_uuid", ""); String ownerName = root.getString(key + ".owner_name", "?"); String world = root.getString(key + ".world", "world"); @@ -79,7 +81,7 @@ final class ChunkLoaders { int chunkZ = root.getInt(key + ".chunk_z", z >> 4); long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis()); - loaders.add(new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); + loaders.add(new ChunkLoader(id, name, enabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); if (id > maxId) { maxId = id; } @@ -94,10 +96,14 @@ final class ChunkLoaders { * Registers a new chunk loader and activates the ticket in the world. */ ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) { + return add(ownerUuid, ownerName, world, x, y, z, ""); + } + + ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) { int chunkX = x >> 4; int chunkZ = z >> 4; long id = nextId.getAndIncrement(); - ChunkLoader loader = new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); + ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); synchronized (loaders) { loaders.add(loader); @@ -107,6 +113,79 @@ final class ChunkLoaders { return loader; } + /** + * Renames an existing chunk loader. + */ + boolean rename(long id, String newName) { + synchronized (loaders) { + for (int i = 0; i < loaders.size(); i++) { + ChunkLoader curr = loaders.get(i); + if (curr.id() == id) { + loaders.set(i, curr.withName(newName)); + save(); + return true; + } + } + } + return false; + } + + /** + * Enables or disables (pauses) a chunk loader without destroying the block. + */ + boolean setEnabled(long id, boolean enabled) { + ChunkLoader oldLoader = null; + ChunkLoader updated = null; + synchronized (loaders) { + for (int i = 0; i < loaders.size(); i++) { + ChunkLoader curr = loaders.get(i); + if (curr.id() == id) { + oldLoader = curr; + updated = curr.withEnabled(enabled); + loaders.set(i, updated); + break; + } + } + } + if (oldLoader != null && updated != null) { + if (enabled) { + addTicket(updated); + } else { + removeTicket(oldLoader); + } + save(); + return true; + } + return false; + } + + /** + * Finds a chunk loader by its numeric ID (with or without '#') or custom name. + */ + ChunkLoader find(String query) { + if (query == null || query.isBlank()) { + return null; + } + String trimmed = query.trim(); + try { + long id = Long.parseLong(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed); + ChunkLoader loader = byId(id); + if (loader != null) { + return loader; + } + } catch (NumberFormatException ignored) { + } + + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + if (loader.name() != null && loader.name().equalsIgnoreCase(trimmed)) { + return loader; + } + } + } + return null; + } + /** * Removes a chunk loader by its unique ID and releases the chunk ticket. */ @@ -219,7 +298,9 @@ final class ChunkLoaders { void loadAllTickets() { synchronized (loaders) { for (ChunkLoader loader : loaders) { - addTicket(loader); + if (loader.enabled()) { + addTicket(loader); + } } } } @@ -256,6 +337,9 @@ final class ChunkLoaders { } for (ChunkLoader loader : currentLoaders) { + if (!loader.enabled()) { + continue; + } try { World w = Bukkit.getWorld(loader.world()); if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) { @@ -394,6 +478,8 @@ final class ChunkLoaders { synchronized (loaders) { for (ChunkLoader l : loaders) { String key = "loaders." + l.id(); + yaml.set(key + ".name", l.name()); + yaml.set(key + ".enabled", l.enabled()); yaml.set(key + ".owner_uuid", l.ownerUuid()); yaml.set(key + ".owner_name", l.ownerName()); yaml.set(key + ".world", l.world()); diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index 4ea4c7e..e5d37ea 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -105,6 +105,36 @@ class ChunkLoaderTest { assertEquals("uuid-ana", l2.ownerUuid()); } + @Test + void renamesAndTogglesEnabledState() { + ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200); + assertEquals("#1", loader.simpleName()); + assertTrue(loader.enabled()); + + assertTrue(loaders.rename(loader.id(), "Farm de Ferro")); + ChunkLoader renamed = loaders.byId(loader.id()); + assertNotNull(renamed); + assertEquals("Farm de Ferro", renamed.name()); + assertEquals("Farm de Ferro (#1)", renamed.displayName()); + assertEquals("Farm de Ferro", renamed.simpleName()); + + assertEquals(renamed, loaders.find("Farm de Ferro")); + assertEquals(renamed, loaders.find("#1")); + assertEquals(renamed, loaders.find("1")); + + assertTrue(loaders.setEnabled(loader.id(), false)); + ChunkLoader disabled = loaders.byId(loader.id()); + assertNotNull(disabled); + assertFalse(disabled.enabled()); + + loaders.flush(); + ChunkLoaders reloaded = new ChunkLoaders(null, file); + ChunkLoader reloadedLoader = reloaded.byId(loader.id()); + assertNotNull(reloadedLoader); + assertEquals("Farm de Ferro", reloadedLoader.name()); + assertFalse(reloadedLoader.enabled()); + } + @Test void simulationSafelyNoopsWithNullPlugin() { loaders.startSimulation(); From 1ff830537e0a71d2095169b3856a971174c2487e Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 13:19:57 -0300 Subject: [PATCH 09/15] feat(chunkloader): add fuel charging, duration timers, and /chunkloader tempo --- .../canalhandia/CanalhandiaCommand.java | 53 ++++++++++++++++--- .../marcospaulo/canalhandia/ChunkLoader.java | 37 +++++++++++-- .../canalhandia/ChunkLoaderListener.java | 42 ++++++++++++++- .../marcospaulo/canalhandia/ChunkLoaders.java | 42 +++++++++++++-- .../canalhandia/ChunkLoaderTest.java | 18 +++++++ 5 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index af32693..2ac3f79 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1826,6 +1826,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest); case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true); case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false); + case "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest); case "admin" -> chunkloaderAdmin(sender, rest); case "dar", "give" -> { if (admin(sender)) { @@ -1863,23 +1864,24 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Component line = Component.text(" " + loader.displayName() + " ", NamedTextColor.AQUA) .append(Component.text(loader.place() + " " + loader.chunkCoords() + " ", NamedTextColor.WHITE)) - .append(Component.text(statusText + " ", statusColor)); + .append(Component.text(statusText + " ", statusColor)) + .append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD)); if (!bedrock) { if (loader.enabled()) { - line = line.append(Component.text("[Pausar] ", NamedTextColor.YELLOW) + line = line.append(Component.text(" [Pausar]", NamedTextColor.YELLOW) .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( Component.text("Clique para pausar " + loader.displayName(), NamedTextColor.GRAY))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand( "/chunkloader desativar " + loader.id()))); } else { - line = line.append(Component.text("[Ativar] ", NamedTextColor.GREEN) + line = line.append(Component.text(" [Ativar]", NamedTextColor.GREEN) .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( Component.text("Clique para reativar " + loader.displayName(), NamedTextColor.GRAY))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand( "/chunkloader ativar " + loader.id()))); } - line = line.append(Component.text("[Remover]", NamedTextColor.RED) + line = line.append(Component.text(" [Remover]", NamedTextColor.RED) .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( Component.text("Clique para recolher " + loader.displayName(), NamedTextColor.GRAY))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( @@ -1916,6 +1918,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) .append(Component.text(here.displayName() + " (" + status + ")", statusCol)) .append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE))); + sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY) + .append(Component.text(here.timeLeft(), NamedTextColor.GOLD))); } else { sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) .append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY))); @@ -1976,6 +1980,40 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA.")); } + private void chunkloaderTime(CommandSender sender, String[] args) { + if (args.length < 2) { + Msg.error(sender, "Uso: /chunkloader tempo "); + return; + } + ChunkLoader loader = plugin.chunkLoaders().find(args[0]); + if (loader == null) { + Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'."); + return; + } + boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); + boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin"); + if (!isOwner && !isAdmin) { + Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + "."); + return; + } + try { + double hours = Double.parseDouble(args[1]); + if (hours <= 0 && isAdmin) { + plugin.chunkLoaders().addTime(loader.id(), -loader.expiresAt()); // permanent + Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE."); + } else { + long millis = (long) (hours * 3600_000L); + plugin.chunkLoaders().addTime(loader.id(), millis); + ChunkLoader updated = plugin.chunkLoaders().byId(loader.id()); + String time = (updated != null) ? updated.timeLeft() : "Permanente"; + Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time); + } + plugin.blueMap().syncChunkLoaders(); + } catch (NumberFormatException e) { + Msg.error(sender, "Horas inválidas: " + args[1]); + } + } + private void chunkloaderRemove(CommandSender sender, String[] args) { if (args.length < 1) { Msg.error(sender, "Uso: /chunkloader remover "); @@ -2187,6 +2225,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader tempo ", NamedTextColor.AQUA) + .append(Component.text(" — Adiciona tempo ou abastece a âncora", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader limite", NamedTextColor.AQUA) .append(Component.text(" — Mostra ou configura os limites de âncoras", NamedTextColor.GRAY))); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { @@ -2358,7 +2398,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { if (args.length == 1) { - List options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "remover", "receita", "limite")); + List options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite")); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { options.addAll(List.of("admin", "dar")); } @@ -2367,7 +2407,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del") || args[0].equalsIgnoreCase("nome") || args[0].equalsIgnoreCase("renomear") || args[0].equalsIgnoreCase("ativar") || args[0].equalsIgnoreCase("desativar") - || args[0].equalsIgnoreCase("pausar"))) { + || args[0].equalsIgnoreCase("pausar") || args[0].equalsIgnoreCase("tempo") + || args[0].equalsIgnoreCase("abastecer"))) { if (sender instanceof Player player) { List identifiers = new ArrayList<>(); for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java index d80ef1f..ba8f3a1 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java @@ -10,6 +10,7 @@ record ChunkLoader( long id, String name, boolean enabled, + long expiresAt, String ownerUuid, String ownerName, String world, @@ -21,7 +22,11 @@ record ChunkLoader( long createdAt) { ChunkLoader(long id, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) { - this(id, "", true, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + this(id, "", true, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + } + + ChunkLoader(long id, String name, boolean enabled, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) { + this(id, name, enabled, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); } String blockCoords() { @@ -44,11 +49,37 @@ record ChunkLoader( return (name != null && !name.isBlank()) ? name : "#" + id; } + boolean isExpired() { + return expiresAt > 0 && System.currentTimeMillis() >= expiresAt; + } + + String timeLeft() { + if (expiresAt <= 0) { + return "Permanente"; + } + long diff = expiresAt - System.currentTimeMillis(); + if (diff <= 0) { + return "Expirado"; + } + long hours = diff / (3600_000L); + long minutes = (diff % (3600_000L)) / 60_000L; + if (hours >= 24) { + long days = hours / 24; + hours = hours % 24; + return days + "d " + hours + "h"; + } + return hours + "h " + minutes + "m"; + } + ChunkLoader withName(String newName) { - return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); } ChunkLoader withEnabled(boolean newEnabled) { - return new ChunkLoader(id, name, newEnabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + return new ChunkLoader(id, name, newEnabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + } + + ChunkLoader withExpiresAt(long newExpiresAt) { + return new ChunkLoader(id, name, enabled, Math.max(0L, newExpiresAt), ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index a4bc90b..b729f2e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -188,6 +188,26 @@ final class ChunkLoaderListener implements Listener { boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin"); + ItemStack inHand = player.getInventory().getItemInMainHand(); + long fuelMillis = fuelDuration(inHand.getType()); + if (fuelMillis > 0 && (isOwner || isAdmin)) { + if (player.getGameMode() != GameMode.CREATIVE) { + inHand.subtract(1); + } + plugin.chunkLoaders().addTime(loader.id(), fuelMillis); + ChunkLoader updated = plugin.chunkLoaders().byId(loader.id()); + Location l = block.getLocation().add(0.5, 0.5, 0.5); + try { + l.getWorld().playSound(l, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 1.0f, 1.2f); + l.getWorld().spawnParticle(Particle.FIREWORK, l, 15, 0.2, 0.2, 0.2, 0.05); + } catch (Exception ignored) { + } + plugin.blueMap().syncChunkLoaders(); + String time = (updated != null) ? updated.timeLeft() : "Permanente"; + Msg.ok(player, "Âncora " + loader.displayName() + " abastecida! Tempo restante: " + time); + return; + } + if (player.isSneaking() && (isOwner || isAdmin)) { boolean newState = !loader.enabled(); plugin.chunkLoaders().setEnabled(loader.id(), newState); @@ -212,10 +232,30 @@ final class ChunkLoaderListener implements Listener { } String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA"; - Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr + ". (Shift + Clique Direito para alternar)"); + Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr + + " [Tempo: " + loader.timeLeft() + "]. (Shift + Clique Direito para pausar/ativar | Clique com combustível para abastecer)"); } } + private static long fuelDuration(Material type) { + if (type == Material.REDSTONE || type == Material.GLOWSTONE_DUST) { + return 30 * 60_000L; // 30 min + } + if (type == Material.GLOWSTONE || type == Material.AMETHYST_SHARD) { + return 2 * 3600_000L; // 2h + } + if (type == Material.ENDER_PEARL || type == Material.BLAZE_POWDER || type == Material.BLAZE_ROD) { + return 4 * 3600_000L; // 4h + } + if (type == Material.ENDER_EYE || type == Material.DIAMOND || type == Material.EMERALD) { + return 12 * 3600_000L; // 12h + } + if (type == Material.NETHER_STAR || type == Material.NETHERITE_INGOT || type == Material.END_CRYSTAL) { + return 7 * 24 * 3600_000L; // 7 dias + } + return 0L; + } + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onBlockExplode(BlockExplodeEvent event) { protectExplosions(event.blockList().iterator()); diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 52f6cb0..4d5b380 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -71,6 +71,7 @@ final class ChunkLoaders { long id = Long.parseLong(key); String name = root.getString(key + ".name", ""); boolean enabled = root.getBoolean(key + ".enabled", true); + long expiresAt = root.getLong(key + ".expires_at", 0L); String ownerUuid = root.getString(key + ".owner_uuid", ""); String ownerName = root.getString(key + ".owner_name", "?"); String world = root.getString(key + ".world", "world"); @@ -81,7 +82,7 @@ final class ChunkLoaders { int chunkZ = root.getInt(key + ".chunk_z", z >> 4); long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis()); - loaders.add(new ChunkLoader(id, name, enabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); + loaders.add(new ChunkLoader(id, name, enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); if (id > maxId) { maxId = id; } @@ -96,14 +97,18 @@ final class ChunkLoaders { * Registers a new chunk loader and activates the ticket in the world. */ ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) { - return add(ownerUuid, ownerName, world, x, y, z, ""); + return add(ownerUuid, ownerName, world, x, y, z, "", 0L); } ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) { + return add(ownerUuid, ownerName, world, x, y, z, name, 0L); + } + + ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) { int chunkX = x >> 4; int chunkZ = z >> 4; long id = nextId.getAndIncrement(); - ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); + ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); synchronized (loaders) { loaders.add(loader); @@ -113,6 +118,32 @@ final class ChunkLoaders { return loader; } + /** + * Adds duration (in milliseconds) to a chunk loader's expiration timer. + */ + boolean addTime(long id, long millisToAdd) { + ChunkLoader updated = null; + synchronized (loaders) { + for (int i = 0; i < loaders.size(); i++) { + ChunkLoader curr = loaders.get(i); + if (curr.id() == id) { + long now = System.currentTimeMillis(); + long base = (curr.expiresAt() > now) ? curr.expiresAt() : now; + long newExpires = base + millisToAdd; + updated = curr.withExpiresAt(newExpires).withEnabled(true); + loaders.set(i, updated); + break; + } + } + } + if (updated != null) { + addTicket(updated); + save(); + return true; + } + return false; + } + /** * Renames an existing chunk loader. */ @@ -337,6 +368,10 @@ final class ChunkLoaders { } for (ChunkLoader loader : currentLoaders) { + if (loader.isExpired() && loader.enabled()) { + setEnabled(loader.id(), false); + continue; + } if (!loader.enabled()) { continue; } @@ -480,6 +515,7 @@ final class ChunkLoaders { String key = "loaders." + l.id(); yaml.set(key + ".name", l.name()); yaml.set(key + ".enabled", l.enabled()); + yaml.set(key + ".expires_at", l.expiresAt()); yaml.set(key + ".owner_uuid", l.ownerUuid()); yaml.set(key + ".owner_name", l.ownerName()); yaml.set(key + ".world", l.world()); diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index e5d37ea..3b812c8 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -135,6 +135,24 @@ class ChunkLoaderTest { assertFalse(reloadedLoader.enabled()); } + @Test + void managesExpirationAndFuelTime() { + ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200, "Base", 0L); + assertEquals("Permanente", loader.timeLeft()); + assertFalse(loader.isExpired()); + + assertTrue(loaders.addTime(loader.id(), 2 * 3600_000L)); // +2h + ChunkLoader timed = loaders.byId(loader.id()); + assertNotNull(timed); + assertTrue(timed.expiresAt() > System.currentTimeMillis()); + assertFalse(timed.isExpired()); + assertTrue(timed.timeLeft().contains("h")); + + ChunkLoader expired = timed.withExpiresAt(System.currentTimeMillis() - 1000L); + assertTrue(expired.isExpired()); + assertEquals("Expirado", expired.timeLeft()); + } + @Test void simulationSafelyNoopsWithNullPlugin() { loaders.startSimulation(); From f2a19a729a189924e7073628c03e73f3856a376b Mon Sep 17 00:00:00 2001 From: pragent test Date: Thu, 20 Aug 2026 16:38:42 +0000 Subject: [PATCH 10/15] chore: retrigger pragent review --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6b60d8f..ec75047 100644 --- a/README.md +++ b/README.md @@ -610,3 +610,4 @@ phrase it so the count never has to agree with a translated noun. Statistic constants get renamed between Minecraft releases, so resolve them via `Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity instead of breaking the whole announcement. + From 07e5bb601833703f6dbacd9e82541aa3a98c5277 Mon Sep 17 00:00:00 2001 From: pragent test Date: Thu, 20 Aug 2026 17:17:44 +0000 Subject: [PATCH 11/15] chore: retrigger pragent review (post format-overhaul) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ec75047..30d65f0 100644 --- a/README.md +++ b/README.md @@ -611,3 +611,4 @@ Statistic constants get renamed between Minecraft releases, so resolve them via `Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity instead of breaking the whole announcement. + From cc5c983e41e0c22f97d489756c3f68c49736bc98 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 14:30:26 -0300 Subject: [PATCH 12/15] fix(pr-reviews): address spawner reset, numeric name resolution, and activation validation --- .../canalhandia/CanalhandiaCommand.java | 26 +++++++++----- .../canalhandia/ChunkLoaderListener.java | 4 +++ .../marcospaulo/canalhandia/ChunkLoaders.java | 35 +++++++++++++++---- .../canalhandia/VoidProtection.java | 25 +++++++++++++ .../canalhandia/ChunkLoaderTest.java | 6 ++++ .../canalhandia/VoidProtectionTest.java | 8 +++++ 6 files changed, 88 insertions(+), 16 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 2ac3f79..209b11a 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1975,6 +1975,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + "."); return; } + if (enabled && loader.isExpired()) { + Msg.error(sender, "A âncora " + loader.displayName() + " está expirada! Adicione tempo (/chunkloader tempo " + loader.id() + " ) ou abasteça com combustível antes de reativar."); + return; + } plugin.chunkLoaders().setEnabled(loader.id(), enabled); plugin.blueMap().syncChunkLoaders(); Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA.")); @@ -1998,16 +2002,20 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } try { double hours = Double.parseDouble(args[1]); - if (hours <= 0 && isAdmin) { - plugin.chunkLoaders().addTime(loader.id(), -loader.expiresAt()); // permanent - Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE."); - } else { - long millis = (long) (hours * 3600_000L); - plugin.chunkLoaders().addTime(loader.id(), millis); - ChunkLoader updated = plugin.chunkLoaders().byId(loader.id()); - String time = (updated != null) ? updated.timeLeft() : "Permanente"; - Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time); + if (hours <= 0) { + if (isAdmin) { + plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent + Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE."); + } else { + Msg.error(sender, "A quantidade de horas deve ser maior que zero."); + } + return; } + long millis = (long) (hours * 3600_000L); + plugin.chunkLoaders().addTime(loader.id(), millis); + ChunkLoader updated = plugin.chunkLoaders().byId(loader.id()); + String time = (updated != null) ? updated.timeLeft() : "Permanente"; + Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time); plugin.blueMap().syncChunkLoaders(); } catch (NumberFormatException e) { Msg.error(sender, "Horas inválidas: " + args[1]); diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index b729f2e..f4b0eac 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -209,6 +209,10 @@ final class ChunkLoaderListener implements Listener { } if (player.isSneaking() && (isOwner || isAdmin)) { + if (!loader.enabled() && loader.isExpired()) { + Msg.error(player, "A âncora " + loader.displayName() + " está expirada! Clique com combustível (Pérola do End, Blaze, Diamante, etc.) ou adicione tempo antes de reativar."); + return; + } boolean newState = !loader.enabled(); plugin.chunkLoaders().setEnabled(loader.id(), newState); Location l = block.getLocation().add(0.5, 0.5, 0.5); diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 4d5b380..4d97871 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -191,20 +191,23 @@ final class ChunkLoaders { } /** - * Finds a chunk loader by its numeric ID (with or without '#') or custom name. + * Finds a chunk loader by its numeric ID (with '#' prefix or plain ID) or custom name. + * Custom names take precedence over raw numeric IDs to avoid shadowing named loaders. */ ChunkLoader find(String query) { if (query == null || query.isBlank()) { return null; } String trimmed = query.trim(); - try { - long id = Long.parseLong(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed); - ChunkLoader loader = byId(id); - if (loader != null) { - return loader; + if (trimmed.startsWith("#")) { + try { + long id = Long.parseLong(trimmed.substring(1)); + ChunkLoader loader = byId(id); + if (loader != null) { + return loader; + } + } catch (NumberFormatException ignored) { } - } catch (NumberFormatException ignored) { } synchronized (loaders) { @@ -214,6 +217,15 @@ final class ChunkLoaders { } } } + + try { + long id = Long.parseLong(trimmed); + ChunkLoader loader = byId(id); + if (loader != null) { + return loader; + } + } catch (NumberFormatException ignored) { + } return null; } @@ -503,6 +515,15 @@ final class ChunkLoaders { if (w != null) { w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false); + if (w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) { + Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ()); + for (BlockState state : chunk.getTileEntities()) { + if (state instanceof CreatureSpawner spawner && spawner.getRequiredPlayerRange() > 16) { + spawner.setRequiredPlayerRange(16); + spawner.update(true, false); + } + } + } } } catch (Exception ignored) { } diff --git a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java index 6191934..eb06207 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java +++ b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java @@ -129,6 +129,31 @@ public final class VoidProtection { || mat == Material.SNOW || mat == Material.FERN || mat == Material.LARGE_FERN; } + /** + * Calculates the estimated number of inventory slots needed to store the given items. + */ + public static int calculateRequiredSlots(List items) { + if (items == null || items.isEmpty()) { + return 0; + } + int slots = 0; + for (ItemStack item : items) { + if (item != null && item.getType() != Material.AIR && item.getAmount() > 0) { + int maxStack = Math.max(1, item.getMaxStackSize()); + slots += (int) Math.ceil((double) item.getAmount() / maxStack); + } + } + return slots; + } + + public static boolean canFitInSingleChest(List items) { + return calculateRequiredSlots(items) <= 27; + } + + public static boolean canFitInDoubleChest(List items) { + return calculateRequiredSlots(items) <= 54; + } + /** * Stores items into a chest (and an adjacent chest if needed) at the target location. * All items must be stored without overflow; on any failure, blocks are rolled back diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index 3b812c8..9201a86 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -122,6 +122,12 @@ class ChunkLoaderTest { assertEquals(renamed, loaders.find("#1")); assertEquals(renamed, loaders.find("1")); + // Loader 2 named "1" should be prioritized over ID 1 when querying by plain "1" + ChunkLoader loader2 = loaders.add("uuid-marcos", "Marcos", "world", 200, 64, 300, "1", 0L); + assertEquals(loader2, loaders.find("1")); + assertEquals(renamed, loaders.find("#1")); + assertEquals(loader2, loaders.find("#2")); + assertTrue(loaders.setEnabled(loader.id(), false)); ChunkLoader disabled = loaders.byId(loader.id()); assertNotNull(disabled); diff --git a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java index 2c0bfdb..8e8f639 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java @@ -37,4 +37,12 @@ class VoidProtectionTest { assertFalse(VoidProtection.isSafeGround(null)); assertFalse(VoidProtection.isReplaceable(null)); } + + @Test + void calculatesRequiredSlotsAndChestFit() { + assertEquals(0, VoidProtection.calculateRequiredSlots(null)); + assertEquals(0, VoidProtection.calculateRequiredSlots(List.of())); + assertTrue(VoidProtection.canFitInSingleChest(List.of())); + assertTrue(VoidProtection.canFitInDoubleChest(List.of())); + } } From b6e508c8c9f4937d226e956842075b978d9a1847 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 14:51:06 -0300 Subject: [PATCH 13/15] fix(pr-reviews): complete all PR comments resolutions and test coverage --- .../marcospaulo/canalhandia/ChunkLoaders.java | 54 ++++++++++++++----- .../canalhandia/VoidProtection.java | 29 ++++++---- .../canalhandia/ChunkLoaderTest.java | 44 +++++++++++++++ .../canalhandia/VoidProtectionTest.java | 36 +++++++++++++ 4 files changed, 141 insertions(+), 22 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 4d97871..2dadafb 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -144,6 +144,29 @@ final class ChunkLoaders { return false; } + /** + * Sets a specific expiration timestamp (or 0 for permanent) for a chunk loader. + */ + boolean setExpiresAt(long id, long expiresAt) { + ChunkLoader updated = null; + synchronized (loaders) { + for (int i = 0; i < loaders.size(); i++) { + ChunkLoader curr = loaders.get(i); + if (curr.id() == id) { + updated = curr.withExpiresAt(expiresAt).withEnabled(true); + loaders.set(i, updated); + break; + } + } + } + if (updated != null) { + addTicket(updated); + save(); + return true; + } + return false; + } + /** * Renames an existing chunk loader. */ @@ -459,15 +482,21 @@ final class ChunkLoaders { } static EntityType pickEntityType(World w, Block space, int light) { - World.Environment env = w.getEnvironment(); - ThreadLocalRandom rnd = ThreadLocalRandom.current(); + boolean isSlime = false; + try { + isSlime = space.getChunk().isSlimeChunk(); + } catch (Exception ignored) { + } + return pickEntityType(w.getEnvironment(), isSlime, space.getY(), light, ThreadLocalRandom.current().nextInt(100)); + } + static EntityType pickEntityType(World.Environment env, boolean isSlimeChunk, int y, int light, int roll) { if (env == World.Environment.NETHER) { if (light > 11) return null; - int roll = rnd.nextInt(100); - if (roll < 45) return EntityType.ZOMBIFIED_PIGLIN; - if (roll < 65) return EntityType.WITHER_SKELETON; - if (roll < 85) return EntityType.BLAZE; + int r = Math.floorMod(roll, 100); + if (r < 45) return EntityType.ZOMBIFIED_PIGLIN; + if (r < 65) return EntityType.WITHER_SKELETON; + if (r < 85) return EntityType.BLAZE; return EntityType.MAGMA_CUBE; } @@ -475,8 +504,7 @@ final class ChunkLoaders { return EntityType.ENDERMAN; } - boolean isSlimeChunk = space.getChunk().isSlimeChunk() && space.getY() < 40; - if (isSlimeChunk && light <= 7 && rnd.nextInt(3) == 0) { + if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) { return EntityType.SLIME; } @@ -484,11 +512,11 @@ final class ChunkLoaders { return null; } - int roll = rnd.nextInt(100); - if (roll < 35) return EntityType.ZOMBIE; - if (roll < 65) return EntityType.SKELETON; - if (roll < 85) return EntityType.CREEPER; - if (roll < 95) return EntityType.SPIDER; + int r = Math.floorMod(roll, 100); + if (r < 35) return EntityType.ZOMBIE; + if (r < 65) return EntityType.SKELETON; + if (r < 85) return EntityType.CREEPER; + if (r < 95) return EntityType.SPIDER; return EntityType.WITCH; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java index eb06207..600d6bc 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java +++ b/src/main/java/dev/marcospaulo/canalhandia/VoidProtection.java @@ -108,24 +108,35 @@ public final class VoidProtection { } static boolean isSafeGround(Block block) { - if (block == null) { + return block != null && isSafeGroundMaterial(block.getType()); + } + + public static boolean isSafeGroundMaterial(Material mat) { + if (mat == null || mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR) { return false; } - Material mat = block.getType(); - if (mat.isAir() || !mat.isSolid()) { + if (mat == Material.LAVA || mat == Material.WATER || mat == Material.FIRE || mat == Material.SOUL_FIRE + || mat == Material.CACTUS || mat == Material.MAGMA_BLOCK || mat == Material.SWEET_BERRY_BUSH + || mat == Material.WITHER_ROSE || mat == Material.POWDER_SNOW) { return false; } - return mat != Material.LAVA && mat != Material.FIRE && mat != Material.SOUL_FIRE - && mat != Material.CACTUS && mat != Material.MAGMA_BLOCK && mat != Material.SWEET_BERRY_BUSH - && mat != Material.WITHER_ROSE && mat != Material.POWDER_SNOW; + try { + return mat.isSolid(); + } catch (Exception | LinkageError e) { + return true; + } } static boolean isReplaceable(Block block) { - if (block == null) { + return block != null && isReplaceableMaterial(block.getType()); + } + + public static boolean isReplaceableMaterial(Material mat) { + if (mat == null) { return false; } - Material mat = block.getType(); - return mat.isAir() || mat == Material.SHORT_GRASS || mat == Material.TALL_GRASS + return mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR + || mat == Material.SHORT_GRASS || mat == Material.TALL_GRASS || mat == Material.SNOW || mat == Material.FERN || mat == Material.LARGE_FERN; } diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index 9201a86..cbd8ef8 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -157,6 +157,50 @@ class ChunkLoaderTest { ChunkLoader expired = timed.withExpiresAt(System.currentTimeMillis() - 1000L); assertTrue(expired.isExpired()); assertEquals("Expirado", expired.timeLeft()); + + assertTrue(loaders.setExpiresAt(loader.id(), 0L)); + ChunkLoader permanent = loaders.byId(loader.id()); + assertNotNull(permanent); + assertEquals(0L, permanent.expiresAt()); + assertEquals("Permanente", permanent.timeLeft()); + assertFalse(permanent.isExpired()); + } + + @Test + void pickEntityTypeReturnsExpectedEntitiesByEnvironment() { + // Nether + assertEquals(org.bukkit.entity.EntityType.ZOMBIFIED_PIGLIN, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 10)); + assertEquals(org.bukkit.entity.EntityType.WITHER_SKELETON, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 50)); + assertEquals(org.bukkit.entity.EntityType.BLAZE, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 75)); + assertEquals(org.bukkit.entity.EntityType.MAGMA_CUBE, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 90)); + assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 12, 10)); + + // End + assertEquals(org.bukkit.entity.EntityType.ENDERMAN, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.THE_END, false, 64, 15, 50)); + + // Overworld Slime Chunk + assertEquals(org.bukkit.entity.EntityType.SLIME, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, true, 30, 5, 0)); + + // Overworld Normal Darkness + assertEquals(org.bukkit.entity.EntityType.ZOMBIE, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 10)); + assertEquals(org.bukkit.entity.EntityType.SKELETON, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 45)); + assertEquals(org.bukkit.entity.EntityType.CREEPER, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 75)); + assertEquals(org.bukkit.entity.EntityType.SPIDER, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 90)); + assertEquals(org.bukkit.entity.EntityType.WITCH, + ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 99)); + + // Overworld in Light -> Null + assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 5, 10)); } @Test diff --git a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java index 8e8f639..5e97fa5 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/VoidProtectionTest.java @@ -32,6 +32,42 @@ class VoidProtectionTest { assertFalse(VoidProtection.rescueToChest(List.of(), null)); } + @Test + void validatesSafeGroundMaterials() { + assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.STONE)); + assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.GRASS_BLOCK)); + assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.DIRT)); + assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.OBSIDIAN)); + + assertFalse(VoidProtection.isSafeGroundMaterial(null)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.AIR)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.LAVA)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.FIRE)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SOUL_FIRE)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.CACTUS)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.MAGMA_BLOCK)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SWEET_BERRY_BUSH)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.WITHER_ROSE)); + assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.POWDER_SNOW)); + } + + @Test + void validatesReplaceableMaterials() { + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.AIR)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CAVE_AIR)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.VOID_AIR)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SHORT_GRASS)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.TALL_GRASS)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SNOW)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.FERN)); + assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.LARGE_FERN)); + + assertFalse(VoidProtection.isReplaceableMaterial(null)); + assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.STONE)); + assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CHEST)); + assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.OBSIDIAN)); + } + @Test void nullBlocksAreNeitherSafeNorReplaceable() { assertFalse(VoidProtection.isSafeGround(null)); From 8a34565f3c1083826eed2b95706bc96cd5a44eb9 Mon Sep 17 00:00:00 2001 From: marcos Date: Thu, 20 Aug 2026 17:56:03 +0000 Subject: [PATCH 14/15] chore(pr-review): re-trigger bot review against updated .pr-review.json From bc7a88b9e2bd904111e59d5f8a70cf99bc7875e3 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 15:08:41 -0300 Subject: [PATCH 15/15] chore: sync .pr-review.json with main --- .pr-review.json | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/.pr-review.json b/.pr-review.json index f5f311c..ad368f3 100644 --- a/.pr-review.json +++ b/.pr-review.json @@ -1,21 +1,30 @@ { - "languages": [ - "java", - "yaml", - "markdown" - ], "focus": [ - "thread-safety", - "paper-chunk-ticketing", - "item-loss-prevention", - "resource-cleanup", - "performance-and-chunk-loading", - "null-safety-and-unit-tests" + "correctness", + "security", + "performance" ], "exclude_paths": [ "target/**", - "*.bak*", - "docs/**" + "*.class" ], - "instructions": "Canalhandia is a Minecraft Paper 1.21.x server plugin written in modern Java 25. Enforce these core invariants:\n1. Thread Safety: Bukkit API, World, Entity, and Inventory mutations MUST run on the main server thread. Async threads only do pure calculation or async file I/O.\n2. Item & Inventory Safety: Never discard player items. Always handle full-inventory overflow by dropping excess items at the player's location. On multi-step container placement (e.g. chests), roll back blocks if not all items fit.\n3. Lifecycle & Cleanup: All registered chunk tickets, recipes, schedulers, and I/O executors must be cleanly flushed and unloaded in onDisable() and module toggles.\n4. Chunk Loading: Never trigger synchronous chunk generation or loading inside event handlers. Always check world.isChunkLoaded() before querying blocks.\n5. Test Coverage: All domain logic, coordinates math, parsers, and pure helpers must have corresponding JUnit tests in src/test/java." + "languages": [ + "java" + ], + "style": "balanced", + "require_tests": true, + "exclude_tests": false, + "max_findings": 15, + "severity_threshold": "low", + "patterns": { + "deny": [ + "**/README.md", + "**/*.md" + ] + }, + "cost_target": "claude-sonnet-5", + "additional_context_urls": [ + "http://nexus-service.nexus.svc.cluster.local:8081/repository/raw-hosted/canalhandia/architecture.md" + ], + "instructions": "Minecraft plugin (Paper 26.2, pt-BR, JDK 25 build). Chat-only — never touch gameplay. Watch thread-safety on event handlers (PlayerDeathEvent, PlayerInteractEvent) — the Bukkit main thread is single-threaded but async chunks/events cross it. Avoid main-thread I/O; defer expensive scans (chunk loading, spiral search) to scheduled tasks or async paths. Flag mutable shared state across listener invocations. Hard constraints: chat messages are immutable after send (counts baked into buttons freeze at send time); names go out as translatable components so the singular-form rule applies (number never agrees with the noun); Geyser/Bedrock cannot click and cannot show emoji (every click has a typed fallback); vanilla statistics are the only data source (offline path is /players/stats/.json, NOT /stats); reactions keep counting late (reacao-validade-minutos); Floodgate is optional runtime dep. Flag: real bugs, missing persistence of new settings, comando/permission not in plugin.yml, breaking Bedrock equivalent invariant, removing the frozen-at-send assumption, violating singular-form rule, missing Stats.resolve() on renames." }