From 01ada6d987fbc943545f6a9f635ca684c749731a Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 18 Aug 2026 21:45:51 -0300 Subject: [PATCH] feat(chunkloader): add native chunk loader module with LuckPerms limits and BlueMap support --- specs/chunk-loader-module/plan.md | 37 +++ specs/chunk-loader-module/spec.md | 72 +++++ specs/chunk-loader-module/tasks.md | 28 ++ .../canalhandia/BlueMapBridge.java | 75 ++++- .../marcospaulo/canalhandia/Canalhandia.java | 19 +- .../canalhandia/CanalhandiaCommand.java | 276 +++++++++++++++++ .../canalhandia/ChunkAnchorItem.java | 97 ++++++ .../marcospaulo/canalhandia/ChunkLoader.java | 32 ++ .../canalhandia/ChunkLoaderListener.java | 171 +++++++++++ .../marcospaulo/canalhandia/ChunkLoaders.java | 284 ++++++++++++++++++ .../dev/marcospaulo/canalhandia/Module.java | 3 +- .../dev/marcospaulo/canalhandia/Settings.java | 18 ++ src/main/resources/plugin.yml | 10 + .../canalhandia/ChunkLoaderTest.java | 107 +++++++ 14 files changed, 1212 insertions(+), 17 deletions(-) create mode 100644 specs/chunk-loader-module/plan.md create mode 100644 specs/chunk-loader-module/spec.md create mode 100644 specs/chunk-loader-module/tasks.md create mode 100644 src/main/java/dev/marcospaulo/canalhandia/ChunkAnchorItem.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java diff --git a/specs/chunk-loader-module/plan.md b/specs/chunk-loader-module/plan.md new file mode 100644 index 0000000..116d5df --- /dev/null +++ b/specs/chunk-loader-module/plan.md @@ -0,0 +1,37 @@ +# Plan: Módulo Nativo de Chunk Loader + +## Arquitetura +1. **`ChunkLoader.java` (Record puro)** + - `id`, `ownerUuid`, `ownerName`, `world`, `x`, `y`, `z`, `chunkX`, `chunkZ`, `createdAt`. + - Métodos utilitários: `chunkCoords()`, `blockCoords()`, etc. + +2. **`ChunkLoaders.java` (Gerenciador e Persistência)** + - Gerencia lista em memória sincronizada `List`. + - Persistência em `plugins/Canalhandia/chunks.yml` com salvamento assíncrono. + - Métodos: `add()`, `remove()`, `byChunk()`, `byOwner()`, `loadAll()`, `unloadAll()`, `playerLimit(Player)`. + - Executa `world.addPluginChunkTicket` e `world.removePluginChunkTicket`. + +3. **`Module.java` & `Settings.java`** + - Adiciona `CHUNKLOADER("chunkloader", "Âncoras de carregamento contínuo de chunks")` no enum `Module`. + - Adiciona configurações em `Settings.java` (limite padrão, material do bloco, raio de partículas). + +4. **`ChunkLoaderListener.java` (Eventos do Mundo)** + - `BlockPlaceEvent`: Detecta colocação da Âncora, verifica permissões/limites LuckPerms, registra loader e ticket. + - `BlockBreakEvent`: Protege quebra por não-donos, remove ticket e devolve o item customizado. + - `BlockExplodeEvent` / `EntityExplodeEvent`: Impede destruição por explosão. + - `BlockPistonExtendEvent` / `BlockPistonRetractEvent`: Impede movimentação por pistão. + +5. **`CanalhandiaCommand.java` & `ChunkLoaderCommand.java`** + - Subcomandos de `/chunkloader` / `/ancora` e tab-completion completo. + +6. **`BlueMapBridge.java`** + - Cria conjunto de marcadores para chunk loaders no mapa web. + +7. **Testes Unitários (`ChunkLoaderTest.java`)** + - Validação de regras de permissão, cálculo de limite, serialização em YAML e exclusão de duplicatas na mesma chunk. + +## Riscos & Mitigações +- **Risco:** Descarregamento incorreto no shutdown do servidor gerando tickets órfãos. + - **Mitigação:** `unloadAll()` limpo em `onDisable()`, e tickets do Paper (`PluginChunkTicket`) são re-validados no `onEnable()`. +- **Risco:** Jogador contornar limite colocando em mundos não permitidos ou múltiplas na mesma chunk. + - **Mitigação:** Validação estrita de unicidade de chunk (`byChunk(world, cx, cz) != null`) e verificação de limite antes de aceitar o evento. diff --git a/specs/chunk-loader-module/spec.md b/specs/chunk-loader-module/spec.md new file mode 100644 index 0000000..8fe2ef5 --- /dev/null +++ b/specs/chunk-loader-module/spec.md @@ -0,0 +1,72 @@ +# Spec: Módulo Nativo de Chunk Loader (Canalhandia) + +## 1. Visão Geral +Adiciona ao plugin `Canalhandia` um módulo nativo, performático e equilibrado de **Chunk Loading** para o Paper 1.21.x. +Permite que jogadores mantenham áreas/chunks específicas carregadas para farms, redstone e sistemas automatizados usando a API nativa de tickets do Paper (`addPluginChunkTicket`), com permissões granulares e limites por cargo configuráveis via **LuckPerms**. + +--- + +## 2. Requisitos e Mecânicas + +### 2.1. Bloco e Item Customizado: "Âncora de Chunk" (Chunk Anchor) +- **Item Base:** `RESPAWN_ANCHOR` ou `LODESTONE` com nome formatado (`§b§lÂncora de Chunk`), lore explicativa e `PersistentDataContainer` identificando o item customizado. +- **Receita de Crafting:** + - Configurada em `config.yml` (ex: 4 Obsidianas Choronas, 4 Diamantes, 1 Estrela do Nether ou Olho do Fim). + - Desbloqueia automaticamente no livro de receitas ao obter os ingredientes. + +### 2.2. Colocação e Restrições +- Ao colocar o bloco (`BlockPlaceEvent`): + - Verifica se o jogador possui permissão `canalhandia.chunkloader`. + - Verifica o limite de loaders do jogador no LuckPerms: + - Lê permissões numéricas: `canalhandia.chunkloader.limite.` (ex: `limite.1`, `limite.2`, `limite.5`, `limite.10`). + - Pega o maior `` encontrado nas permissões do jogador. + - Se não houver permissão numérica explícita, usa `chunkloader.limite-padrao` do `config.yml` (padrão: 1). + - Administradores com `canalhandia.admin` não têm limite. + - Verifica se já existe um loader ativo na mesma chunk (máximo 1 por chunk). + - Se aprovado: + - Ativa o ticket na chunk: `world.addPluginChunkTicket(chunkX, chunkZ, plugin)`. + - Salva em `chunks.yml`. + - Cria partícula/efeito sonoro de ativação. + - Registra marcador no BlueMap (opcional/configurável). + - Envia mensagem de confirmação informando quantas âncoras o jogador está usando (ex: `1/3 ativas`). + +### 2.3. Remoção e Proteção +- **Proteção:** Apenas o dono da âncora ou administradores (`canalhandia.admin`) podem quebrar o bloco (`BlockBreakEvent`). +- **Explosões / Pistões:** Protegido contra destruição acidental por TNT/Creeper (`EntityExplodeEvent`, `BlockExplodeEvent`) e empurrão por pistão (`BlockPistonExtendEvent`). +- Ao quebrar: + - Remove o ticket de chunk do Paper: `world.removePluginChunkTicket(chunkX, chunkZ, plugin)`. + - Remove de `chunks.yml` e do BlueMap. + - Devolve o item "Âncora de Chunk" ao jogador. + +### 2.4. Persistência e Ciclo de Vida +- Arquivo `plugins/Canalhandia/chunks.yml`: + - Armazena ID, UUID do dono, nome, mundo, coordenadas (x, y, z), chunk (cx, cz) e data de criação. +- **No `onEnable()` do plugin:** Carrega `chunks.yml` e registra `addPluginChunkTicket` em todas as chunks salvas. +- **No `onDisable()` do plugin:** Remove os tickets do plugin de forma limpa. + +### 2.5. Comandos (`/chunkloader` ou `/ancora`) +- `/chunkloader` ou `/ancora`: + - `/chunkloader info` — Mostra o status da chunk atual (se está carregada por um loader e por quem) e seus limites de uso. + - `/chunkloader listar` — Lista todas as âncoras ativas do jogador com coordenadas e link para deletar/desativar. + - `/chunkloader remover ` — Desativa remotamente uma âncora do próprio jogador. + - `/chunkloader receita` — Mostra a receita de crafting. + - `/chunkloader admin listar [jogador]` — (Admin) Lista todos os chunk loaders do servidor. + - `/chunkloader admin remover ` — (Admin) Força a remoção de qualquer loader. + - `/chunkloader reload` — (Admin) Recarrega configurações e sincroniza tickets. + +--- + +## 3. Integração com LuckPerms +Nós de permissão: +- `canalhandia.chunkloader` — Habilita o jogador a craftar, colocar e gerenciar âncoras. +- `canalhandia.chunkloader.limite.` — Define o limite máximo de âncoras ativas para o cargo (ex: `canalhandia.chunkloader.limite.3` para VIP). +- `canalhandia.chunkloader.admin` — Acesso total aos comandos administrativos de chunk loading. + +--- + +## 4. Critérios de Aceite +1. Módulo pode ser ativado/desativado via `config.yml` e `/canalhandia modulo chunkloader`. +2. Bloco colocado registra ticket via `world.addPluginChunkTicket` que sobrevive a reboots via `chunks.yml`. +3. Limites de permissão do LuckPerms são respeitados estritamente. +4. Blocos não podem ser roubados ou quebrados por terceiros. +5. Suíte de testes unitários (`ChunkLoaderTest.java`) com 100% de aprovação. diff --git a/specs/chunk-loader-module/tasks.md b/specs/chunk-loader-module/tasks.md new file mode 100644 index 0000000..f8b50dc --- /dev/null +++ b/specs/chunk-loader-module/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Módulo Nativo de Chunk Loader + +- [x] **Task 1: Modelo de Dados e Gerenciador (`ChunkLoader.java`, `ChunkLoaders.java` e `ChunkLoaderTest.java`)** + - Implementar record `ChunkLoader` puro. + - Implementar `ChunkLoaders` com persistência em `chunks.yml`, `playerLimit(Player)` (LuckPerms `canalhandia.chunkloader.limite.`), registro de tickets no Paper e busca por chunk/dono. + - Criar suíte de testes unitários `ChunkLoaderTest.java` validando regras de limites, unicidade e serialização. + +- [x] **Task 2: Configuração e Item Customizado (`Module.java`, `Settings.java` e `ChunkAnchorItem.java`)** + - Adicionar `CHUNKLOADER` ao enum `Module`. + - Adicionar chaves de configuração em `config.yml` e `Settings.java`. + - Criar utilitário `ChunkAnchorItem` para gerar o ItemStack com nome, lore e `PersistentDataContainer`, e registrar receita de crafting. + +- [x] **Task 3: Listeners de Proteção e Colocação (`ChunkLoaderListener.java`)** + - Tratar `BlockPlaceEvent` (validação de permissão, cálculo de limite LuckPerms, ativação de ticket). + - Tratar `BlockBreakEvent` (proteção de dono/admin, remoção de ticket, drop do item). + - Tratar explosões e pistões. + +- [x] **Task 4: Comandos e Tab-Completion (`CanalhandiaCommand.java`)** + - Adicionar `/chunkloader` e alias `/ancora` (`listar`, `info`, `remover`, `receita`, `admin`). + - Implementar tab-completion completo com permissões. + +- [x] **Task 5: Integração no Ciclo de Vida e BlueMap (`Canalhandia.java` & `BlueMapBridge.java`)** + - Inicializar `ChunkLoaders` no `onEnable` e descarregar tickets no `onDisable`. + - Adicionar marcadores no BlueMap. + +- [x] **Task 6: Testes, PR e Deploy (`mvn test`, `mvn package`, PR no Gitea)** + - Executar suíte completa de testes. + - Criar branch `feat/chunk-loader-module`, abrir PR com labels `AI-REVIEW` e `AI-USAGE`. diff --git a/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java index f8ca733..454ef21 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java +++ b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java @@ -27,18 +27,26 @@ final class BlueMapBridge { /** Id and label of the marker set this plugin owns on the map. */ private static final String SET_ID = "canalhandia-notas"; private static final String SET_LABEL = "Anotações"; + private static final String SET_LOADERS_ID = "canalhandia-chunkloaders"; + private static final String SET_LOADERS_LABEL = "Âncoras de Chunk"; private final Notes notes; + private final java.util.function.Supplier chunkLoaders; private final Logger logger; private final java.util.function.BooleanSupplier enabled; + private final java.util.function.BooleanSupplier chunkLoadersEnabled; /** False once we know BlueMap is not usable, so we stop retrying. */ private boolean available = true; - BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) { + BlueMapBridge(Notes notes, java.util.function.Supplier chunkLoaders, + Logger logger, java.util.function.BooleanSupplier enabled, + java.util.function.BooleanSupplier chunkLoadersEnabled) { this.notes = notes; + this.chunkLoaders = chunkLoaders; this.logger = logger; this.enabled = enabled; + this.chunkLoadersEnabled = chunkLoadersEnabled; } /** @@ -47,20 +55,19 @@ final class BlueMapBridge { */ void hook() { try { - de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync()); - logger.info("BlueMap encontrado — anotações públicas vão para o mapa."); + de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> { + sync(); + syncChunkLoaders(); + }); + logger.info("BlueMap encontrado — marcadores vão para o mapa."); } catch (NoClassDefFoundError | Exception e) { available = false; - logger.fine("BlueMap não está instalado; anotações ficam só no chat."); + logger.fine("BlueMap não está instalado; marcadores desativados."); } } /** * Rebuilds the marker set from the current public notes. - * - *

Rebuild rather than incremental add/remove: the note list is tiny, and - * a full rebuild cannot drift out of sync with the notes file the way a - * missed delete would. */ void sync() { if (!available || !enabled.getAsBoolean()) { @@ -79,9 +86,6 @@ final class BlueMapBridge { .label(SET_LABEL) .build(); for (Note note : publicNotes) { - // Only notes from the world this map renders. Without the - // check, a Nether note would be drawn at the same numeric - // coordinates in the overworld map, pointing at nothing. if (!sameWorld(map, note.world())) { continue; } @@ -96,13 +100,58 @@ final class BlueMapBridge { map.getMarkerSets().put(SET_ID, set); } } catch (NoClassDefFoundError | Exception e) { - // One line, then stop trying: a broken bridge must never turn into - // a log flood on every note edit. available = false; logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e); } } + void syncChunkLoaders() { + if (!available || !chunkLoadersEnabled.getAsBoolean() || chunkLoaders == null) { + return; + } + ChunkLoaders loaders = chunkLoaders.get(); + if (loaders == null) { + return; + } + try { + var maybeApi = de.bluecolored.bluemap.api.BlueMapAPI.getInstance(); + if (maybeApi.isEmpty()) { + return; + } + var api = maybeApi.get(); + for (var map : api.getMaps()) { + var set = de.bluecolored.bluemap.api.markers.MarkerSet.builder() + .label(SET_LOADERS_LABEL) + .build(); + for (ChunkLoader loader : loaders.all()) { + if (!mapMatchesWorld(map, loader.world())) { + continue; + } + var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder() + .label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")") + .detail("Âncora de Chunk #" + loader.id() + "
Dono: " + + escape(loader.ownerName()) + "
Chunk: " + loader.chunkCoords()) + .position(loader.x(), loader.y(), loader.z()) + .build(); + set.getMarkers().put("loader-" + loader.id(), marker); + } + map.getMarkerSets().put(SET_LOADERS_ID, set); + } + } catch (NoClassDefFoundError | Exception ignored) { + } + } + + private boolean mapMatchesWorld(de.bluecolored.bluemap.api.BlueMapMap map, String world) { + if (world == null || world.isBlank()) { + return false; + } + String mapId = map.getId().toLowerCase(java.util.Locale.ROOT); + String w = world.toLowerCase(java.util.Locale.ROOT); + if (w.contains("nether")) return mapId.contains("nether"); + if (w.contains("the_end") || w.contains("end")) return mapId.contains("end"); + return !mapId.contains("nether") && !mapId.contains("end"); + } + /** * Whether a map renders the world a note was written in. * diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index ab691f6..54527ab 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -86,6 +86,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private Achievements achievements; private WeeklyStats weeklyStats; private PlayerMemory playerMemory; + private ChunkLoaders chunkLoaders; /** Gate for spontaneous AI lines; see Budget for why this is strict. */ private Budget aiBudget; private BlueMapBridge blueMap; @@ -127,13 +128,19 @@ public final class Canalhandia extends JavaPlugin implements Listener { milestones.resyncSilently(); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.yml")); + chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml")); + if (settings.moduleEnabled(Module.CHUNKLOADER)) { + chunkLoaders.loadAllTickets(); + ChunkAnchorItem.registerRecipe(this); + } aiBudget = new Budget(settings.aiSpontaneousPerDay(), settings.aiSpontaneousGapMinutes() * 60_000L, settings.aiSubjectCooldownMinutes() * 60_000L); ai = new Ai(this); // Optional: does nothing (and logs nothing loud) without BlueMap. - blueMap = new BlueMapBridge(notes, getLogger(), - () -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap()); + blueMap = new BlueMapBridge(notes, () -> chunkLoaders, getLogger(), + () -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap(), + () -> settings.moduleEnabled(Module.CHUNKLOADER) && settings.chunkLoaderBlueMap()); blueMap.hook(); // Snapshot the server's recipes on the main thread; RecipeBook.describe // reads from the async answer path and Bukkit.recipeIterator() is not @@ -146,12 +153,13 @@ public final class Canalhandia extends JavaPlugin implements Listener { for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", "errado", "nota", "save", "recado", "recados", "mortes", "conquistas", - "perfil", "titulo")) { + "perfil", "titulo", "chunkloader")) { register(name, root); } getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(new TitleChatListener(this), this); + getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this); rescheduleTimer(); rescheduleMilestones(); @@ -227,6 +235,11 @@ public final class Canalhandia extends JavaPlugin implements Listener { return achievements; } + /** Active chunk loaders. Never null. */ + ChunkLoaders chunkLoaders() { + return chunkLoaders; + } + /** The title each player has chosen to wear in chat. Never null. */ Titles titles() { return titles; diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index c5dd749..983e6ce 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -5,6 +5,8 @@ import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -62,6 +64,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "conquistas" -> conquistas(sender, args); case "perfil" -> perfil(sender, args); case "titulo" -> titulo(sender, args); + case "chunkloader", "ancora", "ancoras", "chunkloaders" -> chunkloader(sender, args); // Typed twin of the [F] mourning button — "f" is not a configured // reaction (the mourning set is hardcoded), so it can't be found via // reactionForCommand; route it directly. Acts on the latest message, @@ -1783,6 +1786,252 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return true; } + // --- /chunkloader ------------------------------------------------------- + + private boolean chunkloader(CommandSender sender, String[] args) { + if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { + Msg.error(sender, "O módulo de Âncoras de Chunk está desativado."); + return true; + } + if (!sender.hasPermission("canalhandia.chunkloader")) { + return denied(sender); + } + + if (args.length == 0) { + return chunkloaderHelp(sender); + } + + String sub = args[0].toLowerCase(Locale.ROOT); + String[] rest = Arrays.copyOfRange(args, 1, args.length); + + switch (sub) { + case "listar", "list" -> chunkloaderList(sender, rest); + case "info", "chunk" -> chunkloaderInfo(sender); + case "remover", "remove", "del" -> chunkloaderRemove(sender, rest); + case "receita", "craft" -> chunkloaderRecipe(sender); + case "admin" -> chunkloaderAdmin(sender, rest); + case "dar", "give" -> { + if (admin(sender)) { + chunkloaderGive(sender, rest); + } + } + default -> chunkloaderHelp(sender); + } + return true; + } + + private void chunkloaderList(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + chunkloaderAdminList(sender, args.length > 0 ? args[0] : null); + return; + } + + List mine = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()); + int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit()); + String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit); + + Msg.header(sender, "Suas Âncoras de Chunk (" + mine.size() + "/" + limitStr + ")"); + if (mine.isEmpty()) { + sender.sendMessage(Component.text(" Você não possui nenhuma Âncora de Chunk ativa.", NamedTextColor.GRAY)); + sender.sendMessage(Component.text(" Use ", NamedTextColor.GRAY) + .append(Component.text("/chunkloader receita", NamedTextColor.AQUA)) + .append(Component.text(" para ver como criar uma.", NamedTextColor.GRAY))); + return; + } + + boolean bedrock = isBedrock(sender); + for (ChunkLoader loader : mine) { + Component line = Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA) + .append(Component.text(loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE)); + + if (!bedrock) { + line = line.append(Component.text(" [Remover]", NamedTextColor.RED) + .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( + Component.text("Clique para desativar a âncora #" + loader.id(), NamedTextColor.GRAY))) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( + "/chunkloader remover " + loader.id()))); + } + sender.sendMessage(line); + } + } + + private void chunkloaderInfo(CommandSender sender) { + if (!(sender instanceof Player player)) { + Msg.error(sender, "Comando exclusivo para jogadores."); + return; + } + + Location loc = player.getLocation(); + String world = loc.getWorld().getName(); + int cx = loc.getBlockX() >> 4; + int cz = loc.getBlockZ() >> 4; + + ChunkLoader here = plugin.chunkLoaders().byChunk(world, cx, cz); + int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit()); + int used = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size(); + String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit); + + Msg.header(sender, "Informações da Chunk"); + sender.sendMessage(Component.text(" Chunk atual: ", NamedTextColor.GRAY) + .append(Component.text("[" + cx + ", " + cz + "]", NamedTextColor.AQUA)) + .append(Component.text(" em " + world, NamedTextColor.WHITE))); + + if (here != null) { + sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) + .append(Component.text("Carregada continuamente por Âncora #" + here.id(), NamedTextColor.GREEN)) + .append(Component.text(" (" + here.ownerName() + ")", NamedTextColor.WHITE))); + } else { + sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) + .append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY))); + } + + sender.sendMessage(Component.text(" Suas âncoras ativas: ", NamedTextColor.GRAY) + .append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW))); + } + + private void chunkloaderRemove(CommandSender sender, String[] args) { + if (args.length < 1) { + Msg.error(sender, "Uso: /chunkloader remover "); + return; + } + + long id = parseLong(args[0]); + if (id <= 0) { + Msg.error(sender, "ID inválido."); + return; + } + + ChunkLoader loader = plugin.chunkLoaders().byId(id); + if (loader == null) { + Msg.error(sender, "Nenhuma Âncora de Chunk encontrada com o ID #" + id); + 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; + } + + plugin.chunkLoaders().remove(id); + plugin.blueMap().syncChunkLoaders(); + + try { + org.bukkit.World w = Bukkit.getWorld(loader.world()); + if (w != null && w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) { + Block b = w.getBlockAt(loader.x(), loader.y(), loader.z()); + if (b.getType() == Material.RESPAWN_ANCHOR) { + b.setType(Material.AIR); + } + } + } catch (Exception ignored) { + } + + if (sender instanceof Player player) { + player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1)); + } + + Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e devolvida ao inventário."); + } + + private void chunkloaderRecipe(CommandSender sender) { + Msg.header(sender, "Receita da Âncora de Chunk"); + sender.sendMessage(Component.text(" Na Bancada de Trabalho:", NamedTextColor.GRAY)); + sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA)); + sender.sendMessage(Component.text(" [ O ] [ E ] [ O ]", NamedTextColor.LIGHT_PURPLE)); + sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA)); + sender.sendMessage(Component.text(" D = Diamante | O = Obsidiana Chorona | E = Olho do Fim", NamedTextColor.YELLOW)); + } + + private void chunkloaderAdmin(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length == 0) { + Msg.header(sender, "Admin Chunk Loaders"); + sender.sendMessage(Component.text(" /chunkloader admin listar [jogador]", NamedTextColor.AQUA)); + sender.sendMessage(Component.text(" /chunkloader admin remover ", NamedTextColor.AQUA)); + sender.sendMessage(Component.text(" /chunkloader dar [quantidade]", NamedTextColor.AQUA)); + return; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "listar", "list" -> chunkloaderAdminList(sender, args.length > 1 ? args[1] : null); + case "remover", "del" -> { + if (args.length < 2) { + Msg.error(sender, "Uso: /chunkloader admin remover "); + return; + } + chunkloaderRemove(sender, new String[]{args[1]}); + } + case "dar", "give" -> chunkloaderGive(sender, Arrays.copyOfRange(args, 1, args.length)); + default -> Msg.error(sender, "Subcomando de admin desconhecido."); + } + } + + private void chunkloaderAdminList(CommandSender sender, String targetName) { + List all = plugin.chunkLoaders().all(); + if (targetName != null && !targetName.isBlank()) { + all = all.stream().filter(l -> l.ownerName().equalsIgnoreCase(targetName)).toList(); + } + + Msg.header(sender, "Todas as Âncoras do Servidor (" + all.size() + ")"); + if (all.isEmpty()) { + sender.sendMessage(Component.text(" Nenhuma âncora ativa encontrada.", NamedTextColor.GRAY)); + return; + } + + for (ChunkLoader loader : all) { + sender.sendMessage(Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA) + .append(Component.text(loader.ownerName(), NamedTextColor.YELLOW)) + .append(Component.text(" — " + loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE))); + } + } + + private void chunkloaderGive(CommandSender sender, String[] args) { + if (args.length < 1) { + Msg.error(sender, "Uso: /chunkloader dar [quantidade]"); + return; + } + + Player target = Bukkit.getPlayer(args[0]); + if (target == null) { + Msg.error(sender, "Jogador não encontrado: " + args[0]); + return; + } + + int amount = 1; + if (args.length >= 2) { + try { + amount = Math.max(1, Integer.parseInt(args[1])); + } catch (NumberFormatException ignored) { + } + } + + target.getInventory().addItem(ChunkAnchorItem.create(plugin, amount)); + Msg.ok(sender, "Entregue " + amount + "x Âncora de Chunk para " + target.getName() + "."); + Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!"); + } + + private boolean chunkloaderHelp(CommandSender sender) { + Msg.header(sender, "Âncoras de Chunk"); + sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA) + .append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA) + .append(Component.text(" — Lista suas âncoras ativas e coordenadas", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader remover ", NamedTextColor.AQUA) + .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) + .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); + if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { + sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW) + .append(Component.text(" — Comandos de administração", NamedTextColor.GRAY))); + } + return true; + } + // --- tab completion ----------------------------------------------------- @Override @@ -1943,6 +2192,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return filter(List.of("on", "off"), args[2]); } } + if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { + if (args.length == 1) { + List options = new ArrayList<>(List.of("info", "listar", "remover", "receita")); + if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { + options.addAll(List.of("admin", "dar")); + } + return filter(options, args[0]); + } + if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del"))) { + if (sender instanceof Player player) { + List ids = new ArrayList<>(); + for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) { + ids.add(String.valueOf(loader.id())); + } + return filter(ids, args[1]); + } + } + if (args.length == 2 && args[0].equalsIgnoreCase("admin")) { + return filter(List.of("listar", "remover", "dar"), args[1]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("dar")) { + return filter(onlineNames(), args[1]); + } + if (args.length == 3 && args[0].equalsIgnoreCase("admin") && args[1].equalsIgnoreCase("dar")) { + return filter(onlineNames(), args[2]); + } + } return List.of(); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkAnchorItem.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkAnchorItem.java new file mode 100644 index 0000000..1068d99 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkAnchorItem.java @@ -0,0 +1,97 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +import java.util.List; + +/** + * Creates and validates the "Âncora de Chunk" custom block item and its crafting recipe. + */ +final class ChunkAnchorItem { + + private static final String KEY_NAME = "chunk_anchor"; + private static final String RECIPE_NAME = "ancora_de_chunk"; + + static NamespacedKey key(Plugin plugin) { + return new NamespacedKey(plugin, KEY_NAME); + } + + static NamespacedKey recipeKey(Plugin plugin) { + return new NamespacedKey(plugin, RECIPE_NAME); + } + + /** + * Creates a new Chunk Anchor item stack. + */ + static ItemStack create(Plugin plugin, int amount) { + ItemStack item = new ItemStack(Material.RESPAWN_ANCHOR, Math.max(1, amount)); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.displayName(Component.text("Âncora de Chunk", NamedTextColor.AQUA) + .decoration(TextDecoration.ITALIC, false) + .decoration(TextDecoration.BOLD, true)); + meta.lore(List.of( + Component.text("Coloque no chão para manter esta chunk", NamedTextColor.GRAY) + .decoration(TextDecoration.ITALIC, false), + Component.text("carregada continuamente no servidor.", NamedTextColor.GRAY) + .decoration(TextDecoration.ITALIC, false), + Component.text("Canalhandia Chunk Loader", NamedTextColor.DARK_GRAY) + .decoration(TextDecoration.ITALIC, false) + )); + meta.getPersistentDataContainer().set(key(plugin), PersistentDataType.BYTE, (byte) 1); + item.setItemMeta(meta); + } + return item; + } + + /** + * Tests if an item is a valid Chunk Anchor. + */ + static boolean isAnchor(Plugin plugin, ItemStack item) { + if (item == null || item.getType() != Material.RESPAWN_ANCHOR) { + return false; + } + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return false; + } + return meta.getPersistentDataContainer().has(key(plugin), PersistentDataType.BYTE); + } + + /** + * Registers the shaped crafting recipe for the Chunk Anchor. + */ + static void registerRecipe(Plugin plugin) { + try { + NamespacedKey rKey = recipeKey(plugin); + if (Bukkit.getRecipe(rKey) != null) { + return; + } + ShapedRecipe recipe = new ShapedRecipe(rKey, create(plugin, 1)); + recipe.shape("DOD", "OEO", "DOD"); + recipe.setIngredient('D', Material.DIAMOND); + recipe.setIngredient('O', Material.CRYING_OBSIDIAN); + recipe.setIngredient('E', Material.ENDER_EYE); + + Bukkit.addRecipe(recipe); + } catch (Exception ignored) { + } + } + + static void unregisterRecipe(Plugin plugin) { + try { + Bukkit.removeRecipe(recipeKey(plugin)); + } catch (Exception ignored) { + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java new file mode 100644 index 0000000..463125c --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java @@ -0,0 +1,32 @@ +package dev.marcospaulo.canalhandia; + +/** + * A persistent chunk loader anchor placed in the world. + * + *

A plain immutable record with no Bukkit types, so the model, coordinate + * math, and persistence rules are testable without a running server. + */ +record ChunkLoader( + long id, + String ownerUuid, + String ownerName, + String world, + int x, + int y, + int z, + int chunkX, + int chunkZ, + long createdAt) { + + String blockCoords() { + return x + ", " + y + ", " + z; + } + + String chunkCoords() { + return "[" + chunkX + ", " + chunkZ + "]"; + } + + String place() { + return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")"); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java new file mode 100644 index 0000000..f9c7dde --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -0,0 +1,171 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityExplodeEvent; + +import java.util.Iterator; + +/** + * Protects and handles block placement, removal, and explosions for Chunk Anchors. + */ +final class ChunkLoaderListener implements Listener { + + private final Canalhandia plugin; + + ChunkLoaderListener(Canalhandia plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onBlockPlace(BlockPlaceEvent event) { + if (!ChunkAnchorItem.isAnchor(plugin, event.getItemInHand())) { + return; + } + + Player player = event.getPlayer(); + if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { + Msg.error(player, "O módulo de Âncoras de Chunk está desativado."); + event.setCancelled(true); + return; + } + + if (!player.hasPermission("canalhandia.chunkloader")) { + Msg.error(player, "Você não tem permissão para usar Âncoras de Chunk."); + event.setCancelled(true); + return; + } + + int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit()); + int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size(); + + if (current >= limit) { + Msg.error(player, "Você atingiu seu limite de Âncoras de Chunk ativas (" + current + "/" + limit + ")."); + event.setCancelled(true); + return; + } + + Location loc = event.getBlockPlaced().getLocation(); + String world = loc.getWorld().getName(); + int chunkX = loc.getBlockX() >> 4; + int chunkZ = loc.getBlockZ() >> 4; + + ChunkLoader existing = plugin.chunkLoaders().byChunk(world, chunkX, chunkZ); + if (existing != null) { + Msg.error(player, "Esta chunk já possui uma Âncora de Chunk ativa em " + existing.blockCoords() + + " (" + existing.ownerName() + ")."); + event.setCancelled(true); + return; + } + + ChunkLoader loader = plugin.chunkLoaders().add( + player.getUniqueId().toString(), + player.getName(), + world, + loc.getBlockX(), + loc.getBlockY(), + loc.getBlockZ() + ); + + try { + loc.getWorld().spawnParticle(Particle.PORTAL, loc.clone().add(0.5, 1.2, 0.5), 35, 0.3, 0.3, 0.3, 0.05); + loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_SET_SPAWN, 1.0f, 1.2f); + } catch (Exception ignored) { + } + + plugin.blueMap().syncChunkLoaders(); + + String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit); + Msg.ok(player, "Âncora de Chunk ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente (" + + (current + 1) + "/" + limitStr + ")."); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onBlockBreak(BlockBreakEvent event) { + Block block = event.getBlock(); + ChunkLoader loader = plugin.chunkLoaders().byLocation( + block.getWorld().getName(), + block.getX(), + block.getY(), + block.getZ() + ); + + if (loader == null) { + return; + } + + Player player = event.getPlayer(); + boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); + boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin"); + + if (!isOwner && !isAdmin) { + Msg.error(player, "Esta Âncora de Chunk pertence a " + loader.ownerName() + " e só pode ser quebrada por ele."); + event.setCancelled(true); + return; + } + + plugin.chunkLoaders().remove(loader.id()); + event.setDropItems(false); + + Location loc = block.getLocation(); + try { + loc.getWorld().dropItemNaturally(loc, ChunkAnchorItem.create(plugin, 1)); + loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f); + } catch (Exception ignored) { + } + + plugin.blueMap().syncChunkLoaders(); + Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida."); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + protectExplosions(event.blockList().iterator()); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + protectExplosions(event.blockList().iterator()); + } + + private void protectExplosions(Iterator it) { + while (it.hasNext()) { + Block b = it.next(); + if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) { + it.remove(); + } + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPistonExtend(BlockPistonExtendEvent event) { + for (Block b : event.getBlocks()) { + if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) { + event.setCancelled(true); + return; + } + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPistonRetract(BlockPistonRetractEvent event) { + for (Block b : event.getBlocks()) { + if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) { + event.setCancelled(true); + return; + } + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java new file mode 100644 index 0000000..9108429 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -0,0 +1,284 @@ +package dev.marcospaulo.canalhandia; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.bukkit.plugin.Plugin; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Manages active chunk loader anchors, ticket registration in Paper, and persistence in chunks.yml. + */ +final class ChunkLoaders { + + private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite."; + + private final Plugin plugin; + private final File file; + private final List loaders = new ArrayList<>(); + private final AtomicLong nextId = new AtomicLong(1); + + private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "canalhandia-chunkloaders-io"); + t.setDaemon(true); + return t; + }); + + ChunkLoaders(Plugin plugin, File file) { + this.plugin = plugin; + this.file = file; + load(); + } + + void load() { + YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null; + synchronized (loaders) { + loaders.clear(); + if (yaml == null) { + return; + } + ConfigurationSection root = yaml.getConfigurationSection("loaders"); + if (root == null) { + return; + } + long maxId = 0; + for (String key : root.getKeys(false)) { + try { + long id = Long.parseLong(key); + String ownerUuid = root.getString(key + ".owner_uuid", ""); + String ownerName = root.getString(key + ".owner_name", "?"); + String world = root.getString(key + ".world", "world"); + int x = root.getInt(key + ".x"); + int y = root.getInt(key + ".y"); + int z = root.getInt(key + ".z"); + int chunkX = root.getInt(key + ".chunk_x", x >> 4); + int chunkZ = root.getInt(key + ".chunk_z", z >> 4); + long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis()); + + loaders.add(new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); + if (id > maxId) { + maxId = id; + } + } catch (NumberFormatException ignored) { + } + } + nextId.set(maxId + 1); + } + } + + /** + * 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) { + int chunkX = x >> 4; + int chunkZ = z >> 4; + long id = nextId.getAndIncrement(); + ChunkLoader loader = new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); + + synchronized (loaders) { + loaders.add(loader); + } + addTicket(loader); + save(); + return loader; + } + + /** + * Removes a chunk loader by its unique ID and releases the chunk ticket. + */ + boolean remove(long id) { + ChunkLoader removed = null; + synchronized (loaders) { + for (int i = 0; i < loaders.size(); i++) { + if (loaders.get(i).id() == id) { + removed = loaders.remove(i); + break; + } + } + } + if (removed != null) { + removeTicket(removed); + save(); + return true; + } + return false; + } + + ChunkLoader byId(long id) { + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + if (loader.id() == id) { + return loader; + } + } + return null; + } + } + + ChunkLoader byChunk(String world, int chunkX, int chunkZ) { + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + if (loader.world().equalsIgnoreCase(world) && loader.chunkX() == chunkX && loader.chunkZ() == chunkZ) { + return loader; + } + } + return null; + } + } + + ChunkLoader byLocation(String world, int x, int y, int z) { + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + if (loader.world().equalsIgnoreCase(world) && loader.x() == x && loader.y() == y && loader.z() == z) { + return loader; + } + } + return null; + } + } + + List byOwner(String ownerUuid) { + List out = new ArrayList<>(); + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + if (loader.ownerUuid().equalsIgnoreCase(ownerUuid)) { + out.add(loader); + } + } + } + return Collections.unmodifiableList(out); + } + + List all() { + synchronized (loaders) { + return Collections.unmodifiableList(new ArrayList<>(loaders)); + } + } + + int size() { + synchronized (loaders) { + return loaders.size(); + } + } + + /** + * Computes the maximum chunk loaders allowed for a player based on LuckPerms permissions. + */ + static int playerLimit(Player player, int defaultLimit) { + if (player == null) { + return 0; + } + if (player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin")) { + return Integer.MAX_VALUE; + } + if (!player.hasPermission("canalhandia.chunkloader")) { + return 0; + } + + int max = -1; + for (PermissionAttachmentInfo info : player.getEffectivePermissions()) { + String perm = info.getPermission().toLowerCase(Locale.ROOT); + if (info.getValue() && perm.startsWith(PERM_LIMIT_PREFIX)) { + String valStr = perm.substring(PERM_LIMIT_PREFIX.length()); + try { + int val = Integer.parseInt(valStr); + if (val > max) { + max = val; + } + } catch (NumberFormatException ignored) { + } + } + } + return max >= 0 ? max : defaultLimit; + } + + void loadAllTickets() { + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + addTicket(loader); + } + } + } + + void unloadAllTickets() { + synchronized (loaders) { + for (ChunkLoader loader : loaders) { + removeTicket(loader); + } + } + } + + private void addTicket(ChunkLoader loader) { + if (plugin == null) { + return; + } + try { + World w = Bukkit.getWorld(loader.world()); + if (w != null) { + w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); + } + } catch (Exception ignored) { + } + } + + private void removeTicket(ChunkLoader loader) { + if (plugin == null) { + return; + } + try { + World w = Bukkit.getWorld(loader.world()); + if (w != null) { + w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); + } + } catch (Exception ignored) { + } + } + + private YamlConfiguration buildYaml() { + YamlConfiguration yaml = new YamlConfiguration(); + synchronized (loaders) { + for (ChunkLoader l : loaders) { + String key = "loaders." + l.id(); + yaml.set(key + ".owner_uuid", l.ownerUuid()); + yaml.set(key + ".owner_name", l.ownerName()); + yaml.set(key + ".world", l.world()); + yaml.set(key + ".x", l.x()); + yaml.set(key + ".y", l.y()); + yaml.set(key + ".z", l.z()); + yaml.set(key + ".chunk_x", l.chunkX()); + yaml.set(key + ".chunk_z", l.chunkZ()); + yaml.set(key + ".created_at", l.createdAt()); + } + } + return yaml; + } + + private void save() { + YamlConfiguration yaml = buildYaml(); + io.execute(() -> { + try { + yaml.save(file); + } catch (Exception ignored) { + } + }); + } + + void flush() { + try { + io.submit(() -> {}).get(2, TimeUnit.SECONDS); + } catch (Exception ignored) { + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java index 6a4d984..213620d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Module.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -14,7 +14,8 @@ enum Module { NOTAS("notas", "Anotações públicas e privadas no chat"), RECADOS("recados", "Recados entregues quando o jogador entra"), CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"), - IA("ia", "Perguntas para a IA"); + IA("ia", "Perguntas para a IA"), + CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"); private final String key; private final String label; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 4f80e9c..bd5148c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -579,6 +579,24 @@ final class Settings { set("categorias." + category.key(), enabled); } + // --- chunkloader -------------------------------------------------------- + + int chunkLoaderDefaultLimit() { + return Math.max(0, plugin.getConfig().getInt("chunkloader.limite-padrao", 1)); + } + + void chunkLoaderDefaultLimit(int limit) { + set("chunkloader.limite-padrao", Math.max(0, limit)); + } + + boolean chunkLoaderBlueMap() { + return plugin.getConfig().getBoolean("chunkloader.bluemap", true); + } + + void chunkLoaderBlueMap(boolean enabled) { + set("chunkloader.bluemap", enabled); + } + // --- plumbing ----------------------------------------------------------- private void set(String path, Object value) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6ac7262..d0f8b40 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -98,10 +98,20 @@ commands: description: Escolhe qual conquista você exibe como título no chat. usage: /titulo [nome|limpar] aliases: [titulos, title] + chunkloader: + description: Gerenciamento de Âncoras de Chunk (chunk loaders). + usage: /chunkloader [listar|info|remover|receita] + aliases: [ancora, ancoras, chunkloaders] permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only, # which would silently stop normal players reacting, voting or guessing. + canalhandia.chunkloader: + description: Permite usar e criar Âncoras de Chunk. + default: true + canalhandia.chunkloader.admin: + description: Permite gerenciar e remover qualquer Âncora de Chunk no servidor. + default: op canalhandia.reagir: description: Permite reagir e prestar luto. default: true diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java new file mode 100644 index 0000000..e251b17 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -0,0 +1,107 @@ +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChunkLoaderTest { + + @TempDir + Path tempDir; + + private File file; + private ChunkLoaders loaders; + + @BeforeEach + void setUp() { + file = tempDir.resolve("chunks.yml").toFile(); + loaders = new ChunkLoaders(null, file); + } + + @AfterEach + void tearDown() { + if (loaders != null) { + loaders.flush(); + } + } + + @Test + void startsEmpty() { + assertEquals(0, loaders.size()); + assertTrue(loaders.all().isEmpty()); + } + + @Test + void addsAndCalculatesChunkCoordinates() { + ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200); + assertEquals(1, loader.id()); + assertEquals("uuid-marcos", loader.ownerUuid()); + assertEquals("Marcos", loader.ownerName()); + assertEquals(6, loader.chunkX()); // 100 >> 4 = 6 + assertEquals(12, loader.chunkZ()); // 200 >> 4 = 12 + assertEquals("100, 64, 200 (world)", loader.place()); + assertEquals("[6, 12]", loader.chunkCoords()); + assertEquals(1, loaders.size()); + } + + @Test + void queriesByChunkLocationAndOwner() { + ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32); + ChunkLoader l2 = loaders.add("uuid-ana", "Ana", "world_nether", 0, 70, 0); + + assertEquals(l1, loaders.byLocation("world", 16, 64, 32)); + assertNull(loaders.byLocation("world", 17, 64, 32)); + + assertEquals(l1, loaders.byChunk("world", 1, 2)); + assertEquals(l2, loaders.byChunk("world_nether", 0, 0)); + assertNull(loaders.byChunk("world", 0, 0)); + + List marcosLoaders = loaders.byOwner("uuid-marcos"); + assertEquals(1, marcosLoaders.size()); + assertEquals(l1, marcosLoaders.get(0)); + + List anaLoaders = loaders.byOwner("uuid-ana"); + assertEquals(1, anaLoaders.size()); + assertEquals(l2, anaLoaders.get(0)); + } + + @Test + void removesLoaderById() { + ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32); + assertTrue(loaders.remove(l1.id())); + assertEquals(0, loaders.size()); + assertNull(loaders.byId(l1.id())); + assertFalse(loaders.remove(l1.id())); + } + + @Test + void persistsToDiskAndReloads() { + loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200); + loaders.add("uuid-ana", "Ana", "world_nether", 50, 70, -50); + loaders.flush(); + + ChunkLoaders reloaded = new ChunkLoaders(null, file); + assertEquals(2, reloaded.size()); + + ChunkLoader l1 = reloaded.byId(1); + assertNotNull(l1); + assertEquals("uuid-marcos", l1.ownerUuid()); + assertEquals(6, l1.chunkX()); + assertEquals(12, l1.chunkZ()); + + ChunkLoader l2 = reloaded.byId(2); + assertNotNull(l2); + assertEquals("uuid-ana", l2.ownerUuid()); + } +}