diff --git a/README.md b/README.md index 8965ef3..ae3e745 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This repository is TF-Minecraft's fork of CoreProtect, developed by [PlayPro](ht - **Detailed activity searches** — narrow results by player, time, action, location, and affected materials. - **Targeted recovery** — roll back selected changes and restore previously rolled-back activity. - **Recovery previews** — review the effect of a rollback or restore before applying it. -- **Broad world coverage** — logging includes player block changes, container contents, explosions, fire, liquids, many entity interactions, and where each online player is once a minute. +- **Broad world coverage** — logging includes player block changes, container contents, explosions, fire, liquids, many entity interactions, skills players use and where they teleport them, and where each online player is once a minute. - **Staff investigation tools** — paginated lookups, inventory history, and WorldEdit selection support help investigate incidents of different sizes. CoreProtect supports both investigation and recovery; the recorded history available to staff depends on the server's logging settings. diff --git a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index 789647d..5b1e632 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java @@ -29,6 +29,7 @@ import net.tfminecraft.coreprotect.language.Selector; import net.tfminecraft.coreprotect.model.action.EntityActionFilter; import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.model.action.SkillLog; import net.tfminecraft.coreprotect.model.lookup.LookupOutputMode; import net.tfminecraft.coreprotect.model.lookup.LookupRollbackState; import net.tfminecraft.coreprotect.utility.Chat; @@ -52,6 +53,9 @@ public static void runCommand(CommandSender player, Command command, boolean per ActionParser.ParseResult actionResult = CommandParser.parseActions(args, true); List argAction = actionResult.getActions(); EntityActionFilter argEntityActionFilter = actionResult.getEntityActionFilter(); + if (actionResult.isSkillLookup()) { + argFilters = SkillLog.lookupFilters(argFilters); + } List argBlocks = CommandParser.parseRestricted(player, args, argAction); Map argExclude = CommandParser.parseExcluded(player, args, argAction); List argExcludeUsers = CommandParser.parseExcludedUsers(player, args); @@ -194,6 +198,11 @@ else if (resultc > 1) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_ACTION)); return; } + // The skill prefix filter would also apply to the other action's rows, such as chat. + if (actionResult.isSkillLookup() && argAction.stream().anyMatch(action -> action != LookupActions.COMMAND)) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INCOMPATIBLE_ACTION, "a:skill")); + return; + } if (rollbackState != LookupRollbackState.ANY && (argAction.contains(LookupActions.CHAT) || argAction.contains(LookupActions.COMMAND) || argAction.contains(LookupActions.SESSION) || argAction.contains(LookupActions.USERNAME) || argAction.contains(LookupActions.SIGN))) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INCOMPATIBLE_ACTION, rollbackState == LookupRollbackState.ROLLED_BACK ? "#rolledback" : "#restored")); return; diff --git a/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java b/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java index bbe39a6..d1d9f63 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java @@ -25,7 +25,7 @@ public class TabHandler implements TabCompleter { // private static String[] COMMANDS = new String[] { "help", "inspect", "rollback", "restore", "lookup", "purge", "reload", "status", "near", "undo" }; // max 10! private static final String[] HELP = new String[] { "inspect", "rollback", "restore", "lookup", "purge", "teleport", "status", "params", "users", "time", "radius", "action", "include", "exclude", "filter" }; private static final String[] PARAMS = new String[] { "user:", "time:", "radius:", "action:", "include:", "exclude:", "filter:", "#container" }; - private static final String[] ACTIONS = new String[] { "block", "+block", "-block", "click", "kill", "spawn", "+container", "-container", "container", "chat", "command", "+inventory", "-inventory", "inventory", "item", "+item", "-item", "sign", "session", "+session", "-session", "ping", "username" }; + private static final String[] ACTIONS = new String[] { "block", "+block", "-block", "click", "kill", "spawn", "+container", "-container", "container", "chat", "command", "skill", "+inventory", "-inventory", "inventory", "item", "+item", "-item", "sign", "session", "+session", "-session", "ping", "username" }; private static final String[] NUMBERS = new String[] { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" }; private static final String[] TIMES = new String[] { "w", "d", "h", "m", "s" }; private static ArrayList materials = null; diff --git a/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java b/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java index a8ccc3b..b1ef825 100644 --- a/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java @@ -48,6 +48,8 @@ public static ParseResult parseActions(String[] inputArguments, boolean allowMul String[] argumentArray = inputArguments.clone(); List result = new ArrayList<>(); EntityActionFilter entityActionFilter = EntityActionFilter.DEFAULT; + boolean skillLookup = false; + boolean commandLookup = false; int count = 0; int next = 0; for (String argument : argumentArray) { @@ -65,6 +67,8 @@ public static ParseResult parseActions(String[] inputArguments, boolean allowMul else if (next == 1 || argument.startsWith("a:") || argument.startsWith("action:")) { result.clear(); entityActionFilter = EntityActionFilter.NONE; + skillLookup = false; + commandLookup = false; argument = argument.replaceAll("action:", ""); argument = argument.replaceAll("a:", ""); if (argument.startsWith("#")) { @@ -76,6 +80,8 @@ else if (next == 1 || argument.startsWith("a:") || argument.startsWith("action:" ParseResult parsedAction = parseActions(new String[] { "lookup", "a:" + action }, false); result.addAll(parsedAction.getActions()); entityActionFilter = entityActionFilter.merge(parsedAction.getEntityActionFilter()); + skillLookup |= parsedAction.isSkillLookup(); + commandLookup |= parsedAction.getActions().contains(LookupActions.COMMAND) && !parsedAction.isSkillLookup(); } } next = 0; @@ -120,6 +126,12 @@ else if (argument.equals("chat") || argument.equals("chats") || argument.equals( } else if (argument.equals("command") || argument.equals("commands")) { result.add(LookupActions.COMMAND); + commandLookup = true; + } + else if (argument.equals("skill") || argument.equals("skills") || argument.equals("ability") || argument.equals("abilities")) { + // Skill casts are command rows; see SkillLog. + result.add(LookupActions.COMMAND); + skillLookup = true; } else if (argument.equals("logins") || argument.equals("login") || argument.equals("+session") || argument.equals("+sessions") || argument.equals("session+") || argument.equals("+connection") || argument.equals("connection+")) { result.add(LookupActions.SESSION); @@ -180,17 +192,27 @@ else if (argument.equals("+item") || argument.equals("item+") || argument.equals } count++; } - return new ParseResult(result, entityActionFilter); + return new ParseResult(result, entityActionFilter, skillLookup && !commandLookup); } public static final class ParseResult { private final List actions; private final EntityActionFilter entityActionFilter; + private final boolean skillLookup; - private ParseResult(List actions, EntityActionFilter entityActionFilter) { + private ParseResult(List actions, EntityActionFilter entityActionFilter, boolean skillLookup) { this.actions = actions; this.entityActionFilter = entityActionFilter; + this.skillLookup = skillLookup; + } + + /** + * Whether command rows should be limited to skill casts. Asking for commands as well + * keeps every command row. + */ + public boolean isSkillLookup() { + return skillLookup; } public List getActions() { diff --git a/src/main/java/net/tfminecraft/coreprotect/config/Config.java b/src/main/java/net/tfminecraft/coreprotect/config/Config.java index 8e3dcda..d87a05b 100644 --- a/src/main/java/net/tfminecraft/coreprotect/config/Config.java +++ b/src/main/java/net/tfminecraft/coreprotect/config/Config.java @@ -100,6 +100,7 @@ public class Config extends Language { public boolean PLAYER_INTERACTIONS; public boolean PLAYER_MESSAGES; public boolean PLAYER_COMMANDS; + public boolean PLAYER_SKILLS; public boolean PLAYER_SESSIONS; public boolean UNKNOWN_LOGGING; public boolean USERNAME_CHANGES; @@ -172,6 +173,7 @@ public class Config extends Language { DEFAULT_VALUES.put("player-interactions", "true"); DEFAULT_VALUES.put("player-messages", "true"); DEFAULT_VALUES.put("player-commands", "true"); + DEFAULT_VALUES.put("player-skills", "true"); DEFAULT_VALUES.put("player-sessions", "true"); DEFAULT_VALUES.put("player-pings", "60"); DEFAULT_VALUES.put("username-changes", "true"); @@ -223,6 +225,7 @@ public class Config extends Language { HEADERS.put("player-interactions", new String[] { "# Track player interactions, such as when a player opens a door, presses", "# a button, or opens a chest. Player interactions can't be rolled back." }); HEADERS.put("player-messages", new String[] { "# Logs messages that players send in the chat." }); HEADERS.put("player-commands", new String[] { "# Logs all commands used by players." }); + HEADERS.put("player-skills", new String[] { "# Logs MythicLib skills that players activate, and where skills teleport them.", "# Search them with \"/co lookup a:skill\"." }); HEADERS.put("player-sessions", new String[] { "# Logs the logins and logouts of players." }); HEADERS.put("player-pings", new String[] { "# Logs the position of each online player at this interval, in seconds.", "# Search them with \"/co lookup a:ping\". Set to \"0\" to disable." }); HEADERS.put("username-changes", new String[] { "# Logs when a player changes their Minecraft username." }); @@ -303,6 +306,7 @@ private void readValues() { this.PLAYER_INTERACTIONS = this.getBoolean("player-interactions"); this.PLAYER_MESSAGES = this.getBoolean("player-messages"); this.PLAYER_COMMANDS = this.getBoolean("player-commands"); + this.PLAYER_SKILLS = this.getBoolean("player-skills"); this.PLAYER_SESSIONS = this.getBoolean("player-sessions"); this.PLAYER_PINGS = this.getInt("player-pings"); this.USERNAME_CHANGES = this.getBoolean("username-changes"); diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java index a8df118..7bbe87d 100755 --- a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java @@ -505,6 +505,10 @@ protected static void queuePlayerCommand(Player player, String message, long tim queueStandardData(new Object[] { null, Process.PLAYER_COMMAND, null, 0, null, 0, 0, null }, new String[] { player.getName(), null }, new Object[] { timestamp, player.getLocation().clone() }, false, Consumer.consumerStrings, message, Consumer.reserveConsumer()); } + protected static void queuePlayerSkill(Player player, String message, long timestamp, Location location) { + queueStandardData(new Object[] { null, Process.PLAYER_COMMAND, null, 0, null, 0, 0, null }, new String[] { player.getName(), null }, new Object[] { timestamp, location.clone() }, false, Consumer.consumerStrings, message, Consumer.reserveConsumer()); + } + protected static void queuePlayerInteraction(String user, BlockState block, Material type) { queueStandardData(new Object[] { null, Process.PLAYER_INTERACTION, type, 0, null, 0, 0, null }, new String[] { user, null }, block, false, Consumer.reserveConsumer()); } diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java index 341b7b8..586656c 100644 --- a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java +++ b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java @@ -62,6 +62,7 @@ import net.tfminecraft.coreprotect.listener.player.PlayerTakeLecternBookListener; import net.tfminecraft.coreprotect.listener.player.ProjectileLaunchListener; import net.tfminecraft.coreprotect.listener.player.SignChangeListener; +import net.tfminecraft.coreprotect.listener.player.SkillCastListener; import net.tfminecraft.coreprotect.listener.player.SpawnEggUseListener; import net.tfminecraft.coreprotect.listener.world.ChunkPopulateListener; import net.tfminecraft.coreprotect.listener.world.LeavesDecayListener; @@ -203,6 +204,7 @@ public ListenerHandler(CoreProtect plugin) { pluginManager.registerEvents(new SpawnEggUseListener(), plugin); pluginManager.registerEvents(new PlayerTakeLecternBookListener(), plugin); pluginManager.registerEvents(new ProjectileLaunchListener(), plugin); + SkillCastListener.register(plugin); // World Listeners pluginManager.registerEvents(new ChunkPopulateListener(), plugin); diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/player/SkillCastListener.java b/src/main/java/net/tfminecraft/coreprotect/listener/player/SkillCastListener.java new file mode 100644 index 0000000..ec429b9 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/listener/player/SkillCastListener.java @@ -0,0 +1,198 @@ +package net.tfminecraft.coreprotect.listener.player; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; + +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.consumer.Queue; +import net.tfminecraft.coreprotect.model.action.SkillLog; +import net.tfminecraft.coreprotect.utility.ErrorReporter; + +/** + * Logs MythicLib skills that players activate, covering MMOCore class skills and MMOItems + * abilities, plus any teleport a skill causes shortly afterwards. MythicLib is read through + * reflection so CoreProtect builds and runs without it. + */ +public final class SkillCastListener extends Queue implements Listener { + + static final String MYTHICLIB = "MythicLib"; + static final String CAST_EVENT = "io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent"; + + // Long enough for delayed and projectile teleport skills, short enough not to credit later teleports. + static final long TELEPORT_WINDOW_MILLIS = 5000L; + static final int MAX_TELEPORTS_PER_CAST = 5; + + private final SkillReader reader; + private final Map recentCasts = new ConcurrentHashMap<>(); + private volatile boolean readFailed; + + SkillCastListener(SkillReader reader) { + this.reader = reader; + } + + public static void register(Plugin plugin) { + PluginManager pluginManager = plugin.getServer().getPluginManager(); + Plugin mythicLib = pluginManager.getPlugin(MYTHICLIB); + if (mythicLib == null || !mythicLib.isEnabled()) { + return; + } + + try { + Class eventClass = Class.forName(CAST_EVENT, true, mythicLib.getClass().getClassLoader()).asSubclass(Event.class); + SkillCastListener listener = new SkillCastListener(SkillReader.of(eventClass)); + pluginManager.registerEvent(eventClass, listener, EventPriority.MONITOR, (ignored, event) -> { + if (eventClass.isInstance(event)) { + listener.onSkillCast(event); + } + }, plugin, true); + pluginManager.registerEvents(listener, plugin); + } + catch (ReflectiveOperationException | ClassCastException | LinkageError e) { + plugin.getLogger().warning("Skill logging is unavailable for this MythicLib version: " + e); + } + } + + void onSkillCast(Event event) { + if (readFailed || !(event instanceof PlayerEvent)) { + return; + } + + Player player = ((PlayerEvent) event).getPlayer(); + if (!Config.getConfig(player.getWorld()).PLAYER_SKILLS) { + return; + } + + SkillCast cast; + try { + cast = reader.read(event); + } + catch (ReflectiveOperationException | RuntimeException e) { + readFailed = true; + ErrorReporter.report(e); + return; + } + // Automatic casts leave the window alone, so a frequent timer ability cannot cut short + // the teleport tracking of a skill the player just used. + if (cast.automatic()) { + return; + } + + String message = SkillLog.cast(cast.name(), cast.id()); + long now = System.currentTimeMillis(); + Queue.queuePlayerSkill(player, message, now / 1000L, player.getLocation()); + recentCasts.put(player.getUniqueId(), new RecentCast(message, now + TELEPORT_WINDOW_MILLIS)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerTeleport(PlayerTeleportEvent event) { + TeleportCause cause = event.getCause(); + if (cause != TeleportCause.PLUGIN && cause != TeleportCause.UNKNOWN) { + return; + } + + Player player = event.getPlayer(); + RecentCast cast = recentCasts.get(player.getUniqueId()); + if (cast == null) { + return; + } + + long now = System.currentTimeMillis(); + if (now > cast.expiresAt) { + recentCasts.remove(player.getUniqueId(), cast); + return; + } + + Location from = event.getFrom(); + Location to = event.getTo(); + if (to == null || from.getWorld() == null || to.getWorld() == null || SkillLog.sameBlock(from, to)) { + return; + } + if (!Config.getConfig(to.getWorld()).PLAYER_SKILLS || !cast.recordTeleport()) { + return; + } + + Queue.queuePlayerSkill(player, SkillLog.teleport(cast.message, from, to), now / 1000L, to); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) { + recentCasts.remove(event.getPlayer().getUniqueId()); + } + + record SkillCast(String id, String name, boolean automatic) { + } + + private static final class RecentCast { + private final String message; + private final long expiresAt; + private final AtomicInteger teleports = new AtomicInteger(); + + private RecentCast(String message, long expiresAt) { + this.message = message; + this.expiresAt = expiresAt; + } + + private boolean recordTeleport() { + return teleports.incrementAndGet() <= MAX_TELEPORTS_PER_CAST; + } + } + + /** + * Reads the skill from a MythicLib PlayerCastSkillEvent. Silent triggers, such as timers, + * attacks and damage, fire without player input; MythicLib marks every click trigger as + * passive, so silence is what separates automatic casts from activated ones. MMOCore and + * MMOItems skills report their configured trigger; plain API casts report the silent API + * trigger. + */ + static final class SkillReader { + private final Method getCast; + private final Method getHandler; + private final Method getTrigger; + private final Method getId; + private final Method getName; + private final Method isSilent; + + private SkillReader(Method getCast, Method getHandler, Method getTrigger, Method getId, Method getName, Method isSilent) { + this.getCast = getCast; + this.getHandler = getHandler; + this.getTrigger = getTrigger; + this.getId = getId; + this.getName = getName; + this.isSilent = isSilent; + } + + static SkillReader of(Class eventClass) throws ReflectiveOperationException { + Method getCast = eventClass.getMethod("getCast"); + Method getHandler = getCast.getReturnType().getMethod("getHandler"); + Method getTrigger = getCast.getReturnType().getMethod("getTrigger"); + Method getId = getHandler.getReturnType().getMethod("getId"); + Method getName = getHandler.getReturnType().getMethod("getName"); + Method isSilent = getTrigger.getReturnType().getMethod("isSilent"); + return new SkillReader(getCast, getHandler, getTrigger, getId, getName, isSilent); + } + + SkillCast read(Event event) throws ReflectiveOperationException { + Object skill = getCast.invoke(event); + Object handler = getHandler.invoke(skill); + Object trigger = getTrigger.invoke(skill); + boolean automatic = trigger != null && Boolean.TRUE.equals(isSilent.invoke(trigger)); + return new SkillCast((String) getId.invoke(handler), (String) getName.invoke(handler), automatic); + } + } +} diff --git a/src/main/java/net/tfminecraft/coreprotect/model/action/SkillLog.java b/src/main/java/net/tfminecraft/coreprotect/model/action/SkillLog.java new file mode 100644 index 0000000..5fe3dd3 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/model/action/SkillLog.java @@ -0,0 +1,74 @@ +package net.tfminecraft.coreprotect.model.action; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.bukkit.Location; +import org.bukkit.World; + +/** + * A skill cast, or a teleport caused by one. It is stored as a command row whose message starts + * with {@link #PREFIX}. Commands always start with "/", so a skill row never matches a command + * prefix filter, and "a:skill" lookups are command lookups filtered to this prefix. + */ +public final class SkillLog { + + public static final String PREFIX = "[skill]"; + + // Legacy "&" and section-sign colour codes, including "&#rrggbb" hex colours. + private static final Pattern COLOUR_CODES = Pattern.compile("(?i)[&\u00A7](#[0-9a-f]{6}|[0-9a-fk-orx])"); + + private SkillLog() { + throw new IllegalStateException("Model class"); + } + + public static String cast(String name, String id) { + String cleanName = clean(name); + String cleanId = clean(id); + if (cleanName.isEmpty()) { + return PREFIX + " " + cleanId; + } + if (cleanId.isEmpty() || cleanId.equalsIgnoreCase(cleanName)) { + return PREFIX + " " + cleanName; + } + return PREFIX + " " + cleanName + " (" + cleanId + ")"; + } + + public static String teleport(String cast, Location from, Location to) { + boolean sameWorld = sameWorld(from.getWorld(), to.getWorld()); + return cast + " teleported from " + position(from, !sameWorld) + " to " + position(to, !sameWorld); + } + + public static boolean sameBlock(Location from, Location to) { + return sameWorld(from.getWorld(), to.getWorld()) && from.getBlockX() == to.getBlockX() && from.getBlockY() == to.getBlockY() && from.getBlockZ() == to.getBlockZ(); + } + + // Prefix filters match the start of the message, so each user filter is narrowed to skill rows. + public static List lookupFilters(List filters) { + if (filters == null || filters.isEmpty()) { + return List.of(PREFIX); + } + return filters.stream().map(filter -> filter.toLowerCase(Locale.ROOT).startsWith(PREFIX) ? filter : PREFIX + " " + filter).collect(Collectors.toList()); + } + + private static boolean sameWorld(World from, World to) { + return from == null ? to == null : to != null && from.getUID().equals(to.getUID()); + } + + private static String position(Location location, boolean includeWorld) { + String coordinates = location.getBlockX() + " " + location.getBlockY() + " " + location.getBlockZ(); + if (!includeWorld || location.getWorld() == null) { + return coordinates; + } + return location.getWorld().getName() + " " + coordinates; + } + + private static String clean(String value) { + if (value == null) { + return ""; + } + return COLOUR_CODES.matcher(value).replaceAll("").trim(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6e3f92f..4f1d98f 100755 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ api-version: '1.21.10' folia-supported: true website: http://coreprotect.net author: Intelli -softdepend: [WorldEdit] +softdepend: [WorldEdit, MythicLib] description: > Provides block protection for your server. libraries: