diff --git a/README.md b/README.md index b187a1a..971e929 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,18 @@ This repository is TF-Minecraft's fork of CoreProtect, developed by [PlayPro](ht - **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. +- **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. CoreProtect supports both investigation and recovery; the recorded history available to staff depends on the server's logging settings. +Named ordinary items are marked as renamed; a display name alone does not prove +MMOItems or MythicMobs identity. These labels also apply to historical records +where the required metadata was saved. MythicLib and MythicMobs integrations are +optional, and the vanilla type remains the fallback. Existing material filters, +aggregate counts and rollback payloads keep their original meaning; custom-ID +search and custom mob restoration are not added by this change. + ## Documentation [Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/CoreProtect/README.md) 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 28563a1..14bb1c4 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,9 @@ 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.MythicIdentity; +import net.tfminecraft.coreprotect.database.statement.EntityStatement; +import net.tfminecraft.coreprotect.utility.AuditLabels; import net.tfminecraft.coreprotect.model.action.LookupActions; import net.tfminecraft.coreprotect.model.action.SessionActions; import net.tfminecraft.coreprotect.model.entity.EntitySpawnRecord; @@ -321,6 +324,17 @@ else if (pageStart < rows) { ? Lookup.performPartialLookup(statement, player, uuidList, userList, blockList, excludedBlocks, excludedUsers, actions, entityActionFilter, messageFilters, entityContext, finalLocation, radius, rowData, timeStart, timeEnd, (int) pageStart, displayResults, restrict_world, true, entityContainerId, rollbackState) : lookupPage.getRows(); + Set killedEntities = new HashSet<>(); + for (String[] row : lookupList) { + if (row.length > 11 && row[7] != null && row[10] != null + && Integer.parseInt(row[7]) == LookupActions.ENTITY_KILL + && Integer.parseInt(row[10]) == -1 && Integer.parseInt(row[5]) != 0 + && (row.length <= 13 || row[13] == null + || Integer.parseInt(row[13]) != InventorySources.ENTITY_INTERACTION)) { + killedEntities.add(Integer.parseInt(row[6])); + } + } + Map> killedEntityData = EntityStatement.loadData(connection, killedEntities); Map entitySpawnRecords = Collections.emptyMap(); Map loadedEntityLocations = Collections.emptyMap(); Set entitySpawnRowIds = new HashSet<>(); @@ -462,6 +476,7 @@ else if (LookupActions.isInventoryLookup(actions)) { Material blockType = ItemUtils.itemFilter(MaterialUtils.getType(dtype), (Integer.parseInt(data[13]) == 0)); String dname = StringUtils.nameFilter(blockType.name().toLowerCase(Locale.ROOT), ddata); byte[] metadata = data[11] == null ? null : data[11].getBytes(StandardCharsets.ISO_8859_1); + dname = AuditLabels.item(metadata, dtype, amount, dname); String tooltip = ItemUtils.getEnchantments(metadata, dtype, amount); Integer itemId = ItemUtils.makeGivableItem(ItemUtils.getItemStack(metadata, dtype, amount)); @@ -566,12 +581,18 @@ else if ((daction == LookupActions.ENTITY_KILL || daction == LookupActions.ENTIT // Functions.sendMessage(player2, timeago+" " + ChatColors.WHITE + "- " + ChatColors.DARK_AQUA+rbd+""+dplayer+" " + ChatColors.WHITE+rbd+""+a+" " + ChatColors.DARK_AQUA+rbd+"#"+dtype+ChatColors.WHITE + ". " + ChatColors.GREY + "(x"+x+"/y"+y+"/z"+z+")"); + if (!entityInteraction && daction == LookupActions.ENTITY_KILL && amount == -1 && !isPlayer) { + MythicIdentity identity = MythicIdentity.fromLookupString(data[11]); + dname = AuditLabels.mob(dname, killedEntityData.get(ddata), identity == null ? null : identity.id()); + } + Phrase phrase = Phrase.LOOKUP_BLOCK; String selector = Selector.FIRST; String action = "a:block"; LockChange lockChange = 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); String tooltip = ItemUtils.getEnchantments(metadata, dtype, amount); Integer itemId = ItemUtils.makeGivableItem(ItemUtils.getItemStack(metadata, dtype, amount)); diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java index 7bbe87d..82a632b 100755 --- a/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/Queue.java @@ -410,7 +410,12 @@ protected static void queueEntityInsert(int id, String name) { // These auxiliary queues still hold distinct legacy payloads consumed by the matching processors. @SuppressWarnings("deprecation") protected static void queueEntityKill(String user, Location location, List data, EntityType type) { - queueStandardData(new Object[] { null, Process.ENTITY_KILL, null, 0, null, 0, 0 }, new String[] { user, null }, new Object[] { getBlockLocation(location), type, null }, false, Consumer.consumerObjectList, data, Consumer.reserveConsumer()); + queueEntityKill(user, location, data, type, null); + } + + @SuppressWarnings("deprecation") + protected static void queueEntityKill(String user, Location location, List data, EntityType type, net.tfminecraft.coreprotect.model.action.MythicIdentity identity) { + queueStandardData(new Object[] { null, Process.ENTITY_KILL, null, 0, null, 0, 0 }, new String[] { user, null }, new Object[] { getBlockLocation(location), type, identity }, false, Consumer.consumerObjectList, data, Consumer.reserveConsumer()); } protected static void queueEntitySpawn(String user, BlockState block, EntityType type, int data) { diff --git a/src/main/java/net/tfminecraft/coreprotect/consumer/process/EntityKillProcess.java b/src/main/java/net/tfminecraft/coreprotect/consumer/process/EntityKillProcess.java index 6a99906..1e0461a 100644 --- a/src/main/java/net/tfminecraft/coreprotect/consumer/process/EntityKillProcess.java +++ b/src/main/java/net/tfminecraft/coreprotect/consumer/process/EntityKillProcess.java @@ -9,6 +9,7 @@ import org.bukkit.entity.EntityType; import net.tfminecraft.coreprotect.consumer.Consumer; +import net.tfminecraft.coreprotect.model.action.MythicIdentity; import net.tfminecraft.coreprotect.database.logger.EntityKillLogger; import net.tfminecraft.coreprotect.utility.EntityUtils; @@ -29,7 +30,8 @@ static void process(ConsumerWriteBatch preparedStmt, ConsumerWriteBatch prepared if (objectLists != null && objectLists.get(id) != null) { List objectList = objectLists.get(id); int entityId = EntityUtils.getEntityId(type); - EntityKillLogger.log(preparedStmt, preparedStmtEntities, preparedStmtEntityKillLinks, batchCount, user, location, objectList, entityId); + EntityKillLogger.log(preparedStmt, preparedStmtEntities, preparedStmtEntityKillLinks, batchCount, user, location, objectList, entityId, + values.length > 2 && values[2] instanceof MythicIdentity identity ? identity : null); } } } diff --git a/src/main/java/net/tfminecraft/coreprotect/database/logger/EntityKillLogger.java b/src/main/java/net/tfminecraft/coreprotect/database/logger/EntityKillLogger.java index 1af4ded..91f3c3c 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/logger/EntityKillLogger.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/logger/EntityKillLogger.java @@ -17,6 +17,7 @@ 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.MythicIdentity; import net.tfminecraft.coreprotect.utility.WorldUtils; public class EntityKillLogger { @@ -25,7 +26,7 @@ private EntityKillLogger() { throw new IllegalStateException("Database class"); } - public static void log(ConsumerWriteBatch preparedStmt, ConsumerWriteBatch preparedStmt2, ConsumerWriteBatch preparedStmtEntityKillLinks, int batchCount, String user, Location location, List data, int type) { + public static void log(ConsumerWriteBatch preparedStmt, ConsumerWriteBatch preparedStmt2, ConsumerWriteBatch preparedStmtEntityKillLinks, int batchCount, String user, Location location, List data, int type, MythicIdentity identity) { try { if (ConfigHandler.isBlacklisted(user)){ return; @@ -68,7 +69,7 @@ public static void log(ConsumerWriteBatch preparedStmt, ConsumerWriteBatch prepa EntitySpawnStatement.addKillLink(preparedStmtEntityKillLinks, (String) data.get(7), entity_key); } - BlockStatement.insert(preparedStmt, batchCount, time, userId, wid, x, y, z, type, entity_key, null, null, LookupActions.ENTITY_KILL, 0); + BlockStatement.insert(preparedStmt, batchCount, time, userId, wid, x, y, z, type, entity_key, identity == null ? null : identity.toMetadata(), null, LookupActions.ENTITY_KILL, 0); } catch (Exception e) { Database.handleWriteFailure(e); diff --git a/src/main/java/net/tfminecraft/coreprotect/database/lookup/ChestTransactionLookup.java b/src/main/java/net/tfminecraft/coreprotect/database/lookup/ChestTransactionLookup.java index 77ab7e3..017cb9e 100644 --- a/src/main/java/net/tfminecraft/coreprotect/database/lookup/ChestTransactionLookup.java +++ b/src/main/java/net/tfminecraft/coreprotect/database/lookup/ChestTransactionLookup.java @@ -186,6 +186,8 @@ else if (exact) { target = target.split(":")[1]; } + target = net.tfminecraft.coreprotect.utility.AuditLabels.item(resultMetadata, resultType, resultAmount, target); + String coordinateInfo = ""; if (entitySpawnRowId != null && (displayWorldId != resultWorldId || displayX != resultX || displayY != resultY || displayZ != resultZ)) { coordinateInfo = ChatUtils.getCoordinateTooltip(resultWorldId, resultX, resultY, resultZ, Phrase.build(Phrase.LOOKUP_ENTITY_ORIGIN), true); diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java index 586656c..cd909c7 100644 --- a/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java +++ b/src/main/java/net/tfminecraft/coreprotect/listener/ListenerHandler.java @@ -205,6 +205,8 @@ public ListenerHandler(CoreProtect plugin) { pluginManager.registerEvents(new PlayerTakeLecternBookListener(), plugin); pluginManager.registerEvents(new ProjectileLaunchListener(), plugin); SkillCastListener.register(plugin); + net.tfminecraft.coreprotect.listener.entity.MythicMobListener.register(plugin); + net.tfminecraft.coreprotect.utility.AuditLabels.register(plugin); // World Listeners pluginManager.registerEvents(new ChunkPopulateListener(), plugin); diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/entity/EntityDeathListener.java b/src/main/java/net/tfminecraft/coreprotect/listener/entity/EntityDeathListener.java index 469f6b9..9770c40 100644 --- a/src/main/java/net/tfminecraft/coreprotect/listener/entity/EntityDeathListener.java +++ b/src/main/java/net/tfminecraft/coreprotect/listener/entity/EntityDeathListener.java @@ -577,7 +577,7 @@ else if (entity instanceof Zoglin) { } if (!(entity instanceof Player)) { - Queue.queueEntityKill(e, entity.getLocation(), data, type); + Queue.queueEntityKill(e, entity.getLocation(), data, type, MythicMobListener.take(entity)); } else { Queue.queuePlayerKill(e, entity.getLocation(), entity.getName()); diff --git a/src/main/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListener.java b/src/main/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListener.java new file mode 100644 index 0000000..5332050 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListener.java @@ -0,0 +1,89 @@ +package net.tfminecraft.coreprotect.listener.entity; + +import java.lang.reflect.Method; + +import org.bukkit.entity.Entity; +import org.bukkit.event.Event; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.Plugin; + +import net.tfminecraft.coreprotect.model.action.MythicIdentity; + +/** Capture while Mythic still owns the mob, before Bukkit's MONITOR death logger. */ +public final class MythicMobListener implements Listener { + private static final String KEY = "coreprotect:mythic-death"; + private static volatile Plugin owner; + private final Plugin plugin; + private final Reader reader; + private boolean warned; + + MythicMobListener(Plugin plugin, Reader reader) { + this.plugin = plugin; + this.reader = reader; + } + + public static void register(Plugin plugin) { + owner = plugin; + Plugin mythic = plugin.getServer().getPluginManager().getPlugin("MythicMobs"); + if (mythic == null || !mythic.isEnabled()) { + return; + } + try { + Class eventClass = Class.forName("io.lumine.mythic.bukkit.events.MythicMobDeathEvent", + true, mythic.getClass().getClassLoader()).asSubclass(Event.class); + MythicMobListener listener = new MythicMobListener(plugin, Reader.of(eventClass)); + plugin.getServer().getPluginManager().registerEvent(eventClass, listener, EventPriority.MONITOR, + (ignored, event) -> listener.onDeath(event), plugin, true); + } + catch (ReflectiveOperationException | LinkageError | RuntimeException exception) { + plugin.getLogger().warning("MythicMobs audit identity is unavailable: " + exception); + } + } + + void onDeath(Event event) { + try { + Entity entity = (Entity) reader.entity.invoke(event); + Object type = reader.type.invoke(event); + MythicIdentity identity = new MythicIdentity((String) reader.id.invoke(type)); + entity.setMetadata(KEY, new FixedMetadataValue(plugin, identity.id())); + } + catch (ReflectiveOperationException | RuntimeException exception) { + if (!warned) { + warned = true; + plugin.getLogger().warning("Unable to read MythicMobs death identity: " + exception); + } + } + } + + public static MythicIdentity take(Entity entity) { + return consume(entity, owner); + } + + static MythicIdentity consume(Entity entity, Plugin plugin) { + if (plugin == null) { + return null; + } + for (MetadataValue value : entity.getMetadata(KEY)) { + if (value.getOwningPlugin() == plugin) { + entity.removeMetadata(KEY, plugin); + try { + return new MythicIdentity(value.asString()); + } + catch (IllegalArgumentException ignored) { + return null; + } + } + } + return null; + } + + record Reader(Method entity, Method type, Method id) { + static Reader of(Class event) throws ReflectiveOperationException { + Method type = event.getMethod("getMobType"); + return new Reader(event.getMethod("getEntity"), type, type.getReturnType().getMethod("getInternalName")); + } + } +} diff --git a/src/main/java/net/tfminecraft/coreprotect/model/action/MythicIdentity.java b/src/main/java/net/tfminecraft/coreprotect/model/action/MythicIdentity.java new file mode 100644 index 0000000..d35d783 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/model/action/MythicIdentity.java @@ -0,0 +1,37 @@ +package net.tfminecraft.coreprotect.model.action; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import net.tfminecraft.coreprotect.database.statement.BlockStatement; + +/** Audit identity only; the original entity rollback payload remains unchanged. */ +public record MythicIdentity(String id) { + public static final String MARKER = "coreprotect:mythic"; + private static final Pattern ID = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); + + public MythicIdentity { + if (id == null || !ID.matcher(id).matches()) { + throw new IllegalArgumentException("Invalid MythicMobs identity"); + } + } + + public List toMetadata() { + return new ArrayList<>(List.of(MARKER, id)); + } + + public static MythicIdentity fromMetadata(List data) { + if (data == null || data.size() != 2 || !MARKER.equals(data.get(0)) + || !(data.get(1) instanceof String id) || !ID.matcher(id).matches()) { + return null; + } + return new MythicIdentity(id); + } + + public static MythicIdentity fromLookupString(String data) { + return data == null || data.isEmpty() ? null + : fromMetadata(BlockStatement.deserializeMetadata(data.getBytes(StandardCharsets.ISO_8859_1))); + } +} diff --git a/src/main/java/net/tfminecraft/coreprotect/utility/AuditLabels.java b/src/main/java/net/tfminecraft/coreprotect/utility/AuditLabels.java new file mode 100644 index 0000000..92c24c6 --- /dev/null +++ b/src/main/java/net/tfminecraft/coreprotect/utility/AuditLabels.java @@ -0,0 +1,130 @@ +package net.tfminecraft.coreprotect.utility; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.plugin.Plugin; + +/** Labels derived from the recorded stack or entity, never from a current item definition. */ +public final class AuditLabels { + private static final int MAX_LABEL_LENGTH = 160; + private static final Pattern ID = Pattern.compile("[A-Za-z0-9_.:-]{1,128}"); + private static final Pattern COLORS = Pattern.compile("(?i)[§&][0-9a-fk-orx]"); + private static final Pattern UNSAFE_TEXT = Pattern.compile("[\\p{Cntrl}\\p{Cf}\\s<>|]+", Pattern.UNICODE_CHARACTER_CLASS); + private static volatile NbtReader nbtReader; + + private AuditLabels() { + } + + /** Resolve optional MythicLib access once, using its own plugin classloader. */ + public static void register(Plugin plugin) { + nbtReader = null; + Plugin mythicLib = plugin.getServer().getPluginManager().getPlugin("MythicLib"); + if (mythicLib == null || !mythicLib.isEnabled()) { + return; + } + try { + Class nbtItem = Class.forName("io.lumine.mythic.lib.api.item.NBTItem", true, mythicLib.getClass().getClassLoader()); + nbtReader = new NbtReader(nbtItem.getMethod("get", ItemStack.class), nbtItem.getMethod("getString", String.class)); + } + catch (ReflectiveOperationException | RuntimeException | LinkageError exception) { + plugin.getLogger().warning("MMOItems audit identities are unavailable for this MythicLib version: " + exception); + } + } + + @SuppressWarnings("deprecation") + public static String item(byte[] metadata, int type, int amount, String vanilla) { + if (metadata == null) { + return bounded(clean(vanilla)); + } + ItemStack stack; + String name = null; + try { + stack = ItemUtils.getItemStack(metadata, type, amount); + if (stack == null) { + return bounded(clean(vanilla)); + } + ItemMeta meta = stack.getItemMeta(); + if (meta != null) { + name = meta.hasDisplayName() ? meta.getDisplayName() : meta.hasItemName() ? meta.getItemName() : null; + } + } + catch (RuntimeException | LinkageError exception) { + return bounded(clean(vanilla)); + } + + String itemType = null; + String itemId = null; + NbtReader reader = nbtReader; + if (reader != null) { + try { + // The reconstructed stack is detached from inventories; MythicLib may wrap it. + Object nbt = reader.get.invoke(null, stack); + itemType = (String) reader.getString.invoke(nbt, "MMOITEMS_ITEM_TYPE"); + itemId = (String) reader.getString.invoke(nbt, "MMOITEMS_ITEM_ID"); + } + catch (ReflectiveOperationException | RuntimeException | LinkageError exception) { + // A malformed historical stack must not prevent the remaining lookup rows. + itemType = null; + itemId = null; + } + } + return item(vanilla, name, itemType, itemId); + } + + static String item(String vanilla, String name, String type, String id) { + String base = clean(vanilla); + String savedName = clean(name); + if (validId(type) && validId(id)) { + return (savedName.isEmpty() ? humanize(id) : savedName) + " [MMOItems " + type + ":" + id + "]"; + } + return savedName.isEmpty() ? base : base + " (renamed: " + savedName + ")"; + } + + public static String mob(String vanilla, List entityData, String identity) { + String name = entityData != null && entityData.size() > 4 && entityData.get(4) instanceof String + ? clean((String) entityData.get(4)) : ""; + if (validId(identity)) { + return (name.isEmpty() ? humanize(identity) : name) + " [MythicMobs " + identity + "]"; + } + String base = clean(vanilla); + return name.isEmpty() ? base : base + " (named: " + name + ")"; + } + + private static boolean validId(String value) { + return value != null && ID.matcher(value).matches(); + } + + private static String clean(String value) { + return value == null ? "" : bounded(UNSAFE_TEXT.matcher(COLORS.matcher(value).replaceAll("")).replaceAll(" ").trim()); + } + + private static String humanize(String id) { + String words = id.replace('_', ' ').toLowerCase(Locale.ROOT); + StringBuilder result = new StringBuilder(words.length()); + boolean first = true; + for (char character : words.toCharArray()) { + result.append(first ? Character.toUpperCase(character) : character); + first = character == ' '; + } + return words.isEmpty() ? "" : result.toString(); + } + + private static String bounded(String value) { + if (value.length() <= MAX_LABEL_LENGTH) { + return value; + } + int end = MAX_LABEL_LENGTH - 1; + if (Character.isHighSurrogate(value.charAt(end - 1))) { + end--; + } + return value.substring(0, end) + "…"; + } + + private record NbtReader(Method get, Method getString) { + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4f1d98f..0218cb4 100755 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ api-version: '1.21.10' folia-supported: true website: http://coreprotect.net author: Intelli -softdepend: [WorldEdit, MythicLib] +softdepend: [WorldEdit, MythicLib, MythicMobs] description: > Provides block protection for your server. libraries: diff --git a/src/test/java/net/tfminecraft/coreprotect/database/logger/EntityKillLoggerTest.java b/src/test/java/net/tfminecraft/coreprotect/database/logger/EntityKillLoggerTest.java new file mode 100644 index 0000000..ba9378c --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/database/logger/EntityKillLoggerTest.java @@ -0,0 +1,54 @@ +package net.tfminecraft.coreprotect.database.logger; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.EntityType; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import net.tfminecraft.coreprotect.config.Config; +import net.tfminecraft.coreprotect.config.ConfigHandler; +import net.tfminecraft.coreprotect.database.ConsumerWriteBatch; +import net.tfminecraft.coreprotect.database.statement.*; +import net.tfminecraft.coreprotect.model.action.MythicIdentity; +import net.tfminecraft.coreprotect.utility.EntityUtils; +import net.tfminecraft.coreprotect.utility.WorldUtils; + +class EntityKillLoggerTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void writesOneKillAndPreservesRollbackPayloadAndTrackedUuid(boolean custom) { + var blockBatch = mock(ConsumerWriteBatch.class); + var entityBatch = mock(ConsumerWriteBatch.class); + var linksBatch = mock(ConsumerWriteBatch.class); + World world = mock(World.class); + when(world.getName()).thenReturn("world"); + var state = new ArrayList(Arrays.asList(0, false, new ArrayList<>(), true, "Ghoul", null, null, "tracked-uuid")); + MythicIdentity identity = custom ? new MythicIdentity("ghoul") : null; + try (var config = mockStatic(Config.class); + var handler = mockStatic(ConfigHandler.class); + var types = mockStatic(EntityUtils.class); + var worlds = mockStatic(WorldUtils.class); + var users = mockStatic(UserStatement.class); + var entities = mockStatic(EntityStatement.class); + var links = mockStatic(EntitySpawnStatement.class); + var blocks = mockStatic(BlockStatement.class)) { + config.when(Config::getGlobal).thenReturn(mock(Config.class)); + types.when(() -> EntityUtils.getEntityType(9)).thenReturn(EntityType.ZOMBIE); + worlds.when(() -> WorldUtils.getWorldId("world")).thenReturn(1); + users.when(() -> UserStatement.getId(blockBatch, "Player", true)).thenReturn(2); + entities.when(() -> EntityStatement.insert(eq(entityBatch), anyInt(), same(state))).thenReturn(42); + EntityKillLogger.log(blockBatch, entityBatch, linksBatch, 0, "Player", new Location(world, 3, 4, 5), state, 9, identity); + entities.verify(() -> EntityStatement.insert(eq(entityBatch), anyInt(), same(state))); + links.verify(() -> EntitySpawnStatement.addKillLink(linksBatch, "tracked-uuid", 42)); + blocks.verify(() -> BlockStatement.insert(eq(blockBatch), eq(0), anyInt(), eq(2), eq(1), eq(3), eq(4), eq(5), + eq(9), eq(42), eq(custom ? identity.toMetadata() : null), isNull(), eq(3), eq(0))); + blocks.verifyNoMoreInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListenerTest.java b/src/test/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListenerTest.java new file mode 100644 index 0000000..710698f --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/listener/entity/MythicMobListenerTest.java @@ -0,0 +1,65 @@ +package net.tfminecraft.coreprotect.listener.entity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import org.bukkit.entity.Entity; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; + +class MythicMobListenerTest { + public static class MobType { + public String getInternalName() { return "cursed_ghoul"; } + } + public static class Death extends Event { + private final Entity entity; + Death(Entity entity) { this.entity = entity; } + public Entity getEntity() { return entity; } + public MobType getMobType() { return new MobType(); } + public HandlerList getHandlers() { return new HandlerList(); } + } + + @Test + void capturesBeforeBukkitDeathAndConsumesOnlyOwnIdentity() throws Exception { + Plugin plugin = mock(Plugin.class); + Entity entity = mock(Entity.class); + List metadata = new ArrayList<>(); + metadata.add(new FixedMetadataValue(mock(Plugin.class), "unrelated")); + when(entity.getMetadata(anyString())).thenAnswer(call -> List.copyOf(metadata)); + doAnswer(call -> { metadata.add(call.getArgument(1)); return null; }) + .when(entity).setMetadata(anyString(), any()); + doAnswer(call -> { metadata.removeIf(v -> v.getOwningPlugin() == plugin); return null; }) + .when(entity).removeMetadata(anyString(), eq(plugin)); + var listener = new MythicMobListener(plugin, MythicMobListener.Reader.of(Death.class)); + listener.onDeath(new Death(entity)); + // Mythic's active mob may now be removed. No live manager lookup occurs. + assertEquals("cursed_ghoul", MythicMobListener.consume(entity, plugin).id()); + assertNull(MythicMobListener.consume(entity, plugin)); + assertEquals(1, metadata.size()); + } + + @Test + void missingOptionalPluginAndVanillaMobHaveNoIdentity() { + assertNull(MythicMobListener.consume(mock(Entity.class), null)); + assertNull(MythicMobListener.consume(mock(Entity.class), mock(Plugin.class))); + } + + @Test + void incompatibleEventWarnsOnceWithoutBreakingDeathLogging() throws Exception { + Plugin plugin = mock(Plugin.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + var listener = new MythicMobListener(plugin, MythicMobListener.Reader.of(Death.class)); + listener.onDeath(mock(Event.class)); + listener.onDeath(mock(Event.class)); + verify(logger, times(1)).warning(anyString()); + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/model/action/MythicIdentityTest.java b/src/test/java/net/tfminecraft/coreprotect/model/action/MythicIdentityTest.java new file mode 100644 index 0000000..f349411 --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/model/action/MythicIdentityTest.java @@ -0,0 +1,36 @@ +package net.tfminecraft.coreprotect.model.action; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.coreprotect.database.DatabaseType; +import net.tfminecraft.coreprotect.database.statement.BlockStatement; +import net.tfminecraft.coreprotect.database.rollback.RollbackUtil; + +class MythicIdentityTest { + @ParameterizedTest + @EnumSource(DatabaseType.class) + void existingCodecsAndRollbackReaderAcceptIdentity(DatabaseType type) { + MythicIdentity identity = new MythicIdentity("cursed_ghoul"); + byte[] blob = BlockStatement.serializeMetadata(identity.toMetadata(), type); + assertNotNull(blob); + assertEquals(identity, MythicIdentity.fromMetadata(BlockStatement.deserializeMetadata(blob))); + assertEquals(identity, MythicIdentity.fromMetadata(RollbackUtil.deserializeMetadata(blob))); + assertEquals(identity, MythicIdentity.fromLookupString(new String(blob, StandardCharsets.ISO_8859_1))); + } + + @Test + void otherMetadataIsNotAnIdentity() { + assertNull(MythicIdentity.fromMetadata(null)); + assertNull(MythicIdentity.fromMetadata(List.of("coreprotect:lock", "ghoul"))); + assertNull(MythicIdentity.fromMetadata(List.of("coreprotect:mythic", "bad id"))); + assertNull(MythicIdentity.fromMetadata(List.of("coreprotect:mythic", "ghoul", "extra"))); + assertThrows(IllegalArgumentException.class, () -> new MythicIdentity("x".repeat(129))); + } +} diff --git a/src/test/java/net/tfminecraft/coreprotect/utility/AuditLabelsTest.java b/src/test/java/net/tfminecraft/coreprotect/utility/AuditLabelsTest.java new file mode 100644 index 0000000..c60f48f --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/utility/AuditLabelsTest.java @@ -0,0 +1,69 @@ +package net.tfminecraft.coreprotect.utility; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AuditLabelsTest { + @Test + void distinguishesRenamedVanillaFromRecordedMmoItem() { + assertEquals("iron_ingot (renamed: Mythril)", AuditLabels.item("iron_ingot", "Mythril", null, null)); + assertEquals("Mythril [MMOItems MATERIAL:MYTHRIL]", AuditLabels.item("iron_ingot", "Mythril", "MATERIAL", "MYTHRIL")); + assertEquals("iron_ingot", AuditLabels.item("iron_ingot", null, null, null)); + } + + @Test + void preservesAlloyNamesWhenStableIdsAreShared() { + assertEquals("Mythril Alloy [MMOItems MATERIAL:ALLOY]", AuditLabels.item("iron_ingot", "Mythril Alloy", "MATERIAL", "ALLOY")); + assertEquals("Bronze Alloy [MMOItems MATERIAL:ALLOY]", AuditLabels.item("iron_ingot", "Bronze Alloy", "MATERIAL", "ALLOY")); + } + + @Test + void usesIdentityWhenSavedNameIsAbsent() { + assertEquals("Mythril Ingot [MMOItems MATERIAL:MYTHRIL_INGOT]", AuditLabels.item("iron_ingot", "", "MATERIAL", "MYTHRIL_INGOT")); + assertEquals("Forest Goblin [MythicMobs FOREST_GOBLIN]", AuditLabels.mob("zombie", null, "FOREST_GOBLIN")); + } + + @Test + void rejectsPartialOrMalformedIdentities() { + assertEquals("iron_ingot (renamed: Mythril)", AuditLabels.item("iron_ingot", "Mythril", "MATERIAL", null)); + assertEquals("iron_ingot (renamed: Mythril)", AuditLabels.item("iron_ingot", "Mythril", "MATERIAL", "MYTHRIL")); + assertEquals("iron_ingot", AuditLabels.item("iron_ingot", null, "MATERIAL", "A".repeat(129))); + assertEquals("zombie (named: Goblin)", AuditLabels.mob("zombie", entity("Goblin"), "goblin|bad")); + } + + @Test + void stripsColorsControlsAndComponentMarkup() { + String result = AuditLabels.item("iron_ingot", "§x§1§2§3§4§5§6Mythril\n\t|\u0000§r&kAlloy", "MATERIAL", "ALLOY"); + assertEquals("Mythril COMPONENT Alloy [MMOItems MATERIAL:ALLOY]", result); + assertFalse(result.contains("§")); + assertFalse(result.contains("<")); + assertFalse(result.contains("|")); + } + + @Test + void handlesHistoricalMobRowsWithoutClaimingMythicIdentity() { + assertEquals("zombie (named: Goblin King)", AuditLabels.mob("zombie", entity("§aGoblin King"), null)); + assertEquals("Goblin King [MythicMobs GoblinKing]", AuditLabels.mob("zombie", entity("§aGoblin King"), "GoblinKing")); + assertEquals("zombie", AuditLabels.mob("zombie", List.of(), null)); + assertEquals("zombie", AuditLabels.mob("zombie", entity(null), null)); + assertEquals("zombie", AuditLabels.mob("zombie", entity(123), null)); + } + + @Test + void boundsLabels() { + String result = AuditLabels.item("iron_ingot", "A".repeat(300), "MATERIAL", "ALLOY"); + assertEquals("A".repeat(159) + "… [MMOItems MATERIAL:ALLOY]", result); + assertEquals("A".repeat(159) + "… [MythicMobs Goblin]", AuditLabels.mob("zombie", entity("A".repeat(300)), "Goblin")); + assertTrue(AuditLabels.mob("zombie", entity("A".repeat(300)), null).endsWith("…)")); + } + + private static List entity(Object name) { + return Arrays.asList(null, null, null, false, name); + } +}