From e1e6fad5a35fad5d9571d020207edb1fade1c9c7 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:21:49 +0000 Subject: [PATCH 1/5] feat: log hits, knockouts and kills between players Staff could not tell who attacked first in a fight: CoreProtect only kept the final kill. Every hit one player lands on another is now logged with the weapon and damage, as are player kills and nonlethal knockouts that other plugins report through the new logPvpKnockout API. The rows live in the block table under three new actions (hit, knockout, pvp-kill) with the victim's user id in the data column, so purge and every storage backend handle them without a schema change. Rollbacks never touch them, and block filters and the block inspector skip them. "/co lookup a:pvp" lists them, or a:hit, a:knockout and a:pvp-kill on their own. Lookups need the new coreprotect.lookup.pvp permission, and the "player-pvp" option (default true) turns logging off per world. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- lang/en.yml | 1 + .../coreprotect/CoreProtectAPI.java | 44 +++++ .../coreprotect/command/LookupCommand.java | 4 + .../coreprotect/command/PurgeCommand.java | 6 +- .../coreprotect/command/TabHandler.java | 4 +- .../command/lookup/StandardLookupThread.java | 20 +- .../command/parser/ActionParser.java | 12 ++ .../coreprotect/config/Config.java | 4 + .../coreprotect/consumer/Queue.java | 5 + .../consumer/process/PlayerPvpProcess.java | 23 +++ .../coreprotect/consumer/process/Process.java | 4 + .../coreprotect/database/LookupRaw.java | 6 +- .../clickhouse/ClickHouseRetention.java | 2 +- .../database/logger/PlayerPvpLogger.java | 57 ++++++ .../event/CoreProtectPreLogEvent.java | 3 +- .../coreprotect/language/Language.java | 1 + .../coreprotect/language/Phrase.java | 1 + .../coreprotect/listener/ListenerHandler.java | 2 + .../listener/player/PlayerPvpListener.java | 99 ++++++++++ .../model/action/LookupActions.java | 20 ++ .../coreprotect/model/action/PvpEvent.java | 77 ++++++++ src/main/resources/plugin.yml | 5 + .../coreprotect/command/TabHandlerTest.java | 1 + .../command/parser/ActionParserPvpTest.java | 25 +++ .../database/logger/PlayerPvpLoggerTest.java | 55 ++++++ .../player/PlayerPvpListenerTest.java | 186 ++++++++++++++++++ .../model/action/PvpEventTest.java | 66 +++++++ 28 files changed, 724 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/tfminecraft/coreprotect/consumer/process/PlayerPvpProcess.java create mode 100644 src/main/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLogger.java create mode 100644 src/main/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListener.java create mode 100644 src/main/java/net/tfminecraft/coreprotect/model/action/PvpEvent.java create mode 100644 src/test/java/net/tfminecraft/coreprotect/command/parser/ActionParserPvpTest.java create mode 100644 src/test/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLoggerTest.java create mode 100644 src/test/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListenerTest.java create mode 100644 src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java diff --git a/README.md b/README.md index 4caef72..18aa366 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, skills players use and where they teleport them, 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, where each online player is once a minute, and every hit, knockout and kill between two players. - **Custom item and mob labels** — item and chest lookups show saved MMOItems names and IDs; kill lookups show recorded mob names and, for new MythicMobs deaths, the internal mob ID. - **Staff investigation tools** — paginated lookups, inventory history, and WorldEdit selection support help investigate incidents of different sizes. diff --git a/lang/en.yml b/lang/en.yml index 214db09..38eaf9c 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -130,6 +130,7 @@ LOOKUP_LOCK_CHANGE_STAFF: "{0} set {1} lock to {2} (staff override)." LOOKUP_ITEM: "{0} {picked up|dropped} {1} {2}." LOOKUP_LOGIN: "{0} logged {in|out}." LOOKUP_PING: "{0} was online here." +LOOKUP_PVP: "{0} {hit|knocked out|killed} {1}{2}." LOOKUP_PAGE: "Page {0}" LOOKUP_PROJECTILE: "{0} {threw|shot} {1} {2}." LOOKUP_ROWS_FOUND: "{0} {row|rows} found." diff --git a/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java b/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java index 1da4394..cee0b0f 100755 --- a/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java @@ -45,6 +45,8 @@ import net.tfminecraft.coreprotect.language.Phrase; import net.tfminecraft.coreprotect.listener.player.InventoryChangeListener; import net.tfminecraft.coreprotect.model.action.LockChange; +import net.tfminecraft.coreprotect.model.action.PvpEvent; +import net.tfminecraft.coreprotect.listener.player.PlayerPvpListener; import net.tfminecraft.coreprotect.model.action.LookupActions; import net.tfminecraft.coreprotect.utility.Chat; import net.tfminecraft.coreprotect.utility.MaterialUtils; @@ -531,6 +533,48 @@ public boolean logLockChange(String user, Location location, String lockState, b return Queue.queueLockChange(user, location.getBlock().getState(), location.getBlock().getType(), new LockChange(lockState, staffOverride)); } + /** + * Logs a nonlethal knockout of one player by another. Use this when a plugin cancels the + * knockout blow instead of letting the victim die, since cancelled hits are not logged. + * + * @param attacker + * The player who landed the knockout + * @param victim + * The player who was knocked out + * @return True if the knockout was queued for logging + */ + public boolean logPvpKnockout(Player attacker, Player victim) { + if (attacker == null || victim == null) { + return false; + } + + return logPvpKnockout(attacker.getName(), victim.getName(), victim.getLocation(), PlayerPvpListener.heldItem(attacker)); + } + + /** + * Logs a nonlethal knockout of one player by another. + * + * @param attacker + * The attacker's username + * @param victim + * The victim's username + * @param location + * Where the victim was knocked out + * @param weapon + * The item that landed the knockout, for example "iron_sword", or null for an empty hand + * @return True if the knockout was queued for logging + */ + public boolean logPvpKnockout(String attacker, String victim, Location location, String weapon) { + if (!isEnabled() || !isValidUserAndLocation(attacker, location) || victim == null || victim.isEmpty() || attacker.equalsIgnoreCase(victim)) { + return false; + } + if (!Config.getConfig(location.getWorld()).PLAYER_PVP) { + return false; + } + + return Queue.queuePlayerPvp(attacker, location, victim, LookupActions.PVP_KNOCKOUT, new PvpEvent(weapon, 0, "knockout")); + } + /** * Logs a container transaction by a user at a location. * diff --git a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index 5b1e632..472c14d 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java @@ -253,6 +253,10 @@ else if (resultc > 1) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.NO_PERMISSION)); return; } + if (argAction.stream().anyMatch(LookupActions::isPvp) && !player.hasPermission("coreprotect.lookup.pvp")) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.NO_PERMISSION)); + return; + } if (argAction.contains(LookupActions.ENTITY_SPAWN) && !player.hasPermission("coreprotect.lookup.spawn")) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.NO_PERMISSION)); return; diff --git a/src/main/java/net/tfminecraft/coreprotect/command/PurgeCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/PurgeCommand.java index c60b5c8..eef1cda 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/PurgeCommand.java @@ -499,7 +499,7 @@ public void run() { else if (PurgePolicy.isPurgeable(table)) { String blockRestriction = "("; if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") OR type NOT IN(" + includeBlockFinal + ") OR (type IN(" + includeBlockFinal + ") AND "; + blockRestriction = "action IN(" + LookupActions.NON_BLOCK_ACTIONS + ") OR type NOT IN(" + includeBlockFinal + ") OR (type IN(" + includeBlockFinal + ") AND "; } else if (hasBlockRestriction) { purge = false; @@ -574,7 +574,7 @@ else if (argWid == 0 && purge) { String blockRestriction = ""; if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND "; + blockRestriction = "action NOT IN(" + LookupActions.NON_BLOCK_ACTIONS + ") AND type IN(" + includeBlockFinal + ") AND "; } else if (hasBlockRestriction) { purge = false; @@ -646,7 +646,7 @@ else if (argWid > 0) { String blockRestriction = ""; if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { - blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND "; + blockRestriction = "action NOT IN(" + LookupActions.NON_BLOCK_ACTIONS + ") AND type IN(" + includeBlockFinal + ") AND "; } else if (hasBlockRestriction) { purge = false; diff --git a/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java b/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java index c09e422..a9e317c 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/TabHandler.java @@ -31,7 +31,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", "reload", "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", "skill", "+inventory", "-inventory", "inventory", "item", "+item", "-item", "sign", "session", "+session", "-session", "ping", "username" }; + private static final String[] ACTIONS = new String[] { "block", "+block", "-block", "click", "kill", "pvp", "hit", "knockout", "pvp-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; @@ -337,7 +337,7 @@ private List handleActionParamCompletions(CommandSender sender, String c for (String action : ACTIONS) { String category = action.replaceFirst("^[+-]", ""); if (lookup) { - String permission = category.equals("skill") ? "command" : category.equals("ping") ? "session" : category; + String permission = category.equals("skill") ? "command" : category.equals("ping") ? "session" : List.of("hit", "knockout", "pvp-kill").contains(category) ? "pvp" : category; if (!sender.hasPermission("coreprotect.lookup." + permission)) { continue; } diff --git a/src/main/java/net/tfminecraft/coreprotect/command/lookup/StandardLookupThread.java b/src/main/java/net/tfminecraft/coreprotect/command/lookup/StandardLookupThread.java index 14bb1c4..a97686b 100644 --- a/src/main/java/net/tfminecraft/coreprotect/command/lookup/StandardLookupThread.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/lookup/StandardLookupThread.java @@ -36,6 +36,7 @@ import net.tfminecraft.coreprotect.listener.channel.PluginChannelListener; import net.tfminecraft.coreprotect.model.action.EntityActionFilter; import net.tfminecraft.coreprotect.model.action.LockChange; +import net.tfminecraft.coreprotect.model.action.PvpEvent; import net.tfminecraft.coreprotect.model.action.MythicIdentity; import net.tfminecraft.coreprotect.database.statement.EntityStatement; import net.tfminecraft.coreprotect.utility.AuditLabels; @@ -557,6 +558,11 @@ else if (daction == ItemTransactionActions.SELL || daction == ItemTransactionAct if (entityInteraction) { dname = EntityInteractionLookup.entityName(dtype); } + else if (LookupActions.isPvp(daction) && amount == -1) { + // PvP rows keep the victim's user id in the data column + dname = UserStatement.getName(connection, ddata); + isPlayer = true; + } else if ((daction == LookupActions.ENTITY_KILL || daction == LookupActions.ENTITY_SPAWN) && !actions.contains(LookupActions.ITEM) && amount == -1) { if (daction == LookupActions.ENTITY_KILL && dtype == 0) { dname = UserStatement.getName(connection, ddata); @@ -590,6 +596,7 @@ else if ((daction == LookupActions.ENTITY_KILL || daction == LookupActions.ENTIT String selector = Selector.FIRST; String action = "a:block"; LockChange lockChange = null; + PvpEvent pvpEvent = null; if (actions.contains(LookupActions.CONTAINER) || actions.contains(5) || actions.contains(LookupActions.ITEM) || amount > -1) { byte[] metadata = data[11] == null ? null : data[11].getBytes(StandardCharsets.ISO_8859_1); dname = AuditLabels.item(metadata, dtype, amount, dname); @@ -636,6 +643,13 @@ else if (daction == LookupActions.ENTITY_SPAWN) { tag = Color.GREEN + "+"; action = placedEntitySpawn ? "a:block" : "a:spawn"; } + else if (LookupActions.isPvp(daction)) { + pvpEvent = PvpEvent.fromLookupString(data[11]); + phrase = Phrase.LOOKUP_PVP; // {hit|knocked out|killed} + selector = (daction == LookupActions.PVP_HIT ? Selector.FIRST : daction == LookupActions.PVP_KNOCKOUT ? Selector.SECOND : Selector.THIRD); + tag = Color.RED + "-"; + action = "a:" + LookupActions.getActionString(daction); + } else if (daction == LookupActions.INTERACTION || daction == LookupActions.ENTITY_KILL) { if (placedEntityKill) { phrase = Phrase.LOOKUP_BLOCK; @@ -658,7 +672,11 @@ else if (daction == LookupActions.INTERACTION || daction == LookupActions.ENTITY } String message; - if (lockChange != null) { + if (phrase == Phrase.LOOKUP_PVP) { + String detail = pvpEvent == null ? "" : Color.WHITE + rbd + pvpEvent.describe(); + message = Phrase.build(phrase, Color.DARK_AQUA + rbd + dplayer + Color.WHITE + rbd, Color.DARK_AQUA + rbd + dname + Color.WHITE + rbd, detail, selector); + } + else if (lockChange != null) { message = Phrase.build(lockChange.isStaffOverride() ? Phrase.LOOKUP_LOCK_CHANGE_STAFF : Phrase.LOOKUP_LOCK_CHANGE, Color.DARK_AQUA + rbd + dplayer + Color.WHITE + rbd, Color.DARK_AQUA + rbd + dname + Color.WHITE + rbd, Color.DARK_AQUA + rbd + lockChange.getLockState() + Color.WHITE); } else { 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 b1ef825..17aae9c 100644 --- a/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/parser/ActionParser.java @@ -106,6 +106,18 @@ else if (argument.equals("click") || argument.equals("clicks") || argument.equal else if (argument.equals("death") || argument.equals("deaths") || argument.equals("entity-death") || argument.equals("entity-deaths") || argument.equals("kill") || argument.equals("kills") || argument.equals("entity-kill") || argument.equals("entity-kills")) { result.add(LookupActions.ENTITY_KILL); } + else if (argument.equals("pvp") || argument.equals("combat") || argument.equals("fight") || argument.equals("fights")) { + result.addAll(LookupActions.PVP_ACTIONS); + } + else if (argument.equals("hit") || argument.equals("hits") || argument.equals("attack") || argument.equals("attacks") || argument.equals("damage") || argument.equals("pvp-hit") || argument.equals("pvp-hits")) { + result.add(LookupActions.PVP_HIT); + } + else if (argument.equals("knockout") || argument.equals("knockouts") || argument.equals("ko") || argument.equals("pvp-knockout") || argument.equals("pvp-knockouts")) { + result.add(LookupActions.PVP_KNOCKOUT); + } + else if (argument.equals("pvp-kill") || argument.equals("pvp-kills") || argument.equals("player-kill") || argument.equals("player-kills")) { + result.add(LookupActions.PVP_KILL); + } else if (argument.equals("spawn") || argument.equals("spawns") || argument.equals("entity-spawn") || argument.equals("entity-spawns")) { result.add(LookupActions.ENTITY_SPAWN); entityActionFilter = EntityActionFilter.SPAWNED; diff --git a/src/main/java/net/tfminecraft/coreprotect/config/Config.java b/src/main/java/net/tfminecraft/coreprotect/config/Config.java index d87a05b..d4d7320 100644 --- a/src/main/java/net/tfminecraft/coreprotect/config/Config.java +++ b/src/main/java/net/tfminecraft/coreprotect/config/Config.java @@ -113,6 +113,7 @@ public class Config extends Language { public int DUCKDB_THREADS; public int MAX_RADIUS; public int PLAYER_PINGS; + public boolean PLAYER_PVP; static { DEFAULT_VALUES.put("donation-key", ""); @@ -176,6 +177,7 @@ public class Config extends Language { DEFAULT_VALUES.put("player-skills", "true"); DEFAULT_VALUES.put("player-sessions", "true"); DEFAULT_VALUES.put("player-pings", "60"); + DEFAULT_VALUES.put("player-pvp", "true"); DEFAULT_VALUES.put("username-changes", "true"); DEFAULT_VALUES.put("worldedit", "true"); @@ -228,6 +230,7 @@ public class Config extends Language { 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("player-pvp", new String[] { "# Logs every hit, knockout and kill between two players, with the weapon used.", "# Search them with \"/co lookup a:pvp\", or \"a:hit\", \"a:knockout\" and \"a:pvp-kill\"." }); HEADERS.put("username-changes", new String[] { "# Logs when a player changes their Minecraft username." }); HEADERS.put("worldedit", new String[] { "# Logs changes made via the plugin \"WorldEdit\" if it's in use on your server." }); } @@ -309,6 +312,7 @@ private void readValues() { this.PLAYER_SKILLS = this.getBoolean("player-skills"); this.PLAYER_SESSIONS = this.getBoolean("player-sessions"); this.PLAYER_PINGS = this.getInt("player-pings"); + this.PLAYER_PVP = this.getBoolean("player-pvp"); this.USERNAME_CHANGES = this.getBoolean("username-changes"); this.WORLDEDIT = this.getBoolean("worldedit"); } diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java index 82a632b..8637cf8 100755 --- a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java @@ -30,6 +30,7 @@ import net.tfminecraft.coreprotect.listener.block.BlockUtil; import net.tfminecraft.coreprotect.model.BlockGroup; import net.tfminecraft.coreprotect.model.action.LockChange; +import net.tfminecraft.coreprotect.model.action.PvpEvent; import net.tfminecraft.coreprotect.model.entity.EntityContainerRollbackUpdate; import net.tfminecraft.coreprotect.model.entity.EntityContainerTransaction; import net.tfminecraft.coreprotect.model.entity.EntityInteraction; @@ -522,6 +523,10 @@ protected static boolean queueLockChange(String user, BlockState block, Material return queueStandardData(new Object[] { null, Process.LOCK_CHANGE, type, 0, null, 0, 0, null }, new String[] { user, null }, new Object[] { block, lockChange }, false, Consumer.reserveConsumer()); } + protected static boolean queuePlayerPvp(String attacker, Location location, String victim, int action, PvpEvent pvp) { + return queueStandardData(new Object[] { null, Process.PLAYER_PVP, null, 0, null, 0, 0, null }, new String[] { attacker, null }, new Object[] { location.clone(), victim, action, pvp }, false, Consumer.reserveConsumer()); + } + protected static void queuePlayerKill(String user, Location location, String player) { queueStandardData(new Object[] { null, Process.PLAYER_KILL, null, 0, null, 0, 0, null }, new String[] { user, null }, new Object[] { getBlockLocation(location), player }, false, Consumer.reserveConsumer()); } diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/process/PlayerPvpProcess.java b/src/main/java/net/tfminecraft/coreprotect/consumer/process/PlayerPvpProcess.java new file mode 100644 index 0000000..b958366 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/process/PlayerPvpProcess.java @@ -0,0 +1,23 @@ +package net.tfminecraft.coreprotect.consumer.process; + +import org.bukkit.Location; + +import net.tfminecraft.coreprotect.database.ConsumerWriteBatch; +import net.tfminecraft.coreprotect.database.logger.PlayerPvpLogger; +import net.tfminecraft.coreprotect.model.action.PvpEvent; + +class PlayerPvpProcess { + + private PlayerPvpProcess() { + throw new IllegalStateException("Process class"); + } + + static void process(ConsumerWriteBatch preparedStmt, int batchCount, Object object, String user) { + if (object instanceof Object[]) { + Object[] values = (Object[]) object; + if (values.length > 3 && values[0] instanceof Location && values[1] instanceof String && values[2] instanceof Integer && values[3] instanceof PvpEvent) { + PlayerPvpLogger.log(preparedStmt, batchCount, user, (Location) values[0], (String) values[1], (Integer) values[2], (PvpEvent) values[3]); + } + } + } +} diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/process/Process.java b/src/main/java/net/tfminecraft/coreprotect/consumer/process/Process.java index dc3e2d9..34322ba 100755 --- a/src/main/java/net/tfminecraft/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/process/Process.java @@ -71,6 +71,7 @@ public class Process { public static final int ENTITY_INTERACTION = 35; public static final int LOCK_CHANGE = 36; public static final int PLAYER_PING = 37; + public static final int PLAYER_PVP = 38; public static int lastLockUpdate = 0; private static volatile int currentConsumerSize = 0; @@ -439,6 +440,9 @@ else if (ConfigHandler.databaseType.isColumnar()) { case Process.PLAYER_KILL: PlayerKillProcess.process(writeBatch, i, id, object, user); break; + case Process.PLAYER_PVP: + PlayerPvpProcess.process(writeBatch, i, object, user); + break; case Process.BLOCKDATA_INSERT: BlockDataInsertProcess.process(writeBatch, statement, i, object, forceData); break; diff --git a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java index 297c571..d547cae 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java @@ -153,6 +153,8 @@ private static List performLookupRaw(Statement statement, CommandSende List list = new ArrayList<>(); List invalidRollbackActions = new ArrayList<>(); invalidRollbackActions.add(LookupActions.INTERACTION); + // PvP rows record fights, not world changes, so nothing restores or removes them + invalidRollbackActions.addAll(LookupActions.PVP_ACTIONS); if (!entityActionFilter.includesAnySpawn(actionList, Config.getGlobal().ROLLBACK_ENTITIES)) { invalidRollbackActions.add(LookupActions.ENTITY_SPAWN); } @@ -442,7 +444,7 @@ private static ResultSet rawLookupResultSet(Statement statement, CommandSender u try { Set loadedEntityUuids = entityContext.getLoadedEntityUuids(); Set loadedEntityCandidates = entityContext.getLoadedEntityCandidates(); - List validActions = Arrays.asList(LookupActions.BLOCK_BREAK, LookupActions.BLOCK_PLACE, LookupActions.INTERACTION, LookupActions.ENTITY_KILL, LookupActions.ENTITY_SPAWN); + List validActions = Arrays.asList(LookupActions.BLOCK_BREAK, LookupActions.BLOCK_PLACE, LookupActions.INTERACTION, LookupActions.ENTITY_KILL, LookupActions.ENTITY_SPAWN, LookupActions.PVP_HIT, LookupActions.PVP_KNOCKOUT, LookupActions.PVP_KILL); if (radius != null) { restrictWorld = true; } @@ -1054,7 +1056,7 @@ else if (actionList.contains(LookupActions.SIGN)) { } if (includeBlock.length() > 0 || excludeBlock.length() > 0) { - baseQuery = baseQuery.replace("action NOT IN(-1)", "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ")"); // if block specified for include/exclude, filter out entity data + baseQuery = baseQuery.replace("action NOT IN(-1)", "action NOT IN(" + LookupActions.NON_BLOCK_ACTIONS + ")"); // if block specified for include/exclude, filter out entity data } String sourceQuery = restrictSource(baseQuery, pageRows, InventorySources.BLOCK); diff --git a/src/main/java/net/tfminecraft/coreprotect/database/clickhouse/ClickHouseRetention.java b/src/main/java/net/tfminecraft/coreprotect/database/clickhouse/ClickHouseRetention.java index e40bc49..8b7c91e 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/clickhouse/ClickHouseRetention.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/clickhouse/ClickHouseRetention.java @@ -209,7 +209,7 @@ private void selectPrimaryTargets(Connection connection, String targetTable, lon List integerParameters = new ArrayList<>(); if (PurgePolicy.supportsBlockRestriction(family.getTableName()) && !blockTypes.isEmpty()) { - sql.append(" AND action NOT IN(").append(LookupActions.ENTITY_KILL).append(',').append(LookupActions.ENTITY_SPAWN).append(") AND type IN("); + sql.append(" AND action NOT IN(").append(LookupActions.NON_BLOCK_ACTIONS).append(") AND type IN("); StringJoiner placeholders = new StringJoiner(","); for (Integer blockType : blockTypes) { placeholders.add("?"); diff --git a/src/main/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLogger.java b/src/main/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLogger.java new file mode 100644 index 0000000..e75ede4 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLogger.java @@ -0,0 +1,57 @@ +package net.tfminecraft.coreprotect.database.logger; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.EntityType; + +import net.tfminecraft.coreprotect.CoreProtect; +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.config.ConfigHandler; +import net.tfminecraft.coreprotect.database.ConsumerWriteBatch; +import net.tfminecraft.coreprotect.database.Database; +import net.tfminecraft.coreprotect.database.statement.BlockStatement; +import net.tfminecraft.coreprotect.database.statement.UserStatement; +import net.tfminecraft.coreprotect.event.CoreProtectPreLogEvent; +import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.model.action.PvpEvent; +import net.tfminecraft.coreprotect.utility.WorldUtils; + +public class PlayerPvpLogger { + + private PlayerPvpLogger() { + throw new IllegalStateException("Database class"); + } + + /** + * Writes one hit, knockout or kill. The row's user is the attacker, its data the victim's user id + * and its location the victim's position. + */ + public static void log(ConsumerWriteBatch preparedStmt, int batchCount, String attacker, Location location, String victim, int action, PvpEvent pvp) { + try { + if (!LookupActions.isPvp(action) || ConfigHandler.isBlacklisted(attacker)) { + return; + } + + int victimId = UserStatement.getId(preparedStmt, victim, true); + + CoreProtectPreLogEvent event = new CoreProtectPreLogEvent(attacker, location.clone(), CoreProtectPreLogEvent.Action.PLAYER_PVP, action, null, EntityType.PLAYER, pvp.getWeapon()); + if (Config.getGlobal().API_ENABLED && !Bukkit.isPrimaryThread()) { + CoreProtect.getInstance().getServer().getPluginManager().callEvent(event); + } + + if (event.isCancelled()) { + return; + } + + int userId = UserStatement.getId(preparedStmt, event.getUser(), true); + Location eventLocation = event.getLocation(); + int wid = WorldUtils.getWorldId(eventLocation.getWorld().getName()); + int time = (int) (System.currentTimeMillis() / 1000L); + BlockStatement.insert(preparedStmt, batchCount, time, userId, wid, eventLocation.getBlockX(), eventLocation.getBlockY(), eventLocation.getBlockZ(), 0, victimId, pvp.toMetadata(), null, action, 0); + } + catch (Exception e) { + Database.handleWriteFailure(e); + } + } + +} diff --git a/src/main/java/net/tfminecraft/coreprotect/event/CoreProtectPreLogEvent.java b/src/main/java/net/tfminecraft/coreprotect/event/CoreProtectPreLogEvent.java index e0d7b02..ed72e1e 100644 --- a/src/main/java/net/tfminecraft/coreprotect/event/CoreProtectPreLogEvent.java +++ b/src/main/java/net/tfminecraft/coreprotect/event/CoreProtectPreLogEvent.java @@ -21,7 +21,8 @@ public enum Action { ITEM_TRANSACTION, PLAYER_COMMAND, ENTITY_SPAWN, - ENTITY_INTERACTION + ENTITY_INTERACTION, + PLAYER_PVP } private static final HandlerList handlers = new HandlerList(); diff --git a/src/main/java/net/tfminecraft/coreprotect/language/Language.java b/src/main/java/net/tfminecraft/coreprotect/language/Language.java index ba6da3e..15d7d30 100644 --- a/src/main/java/net/tfminecraft/coreprotect/language/Language.java +++ b/src/main/java/net/tfminecraft/coreprotect/language/Language.java @@ -174,6 +174,7 @@ public static void loadPhrases() { phrases.put(Phrase.LOOKUP_ITEM, "{0} {picked up|dropped} {1} {2}."); phrases.put(Phrase.LOOKUP_LOGIN, "{0} logged {in|out}."); phrases.put(Phrase.LOOKUP_PING, "{0} was online here."); + phrases.put(Phrase.LOOKUP_PVP, "{0} {hit|knocked out|killed} {1}{2}."); phrases.put(Phrase.LOOKUP_PAGE, "Page {0}"); phrases.put(Phrase.LOOKUP_PROJECTILE, "{0} {threw|shot} {1} {2}."); phrases.put(Phrase.LOOKUP_ROWS_FOUND, "{0} {row|rows} found."); diff --git a/src/main/java/net/tfminecraft/coreprotect/language/Phrase.java b/src/main/java/net/tfminecraft/coreprotect/language/Phrase.java index 5505c55..77a1213 100644 --- a/src/main/java/net/tfminecraft/coreprotect/language/Phrase.java +++ b/src/main/java/net/tfminecraft/coreprotect/language/Phrase.java @@ -141,6 +141,7 @@ public enum Phrase { LOOKUP_ITEM, LOOKUP_LOGIN, LOOKUP_PING, + LOOKUP_PVP, LOOKUP_PAGE, LOOKUP_PROJECTILE, LOOKUP_ROWS_FOUND, diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java index cd909c7..6697854 100644 --- a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java +++ b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java @@ -57,6 +57,7 @@ import net.tfminecraft.coreprotect.listener.player.PlayerItemBreakListener; import net.tfminecraft.coreprotect.listener.player.PlayerJoinListener; import net.tfminecraft.coreprotect.listener.player.PlayerPingListener; +import net.tfminecraft.coreprotect.listener.player.PlayerPvpListener; import net.tfminecraft.coreprotect.listener.player.PlayerPickupArrowListener; import net.tfminecraft.coreprotect.listener.player.PlayerQuitListener; import net.tfminecraft.coreprotect.listener.player.PlayerTakeLecternBookListener; @@ -200,6 +201,7 @@ public ListenerHandler(CoreProtect plugin) { pluginManager.registerEvents(new PlayerJoinListener(), plugin); pluginManager.registerEvents(new PlayerQuitListener(), plugin); pluginManager.registerEvents(new PlayerPingListener(), plugin); + pluginManager.registerEvents(new PlayerPvpListener(), plugin); pluginManager.registerEvents(new SignChangeListener(), plugin); pluginManager.registerEvents(new SpawnEggUseListener(), plugin); pluginManager.registerEvents(new PlayerTakeLecternBookListener(), plugin); diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListener.java b/src/main/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListener.java new file mode 100644 index 0000000..1b5e2ed --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListener.java @@ -0,0 +1,99 @@ +package net.tfminecraft.coreprotect.listener.player; + +import java.util.Locale; + +import org.bukkit.Material; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; + +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.consumer.Queue; +import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.model.action.PvpEvent; + +/** + * Logs every hit and kill one player lands on another, so staff can see who attacked first. + * Hits that another plugin cancels, such as a nonlethal knockout blow, are not hits; plugins log + * knockouts through the API instead. + */ +public final class PlayerPvpListener extends Queue implements Listener { + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + protected void onPlayerDamage(EntityDamageEvent event) { + if (!(event.getEntity() instanceof Player victim) || event.getFinalDamage() <= 0) { + return; + } + Player attacker = attacker(event); + if (attacker == null || attacker.getUniqueId().equals(victim.getUniqueId()) || !Config.getConfig(victim.getWorld()).PLAYER_PVP) { + return; + } + + PvpEvent pvp = new PvpEvent(weapon(event, attacker), event.getFinalDamage(), event.getCause().name()); + Queue.queuePlayerPvp(attacker.getName(), victim.getLocation(), victim.getName(), LookupActions.PVP_HIT, pvp); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + protected void onPlayerDeath(PlayerDeathEvent event) { + Player victim = event.getEntity(); + EntityDamageEvent lastDamage = victim.getLastDamageCause(); + Player attacker = lastDamage == null ? null : attacker(lastDamage); + if (attacker == null) { + attacker = victim.getKiller(); + } + if (attacker == null || attacker.getUniqueId().equals(victim.getUniqueId()) || !Config.getConfig(victim.getWorld()).PLAYER_PVP) { + return; + } + + PvpEvent pvp = lastDamage == null ? new PvpEvent(heldItem(attacker), 0, "") + : new PvpEvent(weapon(lastDamage, attacker), lastDamage.getFinalDamage(), lastDamage.getCause().name()); + Queue.queuePlayerPvp(attacker.getName(), victim.getLocation(), victim.getName(), LookupActions.PVP_KILL, pvp); + } + + /** The player responsible for the damage, including through projectiles, or null. */ + public static Player attacker(EntityDamageEvent event) { + DamageSource source = event.getDamageSource(); + if (source != null && source.getCausingEntity() instanceof Player player) { + return player; + } + if (event instanceof EntityDamageByEntityEvent byEntity) { + Entity damager = byEntity.getDamager(); + if (damager instanceof Player player) { + return player; + } + if (damager instanceof Projectile projectile && projectile.getShooter() instanceof Player player) { + return player; + } + } + return null; + } + + /** The projectile or other entity that dealt the damage, or else the attacker's held item. */ + static String weapon(EntityDamageEvent event, Player attacker) { + DamageSource source = event.getDamageSource(); + Entity direct = source == null ? null : source.getDirectEntity(); + if (direct == null && event instanceof EntityDamageByEntityEvent byEntity) { + direct = byEntity.getDamager(); + } + if (direct != null && !direct.getUniqueId().equals(attacker.getUniqueId())) { + return direct.getType().name().toLowerCase(Locale.ROOT); + } + return heldItem(attacker); + } + + public static String heldItem(Player player) { + ItemStack item = player.getInventory().getItemInMainHand(); + if (item == null || item.getType() == Material.AIR) { + return "hand"; + } + return item.getType().name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/tfminecraft/coreprotect/model/action/LookupActions.java b/src/main/java/net/tfminecraft/coreprotect/model/action/LookupActions.java index eb2b5c7..93fa401 100644 --- a/src/main/java/net/tfminecraft/coreprotect/model/action/LookupActions.java +++ b/src/main/java/net/tfminecraft/coreprotect/model/action/LookupActions.java @@ -1,6 +1,9 @@ package net.tfminecraft.coreprotect.model.action; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.List; public final class LookupActions { public static final int BLOCK_BREAK = 0; @@ -15,6 +18,13 @@ public final class LookupActions { public static final int SIGN = 10; public static final int ITEM = 11; public static final int ENTITY_SPAWN = 13; + // Player versus player rows live in the block table, but are never rolled back or matched by block filters + public static final int PVP_HIT = 14; + public static final int PVP_KNOCKOUT = 15; + public static final int PVP_KILL = 16; + public static final List PVP_ACTIONS = Collections.unmodifiableList(Arrays.asList(PVP_HIT, PVP_KNOCKOUT, PVP_KILL)); + // Block table actions whose type column is not a block, so block filters must skip them + public static final String NON_BLOCK_ACTIONS = ENTITY_KILL + "," + ENTITY_SPAWN + "," + PVP_HIT + "," + PVP_KNOCKOUT + "," + PVP_KILL; private LookupActions() { throw new IllegalStateException("Model class"); @@ -46,11 +56,21 @@ public static String getActionString(int action) { return "item"; case ENTITY_SPAWN: return "spawn"; + case PVP_HIT: + return "hit"; + case PVP_KNOCKOUT: + return "knockout"; + case PVP_KILL: + return "pvp-kill"; default: return "unknown"; } } + public static boolean isPvp(int action) { + return PVP_ACTIONS.contains(action); + } + public static boolean isInventoryLookup(Collection actions) { return actions.contains(CONTAINER) && actions.contains(ITEM); } diff --git a/src/main/java/net/tfminecraft/coreprotect/model/action/PvpEvent.java b/src/main/java/net/tfminecraft/coreprotect/model/action/PvpEvent.java new file mode 100644 index 0000000..931507a --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/model/action/PvpEvent.java @@ -0,0 +1,77 @@ +package net.tfminecraft.coreprotect.model.action; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import net.tfminecraft.coreprotect.database.statement.BlockStatement; + +/** + * Details of a hit, knockout or kill between two players. It is stored as block metadata starting + * with {@link #MARKER} on a row whose user is the attacker and whose data is the victim's user id. + */ +public final class PvpEvent { + + public static final String MARKER = "coreprotect:pvp"; + + private final String weapon; + private final double damage; + private final String cause; + + /** + * @param weapon + * The item or projectile that dealt the damage, for example "iron_sword" or "arrow" + * @param damage + * The damage dealt, or 0 when it is not known + * @param cause + * The damage cause, for example "entity_attack" + */ + public PvpEvent(String weapon, double damage, String cause) { + this.weapon = weapon == null || weapon.isEmpty() ? "hand" : weapon.toLowerCase(Locale.ROOT); + this.damage = Double.isFinite(damage) && damage > 0 ? damage : 0; + this.cause = cause == null ? "" : cause.toLowerCase(Locale.ROOT); + } + + public String getWeapon() { + return weapon; + } + + public double getDamage() { + return damage; + } + + public String getCause() { + return cause; + } + + public List toMetadata() { + return Arrays.asList(MARKER, weapon, damage, cause); + } + + /** The text after the victim's name in a lookup, such as " with iron_sword (4.5 damage)". */ + public String describe() { + String text = " with " + weapon; + if (damage > 0) { + text += String.format(Locale.ROOT, " (%.1f damage)", damage); + } + return text; + } + + public static PvpEvent fromMetadata(List metadata) { + if (metadata == null || metadata.size() < 4 || !MARKER.equals(metadata.get(0)) || !(metadata.get(1) instanceof String) + || !(metadata.get(2) instanceof Number) || !(metadata.get(3) instanceof String)) { + return null; + } + return new PvpEvent((String) metadata.get(1), ((Number) metadata.get(2)).doubleValue(), (String) metadata.get(3)); + } + + public static PvpEvent fromMetadata(byte[] metadata) { + return metadata == null ? null : fromMetadata(BlockStatement.deserializeMetadata(metadata)); + } + + // Lookup rows carry metadata as ISO-8859-1 strings; see LookupConverter. + public static PvpEvent fromLookupString(String metadata) { + return metadata == null || metadata.isEmpty() ? null : fromMetadata(metadata.getBytes(StandardCharsets.ISO_8859_1)); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0218cb4..e76b35f 100755 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -50,6 +50,7 @@ permissions: coreprotect.lookup.inventory: true coreprotect.lookup.item: true coreprotect.lookup.kill: true + coreprotect.lookup.pvp: true coreprotect.lookup.spawn: true coreprotect.teleport: true coreprotect.reload: true @@ -82,6 +83,7 @@ permissions: coreprotect.lookup.inventory: true coreprotect.lookup.item: true coreprotect.lookup.kill: true + coreprotect.lookup.pvp: true coreprotect.lookup.spawn: true coreprotect.lookup.near: description: Has permission to lookup nearby changes via the near command @@ -119,6 +121,9 @@ permissions: coreprotect.lookup.kill: description: Has permission to lookup entity kills default: op + coreprotect.lookup.pvp: + description: Has permission to lookup hits, knockouts and kills between players + default: op coreprotect.lookup.spawn: description: Has permission to lookup entity spawns default: op diff --git a/src/test/java/net/tfminecraft/coreprotect/command/TabHandlerTest.java b/src/test/java/net/tfminecraft/coreprotect/command/TabHandlerTest.java index bcbd8b8..2b9106b 100644 --- a/src/test/java/net/tfminecraft/coreprotect/command/TabHandlerTest.java +++ b/src/test/java/net/tfminecraft/coreprotect/command/TabHandlerTest.java @@ -272,6 +272,7 @@ void eachLookupActionUsesItsOwnPermissionIncludingInventoryAndSessionVariants() List.of("coreprotect.lookup.block", "a:block", "a:+block", "a:-block"), List.of("coreprotect.lookup.click", "a:click"), List.of("coreprotect.lookup.kill", "a:kill"), + List.of("coreprotect.lookup.pvp", "a:pvp", "a:hit", "a:knockout", "a:pvp-kill"), List.of("coreprotect.lookup.spawn", "a:spawn"), List.of("coreprotect.lookup.container", "a:+container", "a:-container", "a:container"), List.of("coreprotect.lookup.chat", "a:chat"), diff --git a/src/test/java/net/tfminecraft/coreprotect/command/parser/ActionParserPvpTest.java b/src/test/java/net/tfminecraft/coreprotect/command/parser/ActionParserPvpTest.java new file mode 100644 index 0000000..e5c218b --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/command/parser/ActionParserPvpTest.java @@ -0,0 +1,25 @@ +package net.tfminecraft.coreprotect.command.parser; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import net.tfminecraft.coreprotect.model.action.LookupActions; + +class ActionParserPvpTest { + @ParameterizedTest + @CsvSource({ "pvp,14;15;16", "combat,14;15;16", "hit,14", "damage,14", "knockout,15", "ko,15", "pvp-kill,16", "player-kills,16", "kill,3" }) + void pvpActionsParseToTheirOwnIds(String argument, String expected) { + List actions = ActionParser.parseAction(new String[] { "lookup", "a:" + argument }, true); + assertEquals(java.util.Arrays.stream(expected.split(";")).map(Integer::valueOf).toList(), actions); + } + + @Test + void pvpActionsCombine() { + assertEquals(List.of(LookupActions.PVP_HIT, LookupActions.PVP_KNOCKOUT), ActionParser.parseAction(new String[] { "lookup", "a:hit,knockout" }, true)); + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLoggerTest.java b/src/test/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLoggerTest.java new file mode 100644 index 0000000..ef5afaf --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/database/logger/PlayerPvpLoggerTest.java @@ -0,0 +1,55 @@ +package net.tfminecraft.coreprotect.database.logger; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.config.ConfigHandler; +import net.tfminecraft.coreprotect.database.ConsumerWriteBatch; +import net.tfminecraft.coreprotect.database.statement.BlockStatement; +import net.tfminecraft.coreprotect.database.statement.UserStatement; +import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.model.action.PvpEvent; +import net.tfminecraft.coreprotect.utility.WorldUtils; + +class PlayerPvpLoggerTest { + @Test + void writesOneRowWithTheAttackerVictimAndDetails() { + var batch = mock(ConsumerWriteBatch.class); + World world = mock(World.class); + when(world.getName()).thenReturn("world"); + PvpEvent pvp = new PvpEvent("iron_sword", 4.5, "entity_attack"); + try (var config = mockStatic(Config.class); + var handler = mockStatic(ConfigHandler.class); + var worlds = mockStatic(WorldUtils.class); + var users = mockStatic(UserStatement.class); + var blocks = mockStatic(BlockStatement.class)) { + config.when(Config::getGlobal).thenReturn(mock(Config.class)); + worlds.when(() -> WorldUtils.getWorldId("world")).thenReturn(1); + users.when(() -> UserStatement.getId(batch, "Rushork", true)).thenReturn(2); + users.when(() -> UserStatement.getId(batch, "SPShadar", true)).thenReturn(5); + PlayerPvpLogger.log(batch, 0, "Rushork", new Location(world, 3748.7, 193, 1501.2), "SPShadar", LookupActions.PVP_HIT, pvp); + blocks.verify(() -> BlockStatement.insert(eq(batch), eq(0), anyInt(), eq(2), eq(1), eq(3748), eq(193), eq(1501), + eq(0), eq(5), eq(pvp.toMetadata()), isNull(), eq(LookupActions.PVP_HIT), eq(0))); + blocks.verifyNoMoreInteractions(); + } + } + + @Test + void blacklistedAttackersAndNonPvpActionsAreSkipped() { + var batch = mock(ConsumerWriteBatch.class); + World world = mock(World.class); + PvpEvent pvp = new PvpEvent("hand", 1, "entity_attack"); + try (var handler = mockStatic(ConfigHandler.class); + var blocks = mockStatic(BlockStatement.class)) { + handler.when(() -> ConfigHandler.isBlacklisted("Hidden")).thenReturn(true); + PlayerPvpLogger.log(batch, 0, "Hidden", new Location(world, 0, 0, 0), "SPShadar", LookupActions.PVP_HIT, pvp); + PlayerPvpLogger.log(batch, 0, "Rushork", new Location(world, 0, 0, 0), "SPShadar", LookupActions.ENTITY_KILL, pvp); + blocks.verifyNoInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListenerTest.java b/src/test/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListenerTest.java new file mode 100644 index 0000000..d1aebf6 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/listener/player/PlayerPvpListenerTest.java @@ -0,0 +1,186 @@ +package net.tfminecraft.coreprotect.listener.player; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.Arrow; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Zombie; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.consumer.Queue; +import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.model.action.PvpEvent; + +class PlayerPvpListenerTest extends Queue { + private final PlayerPvpListener listener = new PlayerPvpListener(); + private MockedStatic queue; + private MockedStatic config; + private Config worldConfig; + private World world; + private Player attacker; + private Player victim; + + @BeforeEach + void setUp() { + queue = mockStatic(Queue.class); + config = mockStatic(Config.class); + world = mock(World.class); + worldConfig = mock(Config.class); + worldConfig.PLAYER_PVP = true; + config.when(() -> Config.getConfig(world)).thenReturn(worldConfig); + attacker = player("Rushork", Material.IRON_SWORD); + victim = player("SPShadar", Material.AIR); + } + + @AfterEach + void tearDown() { + config.close(); + queue.close(); + } + + private Player player(String name, Material held) { + Player player = mock(Player.class); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getWorld()).thenReturn(world); + when(player.getType()).thenReturn(EntityType.PLAYER); + when(player.getLocation()).thenReturn(new Location(world, 3748, 193, 1501)); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(held); + PlayerInventory inventory = mock(PlayerInventory.class); + when(inventory.getItemInMainHand()).thenReturn(item); + when(player.getInventory()).thenReturn(inventory); + return player; + } + + private DamageSource source(org.bukkit.entity.Entity causing, org.bukkit.entity.Entity direct) { + DamageSource source = mock(DamageSource.class); + when(source.getCausingEntity()).thenReturn(causing); + when(source.getDirectEntity()).thenReturn(direct); + return source; + } + + private EntityDamageByEntityEvent melee(Player damager, Player damaged, double damage) { + DamageSource source = source(damager, damager); + EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); + when(event.getEntity()).thenReturn(damaged); + when(event.getDamager()).thenReturn(damager); + when(event.getDamageSource()).thenReturn(source); + when(event.getFinalDamage()).thenReturn(damage); + when(event.getCause()).thenReturn(DamageCause.ENTITY_ATTACK); + return event; + } + + private PvpEvent loggedHit(String attackerName, String victimName, int action) { + ArgumentCaptor pvp = ArgumentCaptor.forClass(PvpEvent.class); + queue.verify(() -> Queue.queuePlayerPvp(eq(attackerName), any(Location.class), eq(victimName), eq(action), pvp.capture())); + return pvp.getValue(); + } + + @Test + void meleeHitLogsTheAttackerVictimWeaponAndDamage() { + listener.onPlayerDamage(melee(attacker, victim, 4.5)); + PvpEvent pvp = loggedHit("Rushork", "SPShadar", LookupActions.PVP_HIT); + assertEquals("iron_sword", pvp.getWeapon()); + assertEquals(4.5, pvp.getDamage()); + assertEquals("entity_attack", pvp.getCause()); + } + + @Test + void emptyHandedHitsAndProjectilesNameTheirWeapon() { + listener.onPlayerDamage(melee(victim, attacker, 1)); + assertEquals("hand", loggedHit("SPShadar", "Rushork", LookupActions.PVP_HIT).getWeapon()); + + Arrow arrow = mock(Arrow.class); + when(arrow.getUniqueId()).thenReturn(UUID.randomUUID()); + when(arrow.getType()).thenReturn(EntityType.ARROW); + when(arrow.getShooter()).thenReturn(attacker); + EntityDamageByEntityEvent shot = mock(EntityDamageByEntityEvent.class); + when(shot.getEntity()).thenReturn(victim); + when(shot.getDamager()).thenReturn(arrow); + when(shot.getFinalDamage()).thenReturn(6.0); + when(shot.getCause()).thenReturn(DamageCause.PROJECTILE); + assertSame(attacker, PlayerPvpListener.attacker(shot)); + assertEquals("arrow", PlayerPvpListener.weapon(shot, attacker)); + } + + @Test + void indirectDamageIsTracedThroughTheDamageSource() { + DamageSource magicSource = source(attacker, null); + EntityDamageEvent magic = mock(EntityDamageEvent.class); + when(magic.getEntity()).thenReturn(victim); + when(magic.getDamageSource()).thenReturn(magicSource); + when(magic.getFinalDamage()).thenReturn(2.0); + when(magic.getCause()).thenReturn(DamageCause.MAGIC); + listener.onPlayerDamage(magic); + PvpEvent pvp = loggedHit("Rushork", "SPShadar", LookupActions.PVP_HIT); + assertEquals("iron_sword", pvp.getWeapon()); + assertEquals("magic", pvp.getCause()); + } + + @Test + void selfHarmMobsZeroDamageAndDisabledWorldsAreNotLogged() { + listener.onPlayerDamage(melee(victim, victim, 3)); + listener.onPlayerDamage(melee(attacker, victim, 0)); + EntityDamageByEntityEvent mob = mock(EntityDamageByEntityEvent.class); + Zombie zombie = mock(Zombie.class); + when(mob.getEntity()).thenReturn(victim); + when(mob.getDamager()).thenReturn(zombie); + DamageSource mobSource = source(zombie, zombie); + when(mob.getDamageSource()).thenReturn(mobSource); + when(mob.getFinalDamage()).thenReturn(3.0); + listener.onPlayerDamage(mob); + EntityDamageEvent onMob = mock(EntityDamageEvent.class); + when(onMob.getEntity()).thenReturn(zombie); + listener.onPlayerDamage(onMob); + worldConfig.PLAYER_PVP = false; + listener.onPlayerDamage(melee(attacker, victim, 3)); + queue.verifyNoInteractions(); + } + + @Test + void deathByAnotherPlayerLogsAKillWithTheKillingBlow() { + EntityDamageByEntityEvent blow = melee(attacker, victim, 7); + when(victim.getLastDamageCause()).thenReturn(blow); + PlayerDeathEvent death = mock(PlayerDeathEvent.class); + when(death.getEntity()).thenReturn(victim); + listener.onPlayerDeath(death); + PvpEvent pvp = loggedHit("Rushork", "SPShadar", LookupActions.PVP_KILL); + assertEquals("iron_sword", pvp.getWeapon()); + assertEquals(7, pvp.getDamage()); + } + + @Test + void deathFallsBackToTheTrackedKillerAndIgnoresOtherDeaths() { + PlayerDeathEvent death = mock(PlayerDeathEvent.class); + when(death.getEntity()).thenReturn(victim); + listener.onPlayerDeath(death); + queue.verifyNoInteractions(); + + when(victim.getKiller()).thenReturn(attacker); + listener.onPlayerDeath(death); + PvpEvent pvp = loggedHit("Rushork", "SPShadar", LookupActions.PVP_KILL); + assertEquals("iron_sword", pvp.getWeapon()); + assertEquals(0, pvp.getDamage()); + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java b/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java new file mode 100644 index 0000000..de53d15 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java @@ -0,0 +1,66 @@ +package net.tfminecraft.coreprotect.model.action; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import net.tfminecraft.coreprotect.database.DatabaseType; +import net.tfminecraft.coreprotect.database.statement.BlockStatement; + +class PvpEventTest { + @ParameterizedTest + @EnumSource(DatabaseType.class) + void everyCodecRoundTripsTheDetailsALookupShows(DatabaseType type) { + PvpEvent event = new PvpEvent("IRON_SWORD", 4.5, "ENTITY_ATTACK"); + byte[] blob = BlockStatement.serializeMetadata(event.toMetadata(), type); + assertNotNull(blob); + PvpEvent read = PvpEvent.fromLookupString(new String(blob, StandardCharsets.ISO_8859_1)); + assertNotNull(read); + assertEquals("iron_sword", read.getWeapon()); + assertEquals(4.5, read.getDamage()); + assertEquals("entity_attack", read.getCause()); + assertEquals(" with iron_sword (4.5 damage)", read.describe()); + } + + @Test + void missingWeaponIsAnEmptyHandAndUnknownDamageIsLeftOut() { + PvpEvent event = new PvpEvent(null, Double.NaN, null); + assertEquals("hand", event.getWeapon()); + assertEquals(0, event.getDamage()); + assertEquals("", event.getCause()); + assertEquals(" with hand", event.describe()); + assertEquals(" with arrow", new PvpEvent("arrow", -2, "projectile").describe()); + } + + @Test + void otherMetadataIsNotAPvpEvent() { + assertNull(PvpEvent.fromMetadata((List) null)); + assertNull(PvpEvent.fromMetadata((byte[]) null)); + assertNull(PvpEvent.fromLookupString(null)); + assertNull(PvpEvent.fromLookupString("")); + assertNull(PvpEvent.fromMetadata(List.of("coreprotect:lock", "Private", false))); + assertNull(PvpEvent.fromMetadata(List.of(PvpEvent.MARKER, "sword", 1.0))); + assertNull(PvpEvent.fromMetadata(Arrays.asList(PvpEvent.MARKER, 7, 1.0, "cause"))); + assertNull(PvpEvent.fromMetadata(Arrays.asList(PvpEvent.MARKER, "sword", "1", "cause"))); + assertNull(PvpEvent.fromMetadata(Arrays.asList(PvpEvent.MARKER, "sword", 1.0, null))); + } + + @Test + void pvpActionsAreNamedAndKeptOutOfBlockFilters() { + assertEquals(List.of(LookupActions.PVP_HIT, LookupActions.PVP_KNOCKOUT, LookupActions.PVP_KILL), LookupActions.PVP_ACTIONS); + assertEquals("hit", LookupActions.getActionString(LookupActions.PVP_HIT)); + assertEquals("knockout", LookupActions.getActionString(LookupActions.PVP_KNOCKOUT)); + assertEquals("pvp-kill", LookupActions.getActionString(LookupActions.PVP_KILL)); + assertTrue(LookupActions.isPvp(LookupActions.PVP_KILL)); + assertFalse(LookupActions.isPvp(LookupActions.ENTITY_KILL)); + for (int action : LookupActions.PVP_ACTIONS) { + assertTrue(Arrays.asList(LookupActions.NON_BLOCK_ACTIONS.split(",")).contains(Integer.toString(action))); + } + } +} From 6b9e65ec1db245fbf09016099310b8f96d419c84 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:25:47 +0000 Subject: [PATCH 2/5] feat: expose the PvP actions through the API Add PVP_HIT, PVP_KNOCKOUT and PVP_KILL to CoreProtectAction and let the legacy lookup methods accept them, so plugins can read fights back. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/coreprotect/CoreProtectAPI.java | 2 +- .../net/tfminecraft/coreprotect/api/CoreProtectAction.java | 5 ++++- .../tfminecraft/coreprotect/model/action/PvpEventTest.java | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java b/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java index cee0b0f..21ea628 100755 --- a/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/tfminecraft/coreprotect/CoreProtectAPI.java @@ -105,7 +105,7 @@ else if (value instanceof Integer) { } private static boolean isSupportedLegacyAction(Integer action) { - return action != null && (action == LookupActions.BLOCK_BREAK || action == LookupActions.BLOCK_PLACE || action == LookupActions.INTERACTION || action == LookupActions.ENTITY_KILL || action == LookupActions.ENTITY_SPAWN); + return action != null && (action == LookupActions.BLOCK_BREAK || action == LookupActions.BLOCK_PLACE || action == LookupActions.INTERACTION || action == LookupActions.ENTITY_KILL || action == LookupActions.ENTITY_SPAWN || LookupActions.isPvp(action)); } /** diff --git a/src/main/java/net/tfminecraft/coreprotect/api/CoreProtectAction.java b/src/main/java/net/tfminecraft/coreprotect/api/CoreProtectAction.java index d59623e..8fef183 100644 --- a/src/main/java/net/tfminecraft/coreprotect/api/CoreProtectAction.java +++ b/src/main/java/net/tfminecraft/coreprotect/api/CoreProtectAction.java @@ -18,7 +18,10 @@ public enum CoreProtectAction { SIGN(LookupActions.SIGN), ITEM(LookupActions.ITEM), UNKNOWN(-1), - ENTITY_SPAWN(LookupActions.ENTITY_SPAWN); + ENTITY_SPAWN(LookupActions.ENTITY_SPAWN), + PVP_HIT(LookupActions.PVP_HIT), + PVP_KNOCKOUT(LookupActions.PVP_KNOCKOUT), + PVP_KILL(LookupActions.PVP_KILL); private final int id; diff --git a/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java b/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java index de53d15..c5a7e24 100644 --- a/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java +++ b/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java @@ -58,6 +58,8 @@ void pvpActionsAreNamedAndKeptOutOfBlockFilters() { assertEquals("knockout", LookupActions.getActionString(LookupActions.PVP_KNOCKOUT)); assertEquals("pvp-kill", LookupActions.getActionString(LookupActions.PVP_KILL)); assertTrue(LookupActions.isPvp(LookupActions.PVP_KILL)); + assertEquals(net.tfminecraft.coreprotect.api.CoreProtectAction.PVP_KNOCKOUT, net.tfminecraft.coreprotect.api.CoreProtectAction.fromId(LookupActions.PVP_KNOCKOUT)); + assertEquals("pvp-kill", net.tfminecraft.coreprotect.api.CoreProtectAction.PVP_KILL.actionString()); assertFalse(LookupActions.isPvp(LookupActions.ENTITY_KILL)); for (int action : LookupActions.PVP_ACTIONS) { assertTrue(Arrays.asList(LookupActions.NON_BLOCK_ACTIONS.split(",")).contains(Integer.toString(action))); From c9c41f47428fc3a047ffbe41b0871fc93ad5c2e4 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:54:44 +0000 Subject: [PATCH 3/5] fix: list player kills in a:kill lookups When the entity map holds no placed entity types, such as boats or minecarts, the placed-type list fell back to "0". Player kills are stored with type 0, so "a:kill" excluded every player kill and the block inspector could match them. Fall back to an id no row uses instead. Co-Authored-By: Claude Opus 5.5 --- .../coreprotect/database/LookupRaw.java | 3 +- .../database/lookup/BlockLookup.java | 3 +- .../LookupRawActionPredicateTest.java | 39 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java diff --git a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java index d547cae..61f02d7 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java @@ -1401,7 +1401,8 @@ private static String placedEntityTypeIds() { for (Integer id : EntitySpawnTracking.getPlacedEntityTypeIds()) { ids.add(Integer.toString(id)); } - return ids.length() == 0 ? "0" : ids.toString(); + // Type 0 is a player victim, so an empty list must not fall back to it + return ids.length() == 0 ? "-1" : ids.toString(); } private static String entitySpawnTimeQuery(long startTime, long endTime) { diff --git a/src/main/java/net/tfminecraft/coreprotect/database/lookup/BlockLookup.java b/src/main/java/net/tfminecraft/coreprotect/database/lookup/BlockLookup.java index 9f0696b..79afc80 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/lookup/BlockLookup.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/lookup/BlockLookup.java @@ -206,7 +206,8 @@ private static String placedEntityTypeIds() { for (Integer id : EntitySpawnTracking.getPlacedEntityTypeIds()) { ids.add(Integer.toString(id)); } - return ids.length() == 0 ? "0" : ids.toString(); + // Type 0 is a player victim, so an empty list must not fall back to it + return ids.length() == 0 ? "-1" : ids.toString(); } } diff --git a/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java new file mode 100644 index 0000000..ecf96f7 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java @@ -0,0 +1,39 @@ +package net.tfminecraft.coreprotect.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.coreprotect.model.action.EntityActionFilter; +import net.tfminecraft.coreprotect.model.action.LookupActions; +import net.tfminecraft.coreprotect.utility.EntitySpawnTracking; + +class LookupRawActionPredicateTest { + private static String predicate(String actions, List actionList, EntityActionFilter filter) throws Exception { + Method method = LookupRaw.class.getDeclaredMethod("buildActionPredicate", String.class, List.class, EntityActionFilter.class); + method.setAccessible(true); + return (String) method.invoke(null, actions, actionList, filter); + } + + @Test + void killLookupsKeepPlayerVictimsWhenNoPlacedEntityTypesAreKnown() throws Exception { + try (var tracking = mockStatic(EntitySpawnTracking.class)) { + tracking.when(EntitySpawnTracking::getPlacedEntityTypeIds).thenReturn(Set.of()); + String kill = predicate(Integer.toString(LookupActions.ENTITY_KILL), List.of(LookupActions.ENTITY_KILL), EntityActionFilter.NONE); + // Player kills are stored with type 0, so they must not be excluded. + assertFalse(kill.contains("NOT IN(0)"), kill); + assertTrue(kill.contains("type NOT IN(-1)"), kill); + } + } + + @Test + void pvpLookupsSelectOnlyTheirOwnActions() throws Exception { + String pvp = predicate("14,15,16", LookupActions.PVP_ACTIONS, EntityActionFilter.NONE); + assertEquals("(action IN(14,15,16))", pvp); + } +} From d6cd0422d83454806c9e69ea1358bb13a7be1591 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:00:02 +0000 Subject: [PATCH 4/5] fix: keep fights out of lookups without the PvP permission A page request reused the saved actions after the permission check, so a player whose PvP permission was revoked could still page through an earlier a:pvp lookup. Check the restored actions too. A lookup with no action list also returned PvP rows to anyone with block lookup access. Leave them out unless the sender has coreprotect.lookup.pvp, inside the query so page counts stay right. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/coreprotect/command/LookupCommand.java | 5 +++++ .../tfminecraft/coreprotect/database/LookupRaw.java | 8 ++++++++ .../database/LookupRawActionPredicateTest.java | 10 ++++++++++ 3 files changed, 23 insertions(+) diff --git a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index 472c14d..0b430e3 100755 --- a/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java @@ -584,6 +584,11 @@ else if (type == 4 || type == 5) { argExclude = ConfigHandler.lookupElist.get(player.getName()); argExcludeUsers = ConfigHandler.lookupEUserlist.get(player.getName()); argAction = ConfigHandler.lookupAlist.get(player.getName()); + // A page lookup reuses the saved actions, so check them against the current permissions + if (argAction != null && argAction.stream().anyMatch(LookupActions::isPvp) && !player.hasPermission("coreprotect.lookup.pvp")) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.NO_PERMISSION)); + return; + } argEntityActionFilter = ConfigHandler.lookupEntityActionFilter.getOrDefault(player.getName(), EntityActionFilter.DEFAULT); argFilters = ConfigHandler.lookupFlist.getOrDefault(player.getName(), Collections.emptyList()); argRadius = ConfigHandler.lookupRadius.get(player.getName()); diff --git a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java index 61f02d7..2f65efb 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java @@ -849,6 +849,10 @@ else if (actionList.contains(LookupActions.ITEM) && !actionList.contains(LookupA else if (inventoryQuery || actionExclude.length() > 0 || includeBlock.length() > 0 || includeEntity.length() > 0 || excludeBlock.length() > 0 || excludeEntity.length() > 0) { queryBlock = queryBlock + " action NOT IN(-1) AND"; } + if (!validAction && lookup && actionList.isEmpty() && !canLookupPvp(user)) { + // Lookups without an action list fights only for staff who may see them + queryBlock = queryBlock + " action NOT IN(" + LookupActions.PVP_HIT + "," + LookupActions.PVP_KNOCKOUT + "," + LookupActions.PVP_KILL + ") AND"; + } if (includeBlock.length() > 0 || includeEntity.length() > 0) { queryBlock = queryBlock + " type IN(" + (includeBlock.length() > 0 ? includeBlock : "0") + ") AND"; @@ -1396,6 +1400,10 @@ else if (includeOther) { } } + static boolean canLookupPvp(CommandSender user) { + return user == null || user.hasPermission("coreprotect.lookup.pvp"); + } + private static String placedEntityTypeIds() { StringJoiner ids = new StringJoiner(","); for (Integer id : EntitySpawnTracking.getPlacedEntityTypeIds()) { diff --git a/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java index ecf96f7..7d36660 100644 --- a/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java +++ b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java @@ -31,6 +31,16 @@ void killLookupsKeepPlayerVictimsWhenNoPlacedEntityTypesAreKnown() throws Except } } + @Test + void onlyStaffWithThePvpPermissionSeeFightsInActionlessLookups() { + var staff = mock(org.bukkit.command.CommandSender.class); + when(staff.hasPermission("coreprotect.lookup.pvp")).thenReturn(true); + var builder = mock(org.bukkit.command.CommandSender.class); + assertTrue(LookupRaw.canLookupPvp(null)); + assertTrue(LookupRaw.canLookupPvp(staff)); + assertFalse(LookupRaw.canLookupPvp(builder)); + } + @Test void pvpLookupsSelectOnlyTheirOwnActions() throws Exception { String pvp = predicate("14,15,16", LookupActions.PVP_ACTIONS, EntityActionFilter.NONE); From 1a44d641f17968255cadd97dc5e31e2a6b3601e7 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:50 +0000 Subject: [PATCH 5/5] fix: leave fights out of rollback queries in SQL Rollbacks dropped PvP rows only after the query, so a limited rollback query could fill up with hits and miss older block changes, and counts included them. Exclude them in the query when no action is given. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/coreprotect/database/LookupRaw.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java index 2f65efb..dc3e605 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/LookupRaw.java @@ -849,8 +849,9 @@ else if (actionList.contains(LookupActions.ITEM) && !actionList.contains(LookupA else if (inventoryQuery || actionExclude.length() > 0 || includeBlock.length() > 0 || includeEntity.length() > 0 || excludeBlock.length() > 0 || excludeEntity.length() > 0) { queryBlock = queryBlock + " action NOT IN(-1) AND"; } - if (!validAction && lookup && actionList.isEmpty() && !canLookupPvp(user)) { - // Lookups without an action list fights only for staff who may see them + if (!validAction && actionList.isEmpty() && (!lookup || !canLookupPvp(user))) { + // Rollbacks never use fights, and lookups without an action list show them only to staff + // who may see them. Filtering here keeps row limits and counts to the rows that remain. queryBlock = queryBlock + " action NOT IN(" + LookupActions.PVP_HIT + "," + LookupActions.PVP_KNOCKOUT + "," + LookupActions.PVP_KILL + ") AND"; }