feat(chunkloader): add fuel charging, duration timers, and /chunkloader tempo

This commit is contained in:
Marcos Paulo
2026-08-20 13:19:57 -03:00
parent ce3b1ebbd9
commit 1ff830537e
5 changed files with 179 additions and 13 deletions
@@ -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 <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 && 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 <id|nome>");
@@ -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 <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")) {
@@ -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<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "remover", "receita", "limite"));
List<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite"));
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
options.addAll(List.of("admin", "dar"));
}
@@ -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<String> identifiers = new ArrayList<>();
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
@@ -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);
}
}
@@ -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());
@@ -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());
@@ -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();