Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ddc22409e | |||
| bc7a88b9e2 | |||
| 8a34565f3c | |||
| f24af7ab08 | |||
| b6e508c8c9 | |||
| cc5c983e41 | |||
| 07e5bb6018 | |||
| f2a19a729a | |||
| dfb84047ec | |||
| 8821e6d2c0 | |||
| 1ff830537e | |||
| da4a1777a7 | |||
| ce3b1ebbd9 | |||
| 5a6bb9a13d | |||
| 4b9507b36e | |||
| 4261e8b660 | |||
| 2c7f5f98ad | |||
| c3c4906e11 | |||
| d93711e5c1 | |||
| 24ab63b9be | |||
| e059ca6563 |
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"focus": [
|
||||||
|
"correctness",
|
||||||
|
"security",
|
||||||
|
"performance"
|
||||||
|
],
|
||||||
|
"exclude_paths": [
|
||||||
|
"target/**",
|
||||||
|
"*.class"
|
||||||
|
],
|
||||||
|
"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 <world>/players/stats/<uuid>.json, NOT <world>/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."
|
||||||
|
}
|
||||||
@@ -610,3 +610,5 @@ phrase it so the count never has to agree with a translated noun.
|
|||||||
Statistic constants get renamed between Minecraft releases, so resolve them via
|
Statistic constants get renamed between Minecraft releases, so resolve them via
|
||||||
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
||||||
instead of breaking the whole announcement.
|
instead of breaking the whole announcement.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -127,9 +127,13 @@ final class BlueMapBridge {
|
|||||||
if (!mapMatchesWorld(map, loader.world())) {
|
if (!mapMatchesWorld(map, loader.world())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
String statusHtml = loader.enabled()
|
||||||
|
? "<span style='color:#55ff55'>Ativo</span>"
|
||||||
|
: "<span style='color:#ff5555'>Pausado</span>";
|
||||||
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
|
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
|
||||||
.label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")")
|
.label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
|
||||||
.detail("<b>Âncora de Chunk #" + loader.id() + "</b><br>Dono: "
|
.detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
|
||||||
|
+ statusHtml + "<br>Dono: "
|
||||||
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
|
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
|
||||||
.position(loader.x(), loader.y(), loader.z())
|
.position(loader.x(), loader.y(), loader.z())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
|
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
|
||||||
if (settings.moduleEnabled(Module.CHUNKLOADER)) {
|
if (settings.moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
chunkLoaders.loadAllTickets();
|
chunkLoaders.loadAllTickets();
|
||||||
|
chunkLoaders.startSimulation();
|
||||||
ChunkAnchorItem.registerRecipe(this);
|
ChunkAnchorItem.registerRecipe(this);
|
||||||
}
|
}
|
||||||
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
||||||
@@ -192,6 +193,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
poll.hide();
|
poll.hide();
|
||||||
}
|
}
|
||||||
if (chunkLoaders != null) {
|
if (chunkLoaders != null) {
|
||||||
|
chunkLoaders.stopSimulation();
|
||||||
chunkLoaders.unloadAllTickets();
|
chunkLoaders.unloadAllTickets();
|
||||||
chunkLoaders.close();
|
chunkLoaders.close();
|
||||||
ChunkAnchorItem.unregisterRecipe(this);
|
ChunkAnchorItem.unregisterRecipe(this);
|
||||||
@@ -837,6 +839,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
|
||||||
@@ -880,7 +936,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
Location loc = player.getLocation();
|
Location loc = player.getLocation();
|
||||||
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
|
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
|
||||||
+ " (" + loc.getWorld().getName() + ")";
|
+ " (" + loc.getWorld().getName() + ")";
|
||||||
boolean keepInventory = Boolean.TRUE.equals(
|
boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals(
|
||||||
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
||||||
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
|
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
|
||||||
|
|
||||||
|
|||||||
@@ -570,8 +570,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
if (module == Module.CHUNKLOADER) {
|
if (module == Module.CHUNKLOADER) {
|
||||||
if (on) {
|
if (on) {
|
||||||
plugin.chunkLoaders().loadAllTickets();
|
plugin.chunkLoaders().loadAllTickets();
|
||||||
|
plugin.chunkLoaders().startSimulation();
|
||||||
ChunkAnchorItem.registerRecipe(plugin);
|
ChunkAnchorItem.registerRecipe(plugin);
|
||||||
} else {
|
} else {
|
||||||
|
plugin.chunkLoaders().stopSimulation();
|
||||||
plugin.chunkLoaders().unloadAllTickets();
|
plugin.chunkLoaders().unloadAllTickets();
|
||||||
ChunkAnchorItem.unregisterRecipe(plugin);
|
ChunkAnchorItem.unregisterRecipe(plugin);
|
||||||
}
|
}
|
||||||
@@ -1820,6 +1822,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "info", "chunk" -> chunkloaderInfo(sender);
|
case "info", "chunk" -> chunkloaderInfo(sender);
|
||||||
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
|
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
|
||||||
case "receita", "craft" -> chunkloaderRecipe(sender);
|
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 "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest);
|
||||||
case "admin" -> chunkloaderAdmin(sender, rest);
|
case "admin" -> chunkloaderAdmin(sender, rest);
|
||||||
case "dar", "give" -> {
|
case "dar", "give" -> {
|
||||||
if (admin(sender)) {
|
if (admin(sender)) {
|
||||||
@@ -1852,13 +1859,31 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
|
|
||||||
boolean bedrock = isBedrock(sender);
|
boolean bedrock = isBedrock(sender);
|
||||||
for (ChunkLoader loader : mine) {
|
for (ChunkLoader loader : mine) {
|
||||||
Component line = Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA)
|
String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]";
|
||||||
.append(Component.text(loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE));
|
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))
|
||||||
|
.append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD));
|
||||||
|
|
||||||
if (!bedrock) {
|
if (!bedrock) {
|
||||||
|
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)
|
line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
|
||||||
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
.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(
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
|
||||||
"/chunkloader remover " + loader.id())));
|
"/chunkloader remover " + loader.id())));
|
||||||
}
|
}
|
||||||
@@ -1888,9 +1913,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
.append(Component.text(" em " + world, NamedTextColor.WHITE)));
|
.append(Component.text(" em " + world, NamedTextColor.WHITE)));
|
||||||
|
|
||||||
if (here != null) {
|
if (here != null) {
|
||||||
|
String status = here.enabled() ? "Ativa" : "Pausada";
|
||||||
|
NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
|
||||||
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
.append(Component.text("Carregada continuamente por Âncora #" + here.id(), NamedTextColor.GREEN))
|
.append(Component.text(here.displayName() + " (" + status + ")", statusCol))
|
||||||
.append(Component.text(" (" + here.ownerName() + ")", NamedTextColor.WHITE)));
|
.append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE)));
|
||||||
|
sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(here.timeLeft(), NamedTextColor.GOLD)));
|
||||||
} else {
|
} else {
|
||||||
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
|
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
|
||||||
@@ -1900,24 +1929,112 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
.append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW)));
|
.append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRename(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader nome <id|nome_antigo> <novo_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") + " <id|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;
|
||||||
|
}
|
||||||
|
if (loader.enabled() == enabled) {
|
||||||
|
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() + " <horas>) 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."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderTime(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader tempo <id|nome> <horas>");
|
||||||
|
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) {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void chunkloaderRemove(CommandSender sender, String[] args) {
|
private void chunkloaderRemove(CommandSender sender, String[] args) {
|
||||||
if (args.length < 1) {
|
if (args.length < 1) {
|
||||||
Msg.error(sender, "Uso: /chunkloader remover <id>");
|
Msg.error(sender, "Uso: /chunkloader remover <id|nome>");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long id = parseLong(args[0]);
|
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
|
||||||
if (id <= 0) {
|
|
||||||
Msg.error(sender, "ID inválido.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChunkLoader loader = plugin.chunkLoaders().byId(id);
|
|
||||||
if (loader == null) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
long id = loader.id();
|
||||||
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
@@ -1945,7 +2062,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
for (ItemStack drop : overflow.values()) {
|
for (ItemStack drop : overflow.values()) {
|
||||||
player.getWorld().dropItemNaturally(player.getLocation(), drop);
|
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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
||||||
@@ -1955,7 +2072,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} 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() + ".");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2038,16 +2155,88 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!");
|
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 <quantidade>", NamedTextColor.YELLOW)));
|
||||||
|
sender.sendMessage(Component.text(" Definir por jogador (LuckPerms): ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("/chunkloader limite <jogador> <quantidade>", 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 <quantidade>");
|
||||||
|
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) {
|
private boolean chunkloaderHelp(CommandSender sender) {
|
||||||
Msg.header(sender, "Âncoras de Chunk");
|
Msg.header(sender, "Âncoras de Chunk");
|
||||||
sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA)
|
sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA)
|
||||||
.append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY)));
|
.append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY)));
|
||||||
sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA)
|
sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA)
|
||||||
.append(Component.text(" — Lista suas âncoras ativas e coordenadas", NamedTextColor.GRAY)));
|
.append(Component.text(" — Lista suas âncoras, nomes, status e coordenadas", NamedTextColor.GRAY)));
|
||||||
sender.sendMessage(Component.text(" /chunkloader remover <id>", NamedTextColor.AQUA)
|
sender.sendMessage(Component.text(" /chunkloader nome <id|nome> <novo_nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Define um nome/rótulo para a âncora", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader ativar <id|nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Reativa o carregamento contínuo da âncora", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader desativar <id|nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Pausa o carregamento sem quebrar o bloco", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader remover <id|nome>", NamedTextColor.AQUA)
|
||||||
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
|
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
|
||||||
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
|
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
|
||||||
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
|
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader tempo <id|nome> <horas>", 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")) {
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW)
|
sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW)
|
||||||
.append(Component.text(" — Comandos de administração", NamedTextColor.GRAY)));
|
.append(Component.text(" — Comandos de administração", NamedTextColor.GRAY)));
|
||||||
@@ -2217,21 +2406,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
|
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
|
||||||
if (args.length == 1) {
|
if (args.length == 1) {
|
||||||
List<String> options = new ArrayList<>(List.of("info", "listar", "remover", "receita"));
|
List<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite"));
|
||||||
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
options.addAll(List.of("admin", "dar"));
|
options.addAll(List.of("admin", "dar"));
|
||||||
}
|
}
|
||||||
return filter(options, args[0]);
|
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") || args[0].equalsIgnoreCase("tempo")
|
||||||
|
|| args[0].equalsIgnoreCase("abastecer"))) {
|
||||||
if (sender instanceof Player player) {
|
if (sender instanceof Player player) {
|
||||||
List<String> ids = new ArrayList<>();
|
List<String> identifiers = new ArrayList<>();
|
||||||
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
|
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")) {
|
||||||
|
List<String> opts = new ArrayList<>(List.of("padrao"));
|
||||||
|
opts.addAll(onlineNames());
|
||||||
|
return filter(opts, args[1]);
|
||||||
|
}
|
||||||
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
|
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
|
||||||
return filter(List.of("listar", "remover", "dar"), args[1]);
|
return filter(List.of("listar", "remover", "dar"), args[1]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ package dev.marcospaulo.canalhandia;
|
|||||||
*/
|
*/
|
||||||
record ChunkLoader(
|
record ChunkLoader(
|
||||||
long id,
|
long id,
|
||||||
|
String name,
|
||||||
|
boolean enabled,
|
||||||
|
long expiresAt,
|
||||||
String ownerUuid,
|
String ownerUuid,
|
||||||
String ownerName,
|
String ownerName,
|
||||||
String world,
|
String world,
|
||||||
@@ -18,6 +21,14 @@ record ChunkLoader(
|
|||||||
int chunkZ,
|
int chunkZ,
|
||||||
long createdAt) {
|
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, 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() {
|
String blockCoords() {
|
||||||
return x + ", " + y + ", " + z;
|
return x + ", " + y + ", " + z;
|
||||||
}
|
}
|
||||||
@@ -29,4 +40,46 @@ record ChunkLoader(
|
|||||||
String place() {
|
String place() {
|
||||||
return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader withEnabled(boolean newEnabled) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
import org.bukkit.GameMode;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
import org.bukkit.Particle;
|
import org.bukkit.Particle;
|
||||||
import org.bukkit.Sound;
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.World;
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Mob;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
import org.bukkit.event.block.BlockBreakEvent;
|
import org.bukkit.event.block.BlockBreakEvent;
|
||||||
import org.bukkit.event.block.BlockExplodeEvent;
|
import org.bukkit.event.block.BlockExplodeEvent;
|
||||||
import org.bukkit.event.block.BlockPistonExtendEvent;
|
import org.bukkit.event.block.BlockPistonExtendEvent;
|
||||||
import org.bukkit.event.block.BlockPistonRetractEvent;
|
import org.bukkit.event.block.BlockPistonRetractEvent;
|
||||||
import org.bukkit.event.block.BlockPlaceEvent;
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
import org.bukkit.event.entity.EntityExplodeEvent;
|
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protects and handles block placement, removal, and explosions for Chunk Anchors.
|
* Protects and handles block placement, removal, and explosions for Chunk Anchors.
|
||||||
@@ -70,13 +78,27 @@ final class ChunkLoaderListener implements Listener {
|
|||||||
return;
|
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(
|
ChunkLoader loader = plugin.chunkLoaders().add(
|
||||||
player.getUniqueId().toString(),
|
player.getUniqueId().toString(),
|
||||||
player.getName(),
|
player.getName(),
|
||||||
world,
|
world,
|
||||||
loc.getBlockX(),
|
loc.getBlockX(),
|
||||||
loc.getBlockY(),
|
loc.getBlockY(),
|
||||||
loc.getBlockZ()
|
loc.getBlockZ(),
|
||||||
|
customName
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -88,11 +110,11 @@ final class ChunkLoaderListener implements Listener {
|
|||||||
plugin.blueMap().syncChunkLoaders();
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
|
||||||
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
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 + ").");
|
+ (current + 1) + "/" + limitStr + ").");
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
public void onBlockBreak(BlockBreakEvent event) {
|
public void onBlockBreak(BlockBreakEvent event) {
|
||||||
Block block = event.getBlock();
|
Block block = event.getBlock();
|
||||||
ChunkLoader loader = plugin.chunkLoaders().byLocation(
|
ChunkLoader loader = plugin.chunkLoaders().byLocation(
|
||||||
@@ -118,16 +140,124 @@ final class ChunkLoaderListener implements Listener {
|
|||||||
|
|
||||||
plugin.chunkLoaders().remove(loader.id());
|
plugin.chunkLoaders().remove(loader.id());
|
||||||
event.setDropItems(false);
|
event.setDropItems(false);
|
||||||
|
event.setExpToDrop(0);
|
||||||
|
|
||||||
|
if (player.getGameMode() == GameMode.CREATIVE) {
|
||||||
|
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada (Modo Criativo).");
|
||||||
|
} else {
|
||||||
|
Map<Integer, ItemStack> 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.displayName() + " desativada e recolhida para o seu inventário.");
|
||||||
|
}
|
||||||
|
|
||||||
Location loc = block.getLocation();
|
|
||||||
try {
|
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);
|
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin.blueMap().syncChunkLoaders();
|
plugin.blueMap().syncChunkLoaders();
|
||||||
Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida.");
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
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)) {
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
+ " [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)
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
@@ -168,4 +298,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Chunk;
|
||||||
|
import org.bukkit.Material;
|
||||||
import org.bukkit.World;
|
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.ConfigurationSection;
|
||||||
import org.bukkit.configuration.file.YamlConfiguration;
|
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.entity.Player;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
import org.bukkit.permissions.PermissionAttachmentInfo;
|
import org.bukkit.permissions.PermissionAttachmentInfo;
|
||||||
import org.bukkit.plugin.Plugin;
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -15,6 +25,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -29,6 +40,7 @@ final class ChunkLoaders {
|
|||||||
private final File file;
|
private final File file;
|
||||||
private final List<ChunkLoader> loaders = new ArrayList<>();
|
private final List<ChunkLoader> loaders = new ArrayList<>();
|
||||||
private final AtomicLong nextId = new AtomicLong(1);
|
private final AtomicLong nextId = new AtomicLong(1);
|
||||||
|
private BukkitTask simulationTask;
|
||||||
|
|
||||||
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
Thread t = new Thread(r, "canalhandia-chunkloaders-io");
|
Thread t = new Thread(r, "canalhandia-chunkloaders-io");
|
||||||
@@ -57,6 +69,9 @@ final class ChunkLoaders {
|
|||||||
for (String key : root.getKeys(false)) {
|
for (String key : root.getKeys(false)) {
|
||||||
try {
|
try {
|
||||||
long id = Long.parseLong(key);
|
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 ownerUuid = root.getString(key + ".owner_uuid", "");
|
||||||
String ownerName = root.getString(key + ".owner_name", "?");
|
String ownerName = root.getString(key + ".owner_name", "?");
|
||||||
String world = root.getString(key + ".world", "world");
|
String world = root.getString(key + ".world", "world");
|
||||||
@@ -67,7 +82,7 @@ final class ChunkLoaders {
|
|||||||
int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
|
int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
|
||||||
long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis());
|
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, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
|
||||||
if (id > maxId) {
|
if (id > maxId) {
|
||||||
maxId = id;
|
maxId = id;
|
||||||
}
|
}
|
||||||
@@ -82,10 +97,18 @@ final class ChunkLoaders {
|
|||||||
* Registers a new chunk loader and activates the ticket in the world.
|
* 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) {
|
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int 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 chunkX = x >> 4;
|
||||||
int chunkZ = z >> 4;
|
int chunkZ = z >> 4;
|
||||||
long id = nextId.getAndIncrement();
|
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, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
|
||||||
|
|
||||||
synchronized (loaders) {
|
synchronized (loaders) {
|
||||||
loaders.add(loader);
|
loaders.add(loader);
|
||||||
@@ -95,6 +118,140 @@ final class ChunkLoaders {
|
|||||||
return loader;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 '#' 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();
|
||||||
|
if (trimmed.startsWith("#")) {
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(trimmed.substring(1));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(trimmed);
|
||||||
|
ChunkLoader loader = byId(id);
|
||||||
|
if (loader != null) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a chunk loader by its unique ID and releases the chunk ticket.
|
* Removes a chunk loader by its unique ID and releases the chunk ticket.
|
||||||
*/
|
*/
|
||||||
@@ -207,7 +364,9 @@ final class ChunkLoaders {
|
|||||||
void loadAllTickets() {
|
void loadAllTickets() {
|
||||||
synchronized (loaders) {
|
synchronized (loaders) {
|
||||||
for (ChunkLoader loader : loaders) {
|
for (ChunkLoader loader : loaders) {
|
||||||
addTicket(loader);
|
if (loader.enabled()) {
|
||||||
|
addTicket(loader);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +379,147 @@ 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<ChunkLoader> currentLoaders;
|
||||||
|
synchronized (loaders) {
|
||||||
|
currentLoaders = new ArrayList<>(loaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChunkLoader loader : currentLoaders) {
|
||||||
|
if (loader.isExpired() && loader.enabled()) {
|
||||||
|
setEnabled(loader.id(), false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!loader.enabled()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env == World.Environment.THE_END) {
|
||||||
|
return EntityType.ENDERMAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) {
|
||||||
|
return EntityType.SLIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (light > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
private void addTicket(ChunkLoader loader) {
|
private void addTicket(ChunkLoader loader) {
|
||||||
if (plugin == null) {
|
if (plugin == null) {
|
||||||
return;
|
return;
|
||||||
@@ -227,6 +527,7 @@ final class ChunkLoaders {
|
|||||||
try {
|
try {
|
||||||
World w = Bukkit.getWorld(loader.world());
|
World w = Bukkit.getWorld(loader.world());
|
||||||
if (w != null) {
|
if (w != null) {
|
||||||
|
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true);
|
||||||
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
@@ -241,6 +542,16 @@ final class ChunkLoaders {
|
|||||||
World w = Bukkit.getWorld(loader.world());
|
World w = Bukkit.getWorld(loader.world());
|
||||||
if (w != null) {
|
if (w != null) {
|
||||||
w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
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) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
@@ -251,6 +562,9 @@ final class ChunkLoaders {
|
|||||||
synchronized (loaders) {
|
synchronized (loaders) {
|
||||||
for (ChunkLoader l : loaders) {
|
for (ChunkLoader l : loaders) {
|
||||||
String key = "loaders." + l.id();
|
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_uuid", l.ownerUuid());
|
||||||
yaml.set(key + ".owner_name", l.ownerName());
|
yaml.set(key + ".owner_name", l.ownerName());
|
||||||
yaml.set(key + ".world", l.world());
|
yaml.set(key + ".world", l.world());
|
||||||
|
|||||||
@@ -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,273 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
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.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 static final int MAX_CHECKED_COLUMNS = 120;
|
||||||
|
|
||||||
|
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.
|
||||||
|
* 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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int centerX = deathLoc.getBlockX();
|
||||||
|
int centerZ = deathLoc.getBlockZ();
|
||||||
|
int minHeight = world.getMinHeight();
|
||||||
|
int checked = 0;
|
||||||
|
|
||||||
|
for (int r = 0; r <= radius; r += (r > 8 ? 2 : 1)) {
|
||||||
|
Location ringBest = null;
|
||||||
|
double ringBestDistSq = Double.MAX_VALUE;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxHeight = world.getMaxHeight();
|
||||||
|
if (topY <= minHeight || topY >= maxHeight - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (distSq < ringBestDistSq) {
|
||||||
|
ringBestDistSq = distSq;
|
||||||
|
ringBest = space.getLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ringBest != null) {
|
||||||
|
return ringBest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isSafeGround(Block block) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return mat.isSolid();
|
||||||
|
} catch (Exception | LinkageError e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isReplaceable(Block block) {
|
||||||
|
return block != null && isReplaceableMaterial(block.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReplaceableMaterial(Material mat) {
|
||||||
|
if (mat == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the estimated number of inventory slots needed to store the given items.
|
||||||
|
*/
|
||||||
|
public static int calculateRequiredSlots(List<ItemStack> 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<ItemStack> items) {
|
||||||
|
return calculateRequiredSlots(items) <= 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean canFitInDoubleChest(List<ItemStack> 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
|
||||||
|
* and false is returned so caller can safely fall back to keepInventory.
|
||||||
|
*/
|
||||||
|
public static boolean rescueToChest(List<ItemStack> items, Location chestLoc) {
|
||||||
|
if (items == null || items.isEmpty() || chestLoc == null) {
|
||||||
|
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Inventory inv = chest.getInventory();
|
||||||
|
List<ItemStack> remaining = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ItemStack item : items) {
|
||||||
|
if (item != null && !item.getType().isAir()) {
|
||||||
|
Map<Integer, ItemStack> leftover = inv.addItem(item.clone());
|
||||||
|
remaining.addAll(leftover.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remaining.isEmpty()) {
|
||||||
|
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<ItemStack> secondLeftover = new ArrayList<>();
|
||||||
|
for (ItemStack rem : remaining) {
|
||||||
|
Map<Integer, ItemStack> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,4 +104,109 @@ class ChunkLoaderTest {
|
|||||||
assertNotNull(l2);
|
assertNotNull(l2);
|
||||||
assertEquals("uuid-ana", l2.ownerUuid());
|
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"));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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 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());
|
||||||
|
|
||||||
|
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
|
||||||
|
void simulationSafelyNoopsWithNullPlugin() {
|
||||||
|
loaders.startSimulation();
|
||||||
|
loaders.tickMobSimulation();
|
||||||
|
loaders.stopSimulation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
@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));
|
||||||
|
assertNull(VoidProtection.findSafeChestLocation(null, null, 0));
|
||||||
|
assertFalse(VoidProtection.rescueToChest(null, null));
|
||||||
|
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));
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user