Merge branch 'master' of github.com:PlayPro/CoreProtect

This commit is contained in:
Intelli committed 2026-10-12 15:48:04 -06:00
commit 616ed04ca7
11 files changed
+684 -134

No files matched your search

+16 -2
View File
@@ -95,7 +95,7 @@ Purge old block data. Useful for freeing up space on your HDD if you don't need
| Command | Parameters |
| --- | --- |
| /co purge | `t:<time> r:<world> i:<include>` |
| /co purge | `t:<time> r:<world/radius> i:<include> e:<exclude> a:kill` |
For example, `/co purge t:30d` will delete all data older than one month, and only keep the last 30 days of data.
@@ -106,16 +106,30 @@ For example, `/co purge t:30d` will delete all data older than one month, and on
You can optionally specify a world in CoreProtect v19+.
For example, `/co purge t:30d r:#world_nether` will delete all data older than one month in the Nether, without removing data in any other worlds.
**Purging by Radius**
A radius can limit a purge of specific block types or entity kills to the area around you. It only applies to block and entity kill data, so it must be combined with `i:` or `a:kill`, and it can only be used in-game. A radius always applies to the world you are in, so it cannot be combined with a world such as `r:#world_nether`. The radius is limited by `max-radius` in the config, like other commands.
For example, `/co purge t:30d r:50 i:zombie` will delete zombie kills older than one month within 50 blocks of you, without removing any other data. Use `r:50x10` to also limit the height to 10 blocks above and below you.
**Purging Blocks**
You can optionally specify block types in CoreProtect v23+.
For example, `/co purge t:30d i:stone,dirt` will delete all stone and dirt data older than one month, without removing other block data.
**Purging Entity Kills**
Entity kill logs can be purged separately from other data on SQLite, MySQL, and DuckDB. Each kill also removes the saved entity data used to restore the mob on rollback.
* `/co purge t:30d a:kill` deletes all entity kills older than one month, and keeps all other data.
* `/co purge t:30d i:zombie,skeleton` deletes zombie and skeleton kills older than one month. Entity and block types can be combined in `i:`.
* `/co purge t:30d e:villager,wolf` deletes all data older than one month, but keeps villager and wolf kills.
* `/co purge t:30d a:kill e:villager r:#world` deletes entity kills older than one month in the overworld, except villager kills.
`e:` only accepts entity types. It cannot be combined with entity types in `i:`, or with only block types in `i:`, because a block restriction already keeps every kill. `a:kill` cannot be combined with block types in `i:`.
**Database Optimization**
In CoreProtect v2.15+, adding `#optimize` to the end of the command (for example, `/co purge t:30d #optimize`) will also optimize supported database tables and reclaim unused disk space. How this option is handled depends on the database backend:
* SQLite already rebuilds the database from retained data and reclaims unused file space as part of a manual purge, so `#optimize` is not needed.
* MySQL normally deletes matching rows. Adding `#optimize` also optimizes its tables to reclaim unused space.
* MySQL normally deletes matching rows. Adding `#optimize` also removes saved entity data in the purged time range that no entity kill references anymore (this requires the `CREATE TEMPORARY TABLES` privilege), then optimizes its tables to reclaim unused space.
* DuckDB deletes matching rows in one transaction and checkpoints afterward. `#optimize` has no additional effect.
* ClickHouse drops fully covered monthly partitions for an unfiltered time purge and synchronously removes rows from partial or filtered partitions. Adding `#optimize` also runs `OPTIMIZE TABLE ... FINAL`.
+5 -2
View File
@@ -76,8 +76,10 @@ HELP_PARAMS_7: "Exclude blocks/users from the {lookup|rollback|restore}."
HELP_PURGE_1: "Delete data older than specified time."
HELP_PURGE_2: "For example, \"{0}\" will delete all data older than one month, and only keep the last 30 days of data."
HELP_PURGE_3: "Specify the amount of time to purge."
HELP_PURGE_4: "Specify a world to limit the purge to."
HELP_PURGE_5: "Include specific blocks in the purge."
HELP_PURGE_4: "Specify a world or radius to limit the purge to."
HELP_PURGE_5: "Include specific blocks or entity kills in the purge."
HELP_PURGE_6: "Keep entity kills of these types."
HELP_PURGE_7: "Only purge entity kills."
HELP_PURGE_COMMAND: "Delete old block data."
HELP_RADIUS_1: "Specify a radius area."
HELP_RADIUS_2: "Examples: [r:10] (Only make changes within 10 blocks of you)"
@@ -137,6 +139,7 @@ LOOKUP_SEARCHING: "Lookup searching. Please wait..."
LOOKUP_STORAGE: "{0} {deposited|withdrew} {1} {2}."
LOOKUP_TIME: "{0} ago"
LOOKUP_USERNAME: "{0} logged in as {1}."
MAXIMUM_PURGE_RADIUS: "The maximum purge radius is {0}."
MAXIMUM_RADIUS: "The maximum {lookup|rollback|restore} radius is {0}."
MESSAGE_FILTER_TOO_SHORT: "Minimum message filter length is {0} characters."
MISSING_ACTION_USER: "To use that action, please specify a user."
@@ -70,8 +70,10 @@ public class HelpCommand {
else if (helpcommand.equals("purge") || helpcommand.equals("purges")) {
Chat.sendMessage(player, Color.DARK_AQUA + "/co purge " + Color.GREY + "<params> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_1));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "t:<time> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_3));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "r:<world> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_4));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "r:<world/radius> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_4));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "i:<include> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_5));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "e:<exclude> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_6));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "a:kill " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_7));
Chat.sendMessage(player, Color.GREY + Color.ITALIC + Phrase.build(Phrase.HELP_PURGE_2, "/co purge t:30d"));
}
else if (helpcommand.equals("reload")) {
@@ -13,6 +13,7 @@ import java.sql.Statement;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -25,9 +26,11 @@ import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import net.coreprotect.bukkit.BukkitAdapter;
import net.coreprotect.config.Config;
import net.coreprotect.config.ConfigHandler;
import net.coreprotect.consumer.Consumer;
import net.coreprotect.database.Database;
import net.coreprotect.database.PurgeFilter;
import net.coreprotect.database.PurgePolicy;
import net.coreprotect.language.Phrase;
import net.coreprotect.language.Selector;
@@ -40,6 +43,7 @@ import net.coreprotect.utility.EntityUtils;
import net.coreprotect.utility.EntitySpawnTracking;
import net.coreprotect.utility.MaterialUtils;
import net.coreprotect.utility.VersionUtils;
import net.coreprotect.utility.WorldUtils;
import net.coreprotect.utility.ErrorReporter;
public class PurgeCommand extends Consumer {
@@ -105,8 +109,31 @@ public class PurgeCommand extends Consumer {
ErrorReporter.report(exception);
}
private static String findUnsupportedPurgeArgument(String[] args) {
boolean includeContinuation = false;
/**
* Returns the entity_map ids of every name that resolves to an entity type. A renamed type keeps the kills logged
* under its old name, such as zombie_pigman for zombified_piglin.
*/
private static List<Integer> killTypeIds(EntityType entityType) {
List<Integer> ids = new ArrayList<>();
synchronized (ConfigHandler.entities) {
for (Map.Entry<String, Integer> entry : ConfigHandler.entities.entrySet()) {
try {
if (EntityUtils.getEntityType(entry.getKey()) == entityType) {
ids.add(entry.getValue());
}
}
catch (IllegalArgumentException e) {
// An entity that no longer exists in this version
}
}
}
return ids;
}
static String findUnsupportedPurgeArgument(String[] args) {
boolean listContinuation = false;
String emptyList = null; // an include or exclude argument that has no value yet
boolean hasRadius = false;
for (int i = 1; i < args.length; i++) {
String token = args[i].trim();
if (token.length() == 0) {
@@ -117,18 +144,42 @@ public class PurgeCommand extends Consumer {
argument = argument.replaceAll("\\\\", "");
argument = argument.replaceAll("'", "");
if (includeContinuation) {
includeContinuation = argument.endsWith(",");
continue;
}
if (argument.equals("#optimize")) {
continue;
}
String listValues = null;
if (argument.startsWith("i:") || argument.startsWith("include:") || argument.startsWith("item:") || argument.startsWith("items:") || argument.startsWith("b:") || argument.startsWith("block:") || argument.startsWith("blocks:")) {
String includeValues = argument.replaceAll("include:", "").replaceAll("i:", "").replaceAll("items:", "").replaceAll("item:", "").replaceAll("blocks:", "").replaceAll("block:", "").replaceAll("b:", "");
includeContinuation = includeValues.length() == 0 || includeValues.endsWith(",");
listValues = argument.replaceAll("include:", "").replaceAll("i:", "").replaceAll("items:", "").replaceAll("item:", "").replaceAll("blocks:", "").replaceAll("block:", "").replaceAll("b:", "");
}
else if (argument.startsWith("e:") || argument.startsWith("exclude:")) {
listValues = argument.replaceAll("exclude:", "").replaceAll("e:", "");
}
if (listValues != null) {
if (emptyList != null) {
return emptyList;
}
listContinuation = listValues.length() == 0 || listValues.endsWith(",");
emptyList = hasListValue(listValues) ? null : token;
continue;
}
if (listContinuation && isListValue(argument)) {
listContinuation = argument.endsWith(",");
if (hasListValue(argument)) {
emptyList = null;
}
continue;
}
// The radius, world and action parsers read a key such as r: or a: even inside a list
listContinuation = false;
if (argument.startsWith("a:") || argument.startsWith("action:")) {
// Only kills can be purged by action; any other value must not fall through to an unrestricted purge.
if (!CommandParser.parseAction(new String[] { "purge", argument }).equals(Collections.singletonList(LookupActions.ENTITY_KILL))) {
return token;
}
continue;
}
@@ -137,6 +188,11 @@ public class PurgeCommand extends Consumer {
}
if (argument.startsWith("r:") || argument.startsWith("radius:")) {
if (hasRadius) {
// A second radius conflicts with the first, such as r:50 with r:#world_nether
return token;
}
hasRadius = true;
continue;
}
@@ -145,7 +201,17 @@ public class PurgeCommand extends Consumer {
}
}
return null;
// An empty list would silently purge without that include or exclude restriction
return emptyList;
}
private static boolean hasListValue(String values) {
return values.replace(",", "").length() > 0;
}
private static boolean isListValue(String argument) {
int separator = argument.indexOf(':');
return separator < 0 || argument.substring(0, separator).equals("minecraft");
}
protected static void runCommand(final CommandSender player, boolean permission, String[] args) {
@@ -158,7 +224,7 @@ public class PurgeCommand extends Consumer {
final List<String> argExcludeUsers = CommandParser.parseExcludedUsers(player, args);
final long[] argTime = CommandParser.parseTime(args);
final int argWid = CommandParser.parseWorld(args, false, false);
final List<Integer> supportedActions = Arrays.asList();
final List<Integer> supportedActions = Arrays.asList(LookupActions.ENTITY_KILL);
long startTime = argTime[1] > 0 ? argTime[0] : 0;
long endTime = argTime[1] > 0 ? argTime[1] : argTime[0];
@@ -192,8 +258,21 @@ public class PurgeCommand extends Consumer {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co purge t:<time>"));
return;
}
// Without a location (console), a numeric radius parses to null and the purge would run server-wide
if (argRadius != null || CommandParser.parseRadius(args, player, new Location(null, 0, 0, 0)) != null) {
if (CommandParser.parseWorldEdit(args)) {
Chat.sendMessage(player, new ChatMessage(Phrase.build(Phrase.INVALID_WORLD)).build());
return;
}
if (argRadius != null && argRadius[0] == -1) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_RADIUS));
return;
}
int maxRadius = Config.getGlobal().MAX_RADIUS;
if (argRadius != null && maxRadius > 0 && argRadius[0] > maxRadius) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MAXIMUM_PURGE_RADIUS, Integer.toString(maxRadius)));
return;
}
if (argRadius == null && CommandParser.parseRadius(args, player, new Location(null, 0, 0, 0)) != null) {
// A numeric radius without a location (console) must not fall through to a world-wide purge
Chat.sendMessage(player, new ChatMessage(Phrase.build(Phrase.INVALID_WORLD)).build());
return;
}
@@ -219,18 +298,14 @@ public class PurgeCommand extends Consumer {
}
StringBuilder restrict = new StringBuilder();
String includeBlock = "";
List<Integer> includeBlockIds = new ArrayList<>();
String includeEntity = "";
List<Integer> includeEntityIds = new ArrayList<>();
boolean hasBlock = false;
boolean item = false;
boolean entity = false;
int restrictCount = 0;
if (argBlocks.size() > 0) {
StringBuilder includeListMaterial = new StringBuilder();
StringBuilder includeListEntity = new StringBuilder();
for (Object restrictTarget : argBlocks) {
String targetName = "";
@@ -238,17 +313,10 @@ public class PurgeCommand extends Consumer {
targetName = ((Material) restrictTarget).name();
int blockId = MaterialUtils.getBlockId(targetName, false);
includeBlockIds.add(blockId);
if (includeListMaterial.length() == 0) {
includeListMaterial = includeListMaterial.append(blockId);
}
else {
includeListMaterial.append(",").append(blockId);
}
/* Include legacy IDs */
int legacyId = BukkitAdapter.ADAPTER.getLegacyBlockId((Material) restrictTarget);
if (legacyId > 0) {
includeListMaterial.append(",").append(legacyId);
includeBlockIds.add(legacyId);
}
@@ -257,26 +325,13 @@ public class PurgeCommand extends Consumer {
hasBlock = true;
}
else if (restrictTarget instanceof EntityType) {
targetName = ((EntityType) restrictTarget).name();
if (includeListEntity.length() == 0) {
includeListEntity = includeListEntity.append(EntityUtils.getEntityId(targetName, false));
}
else {
includeListEntity.append(",").append(EntityUtils.getEntityId(targetName, false));
}
includeEntityIds.addAll(killTypeIds((EntityType) restrictTarget));
targetName = ((EntityType) restrictTarget).name().toLowerCase(Locale.ROOT);
entity = true;
}
else if (restrictTarget instanceof String) {
int blockId = MaterialUtils.getBlockId((String) restrictTarget, false);
includeBlockIds.add(blockId);
if (includeListMaterial.length() == 0) {
includeListMaterial = includeListMaterial.append(blockId);
}
else {
includeListMaterial.append(",").append(blockId);
}
targetName = ((String) restrictTarget).toLowerCase(Locale.ROOT);
hasBlock = true;
@@ -291,15 +346,36 @@ public class PurgeCommand extends Consumer {
restrictCount++;
}
includeBlock = includeListMaterial.toString();
includeEntity = includeListEntity.toString();
}
if (entity) {
List<Integer> excludeEntityIds = new ArrayList<>();
StringBuilder exclude = new StringBuilder();
for (Object excludeTarget : argExclude.keySet()) {
if (!(excludeTarget instanceof EntityType)) {
// Purge exclusions only support entity types (kills to keep).
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "e:" + excludeTarget.toString().toLowerCase(Locale.ROOT)));
return;
}
excludeEntityIds.addAll(killTypeIds((EntityType) excludeTarget));
exclude.append(exclude.length() == 0 ? "" : ", ").append(((EntityType) excludeTarget).name().toLowerCase(Locale.ROOT));
}
if (!argExcludeUsers.isEmpty()) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "e:" + argExcludeUsers.get(0)));
return;
}
boolean killsOnly = argAction.contains(LookupActions.ENTITY_KILL);
boolean entityFilter = entity || killsOnly || !excludeEntityIds.isEmpty();
boolean excludeWithoutKills = !excludeEntityIds.isEmpty() && hasBlock && !entity; // a block restriction keeps every kill
if ((killsOnly && hasBlock) || (entity && !excludeEntityIds.isEmpty()) || excludeWithoutKills || ((entityFilter || argRadius != null) && ConfigHandler.databaseType.isClickHouse())) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.ACTION_NOT_SUPPORTED));
return;
}
if (argRadius != null && !hasBlock && !entity && !killsOnly) {
// A radius only limits co_block rows, so the purge must be restricted to them
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co purge t:<time> r:<radius> i:<include>"));
return;
}
boolean optimizeCheck = false;
for (String arg : args) {
@@ -310,11 +386,22 @@ public class PurgeCommand extends Consumer {
}
final StringBuilder restrictTargets = restrict;
final String includeBlockFinal = includeBlock;
final StringBuilder excludeTargets = exclude;
final List<Integer> includeBlockIdsFinal = List.copyOf(includeBlockIds);
final List<Integer> includeEntityIdsFinal = List.copyOf(includeEntityIds);
final List<Integer> excludeEntityIdsFinal = List.copyOf(excludeEntityIds);
final boolean killsOnlyFinal = killsOnly;
final boolean optimize = optimizeCheck;
final boolean hasBlockRestriction = hasBlock;
final int restrictCountFinal = restrictCount;
final int purgeWorldId = argRadius != null ? WorldUtils.getWorldId(location.getWorld().getName()) : argWid;
String restrictSelector = Selector.FIRST; // block
if (hasBlock && entity) {
restrictSelector = Selector.THIRD; // target
}
else if (entity) {
restrictSelector = Selector.SECOND; // entity
}
final String restrictKind = restrictSelector;
class BasicThread implements Runnable {
@@ -341,6 +428,7 @@ public class PurgeCommand extends Consumer {
long timeStart = startTime > 0 ? (timestamp - startTime) : 0;
long timeEnd = timestamp - endTime;
long removed = 0;
PurgeFilter purgeFilter = new PurgeFilter(timeStart, timeEnd, purgeWorldId, argRadius, includeBlockIdsFinal, includeEntityIdsFinal, excludeEntityIdsFinal, killsOnlyFinal);
for (int i = 0; i <= 5; i++) {
requirePurgeNotCancelled();
@@ -375,7 +463,11 @@ public class PurgeCommand extends Consumer {
activePurgeThread = Thread.currentThread();
requirePurgeNotCancelled();
if (argWid > 0) {
if (argRadius != null) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, location.getWorld().getName()));
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_RADIUS, argRadius[0].toString(), (argRadius[0] == 1 ? Selector.FIRST : Selector.SECOND)));
}
else if (argWid > 0) {
String worldName = CommandParser.parseWorldName(args, false);
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, worldName));
}
@@ -383,8 +475,11 @@ public class PurgeCommand extends Consumer {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, "#global"));
}
if (hasBlockRestriction) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, restrictTargets.toString(), Selector.FIRST, Selector.FIRST, (restrictCountFinal == 1 ? Selector.FIRST : Selector.SECOND))); // include
if (restrictCountFinal > 0) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, restrictTargets.toString(), Selector.FIRST, restrictKind, (restrictCountFinal == 1 ? Selector.FIRST : Selector.SECOND))); // include
}
if (excludeEntityIdsFinal.size() > 0) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, excludeTargets.toString(), Selector.SECOND, Selector.SECOND, (excludeEntityIdsFinal.size() == 1 ? Selector.FIRST : Selector.SECOND))); // exclude
}
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_NOTICE_1));
@@ -493,33 +588,17 @@ public class PurgeCommand extends Consumer {
boolean error = false;
if (!excludeTables.contains(table)) {
try {
boolean purge = true;
String timeLimit = "";
if (table.equals("entity_spawn")) {
timeLimit = " WHERE removed=0 OR block_rowid IN(SELECT rowid FROM " + purgePrefix + "block) OR kill_rowid IN(SELECT rowid FROM " + purgePrefix + "entity) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_container) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_interaction)";
}
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 ";
else if (table.equals("entity")) {
timeLimit = " WHERE " + purgeFilter.entityRetainCondition(purgePrefix + "block");
}
else if (hasBlockRestriction) {
purge = false;
}
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
if (purge) {
String worldMatch = "(wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
timeLimit = " WHERE (" + worldMatch + " AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "')) OR NOT " + worldMatch;
}
}
else if (purge) {
timeLimit = " WHERE (" + blockRestriction + "wid = '" + argWid + "' AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "'))) OR (wid != '" + argWid + "')";
}
}
else if (argWid == 0 && purge) {
timeLimit = " WHERE " + blockRestriction + "(time >= '" + timeEnd + "' OR time < '" + timeStart + "'))";
else {
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
if (purgeCondition != null) {
timeLimit = " WHERE NOT (" + purgeCondition + ")";
}
}
query = "INSERT INTO " + purgePrefix + table + insertColumns + " SELECT " + selectColumns + " FROM " + ConfigHandler.prefix + table + timeLimit;
@@ -572,35 +651,21 @@ public class PurgeCommand extends Consumer {
}
try {
boolean purge = PurgePolicy.isPurgeable(table);
String blockRestriction = "";
if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) {
blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND ";
}
else if (hasBlockRestriction) {
purge = false;
}
String worldRestriction = "";
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
}
else {
worldRestriction = " AND wid = '" + argWid + "'";
}
}
else if (argWid > 0) {
purge = false;
}
if (purge) {
query = "DELETE FROM " + purgePrefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction;
if (table.equals("entity")) {
query = purgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block");
preparedStmt = preparePurgeStatement(connection, query);
preparedStmt.execute();
preparedStmt.close();
}
else {
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
if (purgeCondition != null) {
query = "DELETE FROM " + purgePrefix + table + " WHERE " + purgeCondition;
preparedStmt = preparePurgeStatement(connection, query);
preparedStmt.execute();
preparedStmt.close();
}
}
}
catch (Exception e) {
reportPurgeFailure(e);
@@ -644,35 +709,47 @@ public class PurgeCommand extends Consumer {
if (!ConfigHandler.databaseType.isSQLite()) {
try {
boolean purge = PurgePolicy.isPurgeable(table);
String blockRestriction = "";
if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) {
blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND ";
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
boolean deleteEntities = purgeCondition != null && table.equals("block") && purgeFilter.removesKills() && !purgeFilter.purgesEntitiesByTime();
// Kill rows and their entity data go together, or the kept kills lose their rollback data
boolean atomic = deleteEntities && ConfigHandler.databaseType.isMySQL();
if (atomic) {
connection.setAutoCommit(false);
}
else if (hasBlockRestriction) {
purge = false;
}
String worldRestriction = "";
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
}
else {
worldRestriction = " AND wid = '" + argWid + "'";
}
}
else if (argWid > 0) {
purge = false;
}
if (purge) {
query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction;
try {
long deleted = 0;
if (deleteEntities) {
query = purgeFilter.deleteEntitiesOfPurgedKills(ConfigHandler.databaseType, ConfigHandler.prefix);
preparedStmt = preparePurgeStatement(connection, query);
removed = removed + preparedStmt.executeUpdate();
deleted = deleted + preparedStmt.executeUpdate();
preparedStmt.close();
}
if (purgeCondition != null) {
query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + purgeCondition;
preparedStmt = preparePurgeStatement(connection, query);
deleted = deleted + preparedStmt.executeUpdate();
preparedStmt.close();
}
if (atomic) {
connection.commit();
connection.setAutoCommit(true);
}
removed = removed + deleted;
}
catch (Exception e) {
if (atomic) {
try {
connection.rollback();
connection.setAutoCommit(true);
}
catch (SQLException rollbackException) {
e.addSuppressed(rollbackException);
}
}
throw e;
}
}
catch (Exception e) {
if (ConfigHandler.databaseType.isDuckDB()) {
@@ -688,6 +765,32 @@ public class PurgeCommand extends Consumer {
}
}
if (ConfigHandler.databaseType.isMySQL() && optimize) {
try {
for (String sweepQuery : PurgeFilter.mysqlOrphanSweepSetup(ConfigHandler.prefix)) {
preparedStmt = preparePurgeStatement(connection, sweepQuery);
preparedStmt.execute();
preparedStmt.close();
}
preparedStmt = preparePurgeStatement(connection, purgeFilter.mysqlOrphanSweepDelete(ConfigHandler.prefix));
removed = removed + preparedStmt.executeUpdate();
preparedStmt.close();
}
catch (Exception e) {
reportPurgeFailure(e);
}
finally {
try {
preparedStmt = preparePurgeStatement(connection, PurgeFilter.mysqlOrphanSweepTeardown(ConfigHandler.prefix));
preparedStmt.execute();
preparedStmt.close();
}
catch (Exception e) {
reportPurgeFailure(e);
}
}
}
requirePurgeNotCancelled();
String retainedPrefix = ConfigHandler.databaseType.isSQLite() ? purgePrefix : ConfigHandler.prefix;
query = "UPDATE " + retainedPrefix + "entity_spawn SET kill_rowid=NULL WHERE kill_rowid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM " + retainedPrefix + "entity WHERE " + retainedPrefix + "entity.rowid=" + retainedPrefix + "entity_spawn.kill_rowid)";
@@ -447,7 +447,7 @@ public class TabHandler implements TabCompleter {
return StringUtil.copyPartialMatches(argument1, completions, new ArrayList<>(completions.size()));
}
else if (argument0.equals("purge") && sender.hasPermission("coreprotect.purge")) {
List<String> completions = new ArrayList<>(Arrays.asList("t:", "r:", "i:"));
List<String> completions = new ArrayList<>(Arrays.asList("t:", "r:", "i:", "e:", "a:kill"));
return StringUtil.copyPartialMatches(argument1, completions, new ArrayList<>(completions.size()));
}
else if (hasLookupCommand(argument0, sender)) {
@@ -464,10 +464,10 @@ public class TabHandler implements TabCompleter {
String argument2 = arg2.toLowerCase(Locale.ROOT);
if (argument1.startsWith("t:")) {
List<String> completions = new ArrayList<>(Arrays.asList("r:", "i:"));
List<String> completions = new ArrayList<>(Arrays.asList("r:", "i:", "e:", "a:kill"));
return StringUtil.copyPartialMatches(argument2, completions, new ArrayList<>(completions.size()));
}
else if (argument1.startsWith("r:") || argument1.startsWith("i:")) {
else if (argument1.startsWith("r:") || argument1.startsWith("i:") || argument1.startsWith("e:") || argument1.startsWith("a:")) {
List<String> completions = new ArrayList<>(Arrays.asList("t:"));
return StringUtil.copyPartialMatches(argument2, completions, new ArrayList<>(completions.size()));
}
@@ -0,0 +1,258 @@
package net.coreprotect.database;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
import net.coreprotect.model.action.LookupActions;
/**
* Selects the rows that one /co purge removes on the relational backends (SQLite, MySQL, DuckDB).
*
* <p>
* co_entity has no world or type column: each row belongs to the co_block kill row whose data column holds its
* rowid. The statements built here remove co_entity rows through those kill rows, so a scoped purge never leaves
* entity data behind without the kill row that references it. Player kills also use the kill action, but they
* store type 0 and a user id in data, so they are never treated as co_entity references.
*/
public final class PurgeFilter {
private final long timeStart;
private final long timeEnd;
private final int worldId;
private final Integer[] radius;
private final List<Integer> blockTypes;
private final List<Integer> entityTypes;
private final List<Integer> excludedEntityTypes;
private final boolean killsOnly;
/**
* @param timeStart
* the oldest time (inclusive) to purge
* @param timeEnd
* the newest time (exclusive) to purge
* @param worldId
* the world to purge, or 0 for every world
* @param radius
* the area to purge, as returned by CommandParser.parseRadius, or null for the whole world. It limits
* co_block rows only, so it counts as a restriction that leaves the other tables untouched.
* @param blockTypes
* material ids (i:) that restrict the purge to those co_block rows, or an empty list
* @param entityTypes
* entity type ids (i:) that restrict the purge to kills of those types, or an empty list
* @param excludedEntityTypes
* entity type ids (e:) whose kills the purge keeps, or an empty list
* @param killsOnly
* true (a:kill) to restrict the purge to kill rows
*/
public PurgeFilter(long timeStart, long timeEnd, int worldId, Integer[] radius, List<Integer> blockTypes, List<Integer> entityTypes, List<Integer> excludedEntityTypes, boolean killsOnly) {
this.timeStart = timeStart;
this.timeEnd = timeEnd;
this.worldId = worldId;
this.radius = radius == null ? null : radius.clone();
this.blockTypes = List.copyOf(blockTypes);
this.entityTypes = List.copyOf(entityTypes);
this.excludedEntityTypes = List.copyOf(excludedEntityTypes);
this.killsOnly = killsOnly;
}
/**
* Returns whether the purge is limited to selected co_block rows, which leaves the other tables untouched.
*/
private boolean restrictsTables() {
return radius != null || !blockTypes.isEmpty() || !entityTypes.isEmpty() || killsOnly;
}
/**
* Returns whether the purge removes co_block kill rows. A restriction to materials alone keeps every kill row.
*/
public boolean removesKills() {
return blockTypes.isEmpty() || !entityTypes.isEmpty() || killsOnly;
}
/**
* Returns whether co_entity rows can be removed by time alone. This holds when every kill row in the time range
* is purged, because a co_entity row always has the same time as its kill row.
*/
public boolean purgesEntitiesByTime() {
return worldId <= 0 && !restrictsTables() && excludedEntityTypes.isEmpty();
}
/**
* Builds the condition that matches the rows this purge removes from a table.
*
* @param table
* the table name without prefix
* @param prefix
* the prefix of the tables that subqueries read from
* @return the SQL condition, or null when the purge keeps every row of the table
*/
public String deleteCondition(String table, String prefix) {
if (table.equals("block")) {
return blockCondition("");
}
if (table.equals("entity")) {
return purgesEntitiesByTime() ? timeCondition("") : null;
}
if (!PurgePolicy.isPurgeable(table) || restrictsTables()) {
return null;
}
if (worldId <= 0) {
return timeCondition("");
}
if (!PurgePolicy.isWorldScoped(table)) {
return null;
}
if (table.equals("entity_container") || table.equals("entity_interaction")) {
return timeCondition("") + " AND (wid = " + worldId + " OR entity_spawn_rowid IN(SELECT rowid FROM " + prefix + "entity_spawn WHERE current_wid = " + worldId + "))";
}
return timeCondition("") + " AND wid = " + worldId;
}
/**
* Builds the condition that matches the co_block rows this purge removes.
*
* @param qualifier
* the table alias to prefix each column with, such as "b.", or an empty string
* @return the SQL condition
*/
private String blockCondition(String qualifier) {
StringBuilder condition = new StringBuilder(timeCondition(qualifier));
if (worldId > 0) {
condition.append(" AND ").append(qualifier).append("wid = ").append(worldId);
}
if (radius != null) {
condition.append(" AND ").append(qualifier).append("x >= ").append(radius[1]).append(" AND ").append(qualifier).append("x <= ").append(radius[2]);
if (radius[3] != null) {
condition.append(" AND ").append(qualifier).append("y >= ").append(radius[3]).append(" AND ").append(qualifier).append("y <= ").append(radius[4]);
}
condition.append(" AND ").append(qualifier).append("z >= ").append(radius[5]).append(" AND ").append(qualifier).append("z <= ").append(radius[6]);
}
List<String> restrictions = new ArrayList<>();
if (!blockTypes.isEmpty()) {
restrictions.add("(" + qualifier + "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND " + qualifier + "type IN(" + joinIds(blockTypes) + "))");
}
if (!entityTypes.isEmpty()) {
restrictions.add("(" + qualifier + "action = " + LookupActions.ENTITY_KILL + " AND " + qualifier + "type IN(" + joinIds(entityTypes) + "))");
}
else if (killsOnly) {
restrictions.add(qualifier + "action = " + LookupActions.ENTITY_KILL);
}
if (!restrictions.isEmpty()) {
condition.append(" AND (").append(String.join(" OR ", restrictions)).append(")");
}
if (!excludedEntityTypes.isEmpty()) {
condition.append(" AND NOT (").append(qualifier).append("action = ").append(LookupActions.ENTITY_KILL).append(" AND ").append(qualifier).append("type IN(").append(joinIds(excludedEntityTypes)).append("))");
}
return condition.toString();
}
/**
* Builds the MySQL or DuckDB statement that deletes the co_entity rows of the kill rows this purge removes. Run it
* before the co_block delete, while those kill rows still exist.
*
* @param databaseType
* the MySQL or DuckDB backend
* @param prefix
* the table prefix
* @return the SQL statement
*/
public String deleteEntitiesOfPurgedKills(DatabaseType databaseType, String prefix) {
if (databaseType.isMySQL()) {
// The join form avoids a dependent subquery on MySQL 5.7 and MariaDB.
return "DELETE e FROM " + prefix + "entity AS e INNER JOIN " + prefix + "block AS b ON b.data = e.rowid WHERE " + killReference("b.") + " AND " + blockCondition("b.");
}
return "DELETE FROM " + prefix + "entity WHERE rowid IN(SELECT data FROM " + prefix + "block WHERE " + killReference("") + " AND " + blockCondition("") + ")";
}
/**
* Builds the SQLite copy condition that keeps the co_entity rows outside the purge time range, and those inside it
* that a retained kill row still references.
*
* @param retainedBlockTable
* the co_block table that holds the rows the purge keeps
* @return the SQL condition
*/
public String entityRetainCondition(String retainedBlockTable) {
return "NOT (" + timeCondition("") + ") OR rowid IN(" + killReferences(retainedBlockTable) + ")";
}
/**
* Builds the statement that deletes co_entity rows inside the purge time range that no kill row references.
*
* @param entityTable
* the co_entity table to clean
* @param blockTable
* the co_block table that holds the kill rows
* @return the SQL statement
*/
public String deleteUnreferencedEntities(String entityTable, String blockTable) {
return "DELETE FROM " + entityTable + " WHERE " + timeCondition("") + " AND rowid NOT IN(" + killReferences(blockTable) + ")";
}
/**
* Builds the MySQL statements that prepare an orphan sweep: a temporary table of the co_entity ids that kill rows
* reference. The table avoids an anti-join on the unindexed co_block.data column.
*
* @param prefix
* the table prefix
* @return the SQL statements, in order
*/
public static List<String> mysqlOrphanSweepSetup(String prefix) {
String keepTable = prefix + "entity_keep";
List<String> statements = new ArrayList<>();
statements.add("DROP TEMPORARY TABLE IF EXISTS " + keepTable);
statements.add("CREATE TEMPORARY TABLE " + keepTable + " (rowid INT NOT NULL PRIMARY KEY) ENGINE=InnoDB");
statements.add("INSERT IGNORE INTO " + keepTable + " (rowid) " + killReferences(prefix + "block"));
return statements;
}
/**
* Builds the MySQL statement that deletes co_entity rows no kill row references, such as rows left by earlier
* world purges. Run {@link #mysqlOrphanSweepSetup(String)} first.
*
* <p>
* Only rows inside the purge time range are deleted. Another installation sharing the database can write a kill
* after the setup snapshot, but its co_entity row gets the current time, which is always newer than the range.
*
* @param prefix
* the table prefix
* @return the SQL statement
*/
public String mysqlOrphanSweepDelete(String prefix) {
return "DELETE e FROM " + prefix + "entity AS e LEFT JOIN " + prefix + "entity_keep AS k ON k.rowid = e.rowid WHERE k.rowid IS NULL AND " + timeCondition("e.");
}
/**
* Builds the MySQL statement that drops the temporary table of an orphan sweep.
*
* @param prefix
* the table prefix
* @return the SQL statement
*/
public static String mysqlOrphanSweepTeardown(String prefix) {
return "DROP TEMPORARY TABLE IF EXISTS " + prefix + "entity_keep";
}
private String timeCondition(String qualifier) {
return qualifier + "time < " + timeEnd + " AND " + qualifier + "time >= " + timeStart;
}
private static String killReferences(String blockTable) {
return "SELECT data FROM " + blockTable + " WHERE " + killReference("") + " AND data IS NOT NULL";
}
private static String killReference(String qualifier) {
return qualifier + "action = " + LookupActions.ENTITY_KILL + " AND " + qualifier + "type <> 0";
}
private static String joinIds(List<Integer> ids) {
StringJoiner joiner = new StringJoiner(",");
for (Integer id : ids) {
joiner.add(String.valueOf(id));
}
return joiner.toString();
}
}
@@ -113,8 +113,10 @@ public class Language {
phrases.put(Phrase.HELP_PURGE_1, "Delete data older than specified time.");
phrases.put(Phrase.HELP_PURGE_2, "For example, \"{0}\" will delete all data older than one month, and only keep the last 30 days of data.");
phrases.put(Phrase.HELP_PURGE_3, "Specify the amount of time to purge.");
phrases.put(Phrase.HELP_PURGE_4, "Specify a world to limit the purge to.");
phrases.put(Phrase.HELP_PURGE_5, "Include specific blocks in the purge.");
phrases.put(Phrase.HELP_PURGE_4, "Specify a world or radius to limit the purge to.");
phrases.put(Phrase.HELP_PURGE_5, "Include specific blocks or entity kills in the purge.");
phrases.put(Phrase.HELP_PURGE_6, "Keep entity kills of these types.");
phrases.put(Phrase.HELP_PURGE_7, "Only purge entity kills.");
phrases.put(Phrase.HELP_PURGE_COMMAND, "Delete old block data.");
phrases.put(Phrase.HELP_RADIUS_1, "Specify a radius area.");
phrases.put(Phrase.HELP_RADIUS_2, "Examples: [r:10] (Only make changes within 10 blocks of you)");
@@ -174,6 +176,7 @@ public class Language {
phrases.put(Phrase.LOOKUP_STORAGE, "{0} {deposited|withdrew} {1} {2}.");
phrases.put(Phrase.LOOKUP_TIME, "{0} ago");
phrases.put(Phrase.LOOKUP_USERNAME, "{0} logged in as {1}.");
phrases.put(Phrase.MAXIMUM_PURGE_RADIUS, "The maximum purge radius is {0}.");
phrases.put(Phrase.MAXIMUM_RADIUS, "The maximum {lookup|rollback|restore} radius is {0}.");
phrases.put(Phrase.MESSAGE_FILTER_TOO_SHORT, "Minimum message filter length is {0} characters.");
phrases.put(Phrase.MISSING_ACTION_USER, "To use that action, please specify a user.");
@@ -90,6 +90,8 @@ public enum Phrase {
HELP_PURGE_3,
HELP_PURGE_4,
HELP_PURGE_5,
HELP_PURGE_6,
HELP_PURGE_7,
HELP_PURGE_COMMAND,
HELP_RADIUS_1,
HELP_RADIUS_2,
@@ -145,6 +147,7 @@ public enum Phrase {
LOOKUP_STORAGE,
LOOKUP_TIME,
LOOKUP_USERNAME,
MAXIMUM_PURGE_RADIUS,
MAXIMUM_RADIUS,
MESSAGE_FILTER_TOO_SHORT,
MISSING_ACTION_USER,
@@ -83,7 +83,9 @@ import net.coreprotect.paper.PaperAdapter;
import net.coreprotect.spigot.SpigotAdapter;
import net.coreprotect.thread.CacheHandler;
import net.coreprotect.thread.Scheduler;
import net.coreprotect.utility.AttributeUtils;
import net.coreprotect.utility.EntitySpawnTracking;
import net.coreprotect.utility.entity.EntityUtil;
import net.coreprotect.utility.entity.LivingEntityDetails;
import net.coreprotect.utility.serialize.ItemMetaHandler;
@@ -337,11 +339,19 @@ public final class EntityDeathListener extends Queue implements Listener {
}
}
long baselineBits = 0;
if (entity instanceof Attributable) {
Attributable attributable = entity;
for (Attribute attribute : Lists.newArrayList(Registry.ATTRIBUTE)) {
AttributeInstance attributeInstance = attributable.getAttribute(attribute);
if (attributeInstance != null) {
if (attributeInstance == null) {
continue;
}
int baselineBit = AttributeUtils.baselineBit(attributeInstance);
if (baselineBit >= 0) {
baselineBits |= 1L << baselineBit;
}
else {
List<Object> attributeData = new ArrayList<>();
List<Object> attributeModifiers = new ArrayList<>();
attributeData.add(BukkitAdapter.ADAPTER.getRegistryKey(attributeInstance.getAttribute()));
@@ -613,9 +623,10 @@ public final class EntityDeathListener extends Queue implements Listener {
data.add(entity.getCustomName());
data.add(attributes);
data.add(details);
if (EntitySpawnTracking.isTracked(entity)) {
data.add(entity.getUniqueId().toString());
}
data.add(EntitySpawnTracking.isTracked(entity) ? entity.getUniqueId().toString() : null);
data.add(null); // kill location, only stored for placed entities
data.add(EntityUtil.BASELINE_ATTRIBUTES);
data.add(baselineBits);
if (!(entity instanceof Player)) {
Queue.queueEntityKill(e, entity.getLocation(), data, type);
@@ -1,14 +1,68 @@
package net.coreprotect.utility;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.attribute.Attributable;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.attribute.AttributeModifier;
public final class AttributeUtils {
/**
* Attribute base values that entity kill data can leave out, frozen from the 26.2 attribute registry defaults.
* Kill data marks each left-out attribute with the bit of its position here, and a restore sets exactly this
* value, whatever the defaults of the restoring server are. Entries may only be appended: changing or reordering
* one would change what the bits of stored kill data mean. The bits are a long, so at most 64 entries fit.
*/
private static final List<BaselineAttribute> BASELINE = List.of(
new BaselineAttribute("air_drag_modifier", 1.0),
new BaselineAttribute("armor", 0.0),
new BaselineAttribute("armor_toughness", 0.0),
new BaselineAttribute("attack_damage", 2.0),
new BaselineAttribute("attack_knockback", 0.0),
new BaselineAttribute("attack_speed", 4.0),
new BaselineAttribute("below_name_distance", 10.0),
new BaselineAttribute("block_break_speed", 1.0),
new BaselineAttribute("block_interaction_range", 4.5),
new BaselineAttribute("bounciness", 0.0),
new BaselineAttribute("burning_time", 1.0),
new BaselineAttribute("camera_distance", 4.0),
new BaselineAttribute("entity_interaction_range", 3.0),
new BaselineAttribute("explosion_knockback_resistance", 0.0),
new BaselineAttribute("fall_damage_multiplier", 1.0),
new BaselineAttribute("flying_speed", 0.4),
new BaselineAttribute("follow_range", 32.0),
new BaselineAttribute("friction_modifier", 1.0),
new BaselineAttribute("gravity", 0.08),
new BaselineAttribute("jump_strength", 0.41999998688697815),
new BaselineAttribute("knockback_resistance", 0.0),
new BaselineAttribute("luck", 0.0),
new BaselineAttribute("max_absorption", 0.0),
new BaselineAttribute("max_health", 20.0),
new BaselineAttribute("mining_efficiency", 0.0),
new BaselineAttribute("movement_efficiency", 0.0),
new BaselineAttribute("movement_speed", 0.7),
new BaselineAttribute("name_tag_distance", 64.0),
new BaselineAttribute("oxygen_bonus", 0.0),
new BaselineAttribute("safe_fall_distance", 3.0),
new BaselineAttribute("scale", 1.0),
new BaselineAttribute("sneaking_speed", 0.3),
new BaselineAttribute("spawn_reinforcements", 0.0),
new BaselineAttribute("step_height", 0.6),
new BaselineAttribute("submerged_mining_speed", 0.2),
new BaselineAttribute("sweeping_damage_ratio", 0.0),
new BaselineAttribute("tempt_range", 10.0),
new BaselineAttribute("water_movement_efficiency", 0.0),
new BaselineAttribute("waypoint_receive_range", 0.0),
new BaselineAttribute("waypoint_transmit_range", 0.0)
);
private AttributeUtils() {
throw new IllegalStateException("Utility class");
}
@@ -81,4 +135,74 @@ public final class AttributeUtils {
return category.equals("generic") || category.equals("player")
|| category.equals("horse") || category.equals("zombie");
}
/**
* Returns the baseline bit of an attribute that kill data can leave out: it has no modifiers and its base value is
* the baseline value.
*
* @param attributeInstance
* the attribute of the entity that is being logged
* @return the bit, or -1 when the attribute has to be stored
*/
public static int baselineBit(AttributeInstance attributeInstance) {
if (!attributeInstance.getModifiers().isEmpty()) {
return -1;
}
NamespacedKey key = ((Keyed) attributeInstance.getAttribute()).getKey();
if (!key.getNamespace().equals(NamespacedKey.MINECRAFT)) {
return -1;
}
String name = normalize(key.getKey());
for (int bit = 0; bit < BASELINE.size(); bit++) {
BaselineAttribute baseline = BASELINE.get(bit);
if (baseline.key.equals(name)) {
return Double.compare(attributeInstance.getBaseValue(), baseline.value) == 0 ? bit : -1;
}
}
return -1;
}
/**
* Sets every attribute whose bit is set to its baseline value without modifiers. Attributes without a bit keep the
* value they spawned with, including attributes that did not exist when the kill was logged.
*
* @param attributable
* the restored entity
* @param baselineBits
* the baseline bits stored with the kill data
*/
public static void restoreBaseline(Attributable attributable, long baselineBits) {
for (int bit = 0; bit < BASELINE.size(); bit++) {
if ((baselineBits & (1L << bit)) == 0) {
continue;
}
BaselineAttribute baseline = BASELINE.get(bit);
Attribute attribute;
try {
attribute = resolve(NamespacedKey.MINECRAFT + ":" + baseline.key);
}
catch (IllegalArgumentException exception) {
continue;
}
AttributeInstance attributeInstance = attributable.getAttribute(attribute);
if (attributeInstance == null) {
continue;
}
attributeInstance.setBaseValue(baseline.value);
for (AttributeModifier modifier : new ArrayList<>(attributeInstance.getModifiers())) {
attributeInstance.removeModifier(modifier);
}
}
}
private static final class BaselineAttribute {
private final String key;
private final double value;
private BaselineAttribute(String key, double value) {
this.key = key;
this.value = value;
}
}
}
@@ -84,6 +84,19 @@ public class EntityUtil {
private static final long ENTITY_RESTORE_TIMEOUT_SECONDS = 30L;
/**
* Position in the entity kill data list of the attribute format marker, followed by the baseline bits. Index 7
* holds the entity UUID or null, and index 8 the kill location that EntitySpawnTracking stores for placed entities.
*/
public static final int ATTRIBUTE_FORMAT_INDEX = 9;
/**
* Attribute format marker: the attribute list leaves out the attributes whose bits are set in the long that
* follows the marker (see {@link AttributeUtils#baselineBit}). Rows without the marker list every attribute the
* entity had.
*/
public static final int BASELINE_ATTRIBUTES = 1;
private EntityUtil() {
throw new IllegalStateException("Utility class");
}
@@ -216,6 +229,10 @@ public class EntityUtil {
Attributable attributable = (Attributable) entity;
@SuppressWarnings("unchecked")
List<Object> attributes = (List<Object>) list.get(5);
Long baselineBits = baselineBits(list);
if (baselineBits != null) {
AttributeUtils.restoreBaseline(attributable, baselineBits);
}
restoreAttributes(attributable, attributes);
}
@@ -718,6 +735,18 @@ public class EntityUtil {
return completion;
}
static Long baselineBits(List<Object> list) {
if (list.size() <= ATTRIBUTE_FORMAT_INDEX + 1) {
return null;
}
Object format = list.get(ATTRIBUTE_FORMAT_INDEX);
Object bits = list.get(ATTRIBUTE_FORMAT_INDEX + 1);
if (format instanceof Number && ((Number) format).intValue() == BASELINE_ATTRIBUTES && bits instanceof Long) {
return (Long) bits;
}
return null;
}
static void restoreAttributes(Attributable attributable, List<Object> attributes) {
for (Object value : attributes) {
@SuppressWarnings("unchecked")