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)); + } +}