From c4a203aaf433a98143baf2ddf9a9d3477df34aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:01:22 +0000 Subject: [PATCH 01/14] Fixed world purges leaving orphaned entity data co_entity has no world column, so /co purge r:#world deleted the kill rows in co_block but kept every co_entity row. Those blobs became orphans that no later purge removed. - SQLite: copy only the co_entity rows that a retained kill row still references. This also drops orphans left by earlier purges. - MySQL/DuckDB: delete the co_entity rows of the kill rows a world purge removes, before co_block is purged. MySQL uses a join so MariaDB and MySQL 5.7 do not run a dependent subquery. Global purges keep the cheaper time-based delete, which is equivalent. If MySQL stops between the two deletes, running the same purge again removes the remaining kill rows. Player kills use the kill action with type 0 and a user id in data, so they are never treated as co_entity references. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- .../net/coreprotect/command/PurgeCommand.java | 20 ++- .../net/coreprotect/database/PurgeFilter.java | 144 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/coreprotect/database/PurgeFilter.java diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..ea3e67437 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -28,6 +28,7 @@ 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; @@ -340,6 +341,7 @@ public void run() { long timeStart = startTime > 0 ? (timestamp - startTime) : 0; long timeEnd = timestamp - endTime; long removed = 0; + PurgeFilter purgeFilter = new PurgeFilter(timeStart, timeEnd, argWid, includeBlockIdsFinal); for (int i = 0; i <= 5; i++) { requirePurgeNotCancelled(); @@ -497,6 +499,9 @@ public void run() { 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 (table.equals("entity")) { + timeLimit = " WHERE " + PurgeFilter.entityRetainCondition(purgePrefix + "block"); + } else if (PurgePolicy.isPurgeable(table)) { String blockRestriction = "("; if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) { @@ -594,7 +599,13 @@ else if (argWid > 0) { purge = false; } - if (purge) { + if (table.equals("entity")) { + query = PurgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block"); + preparedStmt = preparePurgeStatement(connection, query); + preparedStmt.execute(); + preparedStmt.close(); + } + else if (purge) { query = "DELETE FROM " + purgePrefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction; preparedStmt = preparePurgeStatement(connection, query); preparedStmt.execute(); @@ -666,6 +677,13 @@ else if (argWid > 0) { purge = false; } + if (purge && table.equals("block") && purgeFilter.removesKills() && !purgeFilter.purgesEntitiesByTime()) { + query = purgeFilter.deleteEntitiesOfPurgedKills(ConfigHandler.databaseType, ConfigHandler.prefix); + preparedStmt = preparePurgeStatement(connection, query); + removed = removed + preparedStmt.executeUpdate(); + preparedStmt.close(); + } + if (purge) { query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction; preparedStmt = preparePurgeStatement(connection, query); diff --git a/src/main/java/net/coreprotect/database/PurgeFilter.java b/src/main/java/net/coreprotect/database/PurgeFilter.java new file mode 100644 index 000000000..97cd450e9 --- /dev/null +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -0,0 +1,144 @@ +package net.coreprotect.database; + +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). + * + *

+ * 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 List blockTypes; + + /** + * @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 blockTypes + * material ids (i:) that restrict the purge to those co_block rows, or an empty list + */ + public PurgeFilter(long timeStart, long timeEnd, int worldId, List blockTypes) { + this.timeStart = timeStart; + this.timeEnd = timeEnd; + this.worldId = worldId; + this.blockTypes = List.copyOf(blockTypes); + } + + /** + * Returns whether the purge is limited to selected co_block rows, which leaves the other tables untouched. + */ + private boolean restrictsTables() { + return !blockTypes.isEmpty(); + } + + /** + * Returns whether the purge removes co_block kill rows. A restriction to materials keeps every kill row. + */ + public boolean removesKills() { + return blockTypes.isEmpty(); + } + + /** + * 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(); + } + + /** + * 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 (!blockTypes.isEmpty()) { + condition.append(" AND ").append(qualifier).append("action NOT IN(").append(LookupActions.ENTITY_KILL).append(",").append(LookupActions.ENTITY_SPAWN).append(")"); + condition.append(" AND ").append(qualifier).append("type IN(").append(joinIds(blockTypes)).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 still referenced by a retained kill row. + * + * @param retainedBlockTable + * the co_block table that holds the rows the purge keeps + * @return the SQL condition + */ + public static String entityRetainCondition(String retainedBlockTable) { + return "rowid IN(" + killReferences(retainedBlockTable) + ")"; + } + + /** + * Builds the statement that deletes co_entity rows 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 static String deleteUnreferencedEntities(String entityTable, String blockTable) { + return "DELETE FROM " + entityTable + " WHERE rowid NOT IN(" + killReferences(blockTable) + ")"; + } + + 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 ids) { + StringJoiner joiner = new StringJoiner(","); + for (Integer id : ids) { + joiner.add(String.valueOf(id)); + } + return joiner.toString(); + } +} From 57d80f03333502589af8fccb2c371c3dd06ee48a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:54:46 +0000 Subject: [PATCH 02/14] Fixed SQLite purges with world and block filters purging other tables On SQLite, /co purge r:#world i: built the retain condition for world-scoped tables without checking the block restriction, so it purged co_container, co_chat and the other world-scoped tables in that world although a block restriction should leave them untouched. The entity_container/entity_interaction branch and the MySQL path already check it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- src/main/java/net/coreprotect/command/PurgeCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..53befa4c0 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -513,7 +513,7 @@ else if (hasBlockRestriction) { timeLimit = " WHERE (" + worldMatch + " AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "')) OR NOT " + worldMatch; } } - else { + else if (purge) { timeLimit = " WHERE (" + blockRestriction + "wid = '" + argWid + "' AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "'))) OR (wid != '" + argWid + "')"; } } From a76848536f6e1e3877670502f57704ffe5c2afd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:18:07 +0000 Subject: [PATCH 03/14] Fixed console purges with a radius purging all worlds /co purge rejects a numeric radius, but the check only saw the radius when the sender had a location. From the console, /co purge t:30d r:50 parsed no radius and no world, so it ran as a server-wide purge. The radius is now also parsed against a placeholder location, so it is rejected for every sender. r:#world is unaffected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f --- src/main/java/net/coreprotect/command/PurgeCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 40a8cdcb3..880ad336a 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -192,7 +192,8 @@ protected static void runCommand(final CommandSender player, boolean permission, Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co purge t:

+ * 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 static 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"; + 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."); } /** From 5d18b344e5d5596de4d5b0cf4f23a2eef99f6d70 Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sun, 4 Oct 2026 05:37:53 -0700 Subject: [PATCH 09/14] Limited SQLite entity data cleanup to the purge time range A SQLite purge dropped every co_entity row no kill referenced, including rows newer than the purge range, and counted them as deleted. It now only removes unreferenced rows inside the range, like the MySQL #optimize sweep. Co-Authored-By: Claude Opus 5.5 --- .../java/net/coreprotect/command/PurgeCommand.java | 4 ++-- .../java/net/coreprotect/database/PurgeFilter.java | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index a6f41d240..3192dd22f 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -501,7 +501,7 @@ public void run() { 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 (table.equals("entity")) { - timeLimit = " WHERE " + PurgeFilter.entityRetainCondition(purgePrefix + "block"); + timeLimit = " WHERE " + purgeFilter.entityRetainCondition(purgePrefix + "block"); } else if (PurgePolicy.isPurgeable(table)) { String blockRestriction = "("; @@ -601,7 +601,7 @@ else if (argWid > 0) { } if (table.equals("entity")) { - query = PurgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block"); + query = purgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block"); preparedStmt = preparePurgeStatement(connection, query); preparedStmt.execute(); preparedStmt.close(); diff --git a/src/main/java/net/coreprotect/database/PurgeFilter.java b/src/main/java/net/coreprotect/database/PurgeFilter.java index 5ba37ecc3..89b48a123 100644 --- a/src/main/java/net/coreprotect/database/PurgeFilter.java +++ b/src/main/java/net/coreprotect/database/PurgeFilter.java @@ -100,18 +100,19 @@ public String deleteEntitiesOfPurgedKills(DatabaseType databaseType, String pref } /** - * Builds the SQLite copy condition that keeps the co_entity rows still referenced by a retained kill row. + * 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 static String entityRetainCondition(String retainedBlockTable) { - return "rowid IN(" + killReferences(retainedBlockTable) + ")"; + public String entityRetainCondition(String retainedBlockTable) { + return "NOT (" + timeCondition("") + ") OR rowid IN(" + killReferences(retainedBlockTable) + ")"; } /** - * Builds the statement that deletes co_entity rows that no kill row references. + * 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 @@ -119,8 +120,8 @@ public static String entityRetainCondition(String retainedBlockTable) { * the co_block table that holds the kill rows * @return the SQL statement */ - public static String deleteUnreferencedEntities(String entityTable, String blockTable) { - return "DELETE FROM " + entityTable + " WHERE rowid NOT IN(" + killReferences(blockTable) + ")"; + public String deleteUnreferencedEntities(String entityTable, String blockTable) { + return "DELETE FROM " + entityTable + " WHERE " + timeCondition("") + " AND rowid NOT IN(" + killReferences(blockTable) + ")"; } /** From 166b9b86652b0bf26d0a70774cefa6fd472258a5 Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sat, 3 Oct 2026 12:04:20 -0700 Subject: [PATCH 10/14] Rejected purge include and exclude lists without a value /co purge t:30d e: passed validation, parsed to no exclusions, and ran as an unrestricted time purge. e:, and e:,,, did the same, as did an e: at the end of the command and an empty i:. The argument check now follows each include or exclude list across its continuation tokens and rejects a list that ends without a value. Space-separated lists such as e:zombie, villager still work. Co-Authored-By: Claude Opus 5.5 --- .../net/coreprotect/command/PurgeCommand.java | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index b5fbdebfa..af8377a48 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -108,7 +108,8 @@ private static void reportPurgeFailure(Exception exception) throws Exception { } static String findUnsupportedPurgeArgument(String[] args) { - boolean includeContinuation = false; + boolean listContinuation = false; + String emptyList = null; // an include or exclude argument that has no value yet for (int i = 1; i < args.length; i++) { String token = args[i].trim(); if (token.length() == 0) { @@ -119,24 +120,32 @@ static String findUnsupportedPurgeArgument(String[] args) { 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 (argument.startsWith("e:") || argument.startsWith("exclude:")) { - String excludeValues = argument.replaceAll("exclude:", "").replaceAll("e:", ""); - includeContinuation = excludeValues.length() == 0 || excludeValues.endsWith(","); + if (listContinuation) { + listContinuation = argument.endsWith(","); + if (hasListValue(argument)) { + emptyList = null; + } continue; } @@ -161,7 +170,12 @@ static String findUnsupportedPurgeArgument(String[] args) { } } - 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; } protected static void runCommand(final CommandSender player, boolean permission, String[] args) { From ef2e11de3231a6cfdbc361d4197b19dbcc10be65 Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sun, 4 Oct 2026 05:42:44 -0700 Subject: [PATCH 11/14] Matched purge entity filters to kills logged under renamed types Bukkit resolves a legacy name such as zombie_pigman to the renamed type, so the filter only matched the id of the new name. e:zombie_pigman then purged the old kills it was meant to keep, and i:zombie_pigman missed them. The filter now covers every entity map id that resolves to the type. Co-Authored-By: Claude Opus 5.5 --- .../net/coreprotect/command/PurgeCommand.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index af8377a48..b16e0ea27 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -107,6 +107,27 @@ private static void reportPurgeFailure(Exception exception) throws Exception { ErrorReporter.report(exception); } + /** + * 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 killTypeIds(EntityType entityType) { + List ids = new ArrayList<>(); + synchronized (ConfigHandler.entities) { + for (Map.Entry 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 @@ -276,7 +297,7 @@ else if (endTime < 86400) { hasBlock = true; } else if (restrictTarget instanceof EntityType) { - includeEntityIds.add(EntityUtils.getEntityId(((EntityType) restrictTarget).name(), false)); + includeEntityIds.addAll(killTypeIds((EntityType) restrictTarget)); targetName = ((EntityType) restrictTarget).name().toLowerCase(Locale.ROOT); entity = true; } @@ -307,7 +328,7 @@ else if (restrictTarget instanceof String) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "e:" + excludeTarget.toString().toLowerCase(Locale.ROOT))); return; } - excludeEntityIds.add(EntityUtils.getEntityId(((EntityType) excludeTarget).name(), false)); + excludeEntityIds.addAll(killTypeIds((EntityType) excludeTarget)); exclude.append(exclude.length() == 0 ? "" : ", ").append(((EntityType) excludeTarget).name().toLowerCase(Locale.ROOT)); } if (!argExcludeUsers.isEmpty()) { From c7a5b0eca997b88e0c011bbd95154e05d56891d1 Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sat, 3 Oct 2026 12:05:18 -0700 Subject: [PATCH 12/14] Rejected purges with more than one radius argument /co purge t:30d r:50 r:#world_nether i:zombie ignored the explicit world and purged around the player in their current world. Two r: arguments always conflict, so the second one is now rejected before the purge starts, whether it is a world, #global or another radius. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 2 +- src/main/java/net/coreprotect/command/PurgeCommand.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 26ab132a8..22cb64d7b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -107,7 +107,7 @@ 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 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`. 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** diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 5a2cccf30..5a73564ff 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -132,6 +132,7 @@ private static List killTypeIds(EntityType entityType) { 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) { @@ -184,6 +185,11 @@ else if (argument.startsWith("e:") || argument.startsWith("exclude:")) { } 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; } From d23d39bd7b77209933b4008b00c5615aee975489 Mon Sep 17 00:00:00 2001 From: TonyJamesStark Date: Sun, 4 Oct 2026 05:59:42 -0700 Subject: [PATCH 13/14] Applied max-radius to radius purges A radius purge accepted any value. A radius past the int range saturated, the area overflowed, and the purge reported success with nothing deleted. It now stops at max-radius like lookups and rollbacks. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 2 +- lang/en.yml | 1 + src/main/java/net/coreprotect/command/PurgeCommand.java | 6 ++++++ src/main/java/net/coreprotect/language/Language.java | 1 + src/main/java/net/coreprotect/language/Phrase.java | 1 + 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 22cb64d7b..cc1d439f4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -107,7 +107,7 @@ 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`. +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** diff --git a/lang/en.yml b/lang/en.yml index fba066ac9..e7e980d6e 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -139,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." diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 5a73564ff..3d6e15fe7 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -26,6 +26,7 @@ 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; @@ -258,6 +259,11 @@ protected static void runCommand(final CommandSender player, boolean permission, 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()); diff --git a/src/main/java/net/coreprotect/language/Language.java b/src/main/java/net/coreprotect/language/Language.java index 3fa8c5c99..7943bb687 100644 --- a/src/main/java/net/coreprotect/language/Language.java +++ b/src/main/java/net/coreprotect/language/Language.java @@ -176,6 +176,7 @@ public static void loadPhrases() { 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."); diff --git a/src/main/java/net/coreprotect/language/Phrase.java b/src/main/java/net/coreprotect/language/Phrase.java index 6bbe56e31..562599c46 100644 --- a/src/main/java/net/coreprotect/language/Phrase.java +++ b/src/main/java/net/coreprotect/language/Phrase.java @@ -147,6 +147,7 @@ public enum Phrase { LOOKUP_STORAGE, LOOKUP_TIME, LOOKUP_USERNAME, + MAXIMUM_PURGE_RADIUS, MAXIMUM_RADIUS, MESSAGE_FILTER_TOO_SHORT, MISSING_ACTION_USER, From d2c71b2ed11bd27089b3b499e4ffcb366c9eba40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:20:00 +0000 Subject: [PATCH 14/14] Checked radius arguments inside purge include and exclude lists /co purge t:30d i:zombie, r:50 r:#world_nether passed the second-radius check: the validator took r:50 as a value of the include list, then saw r:#world_nether as the only radius. The radius and world parsers read both. A list now continues only with plain or minecraft: namespaced values, so a radius after a trailing comma is counted and the second one is rejected. This is the same change as 39deec3 on entity-kill-purge-filters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LFQuzYmYun57wJi2a5mqkM --- src/main/java/net/coreprotect/command/PurgeCommand.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/coreprotect/command/PurgeCommand.java b/src/main/java/net/coreprotect/command/PurgeCommand.java index 3d6e15fe7..17d761ee3 100755 --- a/src/main/java/net/coreprotect/command/PurgeCommand.java +++ b/src/main/java/net/coreprotect/command/PurgeCommand.java @@ -165,13 +165,15 @@ else if (argument.startsWith("e:") || argument.startsWith("exclude:")) { continue; } - if (listContinuation) { + 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. @@ -207,6 +209,11 @@ 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) { int resultc = args.length; Location location = CommandParser.parseLocation(player, args);