From 1ff830537e0a71d2095169b3856a971174c2487e Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Thu, 20 Aug 2026 13:19:57 -0300 Subject: [PATCH] feat(chunkloader): add fuel charging, duration timers, and /chunkloader tempo --- .../canalhandia/CanalhandiaCommand.java | 53 ++++++++++++++++--- .../marcospaulo/canalhandia/ChunkLoader.java | 37 +++++++++++-- .../canalhandia/ChunkLoaderListener.java | 42 ++++++++++++++- .../marcospaulo/canalhandia/ChunkLoaders.java | 42 +++++++++++++-- .../canalhandia/ChunkLoaderTest.java | 18 +++++++ 5 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index af32693..2ac3f79 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1826,6 +1826,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest); case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true); case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false); + case "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest); case "admin" -> chunkloaderAdmin(sender, rest); case "dar", "give" -> { if (admin(sender)) { @@ -1863,23 +1864,24 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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(statusText + " ", statusColor)) + .append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD)); if (!bedrock) { if (loader.enabled()) { - line = line.append(Component.text("[Pausar] ", NamedTextColor.YELLOW) + 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) + 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( Component.text("Clique para recolher " + loader.displayName(), NamedTextColor.GRAY))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( @@ -1916,6 +1918,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) .append(Component.text(here.displayName() + " (" + status + ")", statusCol)) .append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE))); + sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY) + .append(Component.text(here.timeLeft(), NamedTextColor.GOLD))); } else { sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY) .append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY))); @@ -1976,6 +1980,40 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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 "); + 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 && isAdmin) { + plugin.chunkLoaders().addTime(loader.id(), -loader.expiresAt()); // permanent + Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE."); + } else { + long millis = (long) (hours * 3600_000L); + plugin.chunkLoaders().addTime(loader.id(), millis); + ChunkLoader updated = plugin.chunkLoaders().byId(loader.id()); + String time = (updated != null) ? updated.timeLeft() : "Permanente"; + Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time); + } + plugin.blueMap().syncChunkLoaders(); + } catch (NumberFormatException e) { + Msg.error(sender, "Horas inválidas: " + args[1]); + } + } + private void chunkloaderRemove(CommandSender sender, String[] args) { if (args.length < 1) { Msg.error(sender, "Uso: /chunkloader remover "); @@ -2187,6 +2225,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY))); sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA) .append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY))); + sender.sendMessage(Component.text(" /chunkloader tempo ", 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")) { @@ -2358,7 +2398,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) { if (args.length == 1) { - List options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "remover", "receita", "limite")); + List options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite")); if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) { options.addAll(List.of("admin", "dar")); } @@ -2367,7 +2407,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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("pausar") || args[0].equalsIgnoreCase("tempo") + || args[0].equalsIgnoreCase("abastecer"))) { if (sender instanceof Player player) { List identifiers = new ArrayList<>(); for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java index d80ef1f..ba8f3a1 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoader.java @@ -10,6 +10,7 @@ record ChunkLoader( long id, String name, boolean enabled, + long expiresAt, String ownerUuid, String ownerName, String world, @@ -21,7 +22,11 @@ record ChunkLoader( 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, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, 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() { @@ -44,11 +49,37 @@ record ChunkLoader( 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, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + 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, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt); + 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); } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index a4bc90b..b729f2e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -188,6 +188,26 @@ final class ChunkLoaderListener implements Listener { 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)) { boolean newState = !loader.enabled(); plugin.chunkLoaders().setEnabled(loader.id(), newState); @@ -212,10 +232,30 @@ final class ChunkLoaderListener implements Listener { } String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA"; - Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr + ". (Shift + Clique Direito para alternar)"); + Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr + + " [Tempo: " + loader.timeLeft() + "]. (Shift + Clique Direito para pausar/ativar | Clique com combustível para abastecer)"); } } + private static long fuelDuration(Material type) { + if (type == Material.REDSTONE || type == Material.GLOWSTONE_DUST) { + return 30 * 60_000L; // 30 min + } + if (type == Material.GLOWSTONE || type == Material.AMETHYST_SHARD) { + return 2 * 3600_000L; // 2h + } + if (type == Material.ENDER_PEARL || type == Material.BLAZE_POWDER || type == Material.BLAZE_ROD) { + return 4 * 3600_000L; // 4h + } + if (type == Material.ENDER_EYE || type == Material.DIAMOND || type == Material.EMERALD) { + return 12 * 3600_000L; // 12h + } + if (type == Material.NETHER_STAR || type == Material.NETHERITE_INGOT || type == Material.END_CRYSTAL) { + return 7 * 24 * 3600_000L; // 7 dias + } + return 0L; + } + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onBlockExplode(BlockExplodeEvent event) { protectExplosions(event.blockList().iterator()); diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 52f6cb0..4d5b380 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -71,6 +71,7 @@ final class ChunkLoaders { long id = Long.parseLong(key); String name = root.getString(key + ".name", ""); boolean enabled = root.getBoolean(key + ".enabled", true); + long expiresAt = root.getLong(key + ".expires_at", 0L); String ownerUuid = root.getString(key + ".owner_uuid", ""); String ownerName = root.getString(key + ".owner_name", "?"); String world = root.getString(key + ".world", "world"); @@ -81,7 +82,7 @@ final class ChunkLoaders { int chunkZ = root.getInt(key + ".chunk_z", z >> 4); long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis()); - loaders.add(new ChunkLoader(id, name, enabled, 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) { maxId = id; } @@ -96,14 +97,18 @@ final class ChunkLoaders { * Registers a new chunk loader and activates the ticket in the world. */ ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) { - return add(ownerUuid, ownerName, world, x, y, z, ""); + return add(ownerUuid, ownerName, world, x, y, z, "", 0L); } ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) { + return add(ownerUuid, ownerName, world, x, y, z, name, 0L); + } + + ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) { int chunkX = x >> 4; int chunkZ = z >> 4; long id = nextId.getAndIncrement(); - ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, 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) { loaders.add(loader); @@ -113,6 +118,32 @@ final class ChunkLoaders { 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; + } + /** * Renames an existing chunk loader. */ @@ -337,6 +368,10 @@ final class ChunkLoaders { } for (ChunkLoader loader : currentLoaders) { + if (loader.isExpired() && loader.enabled()) { + setEnabled(loader.id(), false); + continue; + } if (!loader.enabled()) { continue; } @@ -480,6 +515,7 @@ final class ChunkLoaders { String key = "loaders." + l.id(); yaml.set(key + ".name", l.name()); yaml.set(key + ".enabled", l.enabled()); + yaml.set(key + ".expires_at", l.expiresAt()); yaml.set(key + ".owner_uuid", l.ownerUuid()); yaml.set(key + ".owner_name", l.ownerName()); yaml.set(key + ".world", l.world()); diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index e5d37ea..3b812c8 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -135,6 +135,24 @@ class ChunkLoaderTest { 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()); + } + @Test void simulationSafelyNoopsWithNullPlugin() { loaders.startSimulation();