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..21ea628 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; @@ -103,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)); } /** @@ -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/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/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java b/src/main/java/net/tfminecraft/coreprotect/command/LookupCommand.java index 5b1e632..0b430e3 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; @@ -580,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/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..dc3e605 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; } @@ -847,6 +849,11 @@ 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 && 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"; + } if (includeBlock.length() > 0 || includeEntity.length() > 0) { queryBlock = queryBlock + " type IN(" + (includeBlock.length() > 0 ? includeBlock : "0") + ") AND"; @@ -1054,7 +1061,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); @@ -1394,12 +1401,17 @@ 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()) { 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/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/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/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/LookupRawActionPredicateTest.java b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java new file mode 100644 index 0000000..7d36660 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/database/LookupRawActionPredicateTest.java @@ -0,0 +1,49 @@ +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 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); + assertEquals("(action IN(14,15,16))", pvp); + } +} 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..c5a7e24 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/model/action/PvpEventTest.java @@ -0,0 +1,68 @@ +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)); + 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))); + } + } +}