19 Commits

Author SHA1 Message Date
Marcos Paulo bc7a88b9e2 chore: sync .pr-review.json with main 2026-08-20 15:08:41 -03:00
marcos 8a34565f3c chore(pr-review): re-trigger bot review against updated .pr-review.json 2026-08-20 17:56:03 +00:00
Marcos Paulo b6e508c8c9 fix(pr-reviews): complete all PR comments resolutions and test coverage 2026-08-20 14:51:06 -03:00
Marcos Paulo cc5c983e41 fix(pr-reviews): address spawner reset, numeric name resolution, and activation validation 2026-08-20 14:48:21 -03:00
pragent test 07e5bb6018 chore: retrigger pragent review (post format-overhaul) 2026-08-20 17:17:44 +00:00
pragent test f2a19a729a chore: retrigger pragent review 2026-08-20 16:38:42 +00:00
Marcos Paulo 1ff830537e feat(chunkloader): add fuel charging, duration timers, and /chunkloader tempo 2026-08-20 13:19:57 -03:00
Marcos Paulo ce3b1ebbd9 feat(chunkloader): support custom names, anvil placement, and pause/resume 2026-08-20 13:13:06 -03:00
Marcos Paulo 5a6bb9a13d feat(chunkloader): add /chunkloader limite command and tab completion 2026-08-20 12:55:38 -03:00
Marcos Paulo 4b9507b36e feat(chunkloader): add mob spawning simulation, spawner activation and despawn protection 2026-08-20 12:53:19 -03:00
Marcos Paulo 4261e8b660 fix(chunkloader): use setChunkForceLoaded for full block/entity/redstone ticking 2026-08-20 11:16:24 -03:00
Marcos Paulo 2c7f5f98ad fix(review): check event.getKeepInventory(), equipment slot hand, and world height bounds 2026-08-20 10:21:11 -03:00
Marcos Paulo c3c4906e11 fix(review): address review comments and add .pr-review.json for pragent 2026-08-20 10:14:18 -03:00
Marcos Paulo d93711e5c1 fix(chunkloader): deliver anchor directly to inventory on break and protect against accidental interaction 2026-08-20 10:01:59 -03:00
Marcos Paulo 24ab63b9be feat(void): add void death item protection with safe chest and inventory fallback 2026-08-20 09:52:48 -03:00
Marcos Paulo e059ca6563 feat(chunkloader): Módulo nativo de Chunk Loader com limites LuckPerms e BlueMap (#4) 2026-08-19 15:31:22 -03:00
Marcos Paulo e70df329b3 fix(chunkloader): release tickets on disable/toggle, fix BlueMap world matching, and prevent item loss on remove 2026-08-19 15:09:44 -03:00
Marcos Paulo e71babca1c fix(memory): align substring index with trimmed input in extractHeuristicFact 2026-08-18 22:00:42 -03:00
Marcos Paulo 01ada6d987 feat(chunkloader): add native chunk loader module with LuckPerms limits and BlueMap support 2026-08-18 21:45:51 -03:00
21 changed files with 2567 additions and 20 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"focus": [
"correctness",
"security",
"performance"
],
"exclude_paths": [
"target/**",
"*.class"
],
"languages": [
"java"
],
"style": "balanced",
"require_tests": true,
"exclude_tests": false,
"max_findings": 15,
"severity_threshold": "low",
"patterns": {
"deny": [
"**/README.md",
"**/*.md"
]
},
"cost_target": "claude-sonnet-5",
"additional_context_urls": [
"http://nexus-service.nexus.svc.cluster.local:8081/repository/raw-hosted/canalhandia/architecture.md"
],
"instructions": "Minecraft plugin (Paper 26.2, pt-BR, JDK 25 build). Chat-only — never touch gameplay. Watch thread-safety on event handlers (PlayerDeathEvent, PlayerInteractEvent) — the Bukkit main thread is single-threaded but async chunks/events cross it. Avoid main-thread I/O; defer expensive scans (chunk loading, spiral search) to scheduled tasks or async paths. Flag mutable shared state across listener invocations. Hard constraints: chat messages are immutable after send (counts baked into buttons freeze at send time); names go out as translatable components so the singular-form rule applies (number never agrees with the noun); Geyser/Bedrock cannot click and cannot show emoji (every click has a typed fallback); vanilla statistics are the only data source (offline path is <world>/players/stats/<uuid>.json, NOT <world>/stats); reactions keep counting late (reacao-validade-minutos); Floodgate is optional runtime dep. Flag: real bugs, missing persistence of new settings, comando/permission not in plugin.yml, breaking Bedrock equivalent invariant, removing the frozen-at-send assumption, violating singular-form rule, missing Stats.resolve() on renames."
}
+2
View File
@@ -610,3 +610,5 @@ phrase it so the count never has to agree with a translated noun.
Statistic constants get renamed between Minecraft releases, so resolve them via Statistic constants get renamed between Minecraft releases, so resolve them via
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity `Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
instead of breaking the whole announcement. instead of breaking the whole announcement.
+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`.
+23
View File
@@ -0,0 +1,23 @@
# Spec: Proteção de Itens no Vácuo (Void Protection)
## 1. Problema e Motivação
Quando um jogador morre no vácuo (caindo no The End, Nether ou Overworld profundo), todos os itens e armaduras caem abaixo de Y = -64 e são excluídos pelo motor do Minecraft, impossibilitando qualquer recuperação legítima e gerando frustração.
## 2. Solução
Implementar o módulo `salvavoid` no plugin Canalhandia:
1. **Detecção:** Identifica mortes por vácuo (`DamageCause.VOID` ou coordenada Y abaixo da altura mínima do mundo).
2. **Busca de Terreno Seguro:** Procura o bloco sólido mais próximo da posição horizontal onde o jogador caiu (raio configurável, padrão 32 blocos).
3. **Opção A (Baú de Resgate):** Se encontrar terreno seguro:
- Cria um baú (ou baú duplo se necessário) sobre o bloco seguro.
- Guarda todos os itens e armaduras do jogador dentro do baú.
- Limpa os drops do evento de morte (para não cair no vácuo).
- Informa ao jogador a localização exata (coordenadas X, Y, Z) do baú no chat.
4. **Opção B (Preservação Direta no Inventário):** Se NÃO houver nenhum bloco seguro por perto (ex: caiu no meio do vácuo infinito do End):
- Preserva o inventário e nível de XP do jogador (`keepInventory = true`, `keepLevel = true`).
- Limpa os drops do evento de morte.
- Envia mensagem confortando o jogador e avisando que os itens foram mantidos no inventário.
## 3. Critérios de Aceitação
- Módulo integrado ao `/canalhandia modulo salvavoid <on|off>`.
- Configurações em `Settings.java`: `voidProtectionEnabled`, `voidProtectionRadius`, `voidProtectionKeepXp`.
- Testes unitários cobrindo detecção de morte no vácuo, cálculo de busca e empacotamento de inventário.
@@ -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,66 @@ 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;
}
String statusHtml = loader.enabled()
? "<span style='color:#55ff55'>Ativo</span>"
: "<span style='color:#ff5555'>Pausado</span>";
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
.label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
.detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
+ statusHtml + "<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,20 @@ 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();
chunkLoaders.startSimulation();
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 +154,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 +192,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
if (poll != null) { if (poll != null) {
poll.hide(); poll.hide();
} }
if (chunkLoaders != null) {
chunkLoaders.stopSimulation();
chunkLoaders.unloadAllTickets();
chunkLoaders.close();
ChunkAnchorItem.unregisterRecipe(this);
}
} }
Settings settings() { Settings settings() {
@@ -227,6 +242,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;
@@ -819,6 +839,60 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L); }, settings.reactionWindowSeconds() * 20L);
} }
/**
* Rescues player items when falling into the void. Places a chest on the nearest
* safe ground block, or preserves items directly in the inventory if no solid ground is nearby.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onVoidDeath(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.SALVAVOID)) {
return;
}
Player player = event.getEntity();
EntityDamageEvent damage = player.getLastDamageCause();
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
if (!VoidProtection.isVoidDeath(player.getLocation().getY(), player.getWorld().getMinHeight(), cause)) {
return;
}
List<ItemStack> drops = new ArrayList<>(event.getDrops());
Location safeLoc = VoidProtection.findSafeChestLocation(player.getWorld(), player.getLocation(), settings.voidProtectionRadius());
if (safeLoc != null && !drops.isEmpty() && VoidProtection.rescueToChest(drops, safeLoc)) {
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
int x = safeLoc.getBlockX();
int y = safeLoc.getBlockY();
int z = safeLoc.getBlockZ();
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo! Seus itens foram guardados em segurança num baú em ", NamedTextColor.YELLOW))
.append(Component.text(x + ", " + y + ", " + z, NamedTextColor.AQUA, TextDecoration.BOLD))
.append(Component.text(".", NamedTextColor.YELLOW)));
}
}, 20L);
} else {
// No safe ground found within radius: keep inventory directly
event.setKeepInventory(true);
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo sem terra firme por perto! Seus itens foram mantidos no seu inventário.", NamedTextColor.GREEN)));
}
}, 20L);
}
}
/** /**
* Comic death broadcast + private coordinates, gated by the {@code mortes} * Comic death broadcast + private coordinates, gated by the {@code mortes}
* module. Replaces the vanilla translatable death message with a pt-BR * module. Replaces the vanilla translatable death message with a pt-BR
@@ -862,7 +936,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Location loc = player.getLocation(); Location loc = player.getLocation();
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ() String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
+ " (" + loc.getWorld().getName() + ")"; + " (" + loc.getWorld().getName() + ")";
boolean keepInventory = Boolean.TRUE.equals( boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals(
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY)); loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory)); pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
@@ -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,18 @@ 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();
plugin.chunkLoaders().startSimulation();
ChunkAnchorItem.registerRecipe(plugin);
} else {
plugin.chunkLoaders().stopSimulation();
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 +1799,451 @@ 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 "limite", "limit" -> chunkloaderLimit(sender, rest);
case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest);
case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true);
case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false);
case "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest);
case "admin" -> chunkloaderAdmin(sender, rest);
case "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) {
String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]";
NamedTextColor statusColor = loader.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
Component line = Component.text(" " + loader.displayName() + " ", NamedTextColor.AQUA)
.append(Component.text(loader.place() + " " + loader.chunkCoords() + " ", NamedTextColor.WHITE))
.append(Component.text(statusText + " ", statusColor))
.append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD));
if (!bedrock) {
if (loader.enabled()) {
line = line.append(Component.text(" [Pausar]", NamedTextColor.YELLOW)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para pausar " + loader.displayName(), NamedTextColor.GRAY)))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
"/chunkloader desativar " + loader.id())));
} else {
line = line.append(Component.text(" [Ativar]", NamedTextColor.GREEN)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para reativar " + loader.displayName(), NamedTextColor.GRAY)))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
"/chunkloader ativar " + loader.id())));
}
line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para recolher " + loader.displayName(), 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) {
String status = here.enabled() ? "Ativa" : "Pausada";
NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
.append(Component.text(here.displayName() + " (" + status + ")", statusCol))
.append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE)));
sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY)
.append(Component.text(here.timeLeft(), NamedTextColor.GOLD)));
} 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 chunkloaderRename(CommandSender sender, String[] args) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader nome <id|nome_antigo> <novo_nome>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
String newName = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
if (newName.length() > 32) {
Msg.error(sender, "Nome muito longo (máximo 32 caracteres).");
return;
}
plugin.chunkLoaders().rename(loader.id(), newName);
plugin.blueMap().syncChunkLoaders();
Msg.ok(sender, "Âncora #" + loader.id() + " renomeada para '" + newName + "'.");
}
private void chunkloaderSetEnabled(CommandSender sender, String[] args, boolean enabled) {
if (args.length < 1) {
Msg.error(sender, "Uso: /chunkloader " + (enabled ? "ativar" : "desativar") + " <id|nome>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
if (loader.enabled() == enabled) {
Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + ".");
return;
}
if (enabled && loader.isExpired()) {
Msg.error(sender, "A âncora " + loader.displayName() + " está expirada! Adicione tempo (/chunkloader tempo " + loader.id() + " <horas>) ou abasteça com combustível antes de reativar.");
return;
}
plugin.chunkLoaders().setEnabled(loader.id(), enabled);
plugin.blueMap().syncChunkLoaders();
Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA."));
}
private void chunkloaderTime(CommandSender sender, String[] args) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader tempo <id|nome> <horas>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
try {
double hours = Double.parseDouble(args[1]);
if (hours <= 0) {
if (isAdmin) {
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
} else {
Msg.error(sender, "A quantidade de horas deve ser maior que zero.");
}
return;
}
long millis = (long) (hours * 3600_000L);
plugin.chunkLoaders().addTime(loader.id(), millis);
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
String time = (updated != null) ? updated.timeLeft() : "Permanente";
Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time);
plugin.blueMap().syncChunkLoaders();
} catch (NumberFormatException e) {
Msg.error(sender, "Horas inválidas: " + args[1]);
}
}
private void chunkloaderRemove(CommandSender sender, String[] args) {
if (args.length < 1) {
Msg.error(sender, "Uso: /chunkloader remover <id|nome>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma Âncora de Chunk encontrada para '" + args[0] + "'.");
return;
}
long id = loader.id();
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 " + loader.displayName() + " 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 " + loader.displayName() + " 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 void chunkloaderLimit(CommandSender sender, String[] args) {
if (args.length == 0) {
int defaultLimit = plugin.settings().chunkLoaderDefaultLimit();
Msg.header(sender, "Limite de Âncoras de Chunk");
sender.sendMessage(Component.text(" Limite padrão global: ", NamedTextColor.GRAY)
.append(Component.text(defaultLimit, NamedTextColor.YELLOW)));
if (sender instanceof Player player) {
int myLimit = ChunkLoaders.playerLimit(player, defaultLimit);
String myLimitStr = myLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(myLimit);
int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
sender.sendMessage(Component.text(" Seu limite atual: ", NamedTextColor.GRAY)
.append(Component.text(current + "/" + myLimitStr, NamedTextColor.AQUA)));
}
if (admin(sender)) {
sender.sendMessage(Component.text(" Alterar padrão global: ", NamedTextColor.GRAY)
.append(Component.text("/chunkloader limite padrao <quantidade>", NamedTextColor.YELLOW)));
sender.sendMessage(Component.text(" Definir por jogador (LuckPerms): ", NamedTextColor.GRAY)
.append(Component.text("/chunkloader limite <jogador> <quantidade>", NamedTextColor.YELLOW)));
}
return;
}
if (!admin(sender)) {
return;
}
if (args[0].equalsIgnoreCase("padrao") || args[0].equalsIgnoreCase("default")) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader limite padrao <quantidade>");
return;
}
try {
int newLimit = Math.max(0, Integer.parseInt(args[1]));
plugin.settings().chunkLoaderDefaultLimit(newLimit);
Msg.ok(sender, "Limite padrão global de Âncoras alterado para " + newLimit + ".");
} catch (NumberFormatException e) {
Msg.error(sender, "Quantidade inválida: " + args[1]);
}
return;
}
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, "Jogador '" + args[0] + "' não encontrado online.");
return;
}
if (args.length < 2) {
int targetLimit = ChunkLoaders.playerLimit(target, plugin.settings().chunkLoaderDefaultLimit());
String str = targetLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(targetLimit);
int current = plugin.chunkLoaders().byOwner(target.getUniqueId().toString()).size();
Msg.ok(sender, "Limite de " + target.getName() + ": " + current + "/" + str + " âncoras ativas.");
return;
}
try {
int newLimit = Math.max(0, Integer.parseInt(args[1]));
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit);
Msg.ok(sender, "Limite de " + target.getName() + " definido para " + newLimit + " âncoras.");
} catch (NumberFormatException e) {
Msg.error(sender, "Quantidade inválida: " + args[1]);
}
}
private boolean chunkloaderHelp(CommandSender sender) {
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, nomes, status e coordenadas", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader nome <id|nome> <novo_nome>", NamedTextColor.AQUA)
.append(Component.text(" — Define um nome/rótulo para a âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader ativar <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Reativa o carregamento contínuo da âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader desativar <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Pausa o carregamento sem quebrar o bloco", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader remover <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader tempo <id|nome> <horas>", NamedTextColor.AQUA)
.append(Component.text(" — Adiciona tempo ou abastece a âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader limite", NamedTextColor.AQUA)
.append(Component.text(" — Mostra ou configura os limites de âncoras", NamedTextColor.GRAY)));
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
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 +2404,45 @@ 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", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite"));
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")
|| args[0].equalsIgnoreCase("nome") || args[0].equalsIgnoreCase("renomear")
|| args[0].equalsIgnoreCase("ativar") || args[0].equalsIgnoreCase("desativar")
|| args[0].equalsIgnoreCase("pausar") || args[0].equalsIgnoreCase("tempo")
|| args[0].equalsIgnoreCase("abastecer"))) {
if (sender instanceof Player player) {
List<String> identifiers = new ArrayList<>();
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
identifiers.add(String.valueOf(loader.id()));
if (loader.name() != null && !loader.name().isBlank()) {
identifiers.add(loader.name());
}
}
return filter(identifiers, args[1]);
}
}
if (args.length == 2 && args[0].equalsIgnoreCase("limite")) {
List<String> opts = new ArrayList<>(List.of("padrao"));
opts.addAll(onlineNames());
return filter(opts, args[1]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
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,85 @@
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 name,
boolean enabled,
long expiresAt,
String ownerUuid,
String ownerName,
String world,
int x,
int y,
int z,
int chunkX,
int chunkZ,
long createdAt) {
ChunkLoader(long id, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
this(id, "", true, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader(long id, String name, boolean enabled, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
this(id, name, enabled, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
String blockCoords() {
return x + ", " + y + ", " + z;
}
String chunkCoords() {
return "[" + chunkX + ", " + chunkZ + "]";
}
String place() {
return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
}
String displayName() {
return (name != null && !name.isBlank()) ? name + " (#" + id + ")" : "#" + id;
}
String simpleName() {
return (name != null && !name.isBlank()) ? name : "#" + id;
}
boolean isExpired() {
return expiresAt > 0 && System.currentTimeMillis() >= expiresAt;
}
String timeLeft() {
if (expiresAt <= 0) {
return "Permanente";
}
long diff = expiresAt - System.currentTimeMillis();
if (diff <= 0) {
return "Expirado";
}
long hours = diff / (3600_000L);
long minutes = (diff % (3600_000L)) / 60_000L;
if (hours >= 24) {
long days = hours / 24;
hours = hours % 24;
return days + "d " + hours + "h";
}
return hours + "h " + minutes + "m";
}
ChunkLoader withName(String newName) {
return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader withEnabled(boolean newEnabled) {
return new ChunkLoader(id, name, newEnabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader withExpiresAt(long newExpiresAt) {
return new ChunkLoader(id, name, enabled, Math.max(0L, newExpiresAt), ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
}
@@ -0,0 +1,317 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Mob;
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.Action;
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.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Iterator;
import java.util.Map;
/**
* 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;
}
String customName = "";
var meta = event.getItemInHand().getItemMeta();
if (meta != null && meta.hasDisplayName()) {
try {
String raw = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(meta.displayName()).trim();
if (!raw.equalsIgnoreCase("Âncora de Chunk") && !raw.isBlank()) {
customName = raw;
}
} catch (Exception ignored) {
}
}
ChunkLoader loader = plugin.chunkLoaders().add(
player.getUniqueId().toString(),
player.getName(),
world,
loc.getBlockX(),
loc.getBlockY(),
loc.getBlockZ(),
customName
);
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 " + loader.displayName() + " ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente ("
+ (current + 1) + "/" + limitStr + ").");
}
@EventHandler(priority = EventPriority.HIGHEST, 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);
event.setExpToDrop(0);
if (player.getGameMode() == GameMode.CREATIVE) {
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada (Modo Criativo).");
} else {
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
for (ItemStack drop : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), drop);
}
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada e recolhida para o seu inventário.");
}
try {
var loc = block.getLocation().add(0.5, 0.5, 0.5);
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f);
} catch (Exception ignored) {
}
plugin.blueMap().syncChunkLoaders();
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
return;
}
if (event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) {
return;
}
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
Block block = event.getClickedBlock();
if (block == null || block.getType() != Material.RESPAWN_ANCHOR) {
return;
}
ChunkLoader loader = plugin.chunkLoaders().byLocation(
block.getWorld().getName(),
block.getX(),
block.getY(),
block.getZ()
);
if (loader != null) {
event.setCancelled(true);
Player player = event.getPlayer();
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
ItemStack inHand = player.getInventory().getItemInMainHand();
long fuelMillis = fuelDuration(inHand.getType());
if (fuelMillis > 0 && (isOwner || isAdmin)) {
if (player.getGameMode() != GameMode.CREATIVE) {
inHand.subtract(1);
}
plugin.chunkLoaders().addTime(loader.id(), fuelMillis);
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
Location l = block.getLocation().add(0.5, 0.5, 0.5);
try {
l.getWorld().playSound(l, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 1.0f, 1.2f);
l.getWorld().spawnParticle(Particle.FIREWORK, l, 15, 0.2, 0.2, 0.2, 0.05);
} catch (Exception ignored) {
}
plugin.blueMap().syncChunkLoaders();
String time = (updated != null) ? updated.timeLeft() : "Permanente";
Msg.ok(player, "Âncora " + loader.displayName() + " abastecida! Tempo restante: " + time);
return;
}
if (player.isSneaking() && (isOwner || isAdmin)) {
if (!loader.enabled() && loader.isExpired()) {
Msg.error(player, "A âncora " + loader.displayName() + " está expirada! Clique com combustível (Pérola do End, Blaze, Diamante, etc.) ou adicione tempo antes de reativar.");
return;
}
boolean newState = !loader.enabled();
plugin.chunkLoaders().setEnabled(loader.id(), newState);
Location l = block.getLocation().add(0.5, 0.5, 0.5);
if (newState) {
try {
l.getWorld().playSound(l, Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.2f);
l.getWorld().spawnParticle(Particle.PORTAL, l, 25, 0.2, 0.2, 0.2, 0.05);
} catch (Exception ignored) {
}
Msg.ok(player, "Âncora " + loader.displayName() + " ATIVADA.");
} else {
try {
l.getWorld().playSound(l, Sound.BLOCK_BEACON_DEACTIVATE, 1.0f, 0.8f);
l.getWorld().spawnParticle(Particle.SMOKE, l, 20, 0.2, 0.2, 0.2, 0.02);
} catch (Exception ignored) {
}
Msg.ok(player, "Âncora " + loader.displayName() + " PAUSADA/DESATIVADA.");
}
plugin.blueMap().syncChunkLoaders();
return;
}
String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA";
Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr
+ " [Tempo: " + loader.timeLeft() + "]. (Shift + Clique Direito para pausar/ativar | Clique com combustível para abastecer)");
}
}
private static long fuelDuration(Material type) {
if (type == Material.REDSTONE || type == Material.GLOWSTONE_DUST) {
return 30 * 60_000L; // 30 min
}
if (type == Material.GLOWSTONE || type == Material.AMETHYST_SHARD) {
return 2 * 3600_000L; // 2h
}
if (type == Material.ENDER_PEARL || type == Material.BLAZE_POWDER || type == Material.BLAZE_ROD) {
return 4 * 3600_000L; // 4h
}
if (type == Material.ENDER_EYE || type == Material.DIAMOND || type == Material.EMERALD) {
return 12 * 3600_000L; // 12h
}
if (type == Material.NETHER_STAR || type == Material.NETHERITE_INGOT || type == Material.END_CRYSTAL) {
return 7 * 24 * 3600_000L; // 7 dias
}
return 0L;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
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;
}
}
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) {
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
return;
}
Location loc = event.getLocation();
World w = loc.getWorld();
if (w == null) {
return;
}
ChunkLoader loader = plugin.chunkLoaders().byChunk(w.getName(), loc.getBlockX() >> 4, loc.getBlockZ() >> 4);
if (loader != null && event.getEntity() instanceof Mob mob) {
mob.setRemoveWhenFarAway(false);
}
}
}
@@ -0,0 +1,603 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.CreatureSpawner;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.permissions.PermissionAttachmentInfo;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
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.ThreadLocalRandom;
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 BukkitTask simulationTask;
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 name = root.getString(key + ".name", "");
boolean enabled = root.getBoolean(key + ".enabled", true);
long expiresAt = root.getLong(key + ".expires_at", 0L);
String ownerUuid = root.getString(key + ".owner_uuid", "");
String 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, name, enabled, expiresAt, 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) {
return add(ownerUuid, ownerName, world, x, y, z, "", 0L);
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) {
return add(ownerUuid, ownerName, world, x, y, z, name, 0L);
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) {
int chunkX = x >> 4;
int chunkZ = z >> 4;
long id = nextId.getAndIncrement();
ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
synchronized (loaders) {
loaders.add(loader);
}
addTicket(loader);
save();
return loader;
}
/**
* Adds duration (in milliseconds) to a chunk loader's expiration timer.
*/
boolean addTime(long id, long millisToAdd) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
long now = System.currentTimeMillis();
long base = (curr.expiresAt() > now) ? curr.expiresAt() : now;
long newExpires = base + millisToAdd;
updated = curr.withExpiresAt(newExpires).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Sets a specific expiration timestamp (or 0 for permanent) for a chunk loader.
*/
boolean setExpiresAt(long id, long expiresAt) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
updated = curr.withExpiresAt(expiresAt).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Renames an existing chunk loader.
*/
boolean rename(long id, String newName) {
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
loaders.set(i, curr.withName(newName));
save();
return true;
}
}
}
return false;
}
/**
* Enables or disables (pauses) a chunk loader without destroying the block.
*/
boolean setEnabled(long id, boolean enabled) {
ChunkLoader oldLoader = null;
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
oldLoader = curr;
updated = curr.withEnabled(enabled);
loaders.set(i, updated);
break;
}
}
}
if (oldLoader != null && updated != null) {
if (enabled) {
addTicket(updated);
} else {
removeTicket(oldLoader);
}
save();
return true;
}
return false;
}
/**
* Finds a chunk loader by its numeric ID (with '#' prefix or plain ID) or custom name.
* Custom names take precedence over raw numeric IDs to avoid shadowing named loaders.
*/
ChunkLoader find(String query) {
if (query == null || query.isBlank()) {
return null;
}
String trimmed = query.trim();
if (trimmed.startsWith("#")) {
try {
long id = Long.parseLong(trimmed.substring(1));
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
}
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.name() != null && loader.name().equalsIgnoreCase(trimmed)) {
return loader;
}
}
}
try {
long id = Long.parseLong(trimmed);
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
return null;
}
/**
* Removes a chunk loader by its unique ID and releases the chunk ticket.
*/
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) {
if (loader.enabled()) {
addTicket(loader);
}
}
}
}
void unloadAllTickets() {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
removeTicket(loader);
}
}
}
void startSimulation() {
if (plugin == null || simulationTask != null) {
return;
}
simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L);
}
void stopSimulation() {
if (simulationTask != null) {
simulationTask.cancel();
simulationTask = null;
}
}
void tickMobSimulation() {
if (plugin == null) {
return;
}
List<ChunkLoader> currentLoaders;
synchronized (loaders) {
currentLoaders = new ArrayList<>(loaders);
}
for (ChunkLoader loader : currentLoaders) {
if (loader.isExpired() && loader.enabled()) {
setEnabled(loader.id(), false);
continue;
}
if (!loader.enabled()) {
continue;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
continue;
}
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active
for (BlockState state : chunk.getTileEntities()) {
if (state instanceof CreatureSpawner spawner) {
if (spawner.getRequiredPlayerRange() < 1024) {
spawner.setRequiredPlayerRange(2048);
spawner.update(true, false);
}
}
}
// 2. Simulate natural mob spawning (dark platforms / slime / nether farms)
long mobCount = 0;
for (Entity entity : chunk.getEntities()) {
if (entity instanceof Mob) {
mobCount++;
}
}
if (mobCount >= 20) {
continue; // Respect chunk mob cap
}
simulateNaturalSpawning(w, loader, chunk);
} catch (Exception ignored) {
}
}
}
private void simulateNaturalSpawning(World w, ChunkLoader loader, Chunk chunk) {
int baseX = loader.chunkX() << 4;
int baseZ = loader.chunkZ() << 4;
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int i = 0; i < 2; i++) {
int rx = baseX + rnd.nextInt(16);
int rz = baseZ + rnd.nextInt(16);
int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25)));
Block ground = w.getBlockAt(rx, topY - 1, rz);
Block space = w.getBlockAt(rx, topY, rz);
Block spaceAbove = w.getBlockAt(rx, topY + 1, rz);
if (!ground.getType().isSolid() || !space.getType().isAir() || !spaceAbove.getType().isAir()) {
continue;
}
if (ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
continue;
}
int light = space.getLightLevel();
EntityType toSpawn = pickEntityType(w, space, light);
if (toSpawn == null) {
continue;
}
try {
Entity spawned = w.spawnEntity(space.getLocation().add(0.5, 0, 0.5), toSpawn, CreatureSpawnEvent.SpawnReason.NATURAL);
if (spawned instanceof Mob mob) {
mob.setRemoveWhenFarAway(false);
}
} catch (Exception ignored) {
}
}
}
static EntityType pickEntityType(World w, Block space, int light) {
boolean isSlime = false;
try {
isSlime = space.getChunk().isSlimeChunk();
} catch (Exception ignored) {
}
return pickEntityType(w.getEnvironment(), isSlime, space.getY(), light, ThreadLocalRandom.current().nextInt(100));
}
static EntityType pickEntityType(World.Environment env, boolean isSlimeChunk, int y, int light, int roll) {
if (env == World.Environment.NETHER) {
if (light > 11) return null;
int r = Math.floorMod(roll, 100);
if (r < 45) return EntityType.ZOMBIFIED_PIGLIN;
if (r < 65) return EntityType.WITHER_SKELETON;
if (r < 85) return EntityType.BLAZE;
return EntityType.MAGMA_CUBE;
}
if (env == World.Environment.THE_END) {
return EntityType.ENDERMAN;
}
if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) {
return EntityType.SLIME;
}
if (light > 0) {
return null;
}
int r = Math.floorMod(roll, 100);
if (r < 35) return EntityType.ZOMBIE;
if (r < 65) return EntityType.SKELETON;
if (r < 85) return EntityType.CREEPER;
if (r < 95) return EntityType.SPIDER;
return EntityType.WITCH;
}
private void addTicket(ChunkLoader loader) {
if (plugin == null) {
return;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w != null) {
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true);
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);
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false);
if (w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
for (BlockState state : chunk.getTileEntities()) {
if (state instanceof CreatureSpawner spawner && spawner.getRequiredPlayerRange() > 16) {
spawner.setRequiredPlayerRange(16);
spawner.update(true, false);
}
}
}
}
} catch (Exception ignored) {
}
}
private YamlConfiguration buildYaml() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (loaders) {
for (ChunkLoader l : loaders) {
String key = "loaders." + l.id();
yaml.set(key + ".name", l.name());
yaml.set(key + ".enabled", l.enabled());
yaml.set(key + ".expires_at", l.expiresAt());
yaml.set(key + ".owner_uuid", l.ownerUuid());
yaml.set(key + ".owner_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,9 @@ 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"),
SALVAVOID("salvavoid", "Proteção de itens no vácuo");
private final String key; private final String key;
private final String label; private final String label;
@@ -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,42 @@ 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);
}
// --- salvavoid ----------------------------------------------------------
int voidProtectionRadius() {
return Math.max(1, plugin.getConfig().getInt("salvavoid.raio-busca", 32));
}
void voidProtectionRadius(int radius) {
set("salvavoid.raio-busca", Math.max(1, radius));
}
boolean voidProtectionKeepXp() {
return plugin.getConfig().getBoolean("salvavoid.preservar-xp", true);
}
void voidProtectionKeepXp(boolean keep) {
set("salvavoid.preservar-xp", keep);
}
// --- plumbing ----------------------------------------------------------- // --- plumbing -----------------------------------------------------------
private void set(String path, Object value) { private void set(String path, Object value) {
@@ -0,0 +1,273 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Chest;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Rescues player items on void death, either into a safe nearby chest
* on solid ground or directly retained in the player's inventory.
*/
public final class VoidProtection {
private static final int MAX_CHECKED_COLUMNS = 120;
private VoidProtection() {
}
/**
* Determines whether a death was caused by falling into the void.
*/
public static boolean isVoidDeath(double y, double minHeight, EntityDamageEvent.DamageCause cause) {
if (cause == EntityDamageEvent.DamageCause.VOID) {
return true;
}
return y < minHeight;
}
/**
* Finds the nearest safe solid block with air space above it within the given horizontal radius.
* Only checks already-loaded chunks and caps checked columns to prevent server hitches.
*/
public static Location findSafeChestLocation(World world, Location deathLoc, int radius) {
if (world == null || deathLoc == null || radius <= 0) {
return null;
}
int centerX = deathLoc.getBlockX();
int centerZ = deathLoc.getBlockZ();
int minHeight = world.getMinHeight();
int checked = 0;
for (int r = 0; r <= radius; r += (r > 8 ? 2 : 1)) {
Location ringBest = null;
double ringBestDistSq = Double.MAX_VALUE;
for (int dx = -r; dx <= r; dx += (r > 8 ? 2 : 1)) {
for (int dz = -r; dz <= r; dz += (r > 8 ? 2 : 1)) {
if (r > 0 && Math.abs(dx) != r && Math.abs(dz) != r) {
continue;
}
if (++checked > MAX_CHECKED_COLUMNS) {
return ringBest;
}
int x = centerX + dx;
int z = centerZ + dz;
// Do not load or generate new chunks synchronously on death
if (!world.isChunkLoaded(x >> 4, z >> 4)) {
continue;
}
int topY;
try {
topY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES);
} catch (Exception e) {
continue;
}
int maxHeight = world.getMaxHeight();
if (topY <= minHeight || topY >= maxHeight - 1) {
continue;
}
Block ground;
Block space;
try {
ground = world.getBlockAt(x, topY, z);
space = world.getBlockAt(x, topY + 1, z);
} catch (Exception e) {
continue;
}
if (isSafeGround(ground) && isReplaceable(space)) {
double distSq = (dx * dx) + (dz * dz);
if (distSq < ringBestDistSq) {
ringBestDistSq = distSq;
ringBest = space.getLocation();
}
}
}
}
if (ringBest != null) {
return ringBest;
}
}
return null;
}
static boolean isSafeGround(Block block) {
return block != null && isSafeGroundMaterial(block.getType());
}
public static boolean isSafeGroundMaterial(Material mat) {
if (mat == null || mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR) {
return false;
}
if (mat == Material.LAVA || mat == Material.WATER || mat == Material.FIRE || mat == Material.SOUL_FIRE
|| mat == Material.CACTUS || mat == Material.MAGMA_BLOCK || mat == Material.SWEET_BERRY_BUSH
|| mat == Material.WITHER_ROSE || mat == Material.POWDER_SNOW) {
return false;
}
try {
return mat.isSolid();
} catch (Exception | LinkageError e) {
return true;
}
}
static boolean isReplaceable(Block block) {
return block != null && isReplaceableMaterial(block.getType());
}
public static boolean isReplaceableMaterial(Material mat) {
if (mat == null) {
return false;
}
return mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR
|| mat == Material.SHORT_GRASS || mat == Material.TALL_GRASS
|| mat == Material.SNOW || mat == Material.FERN || mat == Material.LARGE_FERN;
}
/**
* Calculates the estimated number of inventory slots needed to store the given items.
*/
public static int calculateRequiredSlots(List<ItemStack> items) {
if (items == null || items.isEmpty()) {
return 0;
}
int slots = 0;
for (ItemStack item : items) {
if (item != null && item.getType() != Material.AIR && item.getAmount() > 0) {
int maxStack = Math.max(1, item.getMaxStackSize());
slots += (int) Math.ceil((double) item.getAmount() / maxStack);
}
}
return slots;
}
public static boolean canFitInSingleChest(List<ItemStack> items) {
return calculateRequiredSlots(items) <= 27;
}
public static boolean canFitInDoubleChest(List<ItemStack> items) {
return calculateRequiredSlots(items) <= 54;
}
/**
* Stores items into a chest (and an adjacent chest if needed) at the target location.
* All items must be stored without overflow; on any failure, blocks are rolled back
* and false is returned so caller can safely fall back to keepInventory.
*/
public static boolean rescueToChest(List<ItemStack> items, Location chestLoc) {
if (items == null || items.isEmpty() || chestLoc == null) {
return false;
}
Block chestBlock = chestLoc.getBlock();
Material orig1 = chestBlock.getType();
Block adjacent = null;
Material orig2 = null;
try {
chestBlock.setType(Material.CHEST, false);
BlockState state = chestBlock.getState();
if (!(state instanceof Chest chest)) {
chestBlock.setType(orig1, false);
return false;
}
Inventory inv = chest.getInventory();
List<ItemStack> remaining = new ArrayList<>();
for (ItemStack item : items) {
if (item != null && !item.getType().isAir()) {
Map<Integer, ItemStack> leftover = inv.addItem(item.clone());
remaining.addAll(leftover.values());
}
}
if (!remaining.isEmpty()) {
adjacent = findAdjacentSpace(chestLoc);
if (adjacent == null) {
// Cannot fit all items and no space for second chest -> rollback
inv.clear();
chestBlock.setType(orig1, false);
return false;
}
orig2 = adjacent.getType();
adjacent.setType(Material.CHEST, false);
BlockState adjState = adjacent.getState();
if (!(adjState instanceof Chest adjChest)) {
inv.clear();
chestBlock.setType(orig1, false);
adjacent.setType(orig2, false);
return false;
}
Inventory adjInv = adjChest.getInventory();
List<ItemStack> secondLeftover = new ArrayList<>();
for (ItemStack rem : remaining) {
Map<Integer, ItemStack> leftover = adjInv.addItem(rem);
secondLeftover.addAll(leftover.values());
}
if (!secondLeftover.isEmpty()) {
// Still overflowed double chest -> rollback everything
inv.clear();
adjInv.clear();
chestBlock.setType(orig1, false);
adjacent.setType(orig2, false);
return false;
}
}
return true;
} catch (Exception e) {
try {
if (chestBlock.getType() == Material.CHEST) {
BlockState s = chestBlock.getState();
if (s instanceof Chest c) c.getInventory().clear();
chestBlock.setType(orig1, false);
}
if (adjacent != null && adjacent.getType() == Material.CHEST) {
BlockState s2 = adjacent.getState();
if (s2 instanceof Chest c2) c2.getInventory().clear();
adjacent.setType(orig2 != null ? orig2 : Material.AIR, false);
}
} catch (Exception ignored) {
}
return false;
}
}
private static Block findAdjacentSpace(Location loc) {
World w = loc.getWorld();
if (w == null) return null;
int x = loc.getBlockX();
int y = loc.getBlockY();
int z = loc.getBlockZ();
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] off : offsets) {
Block b = w.getBlockAt(x + off[0], y, z + off[1]);
Block ground = w.getBlockAt(x + off[0], y - 1, z + off[1]);
if (isReplaceable(b) && isSafeGround(ground)) {
return b;
}
}
return null;
}
}
+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,212 @@
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());
}
@Test
void renamesAndTogglesEnabledState() {
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
assertEquals("#1", loader.simpleName());
assertTrue(loader.enabled());
assertTrue(loaders.rename(loader.id(), "Farm de Ferro"));
ChunkLoader renamed = loaders.byId(loader.id());
assertNotNull(renamed);
assertEquals("Farm de Ferro", renamed.name());
assertEquals("Farm de Ferro (#1)", renamed.displayName());
assertEquals("Farm de Ferro", renamed.simpleName());
assertEquals(renamed, loaders.find("Farm de Ferro"));
assertEquals(renamed, loaders.find("#1"));
assertEquals(renamed, loaders.find("1"));
// Loader 2 named "1" should be prioritized over ID 1 when querying by plain "1"
ChunkLoader loader2 = loaders.add("uuid-marcos", "Marcos", "world", 200, 64, 300, "1", 0L);
assertEquals(loader2, loaders.find("1"));
assertEquals(renamed, loaders.find("#1"));
assertEquals(loader2, loaders.find("#2"));
assertTrue(loaders.setEnabled(loader.id(), false));
ChunkLoader disabled = loaders.byId(loader.id());
assertNotNull(disabled);
assertFalse(disabled.enabled());
loaders.flush();
ChunkLoaders reloaded = new ChunkLoaders(null, file);
ChunkLoader reloadedLoader = reloaded.byId(loader.id());
assertNotNull(reloadedLoader);
assertEquals("Farm de Ferro", reloadedLoader.name());
assertFalse(reloadedLoader.enabled());
}
@Test
void managesExpirationAndFuelTime() {
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200, "Base", 0L);
assertEquals("Permanente", loader.timeLeft());
assertFalse(loader.isExpired());
assertTrue(loaders.addTime(loader.id(), 2 * 3600_000L)); // +2h
ChunkLoader timed = loaders.byId(loader.id());
assertNotNull(timed);
assertTrue(timed.expiresAt() > System.currentTimeMillis());
assertFalse(timed.isExpired());
assertTrue(timed.timeLeft().contains("h"));
ChunkLoader expired = timed.withExpiresAt(System.currentTimeMillis() - 1000L);
assertTrue(expired.isExpired());
assertEquals("Expirado", expired.timeLeft());
assertTrue(loaders.setExpiresAt(loader.id(), 0L));
ChunkLoader permanent = loaders.byId(loader.id());
assertNotNull(permanent);
assertEquals(0L, permanent.expiresAt());
assertEquals("Permanente", permanent.timeLeft());
assertFalse(permanent.isExpired());
}
@Test
void pickEntityTypeReturnsExpectedEntitiesByEnvironment() {
// Nether
assertEquals(org.bukkit.entity.EntityType.ZOMBIFIED_PIGLIN,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 10));
assertEquals(org.bukkit.entity.EntityType.WITHER_SKELETON,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 50));
assertEquals(org.bukkit.entity.EntityType.BLAZE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 75));
assertEquals(org.bukkit.entity.EntityType.MAGMA_CUBE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 90));
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 12, 10));
// End
assertEquals(org.bukkit.entity.EntityType.ENDERMAN,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.THE_END, false, 64, 15, 50));
// Overworld Slime Chunk
assertEquals(org.bukkit.entity.EntityType.SLIME,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, true, 30, 5, 0));
// Overworld Normal Darkness
assertEquals(org.bukkit.entity.EntityType.ZOMBIE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 10));
assertEquals(org.bukkit.entity.EntityType.SKELETON,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 45));
assertEquals(org.bukkit.entity.EntityType.CREEPER,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 75));
assertEquals(org.bukkit.entity.EntityType.SPIDER,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 90));
assertEquals(org.bukkit.entity.EntityType.WITCH,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 99));
// Overworld in Light -> Null
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 5, 10));
}
@Test
void simulationSafelyNoopsWithNullPlugin() {
loaders.startSimulation();
loaders.tickMobSimulation();
loaders.stopSimulation();
}
}
@@ -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
@@ -0,0 +1,84 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class VoidProtectionTest {
@Test
void identifiesVoidDeathByCause() {
assertTrue(VoidProtection.isVoidDeath(100.0, -64.0, EntityDamageEvent.DamageCause.VOID));
assertTrue(VoidProtection.isVoidDeath(-70.0, -64.0, EntityDamageEvent.DamageCause.VOID));
}
@Test
void identifiesVoidDeathByCoordinatesBelowMinHeight() {
assertTrue(VoidProtection.isVoidDeath(-65.0, -64.0, EntityDamageEvent.DamageCause.FALL));
assertTrue(VoidProtection.isVoidDeath(-100.0, 0.0, EntityDamageEvent.DamageCause.CUSTOM));
assertFalse(VoidProtection.isVoidDeath(50.0, -64.0, EntityDamageEvent.DamageCause.FALL));
assertFalse(VoidProtection.isVoidDeath(10.0, 0.0, EntityDamageEvent.DamageCause.LAVA));
}
@Test
void handlesNullWorldOrLocationsGracefully() {
assertNull(VoidProtection.findSafeChestLocation(null, null, 10));
assertNull(VoidProtection.findSafeChestLocation(null, null, 0));
assertFalse(VoidProtection.rescueToChest(null, null));
assertFalse(VoidProtection.rescueToChest(List.of(), null));
}
@Test
void validatesSafeGroundMaterials() {
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.STONE));
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.GRASS_BLOCK));
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.DIRT));
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.OBSIDIAN));
assertFalse(VoidProtection.isSafeGroundMaterial(null));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.AIR));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.LAVA));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.FIRE));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SOUL_FIRE));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.CACTUS));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.MAGMA_BLOCK));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SWEET_BERRY_BUSH));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.WITHER_ROSE));
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.POWDER_SNOW));
}
@Test
void validatesReplaceableMaterials() {
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.AIR));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CAVE_AIR));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.VOID_AIR));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SHORT_GRASS));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.TALL_GRASS));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SNOW));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.FERN));
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.LARGE_FERN));
assertFalse(VoidProtection.isReplaceableMaterial(null));
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.STONE));
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CHEST));
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.OBSIDIAN));
}
@Test
void nullBlocksAreNeitherSafeNorReplaceable() {
assertFalse(VoidProtection.isSafeGround(null));
assertFalse(VoidProtection.isReplaceable(null));
}
@Test
void calculatesRequiredSlotsAndChestFit() {
assertEquals(0, VoidProtection.calculateRequiredSlots(null));
assertEquals(0, VoidProtection.calculateRequiredSlots(List.of()));
assertTrue(VoidProtection.canFitInSingleChest(List.of()));
assertTrue(VoidProtection.canFitInDoubleChest(List.of()));
}
}