Compare commits
4 Commits
01e6c28fef
...
e059ca6563
| Author | SHA1 | Date | |
|---|---|---|---|
| e059ca6563 | |||
| e70df329b3 | |||
| e71babca1c | |||
| 01ada6d987 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Tasks: Módulo Nativo de Chunk Loader
|
||||||
|
|
||||||
|
- [x] **Task 1: Modelo de Dados e Gerenciador (`ChunkLoader.java`, `ChunkLoaders.java` e `ChunkLoaderTest.java`)**
|
||||||
|
- Implementar record `ChunkLoader` puro.
|
||||||
|
- Implementar `ChunkLoaders` com persistência em `chunks.yml`, `playerLimit(Player)` (LuckPerms `canalhandia.chunkloader.limite.<N>`), registro de tickets no Paper e busca por chunk/dono.
|
||||||
|
- Criar suíte de testes unitários `ChunkLoaderTest.java` validando regras de limites, unicidade e serialização.
|
||||||
|
|
||||||
|
- [x] **Task 2: Configuração e Item Customizado (`Module.java`, `Settings.java` e `ChunkAnchorItem.java`)**
|
||||||
|
- Adicionar `CHUNKLOADER` ao enum `Module`.
|
||||||
|
- Adicionar chaves de configuração em `config.yml` e `Settings.java`.
|
||||||
|
- Criar utilitário `ChunkAnchorItem` para gerar o ItemStack com nome, lore e `PersistentDataContainer`, e registrar receita de crafting.
|
||||||
|
|
||||||
|
- [x] **Task 3: Listeners de Proteção e Colocação (`ChunkLoaderListener.java`)**
|
||||||
|
- Tratar `BlockPlaceEvent` (validação de permissão, cálculo de limite LuckPerms, ativação de ticket).
|
||||||
|
- Tratar `BlockBreakEvent` (proteção de dono/admin, remoção de ticket, drop do item).
|
||||||
|
- Tratar explosões e pistões.
|
||||||
|
|
||||||
|
- [x] **Task 4: Comandos e Tab-Completion (`CanalhandiaCommand.java`)**
|
||||||
|
- Adicionar `/chunkloader` e alias `/ancora` (`listar`, `info`, `remover`, `receita`, `admin`).
|
||||||
|
- Implementar tab-completion completo com permissões.
|
||||||
|
|
||||||
|
- [x] **Task 5: Integração no Ciclo de Vida e BlueMap (`Canalhandia.java` & `BlueMapBridge.java`)**
|
||||||
|
- Inicializar `ChunkLoaders` no `onEnable` e descarregar tickets no `onDisable`.
|
||||||
|
- Adicionar marcadores no BlueMap.
|
||||||
|
|
||||||
|
- [x] **Task 6: Testes, PR e Deploy (`mvn test`, `mvn package`, PR no Gitea)**
|
||||||
|
- Executar suíte completa de testes.
|
||||||
|
- Criar branch `feat/chunk-loader-module`, abrir PR com labels `AI-REVIEW` e `AI-USAGE`.
|
||||||
@@ -27,18 +27,26 @@ final class BlueMapBridge {
|
|||||||
/** Id and label of the marker set this plugin owns on the map. */
|
/** Id and label of the marker set this plugin owns on the map. */
|
||||||
private static final String SET_ID = "canalhandia-notas";
|
private static final String SET_ID = "canalhandia-notas";
|
||||||
private static final String SET_LABEL = "Anotações";
|
private static final String SET_LABEL = "Anotações";
|
||||||
|
private static final String SET_LOADERS_ID = "canalhandia-chunkloaders";
|
||||||
|
private static final String SET_LOADERS_LABEL = "Âncoras de Chunk";
|
||||||
|
|
||||||
private final Notes notes;
|
private final Notes notes;
|
||||||
|
private final java.util.function.Supplier<ChunkLoaders> chunkLoaders;
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
private final java.util.function.BooleanSupplier enabled;
|
private final java.util.function.BooleanSupplier enabled;
|
||||||
|
private final java.util.function.BooleanSupplier chunkLoadersEnabled;
|
||||||
|
|
||||||
/** False once we know BlueMap is not usable, so we stop retrying. */
|
/** False once we know BlueMap is not usable, so we stop retrying. */
|
||||||
private boolean available = true;
|
private boolean available = true;
|
||||||
|
|
||||||
BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) {
|
BlueMapBridge(Notes notes, java.util.function.Supplier<ChunkLoaders> chunkLoaders,
|
||||||
|
Logger logger, java.util.function.BooleanSupplier enabled,
|
||||||
|
java.util.function.BooleanSupplier chunkLoadersEnabled) {
|
||||||
this.notes = notes;
|
this.notes = notes;
|
||||||
|
this.chunkLoaders = chunkLoaders;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
|
this.chunkLoadersEnabled = chunkLoadersEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,20 +55,19 @@ final class BlueMapBridge {
|
|||||||
*/
|
*/
|
||||||
void hook() {
|
void hook() {
|
||||||
try {
|
try {
|
||||||
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync());
|
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> {
|
||||||
logger.info("BlueMap encontrado — anotações públicas vão para o mapa.");
|
sync();
|
||||||
|
syncChunkLoaders();
|
||||||
|
});
|
||||||
|
logger.info("BlueMap encontrado — marcadores vão para o mapa.");
|
||||||
} catch (NoClassDefFoundError | Exception e) {
|
} catch (NoClassDefFoundError | Exception e) {
|
||||||
available = false;
|
available = false;
|
||||||
logger.fine("BlueMap não está instalado; anotações ficam só no chat.");
|
logger.fine("BlueMap não está instalado; marcadores desativados.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuilds the marker set from the current public notes.
|
* Rebuilds the marker set from the current public notes.
|
||||||
*
|
|
||||||
* <p>Rebuild rather than incremental add/remove: the note list is tiny, and
|
|
||||||
* a full rebuild cannot drift out of sync with the notes file the way a
|
|
||||||
* missed delete would.
|
|
||||||
*/
|
*/
|
||||||
void sync() {
|
void sync() {
|
||||||
if (!available || !enabled.getAsBoolean()) {
|
if (!available || !enabled.getAsBoolean()) {
|
||||||
@@ -79,9 +86,6 @@ final class BlueMapBridge {
|
|||||||
.label(SET_LABEL)
|
.label(SET_LABEL)
|
||||||
.build();
|
.build();
|
||||||
for (Note note : publicNotes) {
|
for (Note note : publicNotes) {
|
||||||
// Only notes from the world this map renders. Without the
|
|
||||||
// check, a Nether note would be drawn at the same numeric
|
|
||||||
// coordinates in the overworld map, pointing at nothing.
|
|
||||||
if (!sameWorld(map, note.world())) {
|
if (!sameWorld(map, note.world())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -96,13 +100,62 @@ final class BlueMapBridge {
|
|||||||
map.getMarkerSets().put(SET_ID, set);
|
map.getMarkerSets().put(SET_ID, set);
|
||||||
}
|
}
|
||||||
} catch (NoClassDefFoundError | Exception e) {
|
} catch (NoClassDefFoundError | Exception e) {
|
||||||
// One line, then stop trying: a broken bridge must never turn into
|
|
||||||
// a log flood on every note edit.
|
|
||||||
available = false;
|
available = false;
|
||||||
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e);
|
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void syncChunkLoaders() {
|
||||||
|
if (!available || !chunkLoadersEnabled.getAsBoolean() || chunkLoaders == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoaders loaders = chunkLoaders.get();
|
||||||
|
if (loaders == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var maybeApi = de.bluecolored.bluemap.api.BlueMapAPI.getInstance();
|
||||||
|
if (maybeApi.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var api = maybeApi.get();
|
||||||
|
for (var map : api.getMaps()) {
|
||||||
|
var set = de.bluecolored.bluemap.api.markers.MarkerSet.builder()
|
||||||
|
.label(SET_LOADERS_LABEL)
|
||||||
|
.build();
|
||||||
|
for (ChunkLoader loader : loaders.all()) {
|
||||||
|
if (!mapMatchesWorld(map, loader.world())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
|
||||||
|
.label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")")
|
||||||
|
.detail("<b>Âncora de Chunk #" + loader.id() + "</b><br>Dono: "
|
||||||
|
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
|
||||||
|
.position(loader.x(), loader.y(), loader.z())
|
||||||
|
.build();
|
||||||
|
set.getMarkers().put("loader-" + loader.id(), marker);
|
||||||
|
}
|
||||||
|
map.getMarkerSets().put(SET_LOADERS_ID, set);
|
||||||
|
}
|
||||||
|
} catch (NoClassDefFoundError | Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mapMatchesWorld(de.bluecolored.bluemap.api.BlueMapMap map, String world) {
|
||||||
|
if (world == null || world.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String mapId = map.getId().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
String w = world.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (w.endsWith("_nether") || w.equals("nether") || w.equals("world_nether")) {
|
||||||
|
return mapId.contains("nether");
|
||||||
|
}
|
||||||
|
if (w.endsWith("_the_end") || w.equals("the_end") || w.equals("world_the_end") || w.equals("end")) {
|
||||||
|
return mapId.contains("end");
|
||||||
|
}
|
||||||
|
return !mapId.contains("nether") && !mapId.contains("end");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a map renders the world a note was written in.
|
* Whether a map renders the world a note was written in.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
private Achievements achievements;
|
private Achievements achievements;
|
||||||
private WeeklyStats weeklyStats;
|
private WeeklyStats weeklyStats;
|
||||||
private PlayerMemory playerMemory;
|
private PlayerMemory playerMemory;
|
||||||
|
private ChunkLoaders chunkLoaders;
|
||||||
/** Gate for spontaneous AI lines; see Budget for why this is strict. */
|
/** Gate for spontaneous AI lines; see Budget for why this is strict. */
|
||||||
private Budget aiBudget;
|
private Budget aiBudget;
|
||||||
private BlueMapBridge blueMap;
|
private BlueMapBridge blueMap;
|
||||||
@@ -127,13 +128,19 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
milestones.resyncSilently();
|
milestones.resyncSilently();
|
||||||
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
|
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
|
||||||
playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.yml"));
|
playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.yml"));
|
||||||
|
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
|
||||||
|
if (settings.moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
chunkLoaders.loadAllTickets();
|
||||||
|
ChunkAnchorItem.registerRecipe(this);
|
||||||
|
}
|
||||||
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
||||||
settings.aiSpontaneousGapMinutes() * 60_000L,
|
settings.aiSpontaneousGapMinutes() * 60_000L,
|
||||||
settings.aiSubjectCooldownMinutes() * 60_000L);
|
settings.aiSubjectCooldownMinutes() * 60_000L);
|
||||||
ai = new Ai(this);
|
ai = new Ai(this);
|
||||||
// Optional: does nothing (and logs nothing loud) without BlueMap.
|
// Optional: does nothing (and logs nothing loud) without BlueMap.
|
||||||
blueMap = new BlueMapBridge(notes, getLogger(),
|
blueMap = new BlueMapBridge(notes, () -> chunkLoaders, getLogger(),
|
||||||
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap());
|
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap(),
|
||||||
|
() -> settings.moduleEnabled(Module.CHUNKLOADER) && settings.chunkLoaderBlueMap());
|
||||||
blueMap.hook();
|
blueMap.hook();
|
||||||
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||||
// reads from the async answer path and Bukkit.recipeIterator() is not
|
// reads from the async answer path and Bukkit.recipeIterator() is not
|
||||||
@@ -146,12 +153,13 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
||||||
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
||||||
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
|
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
|
||||||
"perfil", "titulo")) {
|
"perfil", "titulo", "chunkloader")) {
|
||||||
register(name, root);
|
register(name, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
getServer().getPluginManager().registerEvents(this, this);
|
getServer().getPluginManager().registerEvents(this, this);
|
||||||
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
|
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
|
||||||
rescheduleTimer();
|
rescheduleTimer();
|
||||||
rescheduleMilestones();
|
rescheduleMilestones();
|
||||||
|
|
||||||
@@ -183,6 +191,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
if (poll != null) {
|
if (poll != null) {
|
||||||
poll.hide();
|
poll.hide();
|
||||||
}
|
}
|
||||||
|
if (chunkLoaders != null) {
|
||||||
|
chunkLoaders.unloadAllTickets();
|
||||||
|
chunkLoaders.close();
|
||||||
|
ChunkAnchorItem.unregisterRecipe(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings settings() {
|
Settings settings() {
|
||||||
@@ -227,6 +240,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return achievements;
|
return achievements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active chunk loaders. Never null. */
|
||||||
|
ChunkLoaders chunkLoaders() {
|
||||||
|
return chunkLoaders;
|
||||||
|
}
|
||||||
|
|
||||||
/** The title each player has chosen to wear in chat. Never null. */
|
/** The title each player has chosen to wear in chat. Never null. */
|
||||||
Titles titles() {
|
Titles titles() {
|
||||||
return titles;
|
return titles;
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import net.kyori.adventure.text.event.ClickEvent;
|
|||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.command.TabCompleter;
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -62,6 +65,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "conquistas" -> conquistas(sender, args);
|
case "conquistas" -> conquistas(sender, args);
|
||||||
case "perfil" -> perfil(sender, args);
|
case "perfil" -> perfil(sender, args);
|
||||||
case "titulo" -> titulo(sender, args);
|
case "titulo" -> titulo(sender, args);
|
||||||
|
case "chunkloader", "ancora", "ancoras", "chunkloaders" -> chunkloader(sender, args);
|
||||||
// Typed twin of the [F] mourning button — "f" is not a configured
|
// Typed twin of the [F] mourning button — "f" is not a configured
|
||||||
// reaction (the mourning set is hardcoded), so it can't be found via
|
// reaction (the mourning set is hardcoded), so it can't be found via
|
||||||
// reactionForCommand; route it directly. Acts on the latest message,
|
// reactionForCommand; route it directly. Acts on the latest message,
|
||||||
@@ -563,6 +567,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
boolean on = args[1].equalsIgnoreCase("on");
|
boolean on = args[1].equalsIgnoreCase("on");
|
||||||
plugin.settings().moduleEnabled(module, on);
|
plugin.settings().moduleEnabled(module, on);
|
||||||
|
if (module == Module.CHUNKLOADER) {
|
||||||
|
if (on) {
|
||||||
|
plugin.chunkLoaders().loadAllTickets();
|
||||||
|
ChunkAnchorItem.registerRecipe(plugin);
|
||||||
|
} else {
|
||||||
|
plugin.chunkLoaders().unloadAllTickets();
|
||||||
|
ChunkAnchorItem.unregisterRecipe(plugin);
|
||||||
|
}
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
}
|
||||||
plugin.rescheduleTimer();
|
plugin.rescheduleTimer();
|
||||||
plugin.rescheduleMilestones();
|
plugin.rescheduleMilestones();
|
||||||
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
|
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
|
||||||
@@ -1783,6 +1797,264 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- /chunkloader -------------------------------------------------------
|
||||||
|
|
||||||
|
private boolean chunkloader(CommandSender sender, String[] args) {
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
Msg.error(sender, "O módulo de Âncoras de Chunk está desativado.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!sender.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
return denied(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length == 0) {
|
||||||
|
return chunkloaderHelp(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||||
|
String[] rest = Arrays.copyOfRange(args, 1, args.length);
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "listar", "list" -> chunkloaderList(sender, rest);
|
||||||
|
case "info", "chunk" -> chunkloaderInfo(sender);
|
||||||
|
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
|
||||||
|
case "receita", "craft" -> chunkloaderRecipe(sender);
|
||||||
|
case "admin" -> chunkloaderAdmin(sender, rest);
|
||||||
|
case "dar", "give" -> {
|
||||||
|
if (admin(sender)) {
|
||||||
|
chunkloaderGive(sender, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> chunkloaderHelp(sender);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderList(CommandSender sender, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
chunkloaderAdminList(sender, args.length > 0 ? args[0] : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> mine = plugin.chunkLoaders().byOwner(player.getUniqueId().toString());
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
|
||||||
|
Msg.header(sender, "Suas Âncoras de Chunk (" + mine.size() + "/" + limitStr + ")");
|
||||||
|
if (mine.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text(" Você não possui nenhuma Âncora de Chunk ativa.", NamedTextColor.GRAY));
|
||||||
|
sender.sendMessage(Component.text(" Use ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("/chunkloader receita", NamedTextColor.AQUA))
|
||||||
|
.append(Component.text(" para ver como criar uma.", NamedTextColor.GRAY)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean bedrock = isBedrock(sender);
|
||||||
|
for (ChunkLoader loader : mine) {
|
||||||
|
Component line = Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE));
|
||||||
|
|
||||||
|
if (!bedrock) {
|
||||||
|
line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
|
||||||
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
|
Component.text("Clique para desativar a âncora #" + loader.id(), NamedTextColor.GRAY)))
|
||||||
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
|
||||||
|
"/chunkloader remover " + loader.id())));
|
||||||
|
}
|
||||||
|
sender.sendMessage(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderInfo(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
Msg.error(sender, "Comando exclusivo para jogadores.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Location loc = player.getLocation();
|
||||||
|
String world = loc.getWorld().getName();
|
||||||
|
int cx = loc.getBlockX() >> 4;
|
||||||
|
int cz = loc.getBlockZ() >> 4;
|
||||||
|
|
||||||
|
ChunkLoader here = plugin.chunkLoaders().byChunk(world, cx, cz);
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
int used = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
|
||||||
|
Msg.header(sender, "Informações da Chunk");
|
||||||
|
sender.sendMessage(Component.text(" Chunk atual: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("[" + cx + ", " + cz + "]", NamedTextColor.AQUA))
|
||||||
|
.append(Component.text(" em " + world, NamedTextColor.WHITE)));
|
||||||
|
|
||||||
|
if (here != null) {
|
||||||
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("Carregada continuamente por Âncora #" + here.id(), NamedTextColor.GREEN))
|
||||||
|
.append(Component.text(" (" + here.ownerName() + ")", NamedTextColor.WHITE)));
|
||||||
|
} else {
|
||||||
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.sendMessage(Component.text(" Suas âncoras ativas: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRemove(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader remover <id>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = parseLong(args[0]);
|
||||||
|
if (id <= 0) {
|
||||||
|
Msg.error(sender, "ID inválido.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().byId(id);
|
||||||
|
if (loader == null) {
|
||||||
|
Msg.error(sender, "Nenhuma Âncora de Chunk encontrada com o ID #" + id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.chunkLoaders().remove(id);
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null && w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
|
||||||
|
Block b = w.getBlockAt(loader.x(), loader.y(), loader.z());
|
||||||
|
if (b.getType() == Material.RESPAWN_ANCHOR) {
|
||||||
|
b.setType(Material.AIR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
|
||||||
|
for (ItemStack drop : overflow.values()) {
|
||||||
|
player.getWorld().dropItemNaturally(player.getLocation(), drop);
|
||||||
|
}
|
||||||
|
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e devolvida ao inventário.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.dropItemNaturally(new Location(w, loader.x() + 0.5, loader.y() + 0.5, loader.z() + 0.5),
|
||||||
|
ChunkAnchorItem.create(plugin, 1));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e dropada em " + loader.place() + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRecipe(CommandSender sender) {
|
||||||
|
Msg.header(sender, "Receita da Âncora de Chunk");
|
||||||
|
sender.sendMessage(Component.text(" Na Bancada de Trabalho:", NamedTextColor.GRAY));
|
||||||
|
sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" [ O ] [ E ] [ O ]", NamedTextColor.LIGHT_PURPLE));
|
||||||
|
sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" D = Diamante | O = Obsidiana Chorona | E = Olho do Fim", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderAdmin(CommandSender sender, String[] args) {
|
||||||
|
if (!admin(sender)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.length == 0) {
|
||||||
|
Msg.header(sender, "Admin Chunk Loaders");
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin listar [jogador]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin remover <id>", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader dar <jogador> [quantidade]", NamedTextColor.AQUA));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (args[0].toLowerCase(Locale.ROOT)) {
|
||||||
|
case "listar", "list" -> chunkloaderAdminList(sender, args.length > 1 ? args[1] : null);
|
||||||
|
case "remover", "del" -> {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader admin remover <id>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunkloaderRemove(sender, new String[]{args[1]});
|
||||||
|
}
|
||||||
|
case "dar", "give" -> chunkloaderGive(sender, Arrays.copyOfRange(args, 1, args.length));
|
||||||
|
default -> Msg.error(sender, "Subcomando de admin desconhecido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderAdminList(CommandSender sender, String targetName) {
|
||||||
|
List<ChunkLoader> all = plugin.chunkLoaders().all();
|
||||||
|
if (targetName != null && !targetName.isBlank()) {
|
||||||
|
all = all.stream().filter(l -> l.ownerName().equalsIgnoreCase(targetName)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Msg.header(sender, "Todas as Âncoras do Servidor (" + all.size() + ")");
|
||||||
|
if (all.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text(" Nenhuma âncora ativa encontrada.", NamedTextColor.GRAY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChunkLoader loader : all) {
|
||||||
|
sender.sendMessage(Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(loader.ownerName(), NamedTextColor.YELLOW))
|
||||||
|
.append(Component.text(" — " + loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderGive(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader dar <jogador> [quantidade]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player target = Bukkit.getPlayer(args[0]);
|
||||||
|
if (target == null) {
|
||||||
|
Msg.error(sender, "Jogador não encontrado: " + args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int amount = 1;
|
||||||
|
if (args.length >= 2) {
|
||||||
|
try {
|
||||||
|
amount = Math.max(1, Integer.parseInt(args[1]));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.getInventory().addItem(ChunkAnchorItem.create(plugin, amount));
|
||||||
|
Msg.ok(sender, "Entregue " + amount + "x Âncora de Chunk para " + target.getName() + ".");
|
||||||
|
Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean chunkloaderHelp(CommandSender sender) {
|
||||||
|
Msg.header(sender, "Âncoras de Chunk");
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Lista suas âncoras ativas e coordenadas", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader remover <id>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
|
||||||
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" — Comandos de administração", NamedTextColor.GRAY)));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- tab completion -----------------------------------------------------
|
// --- tab completion -----------------------------------------------------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1943,6 +2215,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return filter(List.of("on", "off"), args[2]);
|
return filter(List.of("on", "off"), args[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
|
||||||
|
if (args.length == 1) {
|
||||||
|
List<String> options = new ArrayList<>(List.of("info", "listar", "remover", "receita"));
|
||||||
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
options.addAll(List.of("admin", "dar"));
|
||||||
|
}
|
||||||
|
return filter(options, args[0]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del"))) {
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
|
||||||
|
ids.add(String.valueOf(loader.id()));
|
||||||
|
}
|
||||||
|
return filter(ids, args[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
|
||||||
|
return filter(List.of("listar", "remover", "dar"), args[1]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("dar")) {
|
||||||
|
return filter(onlineNames(), args[1]);
|
||||||
|
}
|
||||||
|
if (args.length == 3 && args[0].equalsIgnoreCase("admin") && args[1].equalsIgnoreCase("dar")) {
|
||||||
|
return filter(onlineNames(), args[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.ShapedRecipe;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and validates the "Âncora de Chunk" custom block item and its crafting recipe.
|
||||||
|
*/
|
||||||
|
final class ChunkAnchorItem {
|
||||||
|
|
||||||
|
private static final String KEY_NAME = "chunk_anchor";
|
||||||
|
private static final String RECIPE_NAME = "ancora_de_chunk";
|
||||||
|
|
||||||
|
static NamespacedKey key(Plugin plugin) {
|
||||||
|
return new NamespacedKey(plugin, KEY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NamespacedKey recipeKey(Plugin plugin) {
|
||||||
|
return new NamespacedKey(plugin, RECIPE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Chunk Anchor item stack.
|
||||||
|
*/
|
||||||
|
static ItemStack create(Plugin plugin, int amount) {
|
||||||
|
ItemStack item = new ItemStack(Material.RESPAWN_ANCHOR, Math.max(1, amount));
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
if (meta != null) {
|
||||||
|
meta.displayName(Component.text("Âncora de Chunk", NamedTextColor.AQUA)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)
|
||||||
|
.decoration(TextDecoration.BOLD, true));
|
||||||
|
meta.lore(List.of(
|
||||||
|
Component.text("Coloque no chão para manter esta chunk", NamedTextColor.GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false),
|
||||||
|
Component.text("carregada continuamente no servidor.", NamedTextColor.GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false),
|
||||||
|
Component.text("Canalhandia Chunk Loader", NamedTextColor.DARK_GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)
|
||||||
|
));
|
||||||
|
meta.getPersistentDataContainer().set(key(plugin), PersistentDataType.BYTE, (byte) 1);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if an item is a valid Chunk Anchor.
|
||||||
|
*/
|
||||||
|
static boolean isAnchor(Plugin plugin, ItemStack item) {
|
||||||
|
if (item == null || item.getType() != Material.RESPAWN_ANCHOR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
if (meta == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return meta.getPersistentDataContainer().has(key(plugin), PersistentDataType.BYTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the shaped crafting recipe for the Chunk Anchor.
|
||||||
|
*/
|
||||||
|
static void registerRecipe(Plugin plugin) {
|
||||||
|
try {
|
||||||
|
NamespacedKey rKey = recipeKey(plugin);
|
||||||
|
if (Bukkit.getRecipe(rKey) != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShapedRecipe recipe = new ShapedRecipe(rKey, create(plugin, 1));
|
||||||
|
recipe.shape("DOD", "OEO", "DOD");
|
||||||
|
recipe.setIngredient('D', Material.DIAMOND);
|
||||||
|
recipe.setIngredient('O', Material.CRYING_OBSIDIAN);
|
||||||
|
recipe.setIngredient('E', Material.ENDER_EYE);
|
||||||
|
|
||||||
|
Bukkit.addRecipe(recipe);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unregisterRecipe(Plugin plugin) {
|
||||||
|
try {
|
||||||
|
Bukkit.removeRecipe(recipeKey(plugin));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persistent chunk loader anchor placed in the world.
|
||||||
|
*
|
||||||
|
* <p>A plain immutable record with no Bukkit types, so the model, coordinate
|
||||||
|
* math, and persistence rules are testable without a running server.
|
||||||
|
*/
|
||||||
|
record ChunkLoader(
|
||||||
|
long id,
|
||||||
|
String ownerUuid,
|
||||||
|
String ownerName,
|
||||||
|
String world,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int z,
|
||||||
|
int chunkX,
|
||||||
|
int chunkZ,
|
||||||
|
long createdAt) {
|
||||||
|
|
||||||
|
String blockCoords() {
|
||||||
|
return x + ", " + y + ", " + z;
|
||||||
|
}
|
||||||
|
|
||||||
|
String chunkCoords() {
|
||||||
|
return "[" + chunkX + ", " + chunkZ + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
String place() {
|
||||||
|
return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.BlockBreakEvent;
|
||||||
|
import org.bukkit.event.block.BlockExplodeEvent;
|
||||||
|
import org.bukkit.event.block.BlockPistonExtendEvent;
|
||||||
|
import org.bukkit.event.block.BlockPistonRetractEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protects and handles block placement, removal, and explosions for Chunk Anchors.
|
||||||
|
*/
|
||||||
|
final class ChunkLoaderListener implements Listener {
|
||||||
|
|
||||||
|
private final Canalhandia plugin;
|
||||||
|
|
||||||
|
ChunkLoaderListener(Canalhandia plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onBlockPlace(BlockPlaceEvent event) {
|
||||||
|
if (!ChunkAnchorItem.isAnchor(plugin, event.getItemInHand())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
Msg.error(player, "O módulo de Âncoras de Chunk está desativado.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
Msg.error(player, "Você não tem permissão para usar Âncoras de Chunk.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
|
||||||
|
|
||||||
|
if (current >= limit) {
|
||||||
|
Msg.error(player, "Você atingiu seu limite de Âncoras de Chunk ativas (" + current + "/" + limit + ").");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Location loc = event.getBlockPlaced().getLocation();
|
||||||
|
String world = loc.getWorld().getName();
|
||||||
|
int chunkX = loc.getBlockX() >> 4;
|
||||||
|
int chunkZ = loc.getBlockZ() >> 4;
|
||||||
|
|
||||||
|
ChunkLoader existing = plugin.chunkLoaders().byChunk(world, chunkX, chunkZ);
|
||||||
|
if (existing != null) {
|
||||||
|
Msg.error(player, "Esta chunk já possui uma Âncora de Chunk ativa em " + existing.blockCoords()
|
||||||
|
+ " (" + existing.ownerName() + ").");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().add(
|
||||||
|
player.getUniqueId().toString(),
|
||||||
|
player.getName(),
|
||||||
|
world,
|
||||||
|
loc.getBlockX(),
|
||||||
|
loc.getBlockY(),
|
||||||
|
loc.getBlockZ()
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
loc.getWorld().spawnParticle(Particle.PORTAL, loc.clone().add(0.5, 1.2, 0.5), 35, 0.3, 0.3, 0.3, 0.05);
|
||||||
|
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_SET_SPAWN, 1.0f, 1.2f);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
Msg.ok(player, "Âncora de Chunk ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente ("
|
||||||
|
+ (current + 1) + "/" + limitStr + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onBlockBreak(BlockBreakEvent event) {
|
||||||
|
Block block = event.getBlock();
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().byLocation(
|
||||||
|
block.getWorld().getName(),
|
||||||
|
block.getX(),
|
||||||
|
block.getY(),
|
||||||
|
block.getZ()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(player, "Esta Âncora de Chunk pertence a " + loader.ownerName() + " e só pode ser quebrada por ele.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.chunkLoaders().remove(loader.id());
|
||||||
|
event.setDropItems(false);
|
||||||
|
|
||||||
|
Location loc = block.getLocation();
|
||||||
|
try {
|
||||||
|
loc.getWorld().dropItemNaturally(loc, ChunkAnchorItem.create(plugin, 1));
|
||||||
|
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onBlockExplode(BlockExplodeEvent event) {
|
||||||
|
protectExplosions(event.blockList().iterator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onEntityExplode(EntityExplodeEvent event) {
|
||||||
|
protectExplosions(event.blockList().iterator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void protectExplosions(Iterator<Block> it) {
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Block b = it.next();
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPistonExtend(BlockPistonExtendEvent event) {
|
||||||
|
for (Block b : event.getBlocks()) {
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPistonRetract(BlockPistonRetractEvent event) {
|
||||||
|
for (Block b : event.getBlocks()) {
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.permissions.PermissionAttachmentInfo;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages active chunk loader anchors, ticket registration in Paper, and persistence in chunks.yml.
|
||||||
|
*/
|
||||||
|
final class ChunkLoaders {
|
||||||
|
|
||||||
|
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
|
||||||
|
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final File file;
|
||||||
|
private final List<ChunkLoader> loaders = new ArrayList<>();
|
||||||
|
private final AtomicLong nextId = new AtomicLong(1);
|
||||||
|
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "canalhandia-chunkloaders-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
ChunkLoaders(Plugin plugin, File file) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.file = file;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
loaders.clear();
|
||||||
|
if (yaml == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ConfigurationSection root = yaml.getConfigurationSection("loaders");
|
||||||
|
if (root == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long maxId = 0;
|
||||||
|
for (String key : root.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(key);
|
||||||
|
String ownerUuid = root.getString(key + ".owner_uuid", "");
|
||||||
|
String ownerName = root.getString(key + ".owner_name", "?");
|
||||||
|
String world = root.getString(key + ".world", "world");
|
||||||
|
int x = root.getInt(key + ".x");
|
||||||
|
int y = root.getInt(key + ".y");
|
||||||
|
int z = root.getInt(key + ".z");
|
||||||
|
int chunkX = root.getInt(key + ".chunk_x", x >> 4);
|
||||||
|
int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
|
||||||
|
long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis());
|
||||||
|
|
||||||
|
loaders.add(new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
|
||||||
|
if (id > maxId) {
|
||||||
|
maxId = id;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextId.set(maxId + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new chunk loader and activates the ticket in the world.
|
||||||
|
*/
|
||||||
|
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) {
|
||||||
|
int chunkX = x >> 4;
|
||||||
|
int chunkZ = z >> 4;
|
||||||
|
long id = nextId.getAndIncrement();
|
||||||
|
ChunkLoader loader = new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
|
||||||
|
|
||||||
|
synchronized (loaders) {
|
||||||
|
loaders.add(loader);
|
||||||
|
}
|
||||||
|
addTicket(loader);
|
||||||
|
save();
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a chunk loader by its unique ID and releases the chunk ticket.
|
||||||
|
*/
|
||||||
|
boolean remove(long id) {
|
||||||
|
ChunkLoader removed = null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
if (loaders.get(i).id() == id) {
|
||||||
|
removed = loaders.remove(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed != null) {
|
||||||
|
removeTicket(removed);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byId(long id) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.id() == id) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byChunk(String world, int chunkX, int chunkZ) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.world().equalsIgnoreCase(world) && loader.chunkX() == chunkX && loader.chunkZ() == chunkZ) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byLocation(String world, int x, int y, int z) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.world().equalsIgnoreCase(world) && loader.x() == x && loader.y() == y && loader.z() == z) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> byOwner(String ownerUuid) {
|
||||||
|
List<ChunkLoader> out = new ArrayList<>();
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.ownerUuid().equalsIgnoreCase(ownerUuid)) {
|
||||||
|
out.add(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> all() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
return Collections.unmodifiableList(new ArrayList<>(loaders));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int size() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
return loaders.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the maximum chunk loaders allowed for a player based on LuckPerms permissions.
|
||||||
|
*/
|
||||||
|
static int playerLimit(Player player, int defaultLimit) {
|
||||||
|
if (player == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
if (!player.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = -1;
|
||||||
|
for (PermissionAttachmentInfo info : player.getEffectivePermissions()) {
|
||||||
|
String perm = info.getPermission().toLowerCase(Locale.ROOT);
|
||||||
|
if (info.getValue() && perm.startsWith(PERM_LIMIT_PREFIX)) {
|
||||||
|
String valStr = perm.substring(PERM_LIMIT_PREFIX.length());
|
||||||
|
try {
|
||||||
|
int val = Integer.parseInt(valStr);
|
||||||
|
if (val > max) {
|
||||||
|
max = val;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max >= 0 ? max : defaultLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadAllTickets() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
addTicket(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void unloadAllTickets() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
removeTicket(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTicket(ChunkLoader loader) {
|
||||||
|
if (plugin == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeTicket(ChunkLoader loader) {
|
||||||
|
if (plugin == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private YamlConfiguration buildYaml() {
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader l : loaders) {
|
||||||
|
String key = "loaders." + l.id();
|
||||||
|
yaml.set(key + ".owner_uuid", l.ownerUuid());
|
||||||
|
yaml.set(key + ".owner_name", l.ownerName());
|
||||||
|
yaml.set(key + ".world", l.world());
|
||||||
|
yaml.set(key + ".x", l.x());
|
||||||
|
yaml.set(key + ".y", l.y());
|
||||||
|
yaml.set(key + ".z", l.z());
|
||||||
|
yaml.set(key + ".chunk_x", l.chunkX());
|
||||||
|
yaml.set(key + ".chunk_z", l.chunkZ());
|
||||||
|
yaml.set(key + ".created_at", l.createdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yaml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save() {
|
||||||
|
YamlConfiguration yaml = buildYaml();
|
||||||
|
io.execute(() -> {
|
||||||
|
try {
|
||||||
|
yaml.save(file);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
try {
|
||||||
|
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
flush();
|
||||||
|
io.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ enum Module {
|
|||||||
NOTAS("notas", "Anotações públicas e privadas no chat"),
|
NOTAS("notas", "Anotações públicas e privadas no chat"),
|
||||||
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
||||||
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
||||||
IA("ia", "Perguntas para a IA");
|
IA("ia", "Perguntas para a IA"),
|
||||||
|
CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks");
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ final class PlayerMemory {
|
|||||||
if (text == null) {
|
if (text == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String lower = text.toLowerCase(Locale.ROOT).trim();
|
String clean = text.trim();
|
||||||
|
String lower = clean.toLowerCase(Locale.ROOT);
|
||||||
String[] triggers = {
|
String[] triggers = {
|
||||||
"minha base", "meu spawn", "minha casa", "estou construindo",
|
"minha base", "meu spawn", "minha casa", "estou construindo",
|
||||||
"meu plano", "meu objetivo", "sou especialista em", "moro em"
|
"meu plano", "meu objetivo", "sou especialista em", "moro em"
|
||||||
@@ -213,7 +214,7 @@ final class PlayerMemory {
|
|||||||
for (String trigger : triggers) {
|
for (String trigger : triggers) {
|
||||||
int idx = lower.indexOf(trigger);
|
int idx = lower.indexOf(trigger);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
String candidate = text.substring(idx).trim();
|
String candidate = clean.substring(idx).trim();
|
||||||
candidate = candidate.replaceAll("[?!.]+$", "").trim();
|
candidate = candidate.replaceAll("[?!.]+$", "").trim();
|
||||||
if (candidate.length() > 60) {
|
if (candidate.length() > 60) {
|
||||||
candidate = candidate.substring(0, 60) + "…";
|
candidate = candidate.substring(0, 60) + "…";
|
||||||
|
|||||||
@@ -579,6 +579,24 @@ final class Settings {
|
|||||||
set("categorias." + category.key(), enabled);
|
set("categorias." + category.key(), enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- chunkloader --------------------------------------------------------
|
||||||
|
|
||||||
|
int chunkLoaderDefaultLimit() {
|
||||||
|
return Math.max(0, plugin.getConfig().getInt("chunkloader.limite-padrao", 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderDefaultLimit(int limit) {
|
||||||
|
set("chunkloader.limite-padrao", Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderBlueMap() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.bluemap", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderBlueMap(boolean enabled) {
|
||||||
|
set("chunkloader.bluemap", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
// --- plumbing -----------------------------------------------------------
|
// --- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
private void set(String path, Object value) {
|
private void set(String path, Object value) {
|
||||||
|
|||||||
@@ -98,10 +98,20 @@ commands:
|
|||||||
description: Escolhe qual conquista você exibe como título no chat.
|
description: Escolhe qual conquista você exibe como título no chat.
|
||||||
usage: /titulo [nome|limpar]
|
usage: /titulo [nome|limpar]
|
||||||
aliases: [titulos, title]
|
aliases: [titulos, title]
|
||||||
|
chunkloader:
|
||||||
|
description: Gerenciamento de Âncoras de Chunk (chunk loaders).
|
||||||
|
usage: /chunkloader [listar|info|remover|receita]
|
||||||
|
aliases: [ancora, ancoras, chunkloaders]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||||
# which would silently stop normal players reacting, voting or guessing.
|
# which would silently stop normal players reacting, voting or guessing.
|
||||||
|
canalhandia.chunkloader:
|
||||||
|
description: Permite usar e criar Âncoras de Chunk.
|
||||||
|
default: true
|
||||||
|
canalhandia.chunkloader.admin:
|
||||||
|
description: Permite gerenciar e remover qualquer Âncora de Chunk no servidor.
|
||||||
|
default: op
|
||||||
canalhandia.reagir:
|
canalhandia.reagir:
|
||||||
description: Permite reagir e prestar luto.
|
description: Permite reagir e prestar luto.
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ChunkLoaderTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private File file;
|
||||||
|
private ChunkLoaders loaders;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
file = tempDir.resolve("chunks.yml").toFile();
|
||||||
|
loaders = new ChunkLoaders(null, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (loaders != null) {
|
||||||
|
loaders.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsEmpty() {
|
||||||
|
assertEquals(0, loaders.size());
|
||||||
|
assertTrue(loaders.all().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addsAndCalculatesChunkCoordinates() {
|
||||||
|
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
|
||||||
|
assertEquals(1, loader.id());
|
||||||
|
assertEquals("uuid-marcos", loader.ownerUuid());
|
||||||
|
assertEquals("Marcos", loader.ownerName());
|
||||||
|
assertEquals(6, loader.chunkX()); // 100 >> 4 = 6
|
||||||
|
assertEquals(12, loader.chunkZ()); // 200 >> 4 = 12
|
||||||
|
assertEquals("100, 64, 200 (world)", loader.place());
|
||||||
|
assertEquals("[6, 12]", loader.chunkCoords());
|
||||||
|
assertEquals(1, loaders.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queriesByChunkLocationAndOwner() {
|
||||||
|
ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32);
|
||||||
|
ChunkLoader l2 = loaders.add("uuid-ana", "Ana", "world_nether", 0, 70, 0);
|
||||||
|
|
||||||
|
assertEquals(l1, loaders.byLocation("world", 16, 64, 32));
|
||||||
|
assertNull(loaders.byLocation("world", 17, 64, 32));
|
||||||
|
|
||||||
|
assertEquals(l1, loaders.byChunk("world", 1, 2));
|
||||||
|
assertEquals(l2, loaders.byChunk("world_nether", 0, 0));
|
||||||
|
assertNull(loaders.byChunk("world", 0, 0));
|
||||||
|
|
||||||
|
List<ChunkLoader> marcosLoaders = loaders.byOwner("uuid-marcos");
|
||||||
|
assertEquals(1, marcosLoaders.size());
|
||||||
|
assertEquals(l1, marcosLoaders.get(0));
|
||||||
|
|
||||||
|
List<ChunkLoader> anaLoaders = loaders.byOwner("uuid-ana");
|
||||||
|
assertEquals(1, anaLoaders.size());
|
||||||
|
assertEquals(l2, anaLoaders.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesLoaderById() {
|
||||||
|
ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32);
|
||||||
|
assertTrue(loaders.remove(l1.id()));
|
||||||
|
assertEquals(0, loaders.size());
|
||||||
|
assertNull(loaders.byId(l1.id()));
|
||||||
|
assertFalse(loaders.remove(l1.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsToDiskAndReloads() {
|
||||||
|
loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
|
||||||
|
loaders.add("uuid-ana", "Ana", "world_nether", 50, 70, -50);
|
||||||
|
loaders.flush();
|
||||||
|
|
||||||
|
ChunkLoaders reloaded = new ChunkLoaders(null, file);
|
||||||
|
assertEquals(2, reloaded.size());
|
||||||
|
|
||||||
|
ChunkLoader l1 = reloaded.byId(1);
|
||||||
|
assertNotNull(l1);
|
||||||
|
assertEquals("uuid-marcos", l1.ownerUuid());
|
||||||
|
assertEquals(6, l1.chunkX());
|
||||||
|
assertEquals(12, l1.chunkZ());
|
||||||
|
|
||||||
|
ChunkLoader l2 = reloaded.byId(2);
|
||||||
|
assertNotNull(l2);
|
||||||
|
assertEquals("uuid-ana", l2.ownerUuid());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,10 @@ class PlayerMemoryTest {
|
|||||||
assertNotNull(fact);
|
assertNotNull(fact);
|
||||||
assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
|
assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
|
||||||
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
|
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
|
||||||
|
|
||||||
|
// Leading / trailing whitespace index alignment
|
||||||
|
assertEquals("minha base fica no topo", PlayerMemory.extractHeuristicFact(" minha base fica no topo! "));
|
||||||
|
assertEquals("estou construindo uma ponte", PlayerMemory.extractHeuristicFact(" oi, estou construindo uma ponte..."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user