feat(chunkloader): add native chunk loader module with LuckPerms limits and BlueMap support

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