feat(void): add void death item protection with safe chest and inventory fallback
This commit is contained in:
@@ -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 <on|off>`.
|
||||||
|
- 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.
|
||||||
@@ -837,6 +837,60 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}, settings.reactionWindowSeconds() * 20L);
|
}, 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<ItemStack> 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}
|
* Comic death broadcast + private coordinates, gated by the {@code mortes}
|
||||||
* module. Replaces the vanilla translatable death message with a pt-BR
|
* module. Replaces the vanilla translatable death message with a pt-BR
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ enum Module {
|
|||||||
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
||||||
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
||||||
IA("ia", "Perguntas para a IA"),
|
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 key;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -597,6 +597,24 @@ final class Settings {
|
|||||||
set("chunkloader.bluemap", enabled);
|
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 -----------------------------------------------------------
|
// --- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
private void set(String path, Object value) {
|
private void set(String path, Object value) {
|
||||||
|
|||||||
@@ -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<ItemStack> 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<ItemStack> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user