From 05bdd4f6e9ca822679f968e5ed7f0af33f8abf60 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:21:34 +0000 Subject: [PATCH 1/3] feat: log MythicLib skill casts and skill teleports Log each MythicLib skill a player activates, which covers MMOCore class skills and MMOItems abilities, as a command row at the caster's position with the message "[skill] ()". A teleport with cause PLUGIN or UNKNOWN within 5 seconds of the cast adds a second row at the destination naming the skill and its from and to coordinates, capped at five per cast. Skills on silent triggers (timers, attacks, damage, API casts) fire without player input, so they are skipped and do not end the teleport window. "/co lookup a:skill" is a command lookup restricted to the "[skill]" prefix; f: narrows it to one skill. Commands always start with "/", so skill rows never match a command filter. The new player-skills option turns logging off. MythicLib is read through reflection and added as a softdepend, so CoreProtect still builds and runs without it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../coreprotect/command/LookupCommand.java | 4 + .../coreprotect/command/TabHandler.java | 2 +- .../command/parser/ActionParser.java | 26 ++- .../coreprotect/config/Config.java | 4 + .../coreprotect/consumer/Queue.java | 4 + .../coreprotect/listener/ListenerHandler.java | 2 + .../listener/player/SkillCastListener.java | 198 ++++++++++++++++++ .../coreprotect/model/action/SkillLog.java | 74 +++++++ src/main/resources/plugin.yml | 2 +- 9 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/tfminecraft/coreprotect/listener/player/SkillCastListener.java create mode 100644 src/main/java/net/tfminecraft/coreprotect/model/action/SkillLog.java diff --git a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index 789647d..b240b8f 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); 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: From 1fa0da3cc4a6bf07dca5e1c10ab1390546849d4e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:35:48 +0000 Subject: [PATCH 2/3] docs: mention skill logging in the README features Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From f884370c50a42a7b371a6fa59867a6bc67ab2e99 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:47:51 +0000 Subject: [PATCH 3/3] fix: reject a:skill combined with non-command actions The skill prefix filter is shared by every table in a mixed lookup, so "a:skill,chat" hid all chat rows. Refuse the combination with the existing incompatible-action message. "a:skill,command" still lists every command row. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/coreprotect/command/LookupCommand.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index b240b8f..5b1e632 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java @@ -198,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;