4 Commits

16 changed files with 1256 additions and 19 deletions
+37
View File
@@ -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<ChunkLoader>`.
- 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.
+72
View File
@@ -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.<N>` (ex: `limite.1`, `limite.2`, `limite.5`, `limite.10`).
- Pega o maior `<N>` 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 <id>` — 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 <id>` — (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.<N>` — 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.
+28
View File
@@ -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.<N>`), 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`.
@@ -27,18 +27,26 @@ final class BlueMapBridge {
/** Id and label of the marker set this plugin owns on the map. */ /** 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_ID = "canalhandia-notas";
private static final String SET_LABEL = "Anotações"; 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 Notes notes;
private final java.util.function.Supplier<ChunkLoaders> chunkLoaders;
private final Logger logger; private final Logger logger;
private final java.util.function.BooleanSupplier enabled; 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. */ /** False once we know BlueMap is not usable, so we stop retrying. */
private boolean available = true; private boolean available = true;
BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) { BlueMapBridge(Notes notes, java.util.function.Supplier<ChunkLoaders> chunkLoaders,
Logger logger, java.util.function.BooleanSupplier enabled,
java.util.function.BooleanSupplier chunkLoadersEnabled) {
this.notes = notes; this.notes = notes;
this.chunkLoaders = chunkLoaders;
this.logger = logger; this.logger = logger;
this.enabled = enabled; this.enabled = enabled;
this.chunkLoadersEnabled = chunkLoadersEnabled;
} }
/** /**
@@ -47,20 +55,19 @@ final class BlueMapBridge {
*/ */
void hook() { void hook() {
try { try {
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync()); de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> {
logger.info("BlueMap encontrado — anotações públicas vão para o mapa."); sync();
syncChunkLoaders();
});
logger.info("BlueMap encontrado — marcadores vão para o mapa.");
} catch (NoClassDefFoundError | Exception e) { } catch (NoClassDefFoundError | Exception e) {
available = false; 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. * Rebuilds the marker set from the current public notes.
*
* <p>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() { void sync() {
if (!available || !enabled.getAsBoolean()) { if (!available || !enabled.getAsBoolean()) {
@@ -79,9 +86,6 @@ final class BlueMapBridge {
.label(SET_LABEL) .label(SET_LABEL)
.build(); .build();
for (Note note : publicNotes) { 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())) { if (!sameWorld(map, note.world())) {
continue; continue;
} }
@@ -96,13 +100,62 @@ final class BlueMapBridge {
map.getMarkerSets().put(SET_ID, set); map.getMarkerSets().put(SET_ID, set);
} }
} catch (NoClassDefFoundError | Exception e) { } 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; available = false;
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e); 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("<b>Âncora de Chunk #" + loader.id() + "</b><br>Dono: "
+ escape(loader.ownerName()) + "<br>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.endsWith("_nether") || w.equals("nether") || w.equals("world_nether")) {
return mapId.contains("nether");
}
if (w.endsWith("_the_end") || w.equals("the_end") || w.equals("world_the_end") || w.equals("end")) {
return mapId.contains("end");
}
return !mapId.contains("nether") && !mapId.contains("end");
}
/** /**
* Whether a map renders the world a note was written in. * Whether a map renders the world a note was written in.
* *
@@ -86,6 +86,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private Achievements achievements; private Achievements achievements;
private WeeklyStats weeklyStats; private WeeklyStats weeklyStats;
private PlayerMemory playerMemory; private PlayerMemory playerMemory;
private ChunkLoaders chunkLoaders;
/** Gate for spontaneous AI lines; see Budget for why this is strict. */ /** Gate for spontaneous AI lines; see Budget for why this is strict. */
private Budget aiBudget; private Budget aiBudget;
private BlueMapBridge blueMap; private BlueMapBridge blueMap;
@@ -127,13 +128,19 @@ public final class Canalhandia extends JavaPlugin implements Listener {
milestones.resyncSilently(); milestones.resyncSilently();
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.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(), aiBudget = new Budget(settings.aiSpontaneousPerDay(),
settings.aiSpontaneousGapMinutes() * 60_000L, settings.aiSpontaneousGapMinutes() * 60_000L,
settings.aiSubjectCooldownMinutes() * 60_000L); settings.aiSubjectCooldownMinutes() * 60_000L);
ai = new Ai(this); ai = new Ai(this);
// Optional: does nothing (and logs nothing loud) without BlueMap. // Optional: does nothing (and logs nothing loud) without BlueMap.
blueMap = new BlueMapBridge(notes, getLogger(), blueMap = new BlueMapBridge(notes, () -> chunkLoaders, getLogger(),
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap()); () -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap(),
() -> settings.moduleEnabled(Module.CHUNKLOADER) && settings.chunkLoaderBlueMap());
blueMap.hook(); blueMap.hook();
// Snapshot the server's recipes on the main thread; RecipeBook.describe // Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not // 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", for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas", "errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
"perfil", "titulo")) { "perfil", "titulo", "chunkloader")) {
register(name, root); register(name, root);
} }
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(this, this);
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this); getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
rescheduleTimer(); rescheduleTimer();
rescheduleMilestones(); rescheduleMilestones();
@@ -183,6 +191,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
if (poll != null) { if (poll != null) {
poll.hide(); poll.hide();
} }
if (chunkLoaders != null) {
chunkLoaders.unloadAllTickets();
chunkLoaders.close();
ChunkAnchorItem.unregisterRecipe(this);
}
} }
Settings settings() { Settings settings() {
@@ -227,6 +240,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return achievements; return achievements;
} }
/** Active chunk loaders. Never null. */
ChunkLoaders chunkLoaders() {
return chunkLoaders;
}
/** The title each player has chosen to wear in chat. Never null. */ /** The title each player has chosen to wear in chat. Never null. */
Titles titles() { Titles titles() {
return titles; return titles;
@@ -5,11 +5,14 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter; import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@@ -62,6 +65,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "conquistas" -> conquistas(sender, args); case "conquistas" -> conquistas(sender, args);
case "perfil" -> perfil(sender, args); case "perfil" -> perfil(sender, args);
case "titulo" -> titulo(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 // 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 // reaction (the mourning set is hardcoded), so it can't be found via
// reactionForCommand; route it directly. Acts on the latest message, // reactionForCommand; route it directly. Acts on the latest message,
@@ -563,6 +567,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
boolean on = args[1].equalsIgnoreCase("on"); boolean on = args[1].equalsIgnoreCase("on");
plugin.settings().moduleEnabled(module, on); plugin.settings().moduleEnabled(module, on);
if (module == Module.CHUNKLOADER) {
if (on) {
plugin.chunkLoaders().loadAllTickets();
ChunkAnchorItem.registerRecipe(plugin);
} else {
plugin.chunkLoaders().unloadAllTickets();
ChunkAnchorItem.unregisterRecipe(plugin);
}
plugin.blueMap().syncChunkLoaders();
}
plugin.rescheduleTimer(); plugin.rescheduleTimer();
plugin.rescheduleMilestones(); plugin.rescheduleMilestones();
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + "."); Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
@@ -1783,6 +1797,264 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return true; 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<ChunkLoader> 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 <id>");
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) {
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
for (ItemStack drop : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), drop);
}
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e devolvida ao inventário.");
} else {
try {
org.bukkit.World w = Bukkit.getWorld(loader.world());
if (w != null) {
w.dropItemNaturally(new Location(w, loader.x() + 0.5, loader.y() + 0.5, loader.z() + 0.5),
ChunkAnchorItem.create(plugin, 1));
}
} catch (Exception ignored) {
}
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e dropada em " + loader.place() + ".");
}
}
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 <id>", NamedTextColor.AQUA));
sender.sendMessage(Component.text(" /chunkloader dar <jogador> [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 <id>");
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<ChunkLoader> 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 <jogador> [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 <id>", 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 ----------------------------------------------------- // --- tab completion -----------------------------------------------------
@Override @Override
@@ -1943,6 +2215,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return filter(List.of("on", "off"), args[2]); return filter(List.of("on", "off"), args[2]);
} }
} }
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
if (args.length == 1) {
List<String> 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<String> 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(); return List.of();
} }
@@ -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) {
}
}
}
@@ -0,0 +1,32 @@
package dev.marcospaulo.canalhandia;
/**
* A persistent chunk loader anchor placed in the world.
*
* <p>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 + ")");
}
}
@@ -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<Block> 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;
}
}
}
}
@@ -0,0 +1,289 @@
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<ChunkLoader> 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<ChunkLoader> byOwner(String ownerUuid) {
List<ChunkLoader> out = new ArrayList<>();
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.ownerUuid().equalsIgnoreCase(ownerUuid)) {
out.add(loader);
}
}
}
return Collections.unmodifiableList(out);
}
List<ChunkLoader> 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) {
}
}
void close() {
flush();
io.shutdown();
}
}
@@ -14,7 +14,8 @@ enum Module {
NOTAS("notas", "Anotações públicas e privadas no chat"), NOTAS("notas", "Anotações públicas e privadas no chat"),
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");
private final String key; private final String key;
private final String label; private final String label;
@@ -205,7 +205,8 @@ final class PlayerMemory {
if (text == null) { if (text == null) {
return null; return null;
} }
String lower = text.toLowerCase(Locale.ROOT).trim(); String clean = text.trim();
String lower = clean.toLowerCase(Locale.ROOT);
String[] triggers = { String[] triggers = {
"minha base", "meu spawn", "minha casa", "estou construindo", "minha base", "meu spawn", "minha casa", "estou construindo",
"meu plano", "meu objetivo", "sou especialista em", "moro em" "meu plano", "meu objetivo", "sou especialista em", "moro em"
@@ -213,7 +214,7 @@ final class PlayerMemory {
for (String trigger : triggers) { for (String trigger : triggers) {
int idx = lower.indexOf(trigger); int idx = lower.indexOf(trigger);
if (idx >= 0) { if (idx >= 0) {
String candidate = text.substring(idx).trim(); String candidate = clean.substring(idx).trim();
candidate = candidate.replaceAll("[?!.]+$", "").trim(); candidate = candidate.replaceAll("[?!.]+$", "").trim();
if (candidate.length() > 60) { if (candidate.length() > 60) {
candidate = candidate.substring(0, 60) + ""; candidate = candidate.substring(0, 60) + "";
@@ -579,6 +579,24 @@ final class Settings {
set("categorias." + category.key(), enabled); 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 ----------------------------------------------------------- // --- plumbing -----------------------------------------------------------
private void set(String path, Object value) { private void set(String path, Object value) {
+10
View File
@@ -98,10 +98,20 @@ commands:
description: Escolhe qual conquista você exibe como título no chat. description: Escolhe qual conquista você exibe como título no chat.
usage: /titulo [nome|limpar] usage: /titulo [nome|limpar]
aliases: [titulos, title] aliases: [titulos, title]
chunkloader:
description: Gerenciamento de Âncoras de Chunk (chunk loaders).
usage: /chunkloader [listar|info|remover|receita]
aliases: [ancora, ancoras, chunkloaders]
permissions: permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only, # Declared explicitly: an undeclared Bukkit permission falls back to op-only,
# which would silently stop normal players reacting, voting or guessing. # 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: canalhandia.reagir:
description: Permite reagir e prestar luto. description: Permite reagir e prestar luto.
default: true default: true
@@ -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<ChunkLoader> marcosLoaders = loaders.byOwner("uuid-marcos");
assertEquals(1, marcosLoaders.size());
assertEquals(l1, marcosLoaders.get(0));
List<ChunkLoader> 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());
}
}
@@ -143,6 +143,10 @@ class PlayerMemoryTest {
assertNotNull(fact); assertNotNull(fact);
assertTrue(fact.contains("estou construindo uma pirâmide gigante")); assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?")); assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
// Leading / trailing whitespace index alignment
assertEquals("minha base fica no topo", PlayerMemory.extractHeuristicFact(" minha base fica no topo! "));
assertEquals("estou construindo uma ponte", PlayerMemory.extractHeuristicFact(" oi, estou construindo uma ponte..."));
} }
@Test @Test