23 Commits

Author SHA1 Message Date
Marcos Paulo 3a21d3cfd5 fix(xp): prioritize repairing equipped mending items before giving leftover xp to player 2026-08-22 13:44:54 -03:00
Marcos Paulo 2a63a04e4f fix(chunkloader): support trapdoor ceilings and creeper-specific spawning on simulated platforms 2026-08-20 20:20:59 -03:00
Marcos Paulo f35e4a5bff feat(chunkloader): add comprehensive configuration for simulation, passive entities, mob cap, and fuel 2026-08-20 17:43:50 -03:00
Marcos Paulo 45f129df56 feat(xp-items): implement instant xp vacuum and spawned item consolidation listener 2026-08-20 16:55:19 -03:00
Marcos Paulo aa56f385f8 fix(titles): improve title matching with accent normalization and removal aliases 2026-08-20 16:12:58 -03:00
pragent-bot 60989cd72f docs: add CONTRIBUTING.md (testing requirements + pragent notes)
Specifies what counts as "tested" for this repo, the local test
commands the bot treats as gates, and how pragent reads the repo
config (`.pr-review.json` + static architecture.md from Nexus
raw-hosted).
2026-08-20 18:49:06 +00:00
pragent-bot 6b2a73678f chore(pr): add .gitea/pull_request_template.md (pragent workflow)
Auto-loaded by Gitea on every new PR. Lists the surfaces and risks
pragent scores you on; tells the author to add the AI-REVIEW label
to trigger a bot review.
2026-08-20 18:48:58 +00:00
Marcos Paulo 8ddc22409e Merge pull request #5 from feat/void-protection 2026-08-20 15:09:50 -03:00
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
18 changed files with 1878 additions and 45 deletions
+53
View File
@@ -0,0 +1,53 @@
## What
<!-- 1-2 sentences. What does this PR change? -->
## Why
<!-- Link the issue, design doc, or chat thread. Without a "why" the reviewer
has to guess whether the change is wanted at all. -->
Refs: #<!-- issue or N/A -->
## How to test
```bash
# Commands the reviewer can run to reproduce / verify:
docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
maven:3.9-eclipse-temurin-25 mvn -B test
```
<!-- Manual test steps (if any): load a save, join a server, trigger a
death in the void, etc. State what you observed. -->
<!-- Server console output if relevant (translatable components render
in English there — that's normal). -->
## Risk
<!-- Which surface is touched? chat-only / item-loss / chunk-load / permissions.
Is a rollback plan needed? Does it need a config.yml default? -->
- [ ] chat message flow (immutable after send)
- [ ] pt-BR singular-form rule (`5.966 blocos de Pedra`, not `5.966 Pedras`)
- [ ] Bedrock equivalent (`comando:` typed fallback, `texto:` ASCII label)
- [ ] stats path (`<world>/players/stats`, not `<world>/stats`)
- [ ] reaction late-window (`reacao-validade-minutos`)
- [ ] Bukkit main thread (no async chunk loads in event handlers)
- [ ] new `comando:` added to `plugin.yml`
- [ ] new permission declared in `plugin.yml`
## Checklist
- [ ] `mvn -B test` passes locally (JDK 25)
- [ ] new behaviour has a test (`src/test/java/...`)
- [ ] no hand edits to generated sources (none in this repo)
- [ ] PR title is conventional-commit style
- [ ] branch is up to date with `main`
## AI review
Label this PR **`AI-REVIEW`** to trigger `pragent`. Add **`AI-USAGE`** for a
token/cost report on the review. The bot reads `.pr-review.json` from `main`
+ a static `architecture.md` from Nexus raw-hosted — so house rules (chat
immutability, singular-form, Bedrock fallback, JDK 25) are baked in.
+93
View File
@@ -0,0 +1,93 @@
# Contributing — Canalhandia (Canalhandia Minecraft plugin)
Canalhandia is a Minecraft Paper plugin that adds PT-BR chat games, item
protection, and other in-game commands for the Canalhandia server. This repo
holds the plugin source. The companion server config lives at
`gitea_admin/canalhandia-config`.
## Before you open a PR
1. **Fill in the PR template** (`.gitea/pull_request_template.md`). The
*Risk* checklist is the part pragent scores you on — check the boxes that
apply, delete the ones that don't.
2. **Update the static context** if your change introduces new house rules.
See `## Adding house rules` below.
3. **Run the gates locally** (see *Test commands*). CI is a single
`mvn -B test` against JDK 25 — there's no other infra in this repo.
## Test commands
```bash
# Run the test suite (JDK 25 required). Same command CI uses:
mvn -B test
# Compile only (faster):
mvn -B -DskipTests package
# Integration: copy the resulting jar into a test Paper server's plugins/
# directory, start the server, exercise the affected commands. The chat
# and stats paths are NOT covered by unit tests — they need a live server.
```
### What counts as "tested"
For pragent (and reviewers) to consider a change tested, the PR must show:
- [ ] `mvn -B test` output from your machine pasted in the PR, **OR** a CI
run URL.
- [ ] A new behaviour has a JUnit test in `src/test/java/...` that exercises
the new code path. New `comando:` blocks need at least one test that
sends a fake event and asserts the message format.
- [ ] If the change touches the chat immutability rule, the singular-form
rule, or the Bedrock fallback, a manual test on a Paper server is
required **and** a one-line note pasted in the PR.
- [ ] If the change touches `<world>/players/stats`, you must show the
file path on a running server (`ls <world>/players/stats/*.json`)
and confirm the stats file is updated, not the wrong path.
### Out of scope for tests
- Performance / load tests (none in this repo).
- E2E against the live server (manual; verify in `canalhandia-config` repo).
- UI / browser tests (no web surface).
## Commit messages
Conventional commits. Examples:
- `feat: add per-player cobblestone counter command`
- `fix(void): keep inventory when dying in lava void`
- `docs: update README build instructions`
- `chore: bump pom parent to 1.4.2`
## Branch names
`<type>/<short-kebab-description>` matching commit type. Example:
`fix/void-keep-inventory`.
## PR review
- The bot (`pragent`) reviews every PR labelled `AI-REVIEW`. It reads
`.pr-review.json` from `main` + the static `architecture.md` from Nexus
raw-hosted. Adding a new house rule? Update both.
- Add the `AI-USAGE` label if you want the bot to also post a token /
cost-equivalent report on the review.
## Adding house rules
If your PR changes how translatable components behave, how chat edits work,
how stats are read, or how the Bedrock fallback is rendered, you must:
1. Update the relevant section in the static `architecture.md` (upload a new
copy to Nexus raw-hosted at `canalhandia/architecture.md`).
2. Bump `.pr-review.json:instructions` with a one-paragraph summary.
3. Add a finding-checklist item to `.gitea/pull_request_template.md` *Risk*
section so PR authors know to confirm the rule.
Bot does not detect house-rule drift automatically — these three updates
together are the maintainer contract.
## Rollback
A single PR that bumps the plugin jar in `canalhandia-config` rolls back.
Plugins are jar-swapped, no migrations.
+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.
@@ -127,9 +127,13 @@ final class BlueMapBridge {
if (!mapMatchesWorld(map, loader.world())) { if (!mapMatchesWorld(map, loader.world())) {
continue; continue;
} }
String statusHtml = loader.enabled()
? "<span style='color:#55ff55'>Ativo</span>"
: "<span style='color:#ff5555'>Pausado</span>";
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder() var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
.label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")") .label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
.detail("<b>Âncora de Chunk #" + loader.id() + "</b><br>Dono: " .detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
+ statusHtml + "<br>Dono: "
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords()) + escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
.position(loader.x(), loader.y(), loader.z()) .position(loader.x(), loader.y(), loader.z())
.build(); .build();
@@ -131,6 +131,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml")); chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
if (settings.moduleEnabled(Module.CHUNKLOADER)) { if (settings.moduleEnabled(Module.CHUNKLOADER)) {
chunkLoaders.loadAllTickets(); chunkLoaders.loadAllTickets();
chunkLoaders.startSimulation();
ChunkAnchorItem.registerRecipe(this); ChunkAnchorItem.registerRecipe(this);
} }
aiBudget = new Budget(settings.aiSpontaneousPerDay(), aiBudget = new Budget(settings.aiSpontaneousPerDay(),
@@ -160,6 +161,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
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); getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
getServer().getPluginManager().registerEvents(new XpAndItemMergeListener(this), this);
rescheduleTimer(); rescheduleTimer();
rescheduleMilestones(); rescheduleMilestones();
@@ -192,6 +194,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
poll.hide(); poll.hide();
} }
if (chunkLoaders != null) { if (chunkLoaders != null) {
chunkLoaders.stopSimulation();
chunkLoaders.unloadAllTickets(); chunkLoaders.unloadAllTickets();
chunkLoaders.close(); chunkLoaders.close();
ChunkAnchorItem.unregisterRecipe(this); ChunkAnchorItem.unregisterRecipe(this);
@@ -837,6 +840,60 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L); }, settings.reactionWindowSeconds() * 20L);
} }
/**
* Rescues player items when falling into the void. Places a chest on the nearest
* safe ground block, or preserves items directly in the inventory if no solid ground is nearby.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onVoidDeath(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.SALVAVOID)) {
return;
}
Player player = event.getEntity();
EntityDamageEvent damage = player.getLastDamageCause();
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
if (!VoidProtection.isVoidDeath(player.getLocation().getY(), player.getWorld().getMinHeight(), cause)) {
return;
}
List<ItemStack> drops = new ArrayList<>(event.getDrops());
Location safeLoc = VoidProtection.findSafeChestLocation(player.getWorld(), player.getLocation(), settings.voidProtectionRadius());
if (safeLoc != null && !drops.isEmpty() && VoidProtection.rescueToChest(drops, safeLoc)) {
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
int x = safeLoc.getBlockX();
int y = safeLoc.getBlockY();
int z = safeLoc.getBlockZ();
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo! Seus itens foram guardados em segurança num baú em ", NamedTextColor.YELLOW))
.append(Component.text(x + ", " + y + ", " + z, NamedTextColor.AQUA, TextDecoration.BOLD))
.append(Component.text(".", NamedTextColor.YELLOW)));
}
}, 20L);
} else {
// No safe ground found within radius: keep inventory directly
event.setKeepInventory(true);
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo sem terra firme por perto! Seus itens foram mantidos no seu inventário.", NamedTextColor.GREEN)));
}
}, 20L);
}
}
/** /**
* Comic death broadcast + private coordinates, gated by the {@code mortes} * Comic death broadcast + private coordinates, gated by the {@code mortes}
* module. Replaces the vanilla translatable death message with a pt-BR * module. Replaces the vanilla translatable death message with a pt-BR
@@ -880,7 +937,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Location loc = player.getLocation(); Location loc = player.getLocation();
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ() String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
+ " (" + loc.getWorld().getName() + ")"; + " (" + loc.getWorld().getName() + ")";
boolean keepInventory = Boolean.TRUE.equals( boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals(
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY)); loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory)); pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
@@ -570,8 +570,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (module == Module.CHUNKLOADER) { if (module == Module.CHUNKLOADER) {
if (on) { if (on) {
plugin.chunkLoaders().loadAllTickets(); plugin.chunkLoaders().loadAllTickets();
plugin.chunkLoaders().startSimulation();
ChunkAnchorItem.registerRecipe(plugin); ChunkAnchorItem.registerRecipe(plugin);
} else { } else {
plugin.chunkLoaders().stopSimulation();
plugin.chunkLoaders().unloadAllTickets(); plugin.chunkLoaders().unloadAllTickets();
ChunkAnchorItem.unregisterRecipe(plugin); ChunkAnchorItem.unregisterRecipe(plugin);
} }
@@ -1528,8 +1530,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
return true; return true;
} }
String arg = String.join(" ", args); String arg = String.join(" ", args).trim();
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) { if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")
|| arg.equalsIgnoreCase("remover") || arg.equalsIgnoreCase("remove")
|| arg.equalsIgnoreCase("clear")) {
plugin.titles().clear(player.getUniqueId()); plugin.titles().clear(player.getUniqueId());
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido")); Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
return true; return true;
@@ -1544,20 +1548,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
/** Matches typed text to an earned achievement by key or (case-insensitive) title. */ /** Matches typed text to an earned achievement by key, title, or accent-normalized text. */
static Achievement matchEarned(String text, List<Achievement> earned) { static Achievement matchEarned(String text, List<Achievement> earned) {
Achievement byKey = Achievement.byKey(text); if (text == null || text.isBlank() || earned == null || earned.isEmpty()) {
return null;
}
String clean = text.trim();
Achievement byKey = Achievement.byKey(clean);
if (byKey != null && earned.contains(byKey)) { if (byKey != null && earned.contains(byKey)) {
return byKey; return byKey;
} }
for (Achievement achievement : earned) { for (Achievement achievement : earned) {
if (achievement.title().equalsIgnoreCase(text.trim())) { if (achievement.title().equalsIgnoreCase(clean)) {
return achievement;
}
}
String normalizedClean = normalizeText(clean);
for (Achievement achievement : earned) {
if (normalizeText(achievement.title()).equalsIgnoreCase(normalizedClean)
|| normalizeText(achievement.key()).equalsIgnoreCase(normalizedClean)) {
return achievement; return achievement;
} }
} }
return null; return null;
} }
private static String normalizeText(String input) {
if (input == null) return "";
return java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}", "");
}
private static String titlesList(List<Achievement> earned) { private static String titlesList(List<Achievement> earned) {
List<String> names = new ArrayList<>(); List<String> names = new ArrayList<>();
for (Achievement achievement : earned) { for (Achievement achievement : earned) {
@@ -1820,6 +1841,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "info", "chunk" -> chunkloaderInfo(sender); case "info", "chunk" -> chunkloaderInfo(sender);
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest); case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
case "receita", "craft" -> chunkloaderRecipe(sender); case "receita", "craft" -> chunkloaderRecipe(sender);
case "limite", "limit" -> chunkloaderLimit(sender, rest);
case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest);
case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true);
case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false);
case "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest);
case "admin" -> chunkloaderAdmin(sender, rest); case "admin" -> chunkloaderAdmin(sender, rest);
case "dar", "give" -> { case "dar", "give" -> {
if (admin(sender)) { if (admin(sender)) {
@@ -1852,13 +1878,31 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
boolean bedrock = isBedrock(sender); boolean bedrock = isBedrock(sender);
for (ChunkLoader loader : mine) { for (ChunkLoader loader : mine) {
Component line = Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA) String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]";
.append(Component.text(loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE)); NamedTextColor statusColor = loader.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
Component line = Component.text(" " + loader.displayName() + " ", NamedTextColor.AQUA)
.append(Component.text(loader.place() + " " + loader.chunkCoords() + " ", NamedTextColor.WHITE))
.append(Component.text(statusText + " ", statusColor))
.append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD));
if (!bedrock) { if (!bedrock) {
if (loader.enabled()) {
line = line.append(Component.text(" [Pausar]", NamedTextColor.YELLOW)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para pausar " + loader.displayName(), NamedTextColor.GRAY)))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
"/chunkloader desativar " + loader.id())));
} else {
line = line.append(Component.text(" [Ativar]", NamedTextColor.GREEN)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para reativar " + loader.displayName(), NamedTextColor.GRAY)))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
"/chunkloader ativar " + loader.id())));
}
line = line.append(Component.text(" [Remover]", NamedTextColor.RED) line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para desativar a âncora #" + loader.id(), NamedTextColor.GRAY))) Component.text("Clique para recolher " + loader.displayName(), NamedTextColor.GRAY)))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
"/chunkloader remover " + loader.id()))); "/chunkloader remover " + loader.id())));
} }
@@ -1888,9 +1932,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.append(Component.text(" em " + world, NamedTextColor.WHITE))); .append(Component.text(" em " + world, NamedTextColor.WHITE)));
if (here != null) { if (here != null) {
String status = here.enabled() ? "Ativa" : "Pausada";
NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
.append(Component.text("Carregada continuamente por Âncora #" + here.id(), NamedTextColor.GREEN)) .append(Component.text(here.displayName() + " (" + status + ")", statusCol))
.append(Component.text(" (" + here.ownerName() + ")", NamedTextColor.WHITE))); .append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE)));
sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY)
.append(Component.text(here.timeLeft(), NamedTextColor.GOLD)));
} else { } else {
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY))); .append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
@@ -1900,24 +1948,112 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW))); .append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW)));
} }
private void chunkloaderRename(CommandSender sender, String[] args) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader nome <id|nome_antigo> <novo_nome>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
String newName = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
if (newName.length() > 32) {
Msg.error(sender, "Nome muito longo (máximo 32 caracteres).");
return;
}
plugin.chunkLoaders().rename(loader.id(), newName);
plugin.blueMap().syncChunkLoaders();
Msg.ok(sender, "Âncora #" + loader.id() + " renomeada para '" + newName + "'.");
}
private void chunkloaderSetEnabled(CommandSender sender, String[] args, boolean enabled) {
if (args.length < 1) {
Msg.error(sender, "Uso: /chunkloader " + (enabled ? "ativar" : "desativar") + " <id|nome>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
if (loader.enabled() == enabled) {
Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + ".");
return;
}
if (enabled && loader.isExpired()) {
Msg.error(sender, "A âncora " + loader.displayName() + " está expirada! Adicione tempo (/chunkloader tempo " + loader.id() + " <horas>) ou abasteça com combustível antes de reativar.");
return;
}
plugin.chunkLoaders().setEnabled(loader.id(), enabled);
plugin.blueMap().syncChunkLoaders();
Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA."));
}
private void chunkloaderTime(CommandSender sender, String[] args) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader tempo <id|nome> <horas>");
return;
}
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (loader == null) {
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
return;
}
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
if (!isOwner && !isAdmin) {
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
return;
}
try {
double hours = Double.parseDouble(args[1]);
if (hours <= 0) {
if (isAdmin) {
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
} else {
Msg.error(sender, "A quantidade de horas deve ser maior que zero.");
}
return;
}
long millis = (long) (hours * 3600_000L);
plugin.chunkLoaders().addTime(loader.id(), millis);
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
String time = (updated != null) ? updated.timeLeft() : "Permanente";
Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time);
plugin.blueMap().syncChunkLoaders();
} catch (NumberFormatException e) {
Msg.error(sender, "Horas inválidas: " + args[1]);
}
}
private void chunkloaderRemove(CommandSender sender, String[] args) { private void chunkloaderRemove(CommandSender sender, String[] args) {
if (args.length < 1) { if (args.length < 1) {
Msg.error(sender, "Uso: /chunkloader remover <id>"); Msg.error(sender, "Uso: /chunkloader remover <id|nome>");
return; return;
} }
long id = parseLong(args[0]); ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
if (id <= 0) {
Msg.error(sender, "ID inválido.");
return;
}
ChunkLoader loader = plugin.chunkLoaders().byId(id);
if (loader == null) { if (loader == null) {
Msg.error(sender, "Nenhuma Âncora de Chunk encontrada com o ID #" + id); Msg.error(sender, "Nenhuma Âncora de Chunk encontrada para '" + args[0] + "'.");
return; return;
} }
long id = loader.id();
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid()); boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin"); boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
@@ -1945,7 +2081,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
for (ItemStack drop : overflow.values()) { for (ItemStack drop : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), drop); player.getWorld().dropItemNaturally(player.getLocation(), drop);
} }
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e devolvida ao inventário."); Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e devolvida ao inventário.");
} else { } else {
try { try {
org.bukkit.World w = Bukkit.getWorld(loader.world()); org.bukkit.World w = Bukkit.getWorld(loader.world());
@@ -1955,7 +2091,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
Msg.ok(sender, "Âncora de Chunk #" + id + " desativada e dropada em " + loader.place() + "."); Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e dropada em " + loader.place() + ".");
} }
} }
@@ -2038,16 +2174,88 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!"); Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!");
} }
private void chunkloaderLimit(CommandSender sender, String[] args) {
if (args.length == 0) {
int defaultLimit = plugin.settings().chunkLoaderDefaultLimit();
Msg.header(sender, "Limite de Âncoras de Chunk");
sender.sendMessage(Component.text(" Limite padrão global: ", NamedTextColor.GRAY)
.append(Component.text(defaultLimit, NamedTextColor.YELLOW)));
if (sender instanceof Player player) {
int myLimit = ChunkLoaders.playerLimit(player, defaultLimit);
String myLimitStr = myLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(myLimit);
int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
sender.sendMessage(Component.text(" Seu limite atual: ", NamedTextColor.GRAY)
.append(Component.text(current + "/" + myLimitStr, NamedTextColor.AQUA)));
}
if (admin(sender)) {
sender.sendMessage(Component.text(" Alterar padrão global: ", NamedTextColor.GRAY)
.append(Component.text("/chunkloader limite padrao <quantidade>", NamedTextColor.YELLOW)));
sender.sendMessage(Component.text(" Definir por jogador (LuckPerms): ", NamedTextColor.GRAY)
.append(Component.text("/chunkloader limite <jogador> <quantidade>", NamedTextColor.YELLOW)));
}
return;
}
if (!admin(sender)) {
return;
}
if (args[0].equalsIgnoreCase("padrao") || args[0].equalsIgnoreCase("default")) {
if (args.length < 2) {
Msg.error(sender, "Uso: /chunkloader limite padrao <quantidade>");
return;
}
try {
int newLimit = Math.max(0, Integer.parseInt(args[1]));
plugin.settings().chunkLoaderDefaultLimit(newLimit);
Msg.ok(sender, "Limite padrão global de Âncoras alterado para " + newLimit + ".");
} catch (NumberFormatException e) {
Msg.error(sender, "Quantidade inválida: " + args[1]);
}
return;
}
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, "Jogador '" + args[0] + "' não encontrado online.");
return;
}
if (args.length < 2) {
int targetLimit = ChunkLoaders.playerLimit(target, plugin.settings().chunkLoaderDefaultLimit());
String str = targetLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(targetLimit);
int current = plugin.chunkLoaders().byOwner(target.getUniqueId().toString()).size();
Msg.ok(sender, "Limite de " + target.getName() + ": " + current + "/" + str + " âncoras ativas.");
return;
}
try {
int newLimit = Math.max(0, Integer.parseInt(args[1]));
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit);
Msg.ok(sender, "Limite de " + target.getName() + " definido para " + newLimit + " âncoras.");
} catch (NumberFormatException e) {
Msg.error(sender, "Quantidade inválida: " + args[1]);
}
}
private boolean chunkloaderHelp(CommandSender sender) { private boolean chunkloaderHelp(CommandSender sender) {
Msg.header(sender, "Âncoras de Chunk"); Msg.header(sender, "Âncoras de Chunk");
sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA) sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA)
.append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY))); .append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA) sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA)
.append(Component.text(" — Lista suas âncoras ativas e coordenadas", NamedTextColor.GRAY))); .append(Component.text(" — Lista suas âncoras, nomes, status e coordenadas", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader remover <id>", NamedTextColor.AQUA) sender.sendMessage(Component.text(" /chunkloader nome <id|nome> <novo_nome>", NamedTextColor.AQUA)
.append(Component.text(" — Define um nome/rótulo para a âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader ativar <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Reativa o carregamento contínuo da âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader desativar <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Pausa o carregamento sem quebrar o bloco", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader remover <id|nome>", NamedTextColor.AQUA)
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader tempo <id|nome> <horas>", NamedTextColor.AQUA)
.append(Component.text(" — Adiciona tempo ou abastece a âncora", NamedTextColor.GRAY)));
sender.sendMessage(Component.text(" /chunkloader limite", NamedTextColor.AQUA)
.append(Component.text(" — Mostra ou configura os limites de âncoras", NamedTextColor.GRAY)));
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW) sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW)
.append(Component.text(" — Comandos de administração", NamedTextColor.GRAY))); .append(Component.text(" — Comandos de administração", NamedTextColor.GRAY)));
@@ -2217,21 +2425,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
if (args.length == 1) { if (args.length == 1) {
List<String> options = new ArrayList<>(List.of("info", "listar", "remover", "receita")); List<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite"));
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
options.addAll(List.of("admin", "dar")); options.addAll(List.of("admin", "dar"));
} }
return filter(options, args[0]); return filter(options, args[0]);
} }
if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del"))) { if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del")
|| args[0].equalsIgnoreCase("nome") || args[0].equalsIgnoreCase("renomear")
|| args[0].equalsIgnoreCase("ativar") || args[0].equalsIgnoreCase("desativar")
|| args[0].equalsIgnoreCase("pausar") || args[0].equalsIgnoreCase("tempo")
|| args[0].equalsIgnoreCase("abastecer"))) {
if (sender instanceof Player player) { if (sender instanceof Player player) {
List<String> ids = new ArrayList<>(); List<String> identifiers = new ArrayList<>();
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) { for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
ids.add(String.valueOf(loader.id())); identifiers.add(String.valueOf(loader.id()));
if (loader.name() != null && !loader.name().isBlank()) {
identifiers.add(loader.name());
}
} }
return filter(ids, args[1]); return filter(identifiers, args[1]);
} }
} }
if (args.length == 2 && args[0].equalsIgnoreCase("limite")) {
List<String> opts = new ArrayList<>(List.of("padrao"));
opts.addAll(onlineNames());
return filter(opts, args[1]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) { if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
return filter(List.of("listar", "remover", "dar"), args[1]); return filter(List.of("listar", "remover", "dar"), args[1]);
} }
@@ -8,6 +8,9 @@ package dev.marcospaulo.canalhandia;
*/ */
record ChunkLoader( record ChunkLoader(
long id, long id,
String name,
boolean enabled,
long expiresAt,
String ownerUuid, String ownerUuid,
String ownerName, String ownerName,
String world, String world,
@@ -18,6 +21,14 @@ record ChunkLoader(
int chunkZ, int chunkZ,
long createdAt) { long createdAt) {
ChunkLoader(long id, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
this(id, "", true, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader(long id, String name, boolean enabled, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
this(id, name, enabled, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
String blockCoords() { String blockCoords() {
return x + ", " + y + ", " + z; return x + ", " + y + ", " + z;
} }
@@ -29,4 +40,46 @@ record ChunkLoader(
String place() { String place() {
return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")"); return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
} }
String displayName() {
return (name != null && !name.isBlank()) ? name + " (#" + id + ")" : "#" + id;
}
String simpleName() {
return (name != null && !name.isBlank()) ? name : "#" + id;
}
boolean isExpired() {
return expiresAt > 0 && System.currentTimeMillis() >= expiresAt;
}
String timeLeft() {
if (expiresAt <= 0) {
return "Permanente";
}
long diff = expiresAt - System.currentTimeMillis();
if (diff <= 0) {
return "Expirado";
}
long hours = diff / (3600_000L);
long minutes = (diff % (3600_000L)) / 60_000L;
if (hours >= 24) {
long days = hours / 24;
hours = hours % 24;
return days + "d " + hours + "h";
}
return hours + "h " + minutes + "m";
}
ChunkLoader withName(String newName) {
return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader withEnabled(boolean newEnabled) {
return new ChunkLoader(id, name, newEnabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
ChunkLoader withExpiresAt(long newExpiresAt) {
return new ChunkLoader(id, name, enabled, Math.max(0L, newExpiresAt), ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
}
} }
@@ -1,22 +1,30 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component; import org.bukkit.GameMode;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle; import org.bukkit.Particle;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockExplodeEvent; import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonExtendEvent;
import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPistonRetractEvent;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map;
/** /**
* Protects and handles block placement, removal, and explosions for Chunk Anchors. * Protects and handles block placement, removal, and explosions for Chunk Anchors.
@@ -70,13 +78,27 @@ final class ChunkLoaderListener implements Listener {
return; return;
} }
String customName = "";
var meta = event.getItemInHand().getItemMeta();
if (meta != null && meta.hasDisplayName()) {
try {
String raw = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(meta.displayName()).trim();
if (!raw.equalsIgnoreCase("Âncora de Chunk") && !raw.isBlank()) {
customName = raw;
}
} catch (Exception ignored) {
}
}
ChunkLoader loader = plugin.chunkLoaders().add( ChunkLoader loader = plugin.chunkLoaders().add(
player.getUniqueId().toString(), player.getUniqueId().toString(),
player.getName(), player.getName(),
world, world,
loc.getBlockX(), loc.getBlockX(),
loc.getBlockY(), loc.getBlockY(),
loc.getBlockZ() loc.getBlockZ(),
customName
); );
try { try {
@@ -88,11 +110,11 @@ final class ChunkLoaderListener implements Listener {
plugin.blueMap().syncChunkLoaders(); plugin.blueMap().syncChunkLoaders();
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit); String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
Msg.ok(player, "Âncora de Chunk ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente (" Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente ("
+ (current + 1) + "/" + limitStr + ")."); + (current + 1) + "/" + limitStr + ").");
} }
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) { public void onBlockBreak(BlockBreakEvent event) {
Block block = event.getBlock(); Block block = event.getBlock();
ChunkLoader loader = plugin.chunkLoaders().byLocation( ChunkLoader loader = plugin.chunkLoaders().byLocation(
@@ -118,16 +140,124 @@ final class ChunkLoaderListener implements Listener {
plugin.chunkLoaders().remove(loader.id()); plugin.chunkLoaders().remove(loader.id());
event.setDropItems(false); event.setDropItems(false);
event.setExpToDrop(0);
if (player.getGameMode() == GameMode.CREATIVE) {
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada (Modo Criativo).");
} else {
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
for (ItemStack drop : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), drop);
}
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada e recolhida para o seu inventário.");
}
Location loc = block.getLocation();
try { try {
loc.getWorld().dropItemNaturally(loc, ChunkAnchorItem.create(plugin, 1)); var loc = block.getLocation().add(0.5, 0.5, 0.5);
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f); loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
plugin.blueMap().syncChunkLoaders(); plugin.blueMap().syncChunkLoaders();
Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada e recolhida."); }
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
return;
}
if (event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) {
return;
}
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
Block block = event.getClickedBlock();
if (block == null || block.getType() != Material.RESPAWN_ANCHOR) {
return;
}
ChunkLoader loader = plugin.chunkLoaders().byLocation(
block.getWorld().getName(),
block.getX(),
block.getY(),
block.getZ()
);
if (loader != null) {
event.setCancelled(true);
Player player = event.getPlayer();
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
ItemStack inHand = player.getInventory().getItemInMainHand();
long fuelMillis = fuelDuration(inHand.getType());
if (fuelMillis > 0 && (isOwner || isAdmin)) {
if (player.getGameMode() != GameMode.CREATIVE) {
inHand.subtract(1);
}
plugin.chunkLoaders().addTime(loader.id(), fuelMillis);
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
Location l = block.getLocation().add(0.5, 0.5, 0.5);
try {
l.getWorld().playSound(l, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 1.0f, 1.2f);
l.getWorld().spawnParticle(Particle.FIREWORK, l, 15, 0.2, 0.2, 0.2, 0.05);
} catch (Exception ignored) {
}
plugin.blueMap().syncChunkLoaders();
String time = (updated != null) ? updated.timeLeft() : "Permanente";
Msg.ok(player, "Âncora " + loader.displayName() + " abastecida! Tempo restante: " + time);
return;
}
if (player.isSneaking() && (isOwner || isAdmin)) {
if (!loader.enabled() && loader.isExpired()) {
Msg.error(player, "A âncora " + loader.displayName() + " está expirada! Clique com combustível (Pérola do End, Blaze, Diamante, etc.) ou adicione tempo antes de reativar.");
return;
}
boolean newState = !loader.enabled();
plugin.chunkLoaders().setEnabled(loader.id(), newState);
Location l = block.getLocation().add(0.5, 0.5, 0.5);
if (newState) {
try {
l.getWorld().playSound(l, Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.2f);
l.getWorld().spawnParticle(Particle.PORTAL, l, 25, 0.2, 0.2, 0.2, 0.05);
} catch (Exception ignored) {
}
Msg.ok(player, "Âncora " + loader.displayName() + " ATIVADA.");
} else {
try {
l.getWorld().playSound(l, Sound.BLOCK_BEACON_DEACTIVATE, 1.0f, 0.8f);
l.getWorld().spawnParticle(Particle.SMOKE, l, 20, 0.2, 0.2, 0.2, 0.02);
} catch (Exception ignored) {
}
Msg.ok(player, "Âncora " + loader.displayName() + " PAUSADA/DESATIVADA.");
}
plugin.blueMap().syncChunkLoaders();
return;
}
String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA";
Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr
+ " [Tempo: " + loader.timeLeft() + "]. (Shift + Clique Direito para pausar/ativar | Clique com combustível para abastecer)");
}
}
private static long fuelDuration(Material type) {
if (type == Material.REDSTONE || type == Material.GLOWSTONE_DUST) {
return 30 * 60_000L; // 30 min
}
if (type == Material.GLOWSTONE || type == Material.AMETHYST_SHARD) {
return 2 * 3600_000L; // 2h
}
if (type == Material.ENDER_PEARL || type == Material.BLAZE_POWDER || type == Material.BLAZE_ROD) {
return 4 * 3600_000L; // 4h
}
if (type == Material.ENDER_EYE || type == Material.DIAMOND || type == Material.EMERALD) {
return 12 * 3600_000L; // 12h
}
if (type == Material.NETHER_STAR || type == Material.NETHERITE_INGOT || type == Material.END_CRYSTAL) {
return 7 * 24 * 3600_000L; // 7 dias
}
return 0L;
} }
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
@@ -168,4 +298,20 @@ final class ChunkLoaderListener implements Listener {
} }
} }
} }
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) {
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
return;
}
Location loc = event.getLocation();
World w = loc.getWorld();
if (w == null) {
return;
}
ChunkLoader loader = plugin.chunkLoaders().byChunk(w.getName(), loc.getBlockX() >> 4, loc.getBlockZ() >> 4);
if (loader != null && event.getEntity() instanceof Mob mob) {
mob.setRemoveWhenFarAway(false);
}
}
} }
@@ -1,12 +1,22 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.CreatureSpawner;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.permissions.PermissionAttachmentInfo;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
@@ -15,6 +25,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -25,10 +36,11 @@ final class ChunkLoaders {
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite."; private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
private final Plugin plugin; private final Canalhandia plugin;
private final File file; private final File file;
private final List<ChunkLoader> loaders = new ArrayList<>(); private final List<ChunkLoader> loaders = new ArrayList<>();
private final AtomicLong nextId = new AtomicLong(1); private final AtomicLong nextId = new AtomicLong(1);
private BukkitTask simulationTask;
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "canalhandia-chunkloaders-io"); Thread t = new Thread(r, "canalhandia-chunkloaders-io");
@@ -36,7 +48,7 @@ final class ChunkLoaders {
return t; return t;
}); });
ChunkLoaders(Plugin plugin, File file) { ChunkLoaders(Canalhandia plugin, File file) {
this.plugin = plugin; this.plugin = plugin;
this.file = file; this.file = file;
load(); load();
@@ -57,6 +69,9 @@ final class ChunkLoaders {
for (String key : root.getKeys(false)) { for (String key : root.getKeys(false)) {
try { try {
long id = Long.parseLong(key); long id = Long.parseLong(key);
String name = root.getString(key + ".name", "");
boolean enabled = root.getBoolean(key + ".enabled", true);
long expiresAt = root.getLong(key + ".expires_at", 0L);
String ownerUuid = root.getString(key + ".owner_uuid", ""); String ownerUuid = root.getString(key + ".owner_uuid", "");
String ownerName = root.getString(key + ".owner_name", "?"); String ownerName = root.getString(key + ".owner_name", "?");
String world = root.getString(key + ".world", "world"); String world = root.getString(key + ".world", "world");
@@ -67,7 +82,7 @@ final class ChunkLoaders {
int chunkZ = root.getInt(key + ".chunk_z", z >> 4); int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis()); long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis());
loaders.add(new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt)); loaders.add(new ChunkLoader(id, name, enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
if (id > maxId) { if (id > maxId) {
maxId = id; maxId = id;
} }
@@ -82,10 +97,28 @@ final class ChunkLoaders {
* Registers a new chunk loader and activates the ticket in the world. * Registers a new chunk loader and activates the ticket in the world.
*/ */
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) { ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) {
return add(ownerUuid, ownerName, world, x, y, z, "", defaultInitialExpiration());
}
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, defaultInitialExpiration());
}
private long defaultInitialExpiration() {
if (plugin != null && plugin.settings() != null && plugin.settings().chunkLoaderRequiresTime()) {
double hours = plugin.settings().chunkLoaderInitialHours();
if (hours > 0) {
return System.currentTimeMillis() + (long) (hours * 3600_000L);
}
}
return 0L;
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) {
int chunkX = x >> 4; int chunkX = x >> 4;
int chunkZ = z >> 4; int chunkZ = z >> 4;
long id = nextId.getAndIncrement(); long id = nextId.getAndIncrement();
ChunkLoader loader = new ChunkLoader(id, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis()); ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
synchronized (loaders) { synchronized (loaders) {
loaders.add(loader); loaders.add(loader);
@@ -95,6 +128,140 @@ final class ChunkLoaders {
return loader; return loader;
} }
/**
* Adds duration (in milliseconds) to a chunk loader's expiration timer.
*/
boolean addTime(long id, long millisToAdd) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
long now = System.currentTimeMillis();
long base = (curr.expiresAt() > now) ? curr.expiresAt() : now;
long newExpires = base + millisToAdd;
updated = curr.withExpiresAt(newExpires).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Sets a specific expiration timestamp (or 0 for permanent) for a chunk loader.
*/
boolean setExpiresAt(long id, long expiresAt) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
updated = curr.withExpiresAt(expiresAt).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Renames an existing chunk loader.
*/
boolean rename(long id, String newName) {
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
loaders.set(i, curr.withName(newName));
save();
return true;
}
}
}
return false;
}
/**
* Enables or disables (pauses) a chunk loader without destroying the block.
*/
boolean setEnabled(long id, boolean enabled) {
ChunkLoader oldLoader = null;
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
oldLoader = curr;
updated = curr.withEnabled(enabled);
loaders.set(i, updated);
break;
}
}
}
if (oldLoader != null && updated != null) {
if (enabled) {
addTicket(updated);
} else {
removeTicket(oldLoader);
}
save();
return true;
}
return false;
}
/**
* Finds a chunk loader by its numeric ID (with '#' prefix or plain ID) or custom name.
* Custom names take precedence over raw numeric IDs to avoid shadowing named loaders.
*/
ChunkLoader find(String query) {
if (query == null || query.isBlank()) {
return null;
}
String trimmed = query.trim();
if (trimmed.startsWith("#")) {
try {
long id = Long.parseLong(trimmed.substring(1));
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
}
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.name() != null && loader.name().equalsIgnoreCase(trimmed)) {
return loader;
}
}
}
try {
long id = Long.parseLong(trimmed);
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
return null;
}
/** /**
* Removes a chunk loader by its unique ID and releases the chunk ticket. * Removes a chunk loader by its unique ID and releases the chunk ticket.
*/ */
@@ -207,7 +374,9 @@ final class ChunkLoaders {
void loadAllTickets() { void loadAllTickets() {
synchronized (loaders) { synchronized (loaders) {
for (ChunkLoader loader : loaders) { for (ChunkLoader loader : loaders) {
addTicket(loader); if (loader.enabled()) {
addTicket(loader);
}
} }
} }
} }
@@ -220,6 +389,173 @@ final class ChunkLoaders {
} }
} }
void startSimulation() {
if (plugin == null || simulationTask != null) {
return;
}
simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L);
}
void stopSimulation() {
if (simulationTask != null) {
simulationTask.cancel();
simulationTask = null;
}
}
void tickMobSimulation() {
if (plugin == null) {
return;
}
List<ChunkLoader> currentLoaders;
synchronized (loaders) {
currentLoaders = new ArrayList<>(loaders);
}
for (ChunkLoader loader : currentLoaders) {
if (loader.isExpired() && loader.enabled()) {
setEnabled(loader.id(), false);
continue;
}
if (!loader.enabled()) {
continue;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
continue;
}
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
boolean spawnersActive = plugin.settings() == null || plugin.settings().chunkLoaderSpawnersActive();
boolean monsterSpawning = plugin.settings() == null || plugin.settings().chunkLoaderMonsterSpawning();
int monsterCap = plugin.settings() != null ? plugin.settings().chunkLoaderMonsterCap() : 20;
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active if enabled
if (spawnersActive) {
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) if enabled
if (monsterSpawning) {
long mobCount = 0;
for (Entity entity : chunk.getEntities()) {
if (entity instanceof Mob) {
mobCount++;
}
}
if (mobCount < monsterCap) {
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 < 4; 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() || ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
continue;
}
if (!space.getType().isAir()) {
continue;
}
boolean isAirAbove = spaceAbove.getType().isAir();
boolean isTrapdoorAbove = isTrapdoor(spaceAbove.getType());
if (!isAirAbove && !isTrapdoorAbove) {
continue;
}
int light = space.getLightLevel();
EntityType toSpawn;
if (isTrapdoorAbove) {
if (light > 0) {
continue;
}
toSpawn = EntityType.CREEPER;
} else {
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 boolean isTrapdoor(Material mat) {
if (mat == null) return false;
return mat.name().endsWith("_TRAPDOOR");
}
static EntityType pickEntityType(World w, Block space, int light) {
boolean isSlime = false;
try {
isSlime = space.getChunk().isSlimeChunk();
} catch (Exception ignored) {
}
return pickEntityType(w.getEnvironment(), isSlime, space.getY(), light, ThreadLocalRandom.current().nextInt(100));
}
static EntityType pickEntityType(World.Environment env, boolean isSlimeChunk, int y, int light, int roll) {
if (env == World.Environment.NETHER) {
if (light > 11) return null;
int r = Math.floorMod(roll, 100);
if (r < 45) return EntityType.ZOMBIFIED_PIGLIN;
if (r < 65) return EntityType.WITHER_SKELETON;
if (r < 85) return EntityType.BLAZE;
return EntityType.MAGMA_CUBE;
}
if (env == World.Environment.THE_END) {
return EntityType.ENDERMAN;
}
if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) {
return EntityType.SLIME;
}
if (light > 0) {
return null;
}
int r = Math.floorMod(roll, 100);
if (r < 35) return EntityType.ZOMBIE;
if (r < 65) return EntityType.SKELETON;
if (r < 85) return EntityType.CREEPER;
if (r < 95) return EntityType.SPIDER;
return EntityType.WITCH;
}
private void addTicket(ChunkLoader loader) { private void addTicket(ChunkLoader loader) {
if (plugin == null) { if (plugin == null) {
return; return;
@@ -227,6 +563,7 @@ final class ChunkLoaders {
try { try {
World w = Bukkit.getWorld(loader.world()); World w = Bukkit.getWorld(loader.world());
if (w != null) { if (w != null) {
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true);
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
} }
} catch (Exception ignored) { } catch (Exception ignored) {
@@ -241,6 +578,16 @@ final class ChunkLoaders {
World w = Bukkit.getWorld(loader.world()); World w = Bukkit.getWorld(loader.world());
if (w != null) { if (w != null) {
w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin); w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false);
if (w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
for (BlockState state : chunk.getTileEntities()) {
if (state instanceof CreatureSpawner spawner && spawner.getRequiredPlayerRange() > 16) {
spawner.setRequiredPlayerRange(16);
spawner.update(true, false);
}
}
}
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
@@ -251,6 +598,9 @@ final class ChunkLoaders {
synchronized (loaders) { synchronized (loaders) {
for (ChunkLoader l : loaders) { for (ChunkLoader l : loaders) {
String key = "loaders." + l.id(); String key = "loaders." + l.id();
yaml.set(key + ".name", l.name());
yaml.set(key + ".enabled", l.enabled());
yaml.set(key + ".expires_at", l.expiresAt());
yaml.set(key + ".owner_uuid", l.ownerUuid()); yaml.set(key + ".owner_uuid", l.ownerUuid());
yaml.set(key + ".owner_name", l.ownerName()); yaml.set(key + ".owner_name", l.ownerName());
yaml.set(key + ".world", l.world()); yaml.set(key + ".world", l.world());
@@ -15,7 +15,8 @@ enum Module {
RECADOS("recados", "Recados entregues quando o jogador entra"), RECADOS("recados", "Recados entregues quando o jogador entra"),
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"), CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
IA("ia", "Perguntas para a IA"), IA("ia", "Perguntas para a IA"),
CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"); CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"),
SALVAVOID("salvavoid", "Proteção de itens no vácuo");
private final String key; private final String key;
private final String label; private final String label;
@@ -597,6 +597,72 @@ final class Settings {
set("chunkloader.bluemap", enabled); set("chunkloader.bluemap", enabled);
} }
boolean chunkLoaderSpawnersActive() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.gaiolas-ativas", true);
}
void chunkLoaderSpawnersActive(boolean active) {
set("chunkloader.simulacao.gaiolas-ativas", active);
}
boolean chunkLoaderMonsterSpawning() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.spawn-monstros", true);
}
void chunkLoaderMonsterSpawning(boolean spawning) {
set("chunkloader.simulacao.spawn-monstros", spawning);
}
int chunkLoaderMonsterCap() {
return Math.max(1, plugin.getConfig().getInt("chunkloader.simulacao.limite-monstros-chunk", 20));
}
void chunkLoaderMonsterCap(int cap) {
set("chunkloader.simulacao.limite-monstros-chunk", Math.max(1, cap));
}
boolean chunkLoaderPassiveEntities() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.tick-entidades-passivas", true);
}
void chunkLoaderPassiveEntities(boolean active) {
set("chunkloader.simulacao.tick-entidades-passivas", active);
}
boolean chunkLoaderRequiresTime() {
return plugin.getConfig().getBoolean("chunkloader.combustivel.requer-tempo", true);
}
void chunkLoaderRequiresTime(boolean required) {
set("chunkloader.combustivel.requer-tempo", required);
}
double chunkLoaderInitialHours() {
return Math.max(0.0, plugin.getConfig().getDouble("chunkloader.combustivel.tempo-inicial-horas", 24.0));
}
void chunkLoaderInitialHours(double hours) {
set("chunkloader.combustivel.tempo-inicial-horas", Math.max(0.0, hours));
}
// --- 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;
}
}
@@ -0,0 +1,187 @@
package dev.marcospaulo.canalhandia;
import com.destroystokyo.paper.event.player.PlayerPickupExperienceEvent;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ExperienceOrb;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.ItemSpawnEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.inventory.meta.Damageable;
import java.util.ArrayList;
import java.util.List;
/**
* Optimises XP and Item collection by instantly vacuuming nearby XP orbs into the player's
* XP bar (bypassing Vanilla's 2-tick per orb delay), repairing Mending equipment first,
* and consolidating spawned item drops into full stacks.
*/
public final class XpAndItemMergeListener implements Listener {
private final Canalhandia plugin;
public XpAndItemMergeListener(Canalhandia plugin) {
this.plugin = plugin;
}
/**
* Vacuums all experience orbs within radius instantly on player contact and repairs Mending items.
*/
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlayerPickupXp(PlayerPickupExperienceEvent event) {
Player player = event.getPlayer();
Location loc = player.getLocation();
if (loc.getWorld() == null) {
return;
}
int totalExp = 0;
try {
for (Entity entity : loc.getWorld().getNearbyEntities(loc, 6.0, 6.0, 6.0)) {
if (entity instanceof ExperienceOrb orb && orb.isValid()) {
totalExp += orb.getExperience();
orb.remove();
}
}
} catch (Exception ignored) {
}
if (totalExp > 0) {
int leftoverExp = applyMending(player, totalExp);
if (leftoverExp > 0) {
player.giveExp(leftoverExp);
}
try {
player.playSound(loc, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.5f, 1.2f);
} catch (Exception ignored) {
}
event.setCancelled(true);
}
}
/**
* Repairs equipped items with Mending enchantment using the collected XP.
* Returns remaining XP to be added to the player's experience bar.
*/
public static int applyMending(Player player, int totalExp) {
if (player == null || totalExp <= 0) {
return totalExp;
}
int remainingXp = totalExp;
PlayerInventory inv = player.getInventory();
List<ItemStack> candidates = new ArrayList<>();
ItemStack main = inv.getItemInMainHand();
if (isDamagedMending(main)) candidates.add(main);
ItemStack off = inv.getItemInOffHand();
if (isDamagedMending(off)) candidates.add(off);
for (ItemStack armor : inv.getArmorContents()) {
if (isDamagedMending(armor)) candidates.add(armor);
}
for (ItemStack item : candidates) {
if (remainingXp <= 0) {
break;
}
if (item.getItemMeta() instanceof Damageable dmg && dmg.hasDamage()) {
int damage = dmg.getDamage();
int[] repair = calculateMendingRepair(damage, remainingXp);
int repaired = repair[0];
int xpUsed = repair[1];
if (repaired > 0) {
dmg.setDamage(damage - repaired);
item.setItemMeta(dmg);
remainingXp -= xpUsed;
}
}
}
return remainingXp;
}
private static boolean isDamagedMending(ItemStack item) {
if (item == null || item.getType() == Material.AIR || !item.hasItemMeta()) {
return false;
}
if (!item.containsEnchantment(Enchantment.MENDING)) {
return false;
}
if (item.getItemMeta() instanceof Damageable dmg) {
return dmg.hasDamage() && dmg.getDamage() > 0;
}
return false;
}
/**
* Calculates repair amount and used XP for a given damage and available XP.
* Returns an int array: [durabilityRepaired, xpUsed].
*/
public static int[] calculateMendingRepair(int damage, int availableXp) {
if (damage <= 0 || availableXp <= 0) {
return new int[]{0, 0};
}
int durabilityToRepair = Math.min(damage, availableXp * 2);
int xpUsed = (durabilityToRepair + 1) / 2;
return new int[]{durabilityToRepair, xpUsed};
}
/**
* Consolidates spawned items of the same type in a 4-block radius into unified stacks.
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onItemSpawn(ItemSpawnEvent event) {
Item item = event.getEntity();
ItemStack stack = item.getItemStack();
if (stack.getAmount() >= stack.getMaxStackSize() || item.getWorld() == null) {
return;
}
try {
for (Entity e : item.getWorld().getNearbyEntities(item.getLocation(), 4.0, 4.0, 4.0)) {
if (e instanceof Item other && other != item && other.isValid()) {
ItemStack otherStack = other.getItemStack();
if (otherStack.isSimilar(stack)) {
int toTransfer = calculateTransfer(stack.getAmount(), stack.getMaxStackSize(), otherStack.getAmount());
if (toTransfer > 0) {
stack.setAmount(stack.getAmount() + toTransfer);
otherStack.setAmount(otherStack.getAmount() - toTransfer);
if (otherStack.getAmount() <= 0) {
other.remove();
} else {
other.setItemStack(otherStack);
}
item.setItemStack(stack);
if (stack.getAmount() >= stack.getMaxStackSize()) {
break;
}
}
}
}
}
} catch (Exception ignored) {
}
}
/** Pure transfer amount calculator for stack consolidation. */
public static int calculateTransfer(int targetAmount, int targetMax, int sourceAmount) {
if (targetAmount < 0 || targetMax <= 0 || sourceAmount <= 0) {
return 0;
}
int canAdd = targetMax - targetAmount;
if (canAdd <= 0) {
return 0;
}
return Math.min(canAdd, sourceAmount);
}
}
+40
View File
@@ -344,3 +344,43 @@ mortes:
- "BONE | Osso da Sorte | Um ossinho pra você, campeão." - "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora." - "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa." - "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
# --- Âncoras de Chunk (Chunk Loaders) ----------------------------------------
chunkloader:
# Limite máximo de âncoras ativas por jogador por padrão.
# Pode ser alterado por jogador com /chunkloader limite <jogador> <qtd>.
limite-padrao: 1
# Exibe os marcadores de âncoras ativas no BlueMap.
bluemap: true
# Configurações de simulação de entidades na chunk carregada:
simulacao:
# Se true, mantém ativas gaiolas de monstros (spawners de blaze, esqueletos, etc.)
# mesmo sem jogadores por perto.
gaiolas-ativas: true
# Se true, simula o surgimento natural de monstros em plataformas escuras,
# slime chunks e fortalezas do nether. Se false, apenas entidades existentes funcionam.
spawn-monstros: true
# Teto máximo de monstros na chunk antes de pausar o spawn natural (evita acúmulo de lag).
limite-monstros-chunk: 20
# Se true, mantém animais, aldeões, ferro-golems e colheitas sendo processados na chunk.
tick-entidades-passivas: true
# Sistema de combustível e tempo de expiração:
combustivel:
# Se true, âncoras requerem abastecimento com itens e têm tempo de expiração.
# Se false, todas as âncoras colocadas são permanentes por padrão.
requer-tempo: true
# Tempo inicial padrão dado ao criar e colocar uma nova âncora (em horas).
tempo-inicial-horas: 24
# --- Salva-Void (Resgate de Morte no Vazio) -----------------------------------
salvavoid:
# Raio máximo de busca em blocos a partir de onde o jogador caiu para achar
# terreno seguro e colocar o baú com os itens resgatados.
raio-busca: 32
@@ -104,4 +104,128 @@ class ChunkLoaderTest {
assertNotNull(l2); assertNotNull(l2);
assertEquals("uuid-ana", l2.ownerUuid()); assertEquals("uuid-ana", l2.ownerUuid());
} }
@Test
void renamesAndTogglesEnabledState() {
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
assertEquals("#1", loader.simpleName());
assertTrue(loader.enabled());
assertTrue(loaders.rename(loader.id(), "Farm de Ferro"));
ChunkLoader renamed = loaders.byId(loader.id());
assertNotNull(renamed);
assertEquals("Farm de Ferro", renamed.name());
assertEquals("Farm de Ferro (#1)", renamed.displayName());
assertEquals("Farm de Ferro", renamed.simpleName());
assertEquals(renamed, loaders.find("Farm de Ferro"));
assertEquals(renamed, loaders.find("#1"));
assertEquals(renamed, loaders.find("1"));
// Loader 2 named "1" should be prioritized over ID 1 when querying by plain "1"
ChunkLoader loader2 = loaders.add("uuid-marcos", "Marcos", "world", 200, 64, 300, "1", 0L);
assertEquals(loader2, loaders.find("1"));
assertEquals(renamed, loaders.find("#1"));
assertEquals(loader2, loaders.find("#2"));
assertTrue(loaders.setEnabled(loader.id(), false));
ChunkLoader disabled = loaders.byId(loader.id());
assertNotNull(disabled);
assertFalse(disabled.enabled());
loaders.flush();
ChunkLoaders reloaded = new ChunkLoaders(null, file);
ChunkLoader reloadedLoader = reloaded.byId(loader.id());
assertNotNull(reloadedLoader);
assertEquals("Farm de Ferro", reloadedLoader.name());
assertFalse(reloadedLoader.enabled());
}
@Test
void managesExpirationAndFuelTime() {
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200, "Base", 0L);
assertEquals("Permanente", loader.timeLeft());
assertFalse(loader.isExpired());
assertTrue(loaders.addTime(loader.id(), 2 * 3600_000L)); // +2h
ChunkLoader timed = loaders.byId(loader.id());
assertNotNull(timed);
assertTrue(timed.expiresAt() > System.currentTimeMillis());
assertFalse(timed.isExpired());
assertTrue(timed.timeLeft().contains("h"));
ChunkLoader expired = timed.withExpiresAt(System.currentTimeMillis() - 1000L);
assertTrue(expired.isExpired());
assertEquals("Expirado", expired.timeLeft());
assertTrue(loaders.setExpiresAt(loader.id(), 0L));
ChunkLoader permanent = loaders.byId(loader.id());
assertNotNull(permanent);
assertEquals(0L, permanent.expiresAt());
assertEquals("Permanente", permanent.timeLeft());
assertFalse(permanent.isExpired());
}
@Test
void pickEntityTypeReturnsExpectedEntitiesByEnvironment() {
// Nether
assertEquals(org.bukkit.entity.EntityType.ZOMBIFIED_PIGLIN,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 10));
assertEquals(org.bukkit.entity.EntityType.WITHER_SKELETON,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 50));
assertEquals(org.bukkit.entity.EntityType.BLAZE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 75));
assertEquals(org.bukkit.entity.EntityType.MAGMA_CUBE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 90));
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 12, 10));
// End
assertEquals(org.bukkit.entity.EntityType.ENDERMAN,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.THE_END, false, 64, 15, 50));
// Overworld Slime Chunk
assertEquals(org.bukkit.entity.EntityType.SLIME,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, true, 30, 5, 0));
// Overworld Normal Darkness
assertEquals(org.bukkit.entity.EntityType.ZOMBIE,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 10));
assertEquals(org.bukkit.entity.EntityType.SKELETON,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 45));
assertEquals(org.bukkit.entity.EntityType.CREEPER,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 75));
assertEquals(org.bukkit.entity.EntityType.SPIDER,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 90));
assertEquals(org.bukkit.entity.EntityType.WITCH,
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 99));
// Overworld in Light -> Null
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 5, 10));
}
@Test
void createsLoaderWithInitialExpiration() {
long targetExpiry = System.currentTimeMillis() + 86400_000L;
ChunkLoader loader = loaders.add("uuid-1", "Marcos", "world", 100, 64, 200, "Base", targetExpiry);
assertEquals(targetExpiry, loader.expiresAt());
assertFalse(loader.isExpired());
assertTrue(loader.timeLeft().contains("h"));
}
@Test
void recognizesTrapdoorsCorrectly() {
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.OAK_TRAPDOOR));
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.IRON_TRAPDOOR));
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.SPRUCE_TRAPDOOR));
assertFalse(ChunkLoaders.isTrapdoor(org.bukkit.Material.AIR));
assertFalse(ChunkLoaders.isTrapdoor(org.bukkit.Material.STONE));
assertFalse(ChunkLoaders.isTrapdoor(null));
}
@Test
void simulationSafelyNoopsWithNullPlugin() {
loaders.startSimulation();
loaders.tickMobSimulation();
loaders.stopSimulation();
}
} }
@@ -25,10 +25,14 @@ class TitlesTest {
void matchesEarnedTitleByKeyAndByName() { void matchesEarnedTitleByKeyAndByName() {
Achievement pedreiro = Achievement.byKey("pedreiro"); Achievement pedreiro = Achievement.byKey("pedreiro");
Achievement veterano = Achievement.byKey("veterano"); Achievement veterano = Achievement.byKey("veterano");
List<Achievement> earned = List.of(pedreiro, veterano); Achievement cacador = Achievement.byKey("cacador");
List<Achievement> earned = List.of(pedreiro, veterano, cacador);
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned)); assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned)); assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("cacador", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("Caçador", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("Cacador", earned));
} }
@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()));
}
}
@@ -0,0 +1,55 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class XpAndItemMergeTest {
@Test
void handlesZeroOrNegativeAmountsGracefully() {
assertEquals(0, XpAndItemMergeListener.calculateTransfer(0, 0, 0));
assertEquals(0, XpAndItemMergeListener.calculateTransfer(-1, 16, 5));
assertEquals(0, XpAndItemMergeListener.calculateTransfer(16, 16, 5));
assertEquals(0, XpAndItemMergeListener.calculateTransfer(10, 16, 0));
}
@Test
void calculatesTransfersUpToMaxCapacity() {
// Target has 10, max is 16, source has 10 -> can transfer 6
assertEquals(6, XpAndItemMergeListener.calculateTransfer(10, 16, 10));
// Target has 5, max is 64, source has 20 -> can transfer all 20
assertEquals(20, XpAndItemMergeListener.calculateTransfer(5, 64, 20));
// Target has 63, max is 64, source has 10 -> can transfer 1
assertEquals(1, XpAndItemMergeListener.calculateTransfer(63, 64, 10));
}
@Test
void calculatesMendingRepairCorrectly() {
// Zero damage or zero XP -> 0 repair
int[] r1 = XpAndItemMergeListener.calculateMendingRepair(0, 50);
assertEquals(0, r1[0]);
assertEquals(0, r1[1]);
int[] r2 = XpAndItemMergeListener.calculateMendingRepair(50, 0);
assertEquals(0, r2[0]);
assertEquals(0, r2[1]);
// 10 damage, 100 XP -> repairs 10 durability, uses 5 XP
int[] r3 = XpAndItemMergeListener.calculateMendingRepair(10, 100);
assertEquals(10, r3[0]);
assertEquals(5, r3[1]);
// 5 damage, 10 XP -> repairs 5 durability, uses 3 XP
int[] r4 = XpAndItemMergeListener.calculateMendingRepair(5, 10);
assertEquals(5, r4[0]);
assertEquals(3, r4[1]);
// 10 damage, 2 XP -> repairs 4 durability (2 * 2), uses 2 XP
int[] r5 = XpAndItemMergeListener.calculateMendingRepair(10, 2);
assertEquals(4, r5[0]);
assertEquals(2, r5[1]);
}
}