feat(chunkloader): support custom names, anvil placement, and pause/resume

This commit is contained in:
Marcos Paulo
2026-08-20 13:13:06 -03:00
parent 5a6bb9a13d
commit ce3b1ebbd9
6 changed files with 297 additions and 35 deletions
@@ -127,9 +127,13 @@ final class BlueMapBridge {
if (!mapMatchesWorld(map, loader.world())) {
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()
.label("Âncora #" + loader.id() + " (" + loader.ownerName() + ")")
.detail("<b>Âncora de Chunk #" + loader.id() + "</b><br>Dono: "
.label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
.detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
+ statusHtml + "<br>Dono: "
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
.position(loader.x(), loader.y(), loader.z())
.build();
@@ -1823,6 +1823,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
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 "admin" -> chunkloaderAdmin(sender, rest);
case "dar", "give" -> {
if (admin(sender)) {
@@ -1855,13 +1858,30 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
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));
String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]";
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));
if (!bedrock) {
line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
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)
.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(
"/chunkloader remover " + loader.id())));
}
@@ -1891,9 +1911,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.append(Component.text(" em " + world, NamedTextColor.WHITE)));
if (here != null) {
String status = here.enabled() ? "Ativa" : "Pausada";
NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
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)));
.append(Component.text(here.displayName() + " (" + status + ")", statusCol))
.append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE)));
} else {
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
@@ -1903,24 +1925,70 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.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;
}
plugin.chunkLoaders().setEnabled(loader.id(), enabled);
plugin.blueMap().syncChunkLoaders();
Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA."));
}
private void chunkloaderRemove(CommandSender sender, String[] args) {
if (args.length < 1) {
Msg.error(sender, "Uso: /chunkloader remover <id>");
Msg.error(sender, "Uso: /chunkloader remover <id|nome>");
return;
}
long id = parseLong(args[0]);
if (id <= 0) {
Msg.error(sender, "ID inválido.");
return;
}
ChunkLoader loader = plugin.chunkLoaders().byId(id);
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
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;
}
long id = loader.id();
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
@@ -1948,7 +2016,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
for (ItemStack drop : overflow.values()) {
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 {
try {
org.bukkit.World w = Bukkit.getWorld(loader.world());
@@ -1958,7 +2026,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
} 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() + ".");
}
}
@@ -2108,8 +2176,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
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(" — Lista suas âncoras, nomes, status e coordenadas", NamedTextColor.GRAY)));
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)));
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
@@ -2284,19 +2358,25 @@ 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", "remover", "receita", "limite"));
List<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "remover", "receita", "limite"));
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 (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"))) {
if (sender instanceof Player player) {
List<String> ids = new ArrayList<>();
List<String> identifiers = new ArrayList<>();
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")) {
@@ -8,6 +8,8 @@ package dev.marcospaulo.canalhandia;
*/
record ChunkLoader(
long id,
String name,
boolean enabled,
String ownerUuid,
String ownerName,
String world,
@@ -18,6 +20,10 @@ record ChunkLoader(
int chunkZ,
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);
}
String blockCoords() {
return x + ", " + y + ", " + z;
}
@@ -29,4 +35,20 @@ record ChunkLoader(
String place() {
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;
}
ChunkLoader withName(String newName) {
return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, 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);
}
}
@@ -78,13 +78,27 @@ final class ChunkLoaderListener implements Listener {
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(
player.getUniqueId().toString(),
player.getName(),
world,
loc.getBlockX(),
loc.getBlockY(),
loc.getBlockZ()
loc.getBlockZ(),
customName
);
try {
@@ -96,7 +110,7 @@ final class ChunkLoaderListener implements Listener {
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 ("
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente ("
+ (current + 1) + "/" + limitStr + ").");
}
@@ -129,13 +143,13 @@ final class ChunkLoaderListener implements Listener {
event.setExpToDrop(0);
if (player.getGameMode() == GameMode.CREATIVE) {
Msg.ok(player, "Âncora de Chunk #" + loader.id() + " desativada (Modo Criativo).");
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.id() + " desativada e recolhida para o seu inventário.");
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada e recolhida para o seu inventário.");
}
try {
@@ -169,10 +183,36 @@ final class ChunkLoaderListener implements Listener {
block.getZ()
);
if (loader != null) {
// Prevent respawn anchor exploding or depleting charges when used as chunk loader
event.setCancelled(true);
Player player = event.getPlayer();
Msg.ok(player, "Âncora de Chunk #" + loader.id() + " (" + loader.ownerName() + ") está ativa.");
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
if (player.isSneaking() && (isOwner || isAdmin)) {
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 + ". (Shift + Clique Direito para alternar)");
}
}
@@ -69,6 +69,8 @@ final class ChunkLoaders {
for (String key : root.getKeys(false)) {
try {
long id = Long.parseLong(key);
String name = root.getString(key + ".name", "");
boolean enabled = root.getBoolean(key + ".enabled", true);
String ownerUuid = root.getString(key + ".owner_uuid", "");
String ownerName = root.getString(key + ".owner_name", "?");
String world = root.getString(key + ".world", "world");
@@ -79,7 +81,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, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
loaders.add(new ChunkLoader(id, name, enabled, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
if (id > maxId) {
maxId = id;
}
@@ -94,10 +96,14 @@ 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, "");
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) {
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());
ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
synchronized (loaders) {
loaders.add(loader);
@@ -107,6 +113,79 @@ final class ChunkLoaders {
return loader;
}
/**
* 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 or without '#') or custom name.
*/
ChunkLoader find(String query) {
if (query == null || query.isBlank()) {
return null;
}
String trimmed = query.trim();
try {
long id = Long.parseLong(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed);
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;
}
}
}
return null;
}
/**
* Removes a chunk loader by its unique ID and releases the chunk ticket.
*/
@@ -219,7 +298,9 @@ final class ChunkLoaders {
void loadAllTickets() {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
addTicket(loader);
if (loader.enabled()) {
addTicket(loader);
}
}
}
}
@@ -256,6 +337,9 @@ final class ChunkLoaders {
}
for (ChunkLoader loader : currentLoaders) {
if (!loader.enabled()) {
continue;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
@@ -394,6 +478,8 @@ final class ChunkLoaders {
synchronized (loaders) {
for (ChunkLoader l : loaders) {
String key = "loaders." + l.id();
yaml.set(key + ".name", l.name());
yaml.set(key + ".enabled", l.enabled());
yaml.set(key + ".owner_uuid", l.ownerUuid());
yaml.set(key + ".owner_name", l.ownerName());
yaml.set(key + ".world", l.world());
@@ -105,6 +105,36 @@ class ChunkLoaderTest {
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"));
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 simulationSafelyNoopsWithNullPlugin() {
loaders.startSimulation();