From f7fcfa4b458a707cb80856c17ea1d0e7b1f4a086 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:38:43 +0300 Subject: [PATCH 01/13] Add virtual container identifiers and statement constants --- src/main/java/net/coreprotect/config/ConfigHandler.java | 3 +++ src/main/java/net/coreprotect/consumer/process/Process.java | 2 ++ .../java/net/coreprotect/database/ConsumerWriteBatch.java | 1 + src/main/java/net/coreprotect/database/Database.java | 4 ++++ .../java/net/coreprotect/model/item/InventorySources.java | 1 + 5 files changed, 11 insertions(+) diff --git a/src/main/java/net/coreprotect/config/ConfigHandler.java b/src/main/java/net/coreprotect/config/ConfigHandler.java index 512096ddb..9813dd259 100644 --- a/src/main/java/net/coreprotect/config/ConfigHandler.java +++ b/src/main/java/net/coreprotect/config/ConfigHandler.java @@ -79,6 +79,9 @@ public enum CacheType { ), WORLDS( ReferenceKind.WORLD + ), + VIRTUAL_CONTAINERS( + ReferenceKind.VIRTUAL_CONTAINER ); private final ReferenceKind kind; diff --git a/src/main/java/net/coreprotect/consumer/process/Process.java b/src/main/java/net/coreprotect/consumer/process/Process.java index a45690f93..a70e26c87 100755 --- a/src/main/java/net/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/coreprotect/consumer/process/Process.java @@ -78,6 +78,8 @@ public class Process { public static final int ENTITY_CONTAINER_TRANSITION_UPDATE = 34; public static final int ENTITY_INTERACTION = 35; public static final int SIGN_ROLLBACK_UPDATE = 36; + public static final int VIRTUAL_CONTAINER_TRANSACTION = 37; + public static final int VIRTUAL_CONTAINER_INSERT = 38; public static int lastLockUpdate = 0; private static volatile int currentConsumerSize = 0; diff --git a/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java index 4b1b37505..a35206ef7 100644 --- a/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java @@ -11,6 +11,7 @@ enum ReferenceKind { BLOCK_DATA, ENTITY, MATERIAL, + VIRTUAL_CONTAINER, WORLD } diff --git a/src/main/java/net/coreprotect/database/Database.java b/src/main/java/net/coreprotect/database/Database.java index 84a3366af..8f7f181b6 100755 --- a/src/main/java/net/coreprotect/database/Database.java +++ b/src/main/java/net/coreprotect/database/Database.java @@ -60,6 +60,8 @@ public class Database extends Queue { public static final int ENTITY_SPAWN = 14; public static final int ENTITY_CONTAINER = 15; public static final int ENTITY_INTERACTION = 16; + public static final int VIRTUAL_CONTAINER = 17; + public static final int VIRTUAL_CONTAINER_MAP = 18; public static final int DATABASE_LOCK_INACTIVE = 0; public static final int DATABASE_LOCK_ACTIVE = 1; @@ -96,6 +98,8 @@ public class Database extends Queue { SQL_QUERIES.put(ENTITY_SPAWN, "INSERT INTO %sprefix%entity_spawn (time, uuid, wid, current_wid, origin_x, origin_y, origin_z, x, y, z, yaw, pitch, data, removed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); SQL_QUERIES.put(ENTITY_CONTAINER, "INSERT INTO %sprefix%entity_container (time, %user%, entity_spawn_rowid, wid, x, y, z, type, data, amount, metadata, action, rolled_back) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); SQL_QUERIES.put(ENTITY_INTERACTION, "INSERT INTO %sprefix%entity_interaction (time, %user%, entity_spawn_rowid, wid, x, y, z, type, action, metadata, rolled_back) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + SQL_QUERIES.put(VIRTUAL_CONTAINER, "INSERT INTO %sprefix%virtual_container (time, %user%, container_id, type, data, amount, metadata, action, rolled_back) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + SQL_QUERIES.put(VIRTUAL_CONTAINER_MAP, "INSERT INTO %sprefix%virtual_container_map (id, container) VALUES (?, ?)"); } public static void beginTransaction(Statement statement, boolean isMySQL) throws SQLException { diff --git a/src/main/java/net/coreprotect/model/item/InventorySources.java b/src/main/java/net/coreprotect/model/item/InventorySources.java index 8ebb846f5..fc87a19f7 100644 --- a/src/main/java/net/coreprotect/model/item/InventorySources.java +++ b/src/main/java/net/coreprotect/model/item/InventorySources.java @@ -6,6 +6,7 @@ public final class InventorySources { public static final int ITEM = 2; public static final int ENTITY_CONTAINER = 3; public static final int ENTITY_INTERACTION = 4; + public static final int VIRTUAL_CONTAINER = 5; private InventorySources() { throw new IllegalStateException("Model class"); From 8bacf1675846ad5f46131d7b59ab9b5dafb54850 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:41:06 +0300 Subject: [PATCH 02/13] Add virtual container storage tables --- .../net/coreprotect/config/ConfigHandler.java | 33 +++++++++++++ .../coreprotect/consumer/process/Process.java | 3 ++ .../VirtualContainerInsertProcess.java | 47 +++++++++++++++++++ .../net/coreprotect/database/Database.java | 21 ++++++++- .../coreprotect/database/DuckDBDatabase.java | 2 + .../net/coreprotect/database/PurgePolicy.java | 2 +- .../RelationalConsumerWriteBatch.java | 6 ++- 7 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/coreprotect/consumer/process/VirtualContainerInsertProcess.java diff --git a/src/main/java/net/coreprotect/config/ConfigHandler.java b/src/main/java/net/coreprotect/config/ConfigHandler.java index 9813dd259..53fc4f30c 100644 --- a/src/main/java/net/coreprotect/config/ConfigHandler.java +++ b/src/main/java/net/coreprotect/config/ConfigHandler.java @@ -147,6 +147,7 @@ public static String getAscendingEventOrder() { public static volatile int blockdataId = 0; public static volatile int entityId = 0; public static volatile int artId = 0; + public static volatile int virtualContainerId = 0; public static final AtomicLong autoPurgeRowsPurged = new AtomicLong(0); private static boolean duckDBFallbackAllowed = false; @@ -240,6 +241,8 @@ private boolean canStore(int id, String value) { public static volatile Map entitiesReversed = syncMap(); public static volatile Map art = syncMap(); public static volatile Map artReversed = syncMap(); + public static volatile Map virtualContainers = syncMap(); + public static volatile Map virtualContainersReversed = syncMap(); private static final Map IDENTIFIER_STORES = identifierStores(); public static Map rollbackHash = syncMap(); public static Map inspecting = syncMap(); @@ -640,11 +643,36 @@ public static boolean loadEntities(Statement statement) { } } + public static boolean loadVirtualContainers(Statement statement) { + // there is a key per container, too many to preload, so keys are cached as they are resolved + ConfigHandler.virtualContainers = syncMap(); + ConfigHandler.virtualContainersReversed = syncMap(); + // ClickHouse reserves virtual container ids itself + return databaseType.isClickHouse() || loadVirtualContainerId(statement); + } + + /** + * Continues virtual container ids from the highest stored id, and never below an id already handed out. + */ + public static boolean loadVirtualContainerId(Statement statement) { + try (ResultSet resultSet = statement.executeQuery("SELECT MAX(id) FROM " + ConfigHandler.prefix + "virtual_container_map")) { + if (resultSet.next()) { + virtualContainerId = Math.max(virtualContainerId, resultSet.getInt(1)); + } + return true; + } + catch (Exception e) { + ErrorReporter.report(e); + return false; + } + } + public static boolean loadTypes(Statement statement) { boolean loaded = loadMaterials(statement); loaded &= loadBlockdata(statement); loaded &= loadArt(statement); loaded &= loadEntities(statement); + loaded &= loadVirtualContainers(statement); if (loaded && databaseType.isClickHouse() && !Config.getGlobal().DATABASE_LOCK) { for (EntityType type : EntityType.values()) { if (EntitySpawnTracking.isPlacedEntityType(type)) { @@ -736,6 +764,8 @@ private static boolean queueIdentifier(ReferenceKind kind, int id, String value) return tryQueueIdentifierInsert(Process.ENTITY_INSERT, id, value); case MATERIAL: return tryQueueIdentifierInsert(Process.MATERIAL_INSERT, id, value); + case VIRTUAL_CONTAINER: + return tryQueueIdentifierInsert(Process.VIRTUAL_CONTAINER_INSERT, id, value); case WORLD: return tryQueueIdentifierInsert(Process.WORLD_INSERT, id, value); default: @@ -804,6 +834,7 @@ private static Map identifierStores() { stores.put(ReferenceKind.ART, new IdentifierStore(ReferenceKind.ART, () -> art, () -> artReversed, () -> artId, id -> artId = id)); stores.put(ReferenceKind.ENTITY, new IdentifierStore(ReferenceKind.ENTITY, () -> entities, () -> entitiesReversed, () -> entityId, id -> entityId = id)); stores.put(ReferenceKind.WORLD, new IdentifierStore(ReferenceKind.WORLD, () -> worlds, () -> worldsReversed, () -> worldId, id -> worldId = id)); + stores.put(ReferenceKind.VIRTUAL_CONTAINER, new IdentifierStore(ReferenceKind.VIRTUAL_CONTAINER, () -> virtualContainers, () -> virtualContainersReversed, () -> virtualContainerId, id -> virtualContainerId = id)); return stores; } @@ -824,6 +855,8 @@ private static boolean reloadIdentifierCache(ReferenceKind kind) { return loadEntities(statement); case WORLD: return loadWorlds(statement); + case VIRTUAL_CONTAINER: + return loadVirtualContainerId(statement); default: return false; } diff --git a/src/main/java/net/coreprotect/consumer/process/Process.java b/src/main/java/net/coreprotect/consumer/process/Process.java index a70e26c87..2719324db 100755 --- a/src/main/java/net/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/coreprotect/consumer/process/Process.java @@ -477,6 +477,9 @@ else if (ConfigHandler.databaseType.isColumnar()) { case Process.BLOCKDATA_INSERT: BlockDataInsertProcess.process(writeBatch, statement, i, object, forceData); break; + case Process.VIRTUAL_CONTAINER_INSERT: + VirtualContainerInsertProcess.process(writeBatch, statement, i, object, forceData); + break; case Process.ENTITY_SPAWN_LOG: if (!(object instanceof EntitySpawnData)) { break; diff --git a/src/main/java/net/coreprotect/consumer/process/VirtualContainerInsertProcess.java b/src/main/java/net/coreprotect/consumer/process/VirtualContainerInsertProcess.java new file mode 100644 index 000000000..639cdb440 --- /dev/null +++ b/src/main/java/net/coreprotect/consumer/process/VirtualContainerInsertProcess.java @@ -0,0 +1,47 @@ +package net.coreprotect.consumer.process; + +import java.sql.ResultSet; +import java.sql.Statement; + +import net.coreprotect.config.ConfigHandler; +import net.coreprotect.database.ConsumerWriteBatch; +import net.coreprotect.database.Database; +import net.coreprotect.database.statement.MaterialStatement; +import net.coreprotect.language.Phrase; +import net.coreprotect.utility.Chat; + +class VirtualContainerInsertProcess { + + static void process(ConsumerWriteBatch preparedStmt, Statement statement, int batchCount, Object key, int containerId) { + if (key instanceof String) { + String storedKey = ConfigHandler.databaseType.isClickHouse() ? null : getStoredKey(statement, containerId); + if (storedKey == null) { + MaterialStatement.insert(preparedStmt, ConsumerWriteBatch.ReferenceKind.VIRTUAL_CONTAINER, batchCount, containerId, (String) key); + + // validate ID maps to ensure mapping wasn't reloaded from database prior to this insertion completing + ConfigHandler.virtualContainers.put((String) key, containerId); + ConfigHandler.virtualContainersReversed.put(containerId, (String) key); + if (containerId > ConfigHandler.virtualContainerId) { + ConfigHandler.virtualContainerId = containerId; + } + } + else if (!storedKey.equals(key)) { + // another server sharing the database took this id: forget it, so the key is resolved again above the stored ids + Chat.console(Phrase.build(Phrase.CACHE_ERROR, "virtual container")); + ConfigHandler.uncacheIdentifier(ConsumerWriteBatch.ReferenceKind.VIRTUAL_CONTAINER, containerId, (String) key); + ConfigHandler.loadVirtualContainerId(statement); + } + } + } + + private static String getStoredKey(Statement statement, int containerId) { + String query = "SELECT container FROM " + ConfigHandler.prefix + "virtual_container_map WHERE id = " + containerId + " LIMIT 1 OFFSET 0"; + try (ResultSet resultSet = statement.executeQuery(query)) { + return resultSet.next() ? resultSet.getString(1) : null; + } + catch (Exception e) { + Database.handleWriteFailure(e); + return null; + } + } +} diff --git a/src/main/java/net/coreprotect/database/Database.java b/src/main/java/net/coreprotect/database/Database.java index 8f7f181b6..e0b6f9fbf 100755 --- a/src/main/java/net/coreprotect/database/Database.java +++ b/src/main/java/net/coreprotect/database/Database.java @@ -760,7 +760,7 @@ private static void initializeTables(String prefix, Statement statement, Databas } } - static final List DATABASE_TABLES = Arrays.asList("art_map", "block", "chat", "command", "container", "entity_container", "entity_interaction", "item", "database_lock", "entity", "entity_spawn", "entity_map", "material_map", "blockdata_map", "session", "sign", "skull", "user", "username_log", "version", "world"); + static final List DATABASE_TABLES = Arrays.asList("art_map", "block", "chat", "command", "container", "entity_container", "entity_interaction", "item", "database_lock", "entity", "entity_spawn", "entity_map", "material_map", "blockdata_map", "session", "sign", "skull", "user", "username_log", "version", "world", "virtual_container", "virtual_container_map"); public static void createDatabaseTables(String prefix, boolean forcePrefix, Connection forceConnection, boolean mySQL, boolean purge) { createDatabaseTables(prefix, forcePrefix, forceConnection, mySQL ? DatabaseType.MYSQL : DatabaseType.SQLITE, purge); @@ -890,6 +890,14 @@ private static void createMySQLTableStructures(String prefix, Statement statemen // Version statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "version(rowid int NOT NULL AUTO_INCREMENT,PRIMARY KEY(rowid),time int,version varchar(16)) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4"); + // Virtual container + index = ", INDEX(container_id,time), INDEX(user,time), INDEX(type,time)"; + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container(rowid int NOT NULL AUTO_INCREMENT,PRIMARY KEY(rowid), time int, user int, container_id int NOT NULL, type int, data int, amount int, metadata mediumblob, action tinyint, rolled_back tinyint" + index + ") ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4"); + + // Virtual container map + index = ", INDEX(id), INDEX(container)"; + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container_map(rowid int NOT NULL AUTO_INCREMENT,PRIMARY KEY(rowid),id int,container varchar(191)" + index + ") ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4"); + // World index = ", INDEX(id)"; statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "world(rowid int NOT NULL AUTO_INCREMENT,PRIMARY KEY(rowid),id int,world varchar(255)" + index + ") ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4"); @@ -1114,6 +1122,12 @@ private static void createSQLiteTableStructures(String prefix, Statement stateme if (!tableData.contains(prefix + "version")) { statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "version (time INTEGER, version TEXT);"); } + if (!tableData.contains(prefix + "virtual_container")) { + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container (time INTEGER, user INTEGER, container_id INTEGER NOT NULL, type INTEGER, data INTEGER, amount INTEGER, metadata BLOB, action INTEGER, rolled_back INTEGER);"); + } + if (!tableData.contains(prefix + "virtual_container_map")) { + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container_map (id INTEGER, container TEXT);"); + } if (!tableData.contains(prefix + "world")) { statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "world (id INTEGER, world TEXT);"); } @@ -1163,6 +1177,11 @@ private static void createSQLiteIndexes(String prefix, Statement statement, List createSQLiteIndex(statement, indexData, attachDatabase, "user_index", prefix + "user(user)"); createSQLiteIndex(statement, indexData, attachDatabase, "uuid_index", prefix + "user(uuid)"); createSQLiteIndex(statement, indexData, attachDatabase, "username_log_uuid_index", prefix + "username_log(uuid,user)"); + createSQLiteIndex(statement, indexData, attachDatabase, "virtual_container_index", prefix + "virtual_container(container_id,time)"); + createSQLiteIndex(statement, indexData, attachDatabase, "virtual_container_user_index", prefix + "virtual_container(user,time)"); + createSQLiteIndex(statement, indexData, attachDatabase, "virtual_container_type_index", prefix + "virtual_container(type,time)"); + createSQLiteIndex(statement, indexData, attachDatabase, "virtual_container_map_id_index", prefix + "virtual_container_map(id)"); + createSQLiteIndex(statement, indexData, attachDatabase, "virtual_container_map_container_index", prefix + "virtual_container_map(container)"); createSQLiteIndex(statement, indexData, attachDatabase, "world_id_index", prefix + "world(id)"); if (createChatMessagePrefixIndex) { createSQLiteIndex(statement, indexData, attachDatabase, "chat_message_prefix_index", prefix + "chat(substr(message,1,16) COLLATE NOCASE)"); diff --git a/src/main/java/net/coreprotect/database/DuckDBDatabase.java b/src/main/java/net/coreprotect/database/DuckDBDatabase.java index 348575093..b9c5a0735 100644 --- a/src/main/java/net/coreprotect/database/DuckDBDatabase.java +++ b/src/main/java/net/coreprotect/database/DuckDBDatabase.java @@ -97,6 +97,8 @@ static void createTables(String prefix, Connection forceConnection, boolean purg statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "user (" + rowId(prefix, "user") + ", time INTEGER, \"user\" VARCHAR, uuid VARCHAR)"); statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "username_log (" + rowId(prefix, "username_log") + ", time INTEGER, uuid VARCHAR, \"user\" VARCHAR)"); statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "version (" + rowId(prefix, "version") + ", time INTEGER, version VARCHAR)"); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container (" + rowId(prefix, "virtual_container") + ", time INTEGER, \"user\" INTEGER, container_id INTEGER NOT NULL, type INTEGER, data INTEGER, amount INTEGER, metadata BLOB, action TINYINT, rolled_back TINYINT)"); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "virtual_container_map (" + rowId(prefix, "virtual_container_map") + ", id INTEGER, container VARCHAR)"); statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + prefix + "world (" + rowId(prefix, "world") + ", id INTEGER, world VARCHAR)"); DuckDBSpatialIndex.createTable(prefix, statement); validateEntityDataColumns(connection, prefix); diff --git a/src/main/java/net/coreprotect/database/PurgePolicy.java b/src/main/java/net/coreprotect/database/PurgePolicy.java index 7b2b872e3..6a94a434f 100644 --- a/src/main/java/net/coreprotect/database/PurgePolicy.java +++ b/src/main/java/net/coreprotect/database/PurgePolicy.java @@ -6,7 +6,7 @@ public final class PurgePolicy { - private static final List PURGEABLE_TABLES = Collections.unmodifiableList(Arrays.asList("sign", "container", "entity_container", "entity_interaction", "item", "skull", "session", "chat", "command", "entity", "block")); + private static final List PURGEABLE_TABLES = Collections.unmodifiableList(Arrays.asList("sign", "container", "entity_container", "entity_interaction", "item", "skull", "session", "chat", "command", "entity", "block", "virtual_container")); private static final List WORLD_SCOPED_TABLES = Collections.unmodifiableList(Arrays.asList("sign", "container", "entity_container", "entity_interaction", "item", "session", "chat", "command", "block")); private PurgePolicy() { diff --git a/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java index 47f5facb2..9310cbd80 100644 --- a/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java @@ -34,6 +34,7 @@ public final class RelationalConsumerWriteBatch implements ConsumerWriteBatch { private static final int ENTITY_MAP_BATCH = 13; private static final int BLOCK_DATA_BATCH = 14; private static final int ENTITY_KILL_LINK_BATCH = 15; + private static final int VIRTUAL_CONTAINER_MAP_BATCH = 16; private static final int INITIAL_DUCKDB_BLOCK_ID_RESERVATION = 256; private static final int MAXIMUM_DUCKDB_BLOCK_ID_RESERVATION = 65536; private static final long[] EMPTY_ROW_IDS = new long[0]; @@ -41,7 +42,7 @@ public final class RelationalConsumerWriteBatch implements ConsumerWriteBatch { private final Connection connection; private final DatabaseType databaseType; private final Statement transactionStatement; - private final PreparedStatement[] batchStatements = new PreparedStatement[16]; + private final PreparedStatement[] batchStatements = new PreparedStatement[17]; private final List statements = new ArrayList<>(); private PreparedStatement blockReturningStatement; @@ -255,6 +256,9 @@ public void addReference(ReferenceKind kind, int batchCount, int id, String valu case MATERIAL: statement = batchStatement(Database.MATERIAL, MATERIAL_BATCH); break; + case VIRTUAL_CONTAINER: + statement = batchStatement(Database.VIRTUAL_CONTAINER_MAP, VIRTUAL_CONTAINER_MAP_BATCH); + break; case WORLD: statement = batchStatement(Database.WORLD, WORLD_BATCH); break; From bf41aec65eafe0ff1ae5262cfc29f8912d18ddb9 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:42:50 +0300 Subject: [PATCH 03/13] Log virtual container transactions through the consumer --- .../net/coreprotect/config/ConfigHandler.java | 4 ++ .../java/net/coreprotect/consumer/Queue.java | 5 ++ .../process/ContainerTransactionProcess.java | 15 ++++++ .../coreprotect/consumer/process/Process.java | 3 ++ .../database/ConsumerWriteBatch.java | 4 ++ .../RelationalConsumerWriteBatch.java | 46 +++++++++++++++- .../ClickHouseConsumerWriteBatch.java | 10 ++++ .../database/logger/ContainerLogger.java | 52 +++++++++++++++++++ .../statement/ContainerStatement.java | 13 +++++ .../event/CoreProtectPreLogEvent.java | 3 +- .../item/VirtualContainerTransaction.java | 38 ++++++++++++++ 11 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/coreprotect/model/item/VirtualContainerTransaction.java diff --git a/src/main/java/net/coreprotect/config/ConfigHandler.java b/src/main/java/net/coreprotect/config/ConfigHandler.java index 53fc4f30c..cfc2a60ea 100644 --- a/src/main/java/net/coreprotect/config/ConfigHandler.java +++ b/src/main/java/net/coreprotect/config/ConfigHandler.java @@ -773,6 +773,10 @@ private static boolean queueIdentifier(ReferenceKind kind, int id, String value) } } + public static synchronized void cacheIdentifier(CacheType type, int id, String value) { + identifierStore(type.kind).store(id, value); + } + public static synchronized void uncacheIdentifier(ReferenceKind kind, int id, String value) { IdentifierStore store = identifierStore(kind); store.values.get().remove(value, Integer.valueOf(id)); diff --git a/src/main/java/net/coreprotect/consumer/Queue.java b/src/main/java/net/coreprotect/consumer/Queue.java index de14af06a..707bb6845 100755 --- a/src/main/java/net/coreprotect/consumer/Queue.java +++ b/src/main/java/net/coreprotect/consumer/Queue.java @@ -37,6 +37,7 @@ import net.coreprotect.model.entity.EntityInteractionAction; import net.coreprotect.model.entity.EntityInteractionOrigin; import net.coreprotect.model.entity.EntitySpawnData; +import net.coreprotect.model.item.VirtualContainerTransaction; import net.coreprotect.thread.CacheHandler; import net.coreprotect.thread.Scheduler; import net.coreprotect.utility.BlockUtils; @@ -360,6 +361,10 @@ public static void queueEntityContainerTransaction(String user, EntityContainerT queueStandardData(new Object[] { null, Process.ENTITY_CONTAINER_TRANSACTION, null, 0, null, 0, 0, null }, new String[] { user, null }, transaction, false, Consumer.reserveConsumer()); } + protected static boolean queueVirtualContainerTransaction(String user, VirtualContainerTransaction transaction) { + return queueStandardData(new Object[] { null, Process.VIRTUAL_CONTAINER_TRANSACTION, null, 0, null, 0, 0, null }, new String[] { user, null }, transaction, false, Consumer.reserveConsumer()); + } + public static void queueEntityInteraction(String user, Entity entity, EntityInteractionAction action) { queueEntityInteraction(user, entity, action, null); } diff --git a/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java b/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java index 1e029fc61..ca49c91fc 100644 --- a/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java +++ b/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java @@ -17,6 +17,7 @@ import net.coreprotect.database.logger.ContainerLogger; import net.coreprotect.model.entity.EntityContainerTransaction; import net.coreprotect.model.entity.EntitySpawnIdentity; +import net.coreprotect.model.item.VirtualContainerTransaction; import net.coreprotect.utility.ErrorReporter; import net.coreprotect.utility.HopperTransactionUtils; @@ -41,6 +42,20 @@ static boolean processEntity(ConsumerWriteBatch preparedStmtContainer, int batch return true; } + static void processVirtual(ConsumerWriteBatch preparedStmtContainer, int batchCount, String user, Object object) throws Exception { + if (!(object instanceof VirtualContainerTransaction)) { + return; + } + + VirtualContainerTransaction transaction = (VirtualContainerTransaction) object; + if (ConfigHandler.databaseType.isColumnar()) { + preparedStmtContainer.executeAtomically("virtual_container_transaction", () -> ContainerLogger.logVirtual(preparedStmtContainer, batchCount, user, transaction)); + } + else { + ContainerLogger.logVirtual(preparedStmtContainer, batchCount, user, transaction); + } + } + static void process(ConsumerWriteBatch preparedStmtContainer, ConsumerWriteBatch preparedStmtItems, int batchCount, int processId, int id, Material type, int forceData, String user, Object object) { if (object instanceof ContainerLogger.PreparedTransaction) { ((ContainerLogger.PreparedTransaction) object).log(preparedStmtContainer, preparedStmtItems, batchCount, user); diff --git a/src/main/java/net/coreprotect/consumer/process/Process.java b/src/main/java/net/coreprotect/consumer/process/Process.java index 2719324db..9ef35f73a 100755 --- a/src/main/java/net/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/coreprotect/consumer/process/Process.java @@ -337,6 +337,9 @@ else if (action == Process.ENTITY_SPAWN_UPDATE || action == Process.ENTITY_CONTA case Process.CONTAINER_TRANSACTION: ContainerTransactionProcess.process(writeBatch, writeBatch, i, processId, id, blockType, forceData, user, object); break; + case Process.VIRTUAL_CONTAINER_TRANSACTION: + ContainerTransactionProcess.processVirtual(writeBatch, i, user, object); + break; case Process.ENTITY_CONTAINER_TRANSACTION: EntityContainerTransaction transaction = (EntityContainerTransaction) object; EntitySpawnIdentity identity = entitySpawnIdentities.get(transaction.getEntityUuid()); diff --git a/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java index a35206ef7..b7622d3e3 100644 --- a/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/ConsumerWriteBatch.java @@ -33,6 +33,8 @@ default boolean shouldCommit() { int resolveUserId(String user, String uuid) throws Exception; + int resolveVirtualContainerId(String key) throws Exception; + void recordUsername(String user, String uuid, int retainHistory, int time) throws Exception; void updateDatabaseLock(int status, int time) throws Exception; @@ -49,6 +51,8 @@ default boolean shouldCommit() { void addEntityContainer(int batchCount, int time, int userId, int entitySpawnRowId, int worldId, int x, int y, int z, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws Exception; + void addVirtualContainer(int batchCount, int time, int userId, int containerId, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws Exception; + void addItem(int batchCount, int time, int userId, int worldId, int x, int y, int z, int type, byte[] data, int amount, int action, int rolledBack) throws Exception; void addChat(int batchCount, long time, int userId, int worldId, int x, int y, int z, String message) throws Exception; diff --git a/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java index 9310cbd80..15aeb2af4 100644 --- a/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/RelationalConsumerWriteBatch.java @@ -35,6 +35,7 @@ public final class RelationalConsumerWriteBatch implements ConsumerWriteBatch { private static final int BLOCK_DATA_BATCH = 14; private static final int ENTITY_KILL_LINK_BATCH = 15; private static final int VIRTUAL_CONTAINER_MAP_BATCH = 16; + private static final int VIRTUAL_CONTAINER_BATCH = 17; private static final int INITIAL_DUCKDB_BLOCK_ID_RESERVATION = 256; private static final int MAXIMUM_DUCKDB_BLOCK_ID_RESERVATION = 65536; private static final long[] EMPTY_ROW_IDS = new long[0]; @@ -42,7 +43,7 @@ public final class RelationalConsumerWriteBatch implements ConsumerWriteBatch { private final Connection connection; private final DatabaseType databaseType; private final Statement transactionStatement; - private final PreparedStatement[] batchStatements = new PreparedStatement[17]; + private final PreparedStatement[] batchStatements = new PreparedStatement[18]; private final List statements = new ArrayList<>(); private PreparedStatement blockReturningStatement; @@ -61,6 +62,7 @@ public final class RelationalConsumerWriteBatch implements ConsumerWriteBatch { private PreparedStatement usernameHistoryStatement; private PreparedStatement usernameHistoryInsertStatement; private PreparedStatement databaseLockStatement; + private PreparedStatement virtualContainerIdStatement; private PreparedStatement duckDBBlockRowIdStatement; private DuckDBAppender duckDBBlockAppender; private long[] duckDBBlockRowIds = EMPTY_ROW_IDS; @@ -178,6 +180,26 @@ public int resolveUserId(String user, String uuid) throws Exception { return userId; } + @Override + public int resolveVirtualContainerId(String key) throws Exception { + Integer cachedId = ConfigHandler.virtualContainers.get(key); + if (cachedId != null) { + return cachedId; + } + + // keys are not preloaded, so an existing key is read on this connection before a new id is allocated + PreparedStatement statement = virtualContainerIdStatement(); + statement.setString(1, key); + try (ResultSet resultSet = statement.executeQuery()) { + int id = resultSet.next() ? resultSet.getInt(1) : 0; + if (id > 0) { + ConfigHandler.cacheIdentifier(ConfigHandler.CacheType.VIRTUAL_CONTAINERS, id, key); + return id; + } + } + return ConfigHandler.resolveIdentifierId(ConfigHandler.CacheType.VIRTUAL_CONTAINERS, key, true); + } + @Override public void recordUsername(String user, String uuid, int retainHistory, int time) throws Exception { if (ConfigHandler.isBlacklisted(user)) { @@ -346,6 +368,21 @@ public void addEntityContainer(int batchCount, int time, int userId, int entityS trackDuckDBGenerated("entity_container", worldId, x, z, entitySpawnRowId); } + @Override + public void addVirtualContainer(int batchCount, int time, int userId, int containerId, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws Exception { + PreparedStatement statement = batchStatement(Database.VIRTUAL_CONTAINER, VIRTUAL_CONTAINER_BATCH); + statement.setInt(1, time); + statement.setInt(2, userId); + statement.setInt(3, containerId); + statement.setInt(4, type); + statement.setInt(5, data); + statement.setInt(6, amount); + statement.setObject(7, metadata); + statement.setInt(8, action); + statement.setInt(9, rolledBack); + addBatch(statement, batchCount); + } + @Override public void addItem(int batchCount, int time, int userId, int worldId, int x, int y, int z, int type, byte[] data, int amount, int action, int rolledBack) throws Exception { PreparedStatement statement = batchStatement(Database.ITEM, ITEM_BATCH); @@ -681,6 +718,13 @@ private PreparedStatement databaseLockStatement() throws SQLException { return databaseLockStatement; } + private PreparedStatement virtualContainerIdStatement() throws SQLException { + if (virtualContainerIdStatement == null) { + virtualContainerIdStatement = own(connection.prepareStatement("SELECT MIN(id) FROM " + ConfigHandler.prefix + "virtual_container_map WHERE container=?")); + } + return virtualContainerIdStatement; + } + private void appendDuckDBBlock(int time, int userId, int worldId, int x, int y, int z, int type, int data, byte[] meta, byte[] blockData, int action, int rolledBack) throws SQLException { long rowId = nextDuckDBBlockRowId(); if (duckDBBlockAppender == null) { diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java index e31b432f1..b4c2a9e23 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java @@ -141,6 +141,11 @@ public void executeAtomically(String name, Database.SavepointOperation operation } } + @Override + public int resolveVirtualContainerId(String key) throws Exception { + return ConfigHandler.resolveIdentifierId(ConfigHandler.CacheType.VIRTUAL_CONTAINERS, key, true); + } + @Override public int resolveUserId(String user, String uuid) throws Exception { requireBatch(); @@ -296,6 +301,11 @@ public void addEntityContainer(int batchCount, int time, int userId, int entityS events().addEntityContainer(time, userId, entitySpawnRowId, worldId, x, y, z, type, data, amount, metadata, action, rolledBack); } + @Override + public void addVirtualContainer(int batchCount, int time, int userId, int containerId, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws Exception { + throw new SQLException("ClickHouse does not store virtual containers yet"); + } + @Override public void addItem(int batchCount, int time, int userId, int worldId, int x, int y, int z, int type, byte[] data, int amount, int action, int rolledBack) throws Exception { events().addItem(time, userId, worldId, x, y, z, type, data, amount, action, rolledBack); diff --git a/src/main/java/net/coreprotect/database/logger/ContainerLogger.java b/src/main/java/net/coreprotect/database/logger/ContainerLogger.java index 1f847cfee..ff1a7271d 100644 --- a/src/main/java/net/coreprotect/database/logger/ContainerLogger.java +++ b/src/main/java/net/coreprotect/database/logger/ContainerLogger.java @@ -25,6 +25,7 @@ import net.coreprotect.model.entity.EntityContainerTransaction; import net.coreprotect.model.entity.EntitySpawnIdentity; import net.coreprotect.model.item.ItemTransactionActions; +import net.coreprotect.model.item.VirtualContainerTransaction; import net.coreprotect.thread.CacheHandler; import net.coreprotect.utility.BlockUtils; import net.coreprotect.utility.HopperTransactionUtils; @@ -249,6 +250,24 @@ public static void logEntity(ConsumerWriteBatch preparedStmtContainer, int batch logEntityTransaction(preparedStmtContainer, batchCount, player, identity, currentLocation, newInventory, ItemTransactionActions.ADD); } + public static void logVirtual(ConsumerWriteBatch preparedStmtContainer, int batchCount, String player, VirtualContainerTransaction transaction) throws Exception { + if (transaction == null || ConfigHandler.isBlacklisted(player)) { + return; + } + + ItemStack[] oldInventory = transaction.getOldContents(); + ItemStack[] newInventory = transaction.getNewContents(); + if (ItemUtils.compareContainers(oldInventory, newInventory)) { + return; + } + + subtractSharedItems(oldInventory, newInventory); + ItemUtils.mergeItems(Material.CHEST, oldInventory); + ItemUtils.mergeItems(Material.CHEST, newInventory); + logVirtualTransaction(preparedStmtContainer, batchCount, player, transaction, oldInventory, ItemTransactionActions.REMOVE); + logVirtualTransaction(preparedStmtContainer, batchCount, player, transaction, newInventory, ItemTransactionActions.ADD); + } + protected static void logTransaction(ConsumerWriteBatch preparedStmt, int batchCount, String user, Material type, String faceData, ItemStack[] items, int action, Location location) { logTransaction(preparedStmt, batchCount, user, type, faceData, items, action, location, null, true); } @@ -357,6 +376,39 @@ private static void logEntityTransaction(ConsumerWriteBatch preparedStmt, int ba } } + private static void logVirtualTransaction(ConsumerWriteBatch preparedStmt, int batchCount, String user, VirtualContainerTransaction transaction, ItemStack[] items, int action) throws Exception { + int slot = 0; + for (ItemStack item : items) { + if (item == null || item.getAmount() <= 0 || BlockUtils.isAir(item.getType())) { + slot++; + continue; + } + if (ConfigHandler.isFilterBlacklisted(user, item.getType().getKey().toString())) { + slot++; + continue; + } + + List>> metadata = ItemMetaHandler.serialize(item, Material.CHEST, null, slot); + if (metadata.isEmpty()) { + metadata = null; + } + + CoreProtectPreLogEvent event = new CoreProtectPreLogEvent(user, null, CoreProtectPreLogEvent.Action.VIRTUAL_CONTAINER_TRANSACTION, action, item.getType(), null, transaction.getKey()); + if (Config.getGlobal().API_ENABLED && !Bukkit.isPrimaryThread()) { + CoreProtect.getInstance().getServer().getPluginManager().callEvent(event); + } + if (event.isCancelled()) { + return; + } + + int userId = UserStatement.getId(preparedStmt, event.getUser(), true); + int containerId = preparedStmt.resolveVirtualContainerId(transaction.getKey()); + int typeId = MaterialUtils.getBlockId(item.getType().name(), true); + ContainerStatement.insertVirtual(preparedStmt, batchCount, transaction.getTime(), userId, containerId, typeId, 0, item.getAmount(), metadata, action, 0); + slot++; + } + } + private static boolean samePosition(Location first, Location second) { return first != null && second != null && first.getWorld() != null && second.getWorld() != null && first.getWorld().getUID().equals(second.getWorld().getUID()) && Double.compare(first.getX(), second.getX()) == 0 && Double.compare(first.getY(), second.getY()) == 0 && Double.compare(first.getZ(), second.getZ()) == 0; } diff --git a/src/main/java/net/coreprotect/database/statement/ContainerStatement.java b/src/main/java/net/coreprotect/database/statement/ContainerStatement.java index 4c4f1e3fb..487ad3eb6 100644 --- a/src/main/java/net/coreprotect/database/statement/ContainerStatement.java +++ b/src/main/java/net/coreprotect/database/statement/ContainerStatement.java @@ -34,4 +34,17 @@ public static void insertEntity(ConsumerWriteBatch batch, int batchCount, int ti throw new SQLException("Unable to write entity container transaction", exception); } } + + public static void insertVirtual(ConsumerWriteBatch batch, int batchCount, int time, int id, int containerId, int type, int data, int amount, Object metadata, int action, int rolledBack) throws SQLException { + byte[] byteData = ItemUtils.convertByteData(metadata); + try { + batch.addVirtualContainer(batchCount, time, id, containerId, type, data, amount, byteData, action, rolledBack); + } + catch (SQLException exception) { + throw exception; + } + catch (Exception exception) { + throw new SQLException("Unable to write virtual container transaction", exception); + } + } } diff --git a/src/main/java/net/coreprotect/event/CoreProtectPreLogEvent.java b/src/main/java/net/coreprotect/event/CoreProtectPreLogEvent.java index 287217c56..1171c78d1 100644 --- a/src/main/java/net/coreprotect/event/CoreProtectPreLogEvent.java +++ b/src/main/java/net/coreprotect/event/CoreProtectPreLogEvent.java @@ -21,7 +21,8 @@ public enum Action { ITEM_TRANSACTION, PLAYER_COMMAND, ENTITY_SPAWN, - ENTITY_INTERACTION + ENTITY_INTERACTION, + VIRTUAL_CONTAINER_TRANSACTION } private static final HandlerList handlers = new HandlerList(); diff --git a/src/main/java/net/coreprotect/model/item/VirtualContainerTransaction.java b/src/main/java/net/coreprotect/model/item/VirtualContainerTransaction.java new file mode 100644 index 000000000..208de9b61 --- /dev/null +++ b/src/main/java/net/coreprotect/model/item/VirtualContainerTransaction.java @@ -0,0 +1,38 @@ +package net.coreprotect.model.item; + +import org.bukkit.inventory.ItemStack; + +import net.coreprotect.utility.ItemUtils; + +public final class VirtualContainerTransaction { + + private static final ItemStack[] EMPTY_CONTENTS = new ItemStack[0]; + + private final String key; + private final int time; + private final ItemStack[] oldContents; + private final ItemStack[] newContents; + + public VirtualContainerTransaction(String key, ItemStack[] oldContents, ItemStack[] newContents) { + this.key = key; + this.time = (int) (System.currentTimeMillis() / 1000L); + this.oldContents = oldContents == null ? EMPTY_CONTENTS : ItemUtils.getContainerState(oldContents); + this.newContents = newContents == null ? EMPTY_CONTENTS : ItemUtils.getContainerState(newContents); + } + + public String getKey() { + return key; + } + + public int getTime() { + return time; + } + + public ItemStack[] getOldContents() { + return ItemUtils.getContainerState(oldContents); + } + + public ItemStack[] getNewContents() { + return ItemUtils.getContainerState(newContents); + } +} From 994212d262a415435acbd4f258d1eb5672f046d4 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:44:37 +0300 Subject: [PATCH 04/13] Store virtual containers in ClickHouse without changing the partition key --- .../ClickHouseConsumerWriteBatch.java | 9 +++- .../clickhouse/ClickHouseEventBatch.java | 23 +++++++++++ .../database/clickhouse/ClickHouseFamily.java | 41 +++++++++++++------ .../database/clickhouse/ClickHouseSchema.java | 14 ++++--- 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java index b4c2a9e23..59c50123b 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java @@ -260,6 +260,9 @@ public void addReference(ReferenceKind kind, int batchCount, int id, String valu case MATERIAL: events().addMaterialMap(id, requiredValue); break; + case VIRTUAL_CONTAINER: + events().addVirtualContainerMap(id, requiredValue); + break; case WORLD: events().addWorld(id, requiredValue); break; @@ -303,7 +306,7 @@ public void addEntityContainer(int batchCount, int time, int userId, int entityS @Override public void addVirtualContainer(int batchCount, int time, int userId, int containerId, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws Exception { - throw new SQLException("ClickHouse does not store virtual containers yet"); + events().addVirtualContainer(time, userId, containerId, type, data, amount, metadata, action, rolledBack); } @Override @@ -591,6 +594,8 @@ static ClickHouseFamily referenceFamily(ReferenceKind kind) { return ClickHouseFamily.ENTITY_MAP; case MATERIAL: return ClickHouseFamily.MATERIAL_MAP; + case VIRTUAL_CONTAINER: + return ClickHouseFamily.VIRTUAL_CONTAINER_MAP; case WORLD: return ClickHouseFamily.WORLD; default: @@ -608,6 +613,8 @@ static String referenceValueColumn(ReferenceKind kind) { return "entity"; case MATERIAL: return "material"; + case VIRTUAL_CONTAINER: + return "container"; case WORLD: return "world"; default: diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseEventBatch.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseEventBatch.java index 753ebdd8a..7e00ef5a5 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseEventBatch.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseEventBatch.java @@ -71,6 +71,21 @@ public long addEntityContainer(int time, int userId, int entitySpawnRowId, int w return addContainer(ClickHouseFamily.ENTITY_CONTAINER, time, userId, entitySpawnRowId, worldId, x, y, z, type, data, amount, metadata, action, rolledBack); } + public long addVirtualContainer(int time, int userId, int containerId, int type, int data, int amount, byte[] metadata, int action, int rolledBack) throws SQLException { + long rowId = beginRow(ClickHouseFamily.VIRTUAL_CONTAINER, time); + set("user_id", userId); + // the container id shares entity_spawn_rowid and its bloom filter index + set("entity_spawn_rowid", (long) containerId); + set("type", type); + set("data", (long) data); + set("amount", amount); + setBinary("metadata", metadata); + set("action", action); + set("rolled_back", rolledBack); + commitRow(ClickHouseFamily.VIRTUAL_CONTAINER, rowId); + return rowId; + } + public long addEntityInteraction(int time, int userId, int entitySpawnRowId, int worldId, int x, int y, int z, int type, int action, byte[] metadata, int rolledBack) throws SQLException { long rowId = beginRow(ClickHouseFamily.ENTITY_INTERACTION, time); setLocation(userId, worldId, x, y, z); @@ -221,6 +236,10 @@ public long addWorld(int id, String world) throws SQLException { return addNamedMap(ClickHouseFamily.WORLD, id, world); } + public long addVirtualContainerMap(int id, String container) throws SQLException { + return addNamedMap(ClickHouseFamily.VIRTUAL_CONTAINER_MAP, id, container); + } + public void addCompatibilityRow(ClickHouseFamily family, long rowId, Map values) throws SQLException { Objects.requireNonNull(family, "family"); Objects.requireNonNull(values, "values"); @@ -561,10 +580,14 @@ private static String compatibilityColumn(ClickHouseFamily family, String column if (family == ClickHouseFamily.ENTITY_SPAWN && (column.equals("x") || column.equals("y") || column.equals("z"))) { return "current_" + column; } + if (family == ClickHouseFamily.VIRTUAL_CONTAINER && column.equals("container_id")) { + return "entity_spawn_rowid"; + } if ((family == ClickHouseFamily.ART_MAP && column.equals("art")) || (family == ClickHouseFamily.ENTITY_MAP && column.equals("entity")) || (family == ClickHouseFamily.MATERIAL_MAP && column.equals("material")) || (family == ClickHouseFamily.WORLD && column.equals("world")) + || (family == ClickHouseFamily.VIRTUAL_CONTAINER_MAP && column.equals("container")) || (family == ClickHouseFamily.SKULL && column.equals("owner"))) { return "name"; } diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseFamily.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseFamily.java index 07eae8826..dca6cd4b0 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseFamily.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseFamily.java @@ -9,26 +9,28 @@ public enum ClickHouseFamily { ART_MAP("art_map"), - BLOCK("block"), - CHAT("chat"), - COMMAND("command"), - CONTAINER("container"), - ENTITY_CONTAINER("entity_container"), - ENTITY_INTERACTION("entity_interaction"), - ITEM("item"), + BLOCK("block", true), + CHAT("chat", true), + COMMAND("command", true), + CONTAINER("container", true), + ENTITY_CONTAINER("entity_container", true), + ENTITY_INTERACTION("entity_interaction", true), + ITEM("item", true), DATABASE_LOCK("database_lock"), - ENTITY("entity"), + ENTITY("entity", true), ENTITY_SPAWN("entity_spawn"), ENTITY_MAP("entity_map"), MATERIAL_MAP("material_map"), BLOCKDATA_MAP("blockdata_map"), - SESSION("session"), - SIGN("sign"), - SKULL("skull"), + SESSION("session", true), + SIGN("sign", true), + SKULL("skull", true), USER("user"), USERNAME_LOG("username_log"), VERSION("version"), - WORLD("world"); + WORLD("world"), + VIRTUAL_CONTAINER("virtual_container"), + VIRTUAL_CONTAINER_MAP("virtual_container_map"); private static final Map BY_TABLE_NAME; @@ -41,15 +43,30 @@ public enum ClickHouseFamily { } private final String tableName; + private final boolean timePartitioned; ClickHouseFamily(String tableName) { + this(tableName, false); + } + + ClickHouseFamily(String tableName, boolean timePartitioned) { this.tableName = tableName; + this.timePartitioned = timePartitioned; } public String getTableName() { return tableName; } + /** + * Returns whether rows of this family are partitioned by month. Existing event tables are validated against a + * partition key that lists these families, so the set is fixed: a family added later stays in partition 0 and is + * purged by mutation. + */ + boolean isTimePartitioned() { + return timePartitioned; + } + boolean isPurgeable() { return PurgePolicy.isPurgeable(tableName); } diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseSchema.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseSchema.java index d52602163..124dda972 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseSchema.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseSchema.java @@ -22,8 +22,8 @@ public final class ClickHouseSchema { private static final String INTEGER_CODEC = " CODEC(Delta, ZSTD(3))"; private static final String VALUE_CODEC = " CODEC(ZSTD(3))"; private static final String SPARSE_SERIALIZATION_SETTING = "ratio_of_defaults_for_sparse_serialization=0.9"; - private static final String PURGEABLE_FAMILIES = purgeableFamilies(); - private static final String EVENT_PARTITION_KEY = "if(family='" + BATCH_RECEIPT_FAMILY + "',wid,if(family IN (" + PURGEABLE_FAMILIES + private static final String TIME_PARTITIONED_FAMILIES = timePartitionedFamilies(); + private static final String EVENT_PARTITION_KEY = "if(family='" + BATCH_RECEIPT_FAMILY + "',wid,if(family IN (" + TIME_PARTITIONED_FAMILIES + "),toYYYYMM(toDateTime(time,'UTC')),0))"; private static final String EVENT_SORTING_KEY = "(family,wid,x,z,if(family IN ('database_lock','user','version'),0,time),rowid,if(family='" + BATCH_RECEIPT_FAMILY + "',toString(batch_id),''))"; @@ -132,7 +132,7 @@ private ClickHouseSchema() { } static int eventPartitionId(ClickHouseFamily family, int time) { - if (!family.isPurgeable()) { + if (!family.isTimePartitioned()) { return 0; } LocalDateTime timestamp = LocalDateTime.ofEpochSecond(Integer.toUnsignedLong(time), 0, ZoneOffset.UTC); @@ -369,6 +369,8 @@ private static void addCompatibilityViews(List statements, Names names) statements.add(view(names, ClickHouseFamily.USERNAME_LOG, "e.rowid AS rowid,e.time AS time,e.uuid AS uuid,e.user_name AS `user`")); statements.add(currentView(names, ClickHouseFamily.VERSION, "e.rowid AS rowid,e.time AS time,e.version AS version")); statements.add(currentView(names, ClickHouseFamily.WORLD, "e.rowid AS rowid,e.id AS id,e.name AS world")); + statements.add(rollbackView(names, ClickHouseFamily.VIRTUAL_CONTAINER)); + statements.add(currentView(names, ClickHouseFamily.VIRTUAL_CONTAINER_MAP, "e.rowid AS rowid,e.id AS id,e.name AS container")); } private static String view(Names names, ClickHouseFamily family, String projection) { @@ -423,6 +425,8 @@ static String rollbackProjection(String table) { return "e.rowid AS rowid,e.time AS time,e.user_id AS `user`," + location("wid") + "," + location("x") + ",e.y AS y," + location("z") + ",e.action AS action,e.color AS color,e.color_secondary AS color_secondary,e.sign_data AS data,e.waxed AS waxed,e.face AS face,e.line_1 AS line_1,e.line_2 AS line_2,e.line_3 AS line_3,e.line_4 AS line_4,e.line_5 AS line_5,e.line_6 AS line_6,e.line_7 AS line_7,e.line_8 AS line_8"; case "item": return "e.rowid AS rowid,e.time AS time,e.user_id AS `user`," + location("wid") + "," + location("x") + ",e.y AS y," + location("z") + ",e.type AS type," + binary("e.payload", "data") + ",e.amount AS amount,e.action AS action"; + case "virtual_container": + return "e.rowid AS rowid,e.time AS time,e.user_id AS `user`,e.entity_spawn_rowid AS container_id,e.type AS type,e.data AS data,e.amount AS amount," + binary("e.metadata", "metadata") + ",e.action AS action"; default: return null; } @@ -518,10 +522,10 @@ private static String[] columnDefinitions(String[][] columns) { return definitions; } - private static String purgeableFamilies() { + private static String timePartitionedFamilies() { StringJoiner families = new StringJoiner(","); for (ClickHouseFamily family : ClickHouseFamily.values()) { - if (family.isPurgeable()) { + if (family.isTimePartitioned()) { families.add("'" + family.getTableName() + "'"); } } From 5b6fa8d22fdd690de55959ee8cc77777143d0065 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:45:44 +0300 Subject: [PATCH 05/13] Add API method to log virtual container transactions --- .../java/net/coreprotect/CoreProtectAPI.java | 33 +++++++++++++++++ .../utility/VirtualContainerUtils.java | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/java/net/coreprotect/utility/VirtualContainerUtils.java diff --git a/src/main/java/net/coreprotect/CoreProtectAPI.java b/src/main/java/net/coreprotect/CoreProtectAPI.java index 275844f09..f73403fdf 100755 --- a/src/main/java/net/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/coreprotect/CoreProtectAPI.java @@ -11,12 +11,14 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.Server; import org.bukkit.block.Block; import org.bukkit.block.BlockState; import org.bukkit.block.data.BlockData; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import net.coreprotect.api.BlockAPI; import net.coreprotect.api.EntityAPI; @@ -47,9 +49,11 @@ import net.coreprotect.language.Phrase; import net.coreprotect.listener.player.InventoryChangeListener; import net.coreprotect.model.action.LookupActions; +import net.coreprotect.model.item.VirtualContainerTransaction; import net.coreprotect.utility.Chat; import net.coreprotect.utility.MaterialUtils; import net.coreprotect.utility.ErrorReporter; +import net.coreprotect.utility.VirtualContainerUtils; /** * The main API class for CoreProtect. @@ -556,6 +560,35 @@ public boolean logContainerTransaction(String user, Location location) { return InventoryChangeListener.inventoryTransaction(user, location, null); } + /** + * Logs a transaction in a virtual container: an inventory owned by a plugin that has no block or entity. + * CoreProtect records the difference between the two snapshots. + * + * @param user + * The username or plugin actor credited with the change + * @param type + * The container type, a namespaced key whose key part contains no "/" + * @param id + * The container within its type: 1 to 64 characters of a-z, 0-9, ".", "_" or "-" + * @param before + * The contents before the change, or null when empty + * @param after + * The contents after the change, or null when empty + * @return True if the transaction was queued + */ + public boolean logVirtualContainerTransaction(String user, NamespacedKey type, String id, ItemStack[] before, ItemStack[] after) { + if (!isEnabled() || !Config.getGlobal().ITEM_TRANSACTIONS || user == null || user.isEmpty() || type == null) { + return false; + } + + String key = VirtualContainerUtils.getKey(type.getNamespace() + ":" + type.getKey(), id); + if (key == null) { + return false; + } + + return Queue.queueVirtualContainerTransaction(user, new VirtualContainerTransaction(key, before, after)); + } + /** * Logs a block placement by a user. * diff --git a/src/main/java/net/coreprotect/utility/VirtualContainerUtils.java b/src/main/java/net/coreprotect/utility/VirtualContainerUtils.java new file mode 100644 index 000000000..fa0a11d18 --- /dev/null +++ b/src/main/java/net/coreprotect/utility/VirtualContainerUtils.java @@ -0,0 +1,36 @@ +package net.coreprotect.utility; + +import java.util.regex.Pattern; + +public final class VirtualContainerUtils { + + // 191 characters fit a utf8mb4 index on older MySQL versions + private static final int MAXIMUM_KEY_LENGTH = 191; + private static final Pattern TYPE = Pattern.compile("[a-z0-9._-]+:[a-z0-9._-]+"); + private static final Pattern ID = Pattern.compile("[a-z0-9._-]{1,64}"); + + private VirtualContainerUtils() { + throw new IllegalStateException("Utility class"); + } + + /** + * Builds the stored key of a virtual container, "type/id". + * + * @return the key, or null when the type or id is invalid + */ + public static String getKey(String type, String id) { + if (!isValidType(type) || id == null || !ID.matcher(id).matches()) { + return null; + } + String key = type + "/" + id; + return key.length() <= MAXIMUM_KEY_LENGTH ? key : null; + } + + /** + * Returns whether a type is a lowercase namespaced key without "/", so a stored key splits into type and id at + * its only "/". + */ + public static boolean isValidType(String type) { + return type != null && type.length() < MAXIMUM_KEY_LENGTH - 1 && TYPE.matcher(type).matches(); + } +} From 3106a48022a74f77f9aa9b89576a6c472f34e746 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:51:31 +0300 Subject: [PATCH 06/13] Add container: lookups for virtual containers --- .../coreprotect/command/CommandParser.java | 4 + .../coreprotect/command/LookupCommand.java | 28 +++++++ .../command/RollbackRestoreCommand.java | 5 ++ .../command/lookup/StandardLookupThread.java | 10 +++ .../command/parser/MessageFilterParser.java | 1 + .../parser/VirtualContainerParser.java | 84 +++++++++++++++++++ .../net/coreprotect/database/LookupRaw.java | 32 ++++++- .../statement/ContainerStatement.java | 31 +++++++ .../model/lookup/VirtualContainerTarget.java | 24 ++++++ 9 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java create mode 100644 src/main/java/net/coreprotect/model/lookup/VirtualContainerTarget.java diff --git a/src/main/java/net/coreprotect/command/CommandParser.java b/src/main/java/net/coreprotect/command/CommandParser.java index 7dcd46967..50a5eafc7 100644 --- a/src/main/java/net/coreprotect/command/CommandParser.java +++ b/src/main/java/net/coreprotect/command/CommandParser.java @@ -54,6 +54,10 @@ protected static RollbackStateParser.ParseResult parseRollbackState(String[] inp return RollbackStateParser.parse(inputArguments); } + protected static VirtualContainerParser.ParseResult parseVirtualContainer(String[] inputArguments) { + return VirtualContainerParser.parse(inputArguments); + } + /** * Parse coordinates from command arguments * diff --git a/src/main/java/net/coreprotect/command/LookupCommand.java b/src/main/java/net/coreprotect/command/LookupCommand.java index 994876049..54195e17c 100755 --- a/src/main/java/net/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/coreprotect/command/LookupCommand.java @@ -23,12 +23,14 @@ import net.coreprotect.command.parser.ActionParser; import net.coreprotect.command.parser.MessageFilterParser; import net.coreprotect.command.parser.RollbackStateParser; +import net.coreprotect.command.parser.VirtualContainerParser; import net.coreprotect.config.Config; import net.coreprotect.config.ConfigHandler; import net.coreprotect.language.Phrase; import net.coreprotect.language.Selector; import net.coreprotect.model.action.EntityActionFilter; import net.coreprotect.model.action.LookupActions; +import net.coreprotect.model.item.ItemTransactionActions; import net.coreprotect.model.lookup.LookupOutputMode; import net.coreprotect.model.lookup.LookupRollbackState; import net.coreprotect.utility.Chat; @@ -66,6 +68,7 @@ public static void runCommand(CommandSender player, Command command, boolean per : CommandParser.parseCount(args) ? LookupOutputMode.COUNT : LookupOutputMode.DETAIL; RollbackStateParser.ParseResult rollbackStateResult = CommandParser.parseRollbackState(args); LookupRollbackState rollbackState = rollbackStateResult.getState(); + VirtualContainerParser.ParseResult virtualContainerResult = CommandParser.parseVirtualContainer(args); boolean worldedit = CommandParser.parseWorldEdit(args); boolean forceglobal = CommandParser.parseForceGlobal(args); boolean pageLookup = false; @@ -79,6 +82,31 @@ public static void runCommand(CommandSender player, Command command, boolean per return; } + if (virtualContainerResult.isSpecified()) { + if (virtualContainerResult.getTarget() == null) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co l container:[/]")); + return; + } + // virtual containers have no location, so the lookup is always global + if (argRadius != null || argWid > 0 || worldedit) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "r:")); + return; + } + if (argUsers.contains("#container")) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "#container")); + return; + } + boolean containerActions = argAction.stream().allMatch(action -> action == LookupActions.CONTAINER || action == ItemTransactionActions.REMOVE || action == ItemTransactionActions.ADD); + if (!argAction.isEmpty() && (!argAction.contains(LookupActions.CONTAINER) || !containerActions)) { + Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INCOMPATIBLE_ACTION, "container:")); + return; + } + if (argAction.isEmpty()) { + argAction.add(LookupActions.CONTAINER); + } + argBlocks.add(virtualContainerResult.getTarget()); + } + if (args[0].toLowerCase(Locale.ROOT).equals("page") && (args.length != 2 || !args[1].equals(args[1].replaceAll("[^0-9]", "")))) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, Color.WHITE, "/co page ")); return; diff --git a/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java b/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java index eb9a97ba5..217488c82 100755 --- a/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java +++ b/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java @@ -20,6 +20,7 @@ import org.bukkit.entity.Player; import net.coreprotect.command.parser.ActionParser; +import net.coreprotect.command.parser.VirtualContainerParser; import net.coreprotect.config.Config; import net.coreprotect.config.ConfigHandler; import net.coreprotect.consumer.Consumer; @@ -532,6 +533,10 @@ private static String findLookupOnlyFlag(String[] args) { if (argument.equals("#count") || argument.equals("#sum") || argument.equals("#summary") || argument.equals("count") || argument.equals("sum")) { return raw.trim(); } + // virtual containers can be looked up, but not rolled back or restored yet + if (VirtualContainerParser.isParameter(argument)) { + return raw.trim(); + } } return null; } diff --git a/src/main/java/net/coreprotect/command/lookup/StandardLookupThread.java b/src/main/java/net/coreprotect/command/lookup/StandardLookupThread.java index 0df5a9d89..2474e958c 100644 --- a/src/main/java/net/coreprotect/command/lookup/StandardLookupThread.java +++ b/src/main/java/net/coreprotect/command/lookup/StandardLookupThread.java @@ -28,6 +28,7 @@ import net.coreprotect.database.Lookup; import net.coreprotect.database.lookup.EntityInteractionLookup; import net.coreprotect.database.lookup.PlayerLookup; +import net.coreprotect.database.statement.ContainerStatement; import net.coreprotect.database.statement.EntitySpawnStatement; import net.coreprotect.database.statement.UserStatement; import net.coreprotect.language.Phrase; @@ -325,6 +326,7 @@ else if (pageStart < rows) { Map entitySpawnRecords = Collections.emptyMap(); Map loadedEntityLocations = Collections.emptyMap(); Set entitySpawnRowIds = new HashSet<>(); + Set virtualContainerIds = new HashSet<>(); for (String[] data : lookupList) { if (data.length > 7 && data[6] != null && data[7] != null && Integer.parseInt(data[7]) == LookupActions.ENTITY_SPAWN) { entitySpawnRowIds.add(Integer.parseInt(data[6])); @@ -335,7 +337,11 @@ else if (pageStart < rows) { if (data.length > 14 && data[13] != null && data[14] != null && Integer.parseInt(data[13]) == InventorySources.ENTITY_INTERACTION) { entitySpawnRowIds.add(Integer.parseInt(data[14])); } + if (data.length > 14 && data[13] != null && data[14] != null && Integer.parseInt(data[13]) == InventorySources.VIRTUAL_CONTAINER) { + virtualContainerIds.add(Integer.parseInt(data[14])); + } } + Map virtualContainerKeys = ContainerStatement.loadVirtualKeys(connection, virtualContainerIds); if (!entitySpawnRowIds.isEmpty()) { entitySpawnRecords = EntitySpawnStatement.loadLocationRecords(connection, entitySpawnRowIds); Set entitySpawnUuids = new HashSet<>(); @@ -509,6 +515,7 @@ else if (daction == ItemTransactionActions.SELL || daction == ItemTransactionAct int ddata = Integer.parseInt(data[6]); int daction = Integer.parseInt(data[7]); boolean entityInteraction = data[13] != null && Integer.parseInt(data[13]) == InventorySources.ENTITY_INTERACTION; + boolean virtualContainer = data[13] != null && Integer.parseInt(data[13]) == InventorySources.VIRTUAL_CONTAINER; boolean placedEntitySpawn = daction == LookupActions.ENTITY_SPAWN && EntitySpawnTracking.isPlacedEntityType(EntityUtils.getEntityType(dtype)); boolean placedEntityKill = daction == LookupActions.ENTITY_KILL && EntitySpawnTracking.isPlacedEntityType(EntityUtils.getEntityType(dtype)); int wid = Integer.parseInt(data[9]); @@ -635,6 +642,9 @@ else if (daction == LookupActions.INTERACTION || daction == LookupActions.ENTITY action = (actions.size() == 0 ? " (" + action + ")" : ""); String coordinates = ChatUtils.getCoordinates(command.getName(), wid, dataX, dataY, dataZ, true, true); + if (virtualContainer) { + coordinates = Color.GREY + Color.ITALIC + "(" + virtualContainerKeys.getOrDefault(Integer.parseInt(data[14]), "#" + data[14]) + ")"; + } String coordinateInfo = entityLocation == null ? "" : entityLocation.getOriginTooltip(); Chat.sendComponent(player, Color.WHITE + leftPadding + Color.GREY + "^ " + coordinates + Color.GREY + Color.ITALIC + action + coordinateInfo); } diff --git a/src/main/java/net/coreprotect/command/parser/MessageFilterParser.java b/src/main/java/net/coreprotect/command/parser/MessageFilterParser.java index 9edc103ad..20fa98ab5 100644 --- a/src/main/java/net/coreprotect/command/parser/MessageFilterParser.java +++ b/src/main/java/net/coreprotect/command/parser/MessageFilterParser.java @@ -18,6 +18,7 @@ public final class MessageFilterParser { "block:", "blocks:", "c:", + "container:", "coord:", "coords:", "cord:", diff --git a/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java b/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java new file mode 100644 index 000000000..07c48adf7 --- /dev/null +++ b/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java @@ -0,0 +1,84 @@ +package net.coreprotect.command.parser; + +import java.util.Locale; + +import net.coreprotect.model.lookup.VirtualContainerTarget; +import net.coreprotect.utility.VirtualContainerUtils; + +/** + * Parser for the container: lookup parameter, which selects a virtual container type or a single container + */ +public final class VirtualContainerParser { + + private static final String PARAMETER = "container:"; + + private VirtualContainerParser() { + throw new IllegalStateException("Parser class"); + } + + public static ParseResult parse(String[] inputArguments) { + int count = 0; + String value = null; + boolean next = false; + for (int index = 1; index < inputArguments.length; index++) { + String argument = normalize(inputArguments[index]); + if (next) { + value = argument; + next = false; + } + else if (argument.startsWith(PARAMETER)) { + count++; + value = argument.substring(PARAMETER.length()); + next = value.isEmpty(); + } + } + + // a second container: would silently narrow the lookup to the last one + VirtualContainerTarget target = count == 1 ? parseTarget(value) : null; + return new ParseResult(count > 0, target); + } + + public static boolean isParameter(String argument) { + return normalize(argument).startsWith(PARAMETER); + } + + private static VirtualContainerTarget parseTarget(String value) { + if (value == null) { + return null; + } + + int separator = value.indexOf('/'); + if (separator < 0) { + return VirtualContainerUtils.isValidType(value) ? new VirtualContainerTarget(value, null) : null; + } + + String type = value.substring(0, separator); + String id = value.substring(separator + 1); + return VirtualContainerUtils.getKey(type, id) != null ? new VirtualContainerTarget(type, id) : null; + } + + private static String normalize(String argument) { + return argument.trim().toLowerCase(Locale.ROOT).replace("\\", "").replace("'", ""); + } + + public static final class ParseResult { + private final boolean specified; + private final VirtualContainerTarget target; + + private ParseResult(boolean specified, VirtualContainerTarget target) { + this.specified = specified; + this.target = target; + } + + public boolean isSpecified() { + return specified; + } + + /** + * @return the target, or null when container: is missing, repeated, or not a valid type or key + */ + public VirtualContainerTarget getTarget() { + return target; + } + } +} diff --git a/src/main/java/net/coreprotect/database/LookupRaw.java b/src/main/java/net/coreprotect/database/LookupRaw.java index 92908bf1b..ce2d88ddf 100644 --- a/src/main/java/net/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/coreprotect/database/LookupRaw.java @@ -39,6 +39,7 @@ import net.coreprotect.model.lookup.EntityLookupContext; import net.coreprotect.model.lookup.LookupCursor; import net.coreprotect.model.lookup.LookupRollbackState; +import net.coreprotect.model.lookup.VirtualContainerTarget; import net.coreprotect.model.rollback.SignRollbackChange; import net.coreprotect.utility.EntitySpawnTracking; import net.coreprotect.utility.EntityUtils; @@ -566,6 +567,7 @@ private static ResultSet rawLookupResultSet(Statement statement, CommandSender u String entityInteractionLocationQuery = ""; String standardLocationQuery = ""; String entityLocationCte = ""; + VirtualContainerTarget virtualContainer = null; List messageFilterBindings = new ArrayList<>(); if (checkUuids.size() > 0) { @@ -640,6 +642,9 @@ else if (restrictTarget instanceof String) { includeListMaterial.append(",").append(blockId); } } + else if (restrictTarget instanceof VirtualContainerTarget) { + virtualContainer = (VirtualContainerTarget) restrictTarget; + } } includeBlock = includeListMaterial.toString(); @@ -1180,7 +1185,19 @@ else if (actionList.contains(LookupActions.SIGN)) { query = query + unionSelect + "SELECT 2 as tbl," + rows + " FROM " + itemTable + " WHERE" + itemSourceQuery + unionLimit + ")"; } - if (!itemLookup && (actionList.contains(LookupActions.CONTAINER) || actionList.contains(5))) { + if (virtualContainer != null) { + if (!lookup) { + // a rollback would update these rows by rowid in the block container table + throw new IllegalArgumentException("Virtual containers cannot be rolled back or restored"); + } + if (!count && !selectPageRows) { + rows = "rowid as id,time," + userColumn + ",0 as wid,0 as x,0 as y,0 as z,type,metadata,data,amount,action,rolled_back,container_id as entity_spawn_rowid"; + } + String sourceQuery = restrictSource(queryNonBlock + " AND " + virtualContainerQuery(virtualContainer), pageRows, InventorySources.VIRTUAL_CONTAINER); + String sourceTable = sourceTable(statement, "virtual_container", locationWorldId, sourceBounds, entityContext, false, pageRows, clickHouseLookup, pageRows == null ? timeQuery : sourceQuery.trim()); + query = "SELECT " + InventorySources.VIRTUAL_CONTAINER + " as tbl," + rows + " FROM " + sourceTable + " WHERE" + sourceQuery; + } + else if (!itemLookup && (actionList.contains(LookupActions.CONTAINER) || actionList.contains(5))) { if (!count && !selectPageRows) { rows = "rowid as id,time," + userColumn + ",wid,x,y,z,type,metadata,data,amount,action,rolled_back,0 as entity_spawn_rowid"; } @@ -1297,6 +1314,19 @@ private static String blockQuery(String rows, String table, String index, String + " FROM (" + originalQuery + " UNION ALL " + currentQuery + ") AS coreprotect_block"; } + private static String virtualContainerQuery(VirtualContainerTarget target) { + // the parser only accepts a-z, 0-9, ".", "_", "-" and the type's ":", so the key is safe to inline + String keys = "SELECT id FROM " + ConfigHandler.prefix + "virtual_container_map WHERE "; + if (target.getId() != null) { + keys += "container='" + target.getType() + "/" + target.getId() + "'"; + } + else { + String typePrefix = target.getType() + "/"; + keys += "substr(container,1," + typePrefix.length() + ")='" + typePrefix + "'"; + } + return "container_id IN(" + keys + ")"; + } + private static final class PageRow { private final long id; diff --git a/src/main/java/net/coreprotect/database/statement/ContainerStatement.java b/src/main/java/net/coreprotect/database/statement/ContainerStatement.java index 487ad3eb6..52e131146 100644 --- a/src/main/java/net/coreprotect/database/statement/ContainerStatement.java +++ b/src/main/java/net/coreprotect/database/statement/ContainerStatement.java @@ -1,7 +1,15 @@ package net.coreprotect.database.statement; +import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.StringJoiner; +import net.coreprotect.config.ConfigHandler; import net.coreprotect.database.ConsumerWriteBatch; import net.coreprotect.database.Database; import net.coreprotect.utility.ItemUtils; @@ -47,4 +55,27 @@ public static void insertVirtual(ConsumerWriteBatch batch, int batchCount, int t throw new SQLException("Unable to write virtual container transaction", exception); } } + + /** + * Loads virtual container keys from the database rather than the identifier cache, which misses keys that another + * server sharing the database created. + */ + public static Map loadVirtualKeys(Connection connection, Collection containerIds) throws SQLException { + Map keys = new HashMap<>(); + if (containerIds.isEmpty()) { + return keys; + } + + StringJoiner ids = new StringJoiner(","); + for (Integer containerId : containerIds) { + ids.add(containerId.toString()); + } + String query = "SELECT id,container FROM " + ConfigHandler.prefix + "virtual_container_map WHERE id IN(" + ids + ")"; + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(query)) { + while (resultSet.next()) { + keys.put(resultSet.getInt(1), resultSet.getString(2)); + } + } + return keys; + } } diff --git a/src/main/java/net/coreprotect/model/lookup/VirtualContainerTarget.java b/src/main/java/net/coreprotect/model/lookup/VirtualContainerTarget.java new file mode 100644 index 000000000..ba3250920 --- /dev/null +++ b/src/main/java/net/coreprotect/model/lookup/VirtualContainerTarget.java @@ -0,0 +1,24 @@ +package net.coreprotect.model.lookup; + +/** + * The virtual containers selected by a container: lookup: every container of a type, or a single container when the + * id is set. It travels in the lookup's include list. + */ +public final class VirtualContainerTarget { + + private final String type; + private final String id; + + public VirtualContainerTarget(String type, String id) { + this.type = type; + this.id = id; + } + + public String getType() { + return type; + } + + public String getId() { + return id; + } +} From cc45973361fb82ba34da9f60cabd7aee6f49686b Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:52:55 +0300 Subject: [PATCH 07/13] Document virtual container logging and lookups --- docs/api/version/v13.md | 32 ++++++++++++++++++++++++++++++-- docs/commands.md | 13 +++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index b1e093f34..17999c5cd 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -23,7 +23,8 @@ All API v12 methods remain available. API v13 adds the following: | Entity-spawn action | Adds `CoreProtectAction.ENTITY_SPAWN` with ID `13`. Legacy lookup, rollback, and restore methods accept this action explicitly. | | Entity result parsing | `BlockResult` and `ParseResult` expose `getEntityType()` for entity kills and spawns. Their material and block-data getters return `null` for entity rows. | | Tracked containers | Container and inventory lookups include transactions in tracked boats and minecarts. | -| Pre-log events | Adds `CoreProtectPreLogEvent.Action.ENTITY_SPAWN` and `ENTITY_INTERACTION`. | +| Virtual containers | Adds `logVirtualContainerTransaction` for plugin-owned inventories that have no block or entity. | +| Pre-log events | Adds `CoreProtectPreLogEvent.Action.ENTITY_SPAWN`, `ENTITY_INTERACTION`, and `VIRTUAL_CONTAINER_TRANSACTION`. | The existing `blockLookup(Block, LookupOptions)` overload retains entity events for compatibility. The new `blockLookup(LookupOptions)` overload returns only block events; use `entityLookup` for entities. @@ -144,6 +145,7 @@ The table below lists the supported methods on `CoreProtectAPI`. Click a method | [`logChat(Player, String)`](#chat-and-commands) / [`logCommand(Player, String)`](#chat-and-commands) | `boolean` | Queues player messages or commands. | | [`logPlacement(...)`](#block-placement-and-removal) / [`logRemoval(...)`](#block-placement-and-removal) | `boolean` | Queues block changes. | | [`logContainerTransaction(String, Location)`](#container-transactions) | `boolean` | Captures a block inventory before your changes. | +| [`logVirtualContainerTransaction(...)`](#virtual-containers) | `boolean` | Queues a change to a plugin-owned inventory. | | [`logInteraction(String, Location)`](#block-interactions) | `boolean` | Queues a block interaction. | | [`performPurge(int)`](#purging-history) | `void` | Dispatches a database purge command. | @@ -610,6 +612,23 @@ Both return `boolean`, require the API to be enabled, and honor the world's corr This method is for a block container. Tracked entity-container lookup support does not add an entity overload to this logging method. +### Virtual containers + +`boolean logVirtualContainerTransaction(String user, NamespacedKey type, String id, ItemStack[] before, ItemStack[] after)` queues a change to a virtual container: an inventory that belongs to your plugin and has no block or entity, such as a backpack, a team storage, or an ender chest opened by command. + +| Parameter | Meaning | +| --- | --- | +| `user` | Username or plugin actor credited with the change. | +| `type` | Container type. Its key must not contain `/`. | +| `id` | Container within the type: 1 to 64 characters of `a-z`, `0-9`, `.`, `_`, or `-`. | +| `before`, `after` | Contents before and after the change. `null` means empty. | + +CoreProtect records the difference between `before` and `after` as items removed and added, so you can pass full snapshots or only the stacks that changed. Because you supply one actor's change, the record is credited correctly even when several players view the same inventory. Copy the contents before changing them, and call the method on the thread that owns the inventory; CoreProtect copies both arrays before it returns. + +The type and id form the key `namespace:type/id`, which can be at most 191 characters. The method returns `false` without logging when the API is disabled, `item-transactions` is disabled in the global configuration, `user` is empty, or the type or id is invalid. + +Search the records with `/co lookup container:[/]`. Other lookups do not include them, and they cannot be rolled back or restored yet. + ### Block interactions `boolean logInteraction(String user, Location location)` records an interaction with the block currently at the location. It reads the block's material and block data. @@ -664,8 +683,9 @@ The action enum contains: | `CONTAINER_TRANSACTION` | Container item transaction. | | `ITEM_TRANSACTION` | World item transaction. | | `PLAYER_COMMAND` | Player command. | +| `VIRTUAL_CONTAINER_TRANSACTION` | Virtual container item transaction. | -Use `getAction()` to identify the category before interpreting the action ID. Container, item, and sign events use their own stored action values; player-command events use `-1`. Command events expose their command as the message. Entity-interaction events expose the interaction action name. Sign-text events do not provide their lines through `getMessage()`. +Use `getAction()` to identify the category before interpreting the action ID. Container, item, and sign events use their own stored action values; player-command events use `-1`. Command events expose their command as the message. Entity-interaction events expose the interaction action name. Virtual-container events have a `null` location, ignore `setLocation`, and expose the container key as the message. Sign-text events do not provide their lines through `getMessage()`. The event is not emitted for every logging category; for example, chat and session logging do not emit it. @@ -934,6 +954,14 @@ api.logContainerTransaction("Notch", inventory.getLocation()); inventory.addItem(itemStack); ``` +For an inventory that only your plugin knows, such as a backpack, copy its contents before the change and pass both snapshots afterwards: + +```java +ItemStack[] before = Arrays.stream(backpack.getContents()).map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new); +backpack.addItem(itemStack); +api.logVirtualContainerTransaction("Notch", new NamespacedKey(plugin, "backpack"), backpackId, before, backpack.getContents()); +``` + ### Listen for pre-log events This listener cancels block-placement records attributed to a plugin's preview actor: diff --git a/docs/commands.md b/docs/commands.md index cc1d439f4..a78147cc6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -57,6 +57,7 @@ Perform a lookup. Nearly all of the parameters are optional. | [`i:`](#iinclude) | Include specific blocks/entities in the lookup. | | [`e:`](#eexclude) | Exclude blocks/entities from the lookup. | | [`f:`](#ffilter) | Include or exclude chat, command, or sign text by prefix. | +| [`container:`](#containertype) | Look up a plugin's virtual containers. | | [`#`](#hashtag) | Add a hashtag to perform additional actions. | #### Pagination @@ -270,6 +271,18 @@ ___ --- +### `container:` + +*Look up items added to or removed from virtual containers: inventories that a plugin keeps without a block or entity, such as backpacks or team storage, and logs through the API.* + +* Example: `container:myplugin:backpack t:1d` *(every backpack of that plugin)* +* Example: `container:myplugin:backpack/1f0c t:1d` *(a single backpack)* +* Example: `container:myplugin:backpack/1f0c u:Notch t:7d a:-container` *(items Notch removed from it)* + +> Virtual containers have no location, so these lookups are always global and can't be combined with `r:` or `#container`. They accept `u:`, `t:`, `i:`, `e:`, `a:container`, `a:+container`, `a:-container`, `#count`, and `#summary`. Other lookups don't include virtual containers, and they can't be rolled back or restored yet. + +--- + ### `#` Add a hashtag to the end of your command to perform additional actions. From 9fd2b5a46c76e86472f863663539bc3b7a666a45 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 20:26:24 +0300 Subject: [PATCH 08/13] Record the slot of virtual container changes --- docs/api/version/v13.md | 2 +- .../database/logger/ContainerLogger.java | 56 +++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 17999c5cd..3b9816ee2 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -623,7 +623,7 @@ This method is for a block container. Tracked entity-container lookup support do | `id` | Container within the type: 1 to 64 characters of `a-z`, `0-9`, `.`, `_`, or `-`. | | `before`, `after` | Contents before and after the change. `null` means empty. | -CoreProtect records the difference between `before` and `after` as items removed and added, so you can pass full snapshots or only the stacks that changed. Because you supply one actor's change, the record is credited correctly even when several players view the same inventory. Copy the contents before changing them, and call the method on the thread that owns the inventory; CoreProtect copies both arrays before it returns. +CoreProtect records the difference between `before` and `after` as items removed and added, so you can pass full snapshots or only the stacks that changed. When the arrays are index-aligned snapshots (the same length, or one of them `null`), each record also keeps the slot where the item changed, and an item that changed in two slots is recorded once per slot. Because you supply one actor's change, the record is credited correctly even when several players view the same inventory. Copy the contents before changing them, and call the method on the thread that owns the inventory; CoreProtect copies both arrays before it returns. The type and id form the key `namespace:type/id`, which can be at most 191 characters. The method returns `false` without logging when the API is disabled, `item-transactions` is disabled in the global configuration, `user` is empty, or the type or id is invalid. diff --git a/src/main/java/net/coreprotect/database/logger/ContainerLogger.java b/src/main/java/net/coreprotect/database/logger/ContainerLogger.java index ff1a7271d..bf7e3229b 100644 --- a/src/main/java/net/coreprotect/database/logger/ContainerLogger.java +++ b/src/main/java/net/coreprotect/database/logger/ContainerLogger.java @@ -1,6 +1,8 @@ package net.coreprotect.database.logger; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; @@ -377,22 +379,16 @@ private static void logEntityTransaction(ConsumerWriteBatch preparedStmt, int ba } private static void logVirtualTransaction(ConsumerWriteBatch preparedStmt, int batchCount, String user, VirtualContainerTransaction transaction, ItemStack[] items, int action) throws Exception { - int slot = 0; + ItemStack[] before = transaction.getOldContents(); + ItemStack[] after = transaction.getNewContents(); for (ItemStack item : items) { if (item == null || item.getAmount() <= 0 || BlockUtils.isAir(item.getType())) { - slot++; continue; } if (ConfigHandler.isFilterBlacklisted(user, item.getType().getKey().toString())) { - slot++; continue; } - List>> metadata = ItemMetaHandler.serialize(item, Material.CHEST, null, slot); - if (metadata.isEmpty()) { - metadata = null; - } - CoreProtectPreLogEvent event = new CoreProtectPreLogEvent(user, null, CoreProtectPreLogEvent.Action.VIRTUAL_CONTAINER_TRANSACTION, action, item.getType(), null, transaction.getKey()); if (Config.getGlobal().API_ENABLED && !Bukkit.isPrimaryThread()) { CoreProtect.getInstance().getServer().getPluginManager().callEvent(event); @@ -404,11 +400,51 @@ private static void logVirtualTransaction(ConsumerWriteBatch preparedStmt, int b int userId = UserStatement.getId(preparedStmt, event.getUser(), true); int containerId = preparedStmt.resolveVirtualContainerId(transaction.getKey()); int typeId = MaterialUtils.getBlockId(item.getType().name(), true); - ContainerStatement.insertVirtual(preparedStmt, batchCount, transaction.getTime(), userId, containerId, typeId, 0, item.getAmount(), metadata, action, 0); - slot++; + for (Map.Entry slot : getVirtualSlots(item, action == ItemTransactionActions.ADD, before, after).entrySet()) { + List>> metadata = ItemMetaHandler.serialize(item, Material.CHEST, null, slot.getKey()); + if (slot.getKey() >= 0) { + // stored like the slot of an armor stand item, which populateItemStack reads back + Map slotData = new HashMap<>(); + slotData.put("slot", slot.getKey()); + List> slotList = new ArrayList<>(); + slotList.add(slotData); + metadata.add(slotList); + } + ContainerStatement.insertVirtual(preparedStmt, batchCount, transaction.getTime(), userId, containerId, typeId, 0, slot.getValue(), metadata.isEmpty() ? null : metadata, action, 0); + } } } + /** + * Splits the logged change of an item over the slots where that item increased (addition) or decreased (removal), + * in slot order. Slots are only known when the two snapshots are index-aligned: the same length, or one of them + * empty. The amount no slot accounts for is returned under slot -1. + */ + private static Map getVirtualSlots(ItemStack item, boolean addition, ItemStack[] before, ItemStack[] after) { + Map slots = new LinkedHashMap<>(); + int remaining = item.getAmount(); + if (before.length == after.length || before.length == 0 || after.length == 0) { + int length = Math.max(before.length, after.length); + for (int slot = 0; slot < length && remaining > 0; slot++) { + int change = getSlotAmount(after, slot, item) - getSlotAmount(before, slot, item); + int amount = Math.min(remaining, addition ? change : -change); + if (amount > 0) { + slots.put(slot, amount); + remaining -= amount; + } + } + } + if (remaining > 0) { + slots.put(-1, remaining); + } + return slots; + } + + private static int getSlotAmount(ItemStack[] items, int slot, ItemStack item) { + ItemStack slotItem = slot < items.length ? items[slot] : null; + return slotItem != null && slotItem.isSimilar(item) ? slotItem.getAmount() : 0; + } + private static boolean samePosition(Location first, Location second) { return first != null && second != null && first.getWorld() != null && second.getWorld() != null && first.getWorld().getUID().equals(second.getWorld().getUID()) && Double.compare(first.getX(), second.getX()) == 0 && Double.compare(first.getY(), second.getY()) == 0 && Double.compare(first.getZ(), second.getZ()) == 0; } From e204df8805222674b37ca59ee8b3102ded02e4ae Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 19:42:32 +0300 Subject: [PATCH 09/13] Add API to register virtual container rollback handlers --- .../java/net/coreprotect/CoreProtectAPI.java | 29 +++++++++++++ .../api/VirtualContainerChange.java | 42 +++++++++++++++++++ .../api/VirtualContainerHandler.java | 24 +++++++++++ .../VirtualContainerRollbackHandler.java | 31 ++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/main/java/net/coreprotect/api/VirtualContainerChange.java create mode 100644 src/main/java/net/coreprotect/api/VirtualContainerHandler.java create mode 100644 src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java diff --git a/src/main/java/net/coreprotect/CoreProtectAPI.java b/src/main/java/net/coreprotect/CoreProtectAPI.java index f73403fdf..a98a16dfe 100755 --- a/src/main/java/net/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/coreprotect/CoreProtectAPI.java @@ -19,6 +19,7 @@ import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; import net.coreprotect.api.BlockAPI; import net.coreprotect.api.EntityAPI; @@ -30,6 +31,7 @@ import net.coreprotect.api.SessionLookup; import net.coreprotect.api.SignAPI; import net.coreprotect.api.UsernameAPI; +import net.coreprotect.api.VirtualContainerHandler; import net.coreprotect.api.result.BlockResult; import net.coreprotect.api.result.ContainerResult; import net.coreprotect.api.result.EntityResult; @@ -46,6 +48,7 @@ import net.coreprotect.database.Database; import net.coreprotect.database.Lookup; import net.coreprotect.database.rollback.Rollback; +import net.coreprotect.database.rollback.VirtualContainerRollbackHandler; import net.coreprotect.language.Phrase; import net.coreprotect.listener.player.InventoryChangeListener; import net.coreprotect.model.action.LookupActions; @@ -589,6 +592,32 @@ public boolean logVirtualContainerTransaction(String user, NamespacedKey type, S return Queue.queueVirtualContainerTransaction(user, new VirtualContainerTransaction(key, before, after)); } + /** + * Registers the handler that applies rollbacks and restores to a type of virtual container. Registering the type + * again replaces its handler. While the plugin is disabled, rollbacks and restores skip containers of this type. + * + * @param plugin + * The plugin that owns the containers + * @param type + * The container type, as passed to logVirtualContainerTransaction + * @param handler + * The handler that applies the changes + * @return True if the handler was registered + */ + public boolean registerVirtualContainer(Plugin plugin, NamespacedKey type, VirtualContainerHandler handler) { + if (!isEnabled() || plugin == null || type == null || handler == null) { + return false; + } + + String typeKey = type.getNamespace() + ":" + type.getKey(); + if (!VirtualContainerUtils.isValidType(typeKey)) { + return false; + } + + VirtualContainerRollbackHandler.register(plugin, typeKey, handler); + return true; + } + /** * Logs a block placement by a user. * diff --git a/src/main/java/net/coreprotect/api/VirtualContainerChange.java b/src/main/java/net/coreprotect/api/VirtualContainerChange.java new file mode 100644 index 000000000..b3da82c0b --- /dev/null +++ b/src/main/java/net/coreprotect/api/VirtualContainerChange.java @@ -0,0 +1,42 @@ +package net.coreprotect.api; + +import org.bukkit.inventory.ItemStack; + +/** + * One item change that a rollback or restore applies to a virtual container. + */ +public final class VirtualContainerChange { + + private final ItemStack item; + private final boolean addition; + private final int slot; + + public VirtualContainerChange(ItemStack item, boolean addition, int slot) { + this.item = item.clone(); + this.addition = addition; + this.slot = slot; + } + + /** + * @return A copy of the items to add or remove + */ + public ItemStack getItem() { + return item.clone(); + } + + /** + * @return True to add the items to the container, false to remove them. Rollbacks and restores are already + * resolved, so a rollback of a removal is an addition. + */ + public boolean isAddition() { + return addition; + } + + /** + * @return The slot in the container where the logged change happened, or -1 when it is unknown. It is a hint: the + * handler decides where the items go. + */ + public int getSlot() { + return slot; + } +} diff --git a/src/main/java/net/coreprotect/api/VirtualContainerHandler.java b/src/main/java/net/coreprotect/api/VirtualContainerHandler.java new file mode 100644 index 000000000..a133a5e9b --- /dev/null +++ b/src/main/java/net/coreprotect/api/VirtualContainerHandler.java @@ -0,0 +1,24 @@ +package net.coreprotect.api; + +import java.util.List; + +/** + * Applies rollbacks and restores to the virtual containers of one type, registered with + * CoreProtectAPI.registerVirtualContainer. + */ +public interface VirtualContainerHandler { + + /** + * Applies changes to a virtual container. CoreProtect calls this on the server thread (on Folia, the global region + * thread) with the changes of one container in the order they must be applied. Apply them in order and stop at + * the first change that can't be applied, for example an item that is no longer in the container. + * + * @param id + * The container within its type, as passed to logVirtualContainerTransaction + * @param changes + * The changes to apply + * @return How many leading changes were applied. Only those are marked as rolled back or restored, so running the + * command again retries the rest. + */ + int apply(String id, List changes); +} diff --git a/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java b/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java new file mode 100644 index 000000000..94f639737 --- /dev/null +++ b/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java @@ -0,0 +1,31 @@ +package net.coreprotect.database.rollback; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.plugin.Plugin; + +import net.coreprotect.api.VirtualContainerHandler; + +public final class VirtualContainerRollbackHandler { + + private static final Map registrations = new ConcurrentHashMap<>(); + + private VirtualContainerRollbackHandler() { + throw new IllegalStateException("Database class"); + } + + public static void register(Plugin plugin, String type, VirtualContainerHandler handler) { + registrations.put(type, new Registration(plugin, handler)); + } + + private static final class Registration { + private final Plugin plugin; + private final VirtualContainerHandler handler; + + private Registration(Plugin plugin, VirtualContainerHandler handler) { + this.plugin = plugin; + this.handler = handler; + } + } +} From 19d50f427ce7b1d59154d3f8e608201ada035e45 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 19:43:58 +0300 Subject: [PATCH 10/13] Mark virtual container rows rolled back through the consumer --- src/main/java/net/coreprotect/consumer/process/Process.java | 6 +++++- src/main/java/net/coreprotect/database/Database.java | 3 +++ .../database/clickhouse/ClickHouseConsumerWriteBatch.java | 3 +++ .../database/clickhouse/ClickHouseStateBatch.java | 3 ++- .../coreprotect/model/rollback/RollbackUpdateTargets.java | 1 + 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/coreprotect/consumer/process/Process.java b/src/main/java/net/coreprotect/consumer/process/Process.java index 9ef35f73a..36d54f542 100755 --- a/src/main/java/net/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/coreprotect/consumer/process/Process.java @@ -80,6 +80,7 @@ public class Process { public static final int SIGN_ROLLBACK_UPDATE = 36; public static final int VIRTUAL_CONTAINER_TRANSACTION = 37; public static final int VIRTUAL_CONTAINER_INSERT = 38; + public static final int VIRTUAL_CONTAINER_ROLLBACK_UPDATE = 39; public static int lastLockUpdate = 0; private static volatile int currentConsumerSize = 0; @@ -112,7 +113,7 @@ protected static int consumerDelay(boolean backlog) { } public static boolean isRollbackPublication(int action, Object object) { - if (action == ROLLBACK_UPDATE || action == SIGN_ROLLBACK_UPDATE || action == CONTAINER_ROLLBACK_UPDATE || action == INVENTORY_ROLLBACK_UPDATE || action == INVENTORY_CONTAINER_ROLLBACK_UPDATE || action == BLOCK_INVENTORY_ROLLBACK_UPDATE || action == ENTITY_CONTAINER_ROLLBACK_UPDATE || action == ENTITY_CONTAINER_TRANSITION_UPDATE) { + if (action == ROLLBACK_UPDATE || action == SIGN_ROLLBACK_UPDATE || action == CONTAINER_ROLLBACK_UPDATE || action == INVENTORY_ROLLBACK_UPDATE || action == INVENTORY_CONTAINER_ROLLBACK_UPDATE || action == BLOCK_INVENTORY_ROLLBACK_UPDATE || action == ENTITY_CONTAINER_ROLLBACK_UPDATE || action == ENTITY_CONTAINER_TRANSITION_UPDATE || action == VIRTUAL_CONTAINER_ROLLBACK_UPDATE) { return true; } if (action != ENTITY_SPAWN_UPDATE || !(object instanceof EntitySpawnData)) { @@ -418,6 +419,9 @@ else if (ConfigHandler.databaseType.isColumnar()) { case Process.BLOCK_INVENTORY_ROLLBACK_UPDATE: RollbackUpdateProcess.process(writeBatch, processId, id, forceData, RollbackUpdateTargets.BLOCK_INVENTORY); break; + case Process.VIRTUAL_CONTAINER_ROLLBACK_UPDATE: + RollbackUpdateProcess.process(writeBatch, processId, id, forceData, RollbackUpdateTargets.VIRTUAL_CONTAINER); + break; case Process.ENTITY_CONTAINER_ROLLBACK_UPDATE: List entityContainerRows = Consumer.consumerObjectArrayList.get(processId).get(id); if (entityContainerRows != null) { diff --git a/src/main/java/net/coreprotect/database/Database.java b/src/main/java/net/coreprotect/database/Database.java index e0b6f9fbf..1ede16c45 100755 --- a/src/main/java/net/coreprotect/database/Database.java +++ b/src/main/java/net/coreprotect/database/Database.java @@ -689,6 +689,9 @@ private static String getRolledBackTableName(int table) { if (table == RollbackUpdateTargets.ENTITY_CONTAINER) { return "entity_container"; } + if (table == RollbackUpdateTargets.VIRTUAL_CONTAINER) { + return "virtual_container"; + } if (RollbackUpdateTargets.updatesContainerTable(table)) { return "container"; } diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java index 59c50123b..0f20cde19 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseConsumerWriteBatch.java @@ -739,6 +739,9 @@ private static ClickHouseFamily rollbackFamily(int target) { if (target == RollbackUpdateTargets.ENTITY_CONTAINER) { return ClickHouseFamily.ENTITY_CONTAINER; } + if (target == RollbackUpdateTargets.VIRTUAL_CONTAINER) { + return ClickHouseFamily.VIRTUAL_CONTAINER; + } if (RollbackUpdateTargets.updatesContainerTable(target)) { return ClickHouseFamily.CONTAINER; } diff --git a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseStateBatch.java b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseStateBatch.java index effa04b66..11e80b502 100644 --- a/src/main/java/net/coreprotect/database/clickhouse/ClickHouseStateBatch.java +++ b/src/main/java/net/coreprotect/database/clickhouse/ClickHouseStateBatch.java @@ -200,7 +200,8 @@ private static boolean isRollbackFamily(ClickHouseFamily family) { || family == ClickHouseFamily.CONTAINER || family == ClickHouseFamily.ENTITY_CONTAINER || family == ClickHouseFamily.ITEM - || family == ClickHouseFamily.SIGN; + || family == ClickHouseFamily.SIGN + || family == ClickHouseFamily.VIRTUAL_CONTAINER; } static final class Checkpoint { diff --git a/src/main/java/net/coreprotect/model/rollback/RollbackUpdateTargets.java b/src/main/java/net/coreprotect/model/rollback/RollbackUpdateTargets.java index 7adfaef8f..c78b6b90b 100644 --- a/src/main/java/net/coreprotect/model/rollback/RollbackUpdateTargets.java +++ b/src/main/java/net/coreprotect/model/rollback/RollbackUpdateTargets.java @@ -8,6 +8,7 @@ public final class RollbackUpdateTargets { public static final int BLOCK_INVENTORY = 4; public static final int ENTITY_CONTAINER = 5; public static final int SIGN = 6; + public static final int VIRTUAL_CONTAINER = 7; private RollbackUpdateTargets() { throw new IllegalStateException("Model class"); From d594a6dee5e2f831110d65d486916036264f9e29 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 19:49:45 +0300 Subject: [PATCH 11/13] Apply virtual container rollbacks through registered handlers --- .../api/VirtualContainerChange.java | 3 +- .../VirtualContainerRollbackHandler.java | 244 +++++++++++++++++- 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/coreprotect/api/VirtualContainerChange.java b/src/main/java/net/coreprotect/api/VirtualContainerChange.java index b3da82c0b..e69262262 100644 --- a/src/main/java/net/coreprotect/api/VirtualContainerChange.java +++ b/src/main/java/net/coreprotect/api/VirtualContainerChange.java @@ -18,7 +18,8 @@ public VirtualContainerChange(ItemStack item, boolean addition, int slot) { } /** - * @return A copy of the items to add or remove + * @return A copy of the items to add or remove. Logged changes are merged per item, so the amount can exceed the + * maximum stack size. */ public ItemStack getItem() { return item.clone(); diff --git a/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java b/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java index 94f639737..91e7939a2 100644 --- a/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java +++ b/src/main/java/net/coreprotect/database/rollback/VirtualContainerRollbackHandler.java @@ -1,15 +1,42 @@ package net.coreprotect.database.rollback; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; +import net.coreprotect.CoreProtect; +import net.coreprotect.api.VirtualContainerChange; import net.coreprotect.api.VirtualContainerHandler; +import net.coreprotect.consumer.Queue; +import net.coreprotect.consumer.process.Process; +import net.coreprotect.database.statement.ContainerStatement; +import net.coreprotect.model.item.ItemTransactionActions; +import net.coreprotect.thread.Scheduler; +import net.coreprotect.utility.Chat; +import net.coreprotect.utility.ErrorReporter; +import net.coreprotect.utility.MaterialUtils; -public final class VirtualContainerRollbackHandler { +/** + * Rolls back and restores virtual containers through the handlers their plugins registered. Only the rows a handler + * applied are marked, so a later rollback or restore retries the rest. + */ +public final class VirtualContainerRollbackHandler extends Queue { private static final Map registrations = new ConcurrentHashMap<>(); + private static final Set activeContainers = ConcurrentHashMap.newKeySet(); private VirtualContainerRollbackHandler() { throw new IllegalStateException("Database class"); @@ -19,6 +46,221 @@ public static void register(Plugin plugin, String type, VirtualContainerHandler registrations.put(type, new Registration(plugin, handler)); } + /** + * Groups the rows by container in the order they are applied: newest first for a rollback, oldest first for a + * restore. Rows that are already rolled back or restored are left out. Cancels the context when another rollback + * or restore holds one of the containers. + */ + static Work prepare(Connection connection, List rows, int rollbackType, EntitySpawnRollbackHandler.Context context) throws SQLException { + Map> rowsByContainer = new LinkedHashMap<>(); + for (Object[] row : rows) { + if (MaterialUtils.rolledBack((Integer) row[9], false) == rollbackType) { + rowsByContainer.computeIfAbsent((Integer) row[15], key -> new ArrayList<>()).add(row); + } + } + + Work work = new Work(rollbackType); + Map keys = ContainerStatement.loadVirtualKeys(connection, rowsByContainer.keySet()); + for (Map.Entry> entry : rowsByContainer.entrySet()) { + String key = keys.get(entry.getKey()); + if (key == null) { + continue; + } + if (!activeContainers.add(entry.getKey())) { + context.reportFailure("Virtual container " + key + " is already being rolled back or restored", null); + context.cancel(); + break; + } + work.claimedContainers.add(entry.getKey()); + if (rollbackType == 1) { + Collections.reverse(entry.getValue()); + } + work.containers.add(new Container(key, entry.getValue())); + } + return work; + } + + /** + * Applies the containers on the server thread, a batch per tick, as mutations of the rollback context. + */ + static void process(EntitySpawnRollbackHandler.Context context, Work work, String userString) { + if (work.containers.isEmpty()) { + return; + } + + CompletableFuture completion = new CompletableFuture<>(); + context.addPending(completion); + scheduleBatch(context, work, userString, completion); + } + + /** + * Logs how many changes of each type were skipped, and returns the total. + */ + static int reportSkipped(Work work, String userString) { + int total = 0; + for (Map.Entry entry : work.skippedChanges.entrySet()) { + int count = entry.getValue(); + String reason = work.missingTypes.contains(entry.getKey()) ? ": no enabled plugin has registered a handler for it." : "."; + Chat.console("Rollback/restore for " + userString + " skipped " + count + " virtual container " + (count == 1 ? "change" : "changes") + " of type " + entry.getKey() + reason); + total += count; + } + return total; + } + + static void release(Work work) { + activeContainers.removeAll(work.claimedContainers); + } + + private static void scheduleBatch(EntitySpawnRollbackHandler.Context context, Work work, String userString, CompletableFuture completion) { + Scheduler.scheduleSyncDelayedTask(CoreProtect.getInstance(), () -> { + // Nothing is changed once the rollback is cancelled, and cleanup waits for a started mutation to finish + if (!context.beginMutation()) { + completion.complete(false); + return; + } + + try { + applyBatch(context, work, userString); + if (work.index < work.containers.size()) { + scheduleBatch(context, work, userString, completion); + } + else { + completion.complete(true); + } + } + catch (Exception e) { + context.cancel(); + ErrorReporter.report(e); + completion.complete(false); + } + finally { + context.endMutation(); + } + }, null, 0); + } + + private static void applyBatch(EntitySpawnRollbackHandler.Context context, Work work, String userString) { + List updates = new ArrayList<>(); + long batchBudget = Rollback.adaptiveBatchBudgetNanos(); + long batchStart = System.nanoTime(); + try { + do { + updates.addAll(apply(context, work, work.containers.get(work.index++))); + } + while (work.index < work.containers.size() && !context.isCancelled() && (System.nanoTime() - batchStart) < batchBudget); + } + finally { + if (!updates.isEmpty()) { + queueRollbackUpdate(userString, null, updates, Process.VIRTUAL_CONTAINER_ROLLBACK_UPDATE, work.rollbackType); + } + } + } + + /** + * @return The rows the handler applied + */ + static List apply(EntitySpawnRollbackHandler.Context context, Work work, Container container) { + Registration registration = registrations.get(container.type); + if (registration == null || !registration.plugin.isEnabled()) { + work.skip(container.type, container.rows.size(), true); + return Collections.emptyList(); + } + + List rows = new ArrayList<>(container.rows.size()); + List changes = new ArrayList<>(container.rows.size()); + for (Object[] row : container.rows) { + Material type = MaterialUtils.getType((Integer) row[6]); + if (type == null) { + continue; + } + List metadata = RollbackUtil.deserializeMetadata((byte[]) row[12]); + ItemStack item = new ItemStack(type, (Integer) row[11]); + if (metadata != null) { + item = (ItemStack) RollbackUtil.populateItemStack(item, metadata)[2]; + } + boolean addition = (Integer) row[8] == (work.rollbackType == 0 ? ItemTransactionActions.REMOVE : ItemTransactionActions.ADD); + changes.add(new VirtualContainerChange(item, addition, getSlot(metadata))); + rows.add(row); + } + + int applied = 0; + try { + applied = registration.handler.apply(container.id, Collections.unmodifiableList(changes)); + if (applied < 0 || applied > changes.size()) { + registration.plugin.getLogger().warning("Virtual container handler returned " + applied + " for " + changes.size() + " CoreProtect changes to " + container.key + ", so none of them were marked."); + applied = 0; + } + } + catch (Exception e) { + registration.plugin.getLogger().log(Level.WARNING, "Virtual container handler failed to apply CoreProtect changes to " + container.key + ", so none of them were marked.", e); + } + + work.skip(container.type, container.rows.size() - applied, false); + List appliedRows = rows.subList(0, applied); + int items = 0; + for (Object[] row : appliedRows) { + items += (Integer) row[11]; + } + context.addItemCount(items); + return appliedRows; + } + + /** + * @return The slot stored in the metadata of a row, or -1 for a row logged without one. populateItemStack reports + * a missing slot as 0. + */ + private static int getSlot(List metadata) { + if (metadata != null) { + for (Object entry : metadata) { + if (entry instanceof List && !((List) entry).isEmpty() && ((List) entry).get(0) instanceof Map) { + Object slot = ((Map) ((List) entry).get(0)).get("slot"); + if (slot instanceof Integer) { + return (Integer) slot; + } + } + } + } + return -1; + } + + static final class Work { + private final int rollbackType; + private final List containers = new ArrayList<>(); + private final Set claimedContainers = new HashSet<>(); + private final Map skippedChanges = new ConcurrentHashMap<>(); + private final Set missingTypes = ConcurrentHashMap.newKeySet(); + private int index; + + private Work(int rollbackType) { + this.rollbackType = rollbackType; + } + + private void skip(String type, int count, boolean missing) { + if (count > 0) { + skippedChanges.merge(type, count, Integer::sum); + } + if (missing) { + missingTypes.add(type); + } + } + } + + static final class Container { + private final String key; + private final String type; + private final String id; + private final List rows; + + private Container(String key, List rows) { + // a key splits into type and id at its only "/" + int separator = key.indexOf('/'); + this.key = key; + this.type = key.substring(0, separator); + this.id = key.substring(separator + 1); + this.rows = rows; + } + } + private static final class Registration { private final Plugin plugin; private final VirtualContainerHandler handler; From 12f1e9a1ac15f74f8e8eb9efdb30720548ca84cc Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 19:55:10 +0300 Subject: [PATCH 12/13] Roll back and restore virtual containers with container: --- .../coreprotect/command/LookupCommand.java | 27 ++---------- .../command/RollbackRestoreCommand.java | 22 ++++++---- .../parser/VirtualContainerParser.java | 42 +++++++++++++++++-- .../net/coreprotect/database/LookupRaw.java | 4 -- .../database/rollback/Rollback.java | 19 +++++++++ .../database/rollback/RollbackComplete.java | 5 +++ 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/src/main/java/net/coreprotect/command/LookupCommand.java b/src/main/java/net/coreprotect/command/LookupCommand.java index 54195e17c..cf282013c 100755 --- a/src/main/java/net/coreprotect/command/LookupCommand.java +++ b/src/main/java/net/coreprotect/command/LookupCommand.java @@ -30,7 +30,6 @@ import net.coreprotect.language.Selector; import net.coreprotect.model.action.EntityActionFilter; import net.coreprotect.model.action.LookupActions; -import net.coreprotect.model.item.ItemTransactionActions; import net.coreprotect.model.lookup.LookupOutputMode; import net.coreprotect.model.lookup.LookupRollbackState; import net.coreprotect.utility.Chat; @@ -82,29 +81,9 @@ public static void runCommand(CommandSender player, Command command, boolean per return; } - if (virtualContainerResult.isSpecified()) { - if (virtualContainerResult.getTarget() == null) { - Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co l container:[/]")); - return; - } - // virtual containers have no location, so the lookup is always global - if (argRadius != null || argWid > 0 || worldedit) { - Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "r:")); - return; - } - if (argUsers.contains("#container")) { - Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "#container")); - return; - } - boolean containerActions = argAction.stream().allMatch(action -> action == LookupActions.CONTAINER || action == ItemTransactionActions.REMOVE || action == ItemTransactionActions.ADD); - if (!argAction.isEmpty() && (!argAction.contains(LookupActions.CONTAINER) || !containerActions)) { - Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INCOMPATIBLE_ACTION, "container:")); - return; - } - if (argAction.isEmpty()) { - argAction.add(LookupActions.CONTAINER); - } - argBlocks.add(virtualContainerResult.getTarget()); + // virtual containers have no location, so the lookup is always global + if (virtualContainerResult.isSpecified() && !VirtualContainerParser.addTarget(player, virtualContainerResult, "/co l container:[/]", argRadius != null || argWid > 0 || worldedit, argUsers, argAction, argBlocks)) { + return; } if (args[0].toLowerCase(Locale.ROOT).equals("page") && (args.length != 2 || !args[1].equals(args[1].replaceAll("[^0-9]", "")))) { diff --git a/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java b/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java index 217488c82..faac6ff6f 100755 --- a/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java +++ b/src/main/java/net/coreprotect/command/RollbackRestoreCommand.java @@ -65,12 +65,24 @@ public static void runCommand(CommandSender player, Command command, boolean per boolean worldedit = CommandParser.parseWorldEdit(args); boolean forceglobal = CommandParser.parseForceGlobal(args); int preview = CommandParser.parsePreview(args); + VirtualContainerParser.ParseResult virtualContainerResult = CommandParser.parseVirtualContainer(args); String corecommand = args[0].toLowerCase(Locale.ROOT); if (argBlocks == null || argExclude == null || argExcludeUsers == null) { return; } + boolean singleContainer = false; + if (virtualContainerResult.isSpecified()) { + if (!VirtualContainerParser.addTarget(player, virtualContainerResult, "/co " + corecommand + " container:[/]", argRadius != null || argWid > 0 || worldedit, argUsers, argAction, argBlocks)) { + return; + } + // virtual containers have no location, so the rollback is always global + forceglobal = true; + // a single container can be rolled back without a user, every container of a type can't + singleContainer = argUsers.isEmpty() && virtualContainerResult.getTarget().getId() != null; + } + /* check for invalid block/entity combinations (include) */ boolean hasBlock = false; boolean hasEntity = false; @@ -188,7 +200,7 @@ else if (!argEntityActionFilter.includesAnyEntity(argAction, true)) { } } - if (argUsers.size() == 0 && (argWid > 0 || forceglobal) && argRadius == null) { + if (argUsers.size() == 0 && (argWid > 0 || forceglobal) && argRadius == null && !singleContainer) { if (finalAction == 0) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_ROLLBACK_USER, Selector.FIRST)); } @@ -213,7 +225,7 @@ else if (!argAction.contains(LookupActions.CONTAINER) && !argAction.contains(Loo argExclude.put(Material.TNT, true); } - if (g == 1 && (argUsers.size() > 0 || (argUsers.size() == 0 && argRadius != null))) { + if (g == 1 && (argUsers.size() > 0 || (argUsers.size() == 0 && argRadius != null) || singleContainer)) { Integer MAX_RADIUS = Config.getGlobal().MAX_RADIUS; if (argRadius != null) { int radiusValue = argRadius[0]; @@ -225,7 +237,7 @@ else if (!argAction.contains(LookupActions.CONTAINER) && !argAction.contains(Loo } if (argAction.size() > 0) { if (argAction.contains(LookupActions.CONTAINER)) { - if (argUsers.contains("#global") || (argUsers.size() == 0 && argRadius == null)) { + if (argUsers.contains("#global") || (argUsers.size() == 0 && argRadius == null && !singleContainer)) { Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_ACTION_USER)); return; } @@ -533,10 +545,6 @@ private static String findLookupOnlyFlag(String[] args) { if (argument.equals("#count") || argument.equals("#sum") || argument.equals("#summary") || argument.equals("count") || argument.equals("sum")) { return raw.trim(); } - // virtual containers can be looked up, but not rolled back or restored yet - if (VirtualContainerParser.isParameter(argument)) { - return raw.trim(); - } } return null; } diff --git a/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java b/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java index 07c48adf7..7958172eb 100644 --- a/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java +++ b/src/main/java/net/coreprotect/command/parser/VirtualContainerParser.java @@ -1,12 +1,20 @@ package net.coreprotect.command.parser; +import java.util.List; import java.util.Locale; +import org.bukkit.command.CommandSender; + +import net.coreprotect.language.Phrase; +import net.coreprotect.model.action.LookupActions; +import net.coreprotect.model.item.ItemTransactionActions; import net.coreprotect.model.lookup.VirtualContainerTarget; +import net.coreprotect.utility.Chat; +import net.coreprotect.utility.Color; import net.coreprotect.utility.VirtualContainerUtils; /** - * Parser for the container: lookup parameter, which selects a virtual container type or a single container + * Parser for the container: parameter, which selects a virtual container type or a single container */ public final class VirtualContainerParser { @@ -38,8 +46,36 @@ else if (argument.startsWith(PARAMETER)) { return new ParseResult(count > 0, target); } - public static boolean isParameter(String argument) { - return normalize(argument).startsWith(PARAMETER); + /** + * Checks container: against the other parameters, then adds its target to the include list, and a:container to + * the actions when none were given. Virtual containers have no location, so radius, world and #container are + * rejected. + * + * @return False after telling the user why the parameters can't be combined + */ + public static boolean addTarget(CommandSender user, ParseResult result, String usage, boolean located, List users, List actions, List include) { + if (result.getTarget() == null) { + Chat.sendMessage(user, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, usage)); + return false; + } + if (located) { + Chat.sendMessage(user, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "r:")); + return false; + } + if (users.contains("#container")) { + Chat.sendMessage(user, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "#container")); + return false; + } + boolean containerActions = actions.stream().allMatch(action -> action == LookupActions.CONTAINER || action == ItemTransactionActions.REMOVE || action == ItemTransactionActions.ADD); + if (!actions.isEmpty() && (!actions.contains(LookupActions.CONTAINER) || !containerActions)) { + Chat.sendMessage(user, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INCOMPATIBLE_ACTION, PARAMETER)); + return false; + } + if (actions.isEmpty()) { + actions.add(LookupActions.CONTAINER); + } + include.add(result.getTarget()); + return true; } private static VirtualContainerTarget parseTarget(String value) { diff --git a/src/main/java/net/coreprotect/database/LookupRaw.java b/src/main/java/net/coreprotect/database/LookupRaw.java index ce2d88ddf..01a574abd 100644 --- a/src/main/java/net/coreprotect/database/LookupRaw.java +++ b/src/main/java/net/coreprotect/database/LookupRaw.java @@ -1186,10 +1186,6 @@ else if (actionList.contains(LookupActions.SIGN)) { } if (virtualContainer != null) { - if (!lookup) { - // a rollback would update these rows by rowid in the block container table - throw new IllegalArgumentException("Virtual containers cannot be rolled back or restored"); - } if (!count && !selectPageRows) { rows = "rowid as id,time," + userColumn + ",0 as wid,0 as x,0 as y,0 as z,type,metadata,data,amount,action,rolled_back,container_id as entity_spawn_rowid"; } diff --git a/src/main/java/net/coreprotect/database/rollback/Rollback.java b/src/main/java/net/coreprotect/database/rollback/Rollback.java index 35d0ddd24..4ce53c780 100644 --- a/src/main/java/net/coreprotect/database/rollback/Rollback.java +++ b/src/main/java/net/coreprotect/database/rollback/Rollback.java @@ -69,6 +69,7 @@ public static List performRollbackRestore(Statement statement, Command public static List performRollbackRestore(Statement statement, CommandSender user, List checkUuids, List checkUsers, String timeString, List restrictList, Map excludeList, List excludeUserList, List actionList, EntityActionFilter entityActionFilter, Location location, Integer[] radius, long startTime, long endTime, boolean restrictWorld, boolean lookup, boolean verbose, final int rollbackType, final int preview) { List list = new ArrayList<>(); EntitySpawnRollbackHandler.Context entitySpawnContext = null; + VirtualContainerRollbackHandler.Work virtualContainerWork = null; try { long timeStart = System.currentTimeMillis(); @@ -173,6 +174,7 @@ else if ((Integer) row[8] == LookupActions.ENTITY_SPAWN) { } List itemList = new ArrayList<>(); + List virtualContainerList = new ArrayList<>(); if (includeItemLookup) { List itemActionList = new ArrayList<>(actionList); @@ -193,6 +195,11 @@ else if ((Integer) row[8] == LookupActions.ENTITY_SPAWN) { } itemList.removeIf(row -> { + // virtual container rows are marked through their handlers, never by the update queues below + if (row.length > 15 && row[14] instanceof Integer && (Integer) row[14] == InventorySources.VIRTUAL_CONTAINER) { + virtualContainerList.add(row); + return true; + } if (row.length > 15 && row[14] instanceof Integer && (Integer) row[14] == InventorySources.ENTITY_CONTAINER) { if (inventoryRollback) { entityContainerInventoryTrackingRowIds.add((Integer) row[15]); @@ -307,6 +314,7 @@ else if ((Integer) row[8] == LookupActions.ENTITY_SPAWN) { entitySpawnContext.reverseWork(); } addEntitySpawnChunks(entitySpawnContext, worldList, chunkList, location); + virtualContainerWork = VirtualContainerRollbackHandler.prepare(statement.getConnection(), virtualContainerList, rollbackType, entitySpawnContext); if (user != null && verbose && preview == 0 && !actionList.contains(LookupActions.ITEM)) { Integer chunks = chunkList.size(); @@ -380,6 +388,14 @@ else if (table == InventorySources.ENTITY_CONTAINER) { if (!entitySpawnContext.completeDirectTransitions()) { entitySpawnContext.cancel(); } + else { + VirtualContainerRollbackHandler.process(entitySpawnContext, virtualContainerWork, finalUserString); + if (!awaitChunkTasks(entitySpawnContext.drainPending(), preview, entitySpawnContext)) { + Chat.console(Phrase.build(Phrase.ROLLBACK_ABORTED)); + entitySpawnContext.cancel(); + } + } + VirtualContainerRollbackHandler.reportSkipped(virtualContainerWork, finalUserString); chunkList.clear(); dataList.clear(); @@ -413,6 +429,9 @@ else if (table == InventorySources.ENTITY_CONTAINER) { if (entitySpawnContext != null) { entitySpawnContext.close(); } + if (virtualContainerWork != null) { + VirtualContainerRollbackHandler.release(virtualContainerWork); + } } return null; diff --git a/src/main/java/net/coreprotect/database/rollback/RollbackComplete.java b/src/main/java/net/coreprotect/database/rollback/RollbackComplete.java index 14de037d8..1040fc29b 100644 --- a/src/main/java/net/coreprotect/database/rollback/RollbackComplete.java +++ b/src/main/java/net/coreprotect/database/rollback/RollbackComplete.java @@ -16,6 +16,7 @@ import net.coreprotect.language.Selector; import net.coreprotect.model.action.LookupActions; import net.coreprotect.model.item.ItemTransactionActions; +import net.coreprotect.model.lookup.VirtualContainerTarget; import net.coreprotect.utility.Chat; import net.coreprotect.utility.Color; import net.coreprotect.utility.ErrorReporter; @@ -144,6 +145,10 @@ else if (restrictTarget instanceof EntityType) { targetName = ((EntityType) restrictTarget).name().toLowerCase(Locale.ROOT); entity = true; } + else if (restrictTarget instanceof VirtualContainerTarget) { + VirtualContainerTarget target = (VirtualContainerTarget) restrictTarget; + targetName = "container:" + target.getType() + (target.getId() == null ? "" : "/" + target.getId()); + } if (targetCount == 0) { restrictTargets = restrictTargets.append("" + targetName + ""); From 8035917f45afc8db4b3c4827b128f36cfaedf72d Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Wed, 28 Oct 2026 19:58:24 +0300 Subject: [PATCH 13/13] Report skipped virtual container changes and document rollbacks --- docs/api/version/v13.md | 43 ++++++++++++++++++- docs/commands.md | 12 ++++-- lang/en.yml | 1 + .../database/rollback/Rollback.java | 5 ++- .../net/coreprotect/language/Language.java | 1 + .../java/net/coreprotect/language/Phrase.java | 1 + 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 3b9816ee2..b26452dbb 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -23,7 +23,7 @@ All API v12 methods remain available. API v13 adds the following: | Entity-spawn action | Adds `CoreProtectAction.ENTITY_SPAWN` with ID `13`. Legacy lookup, rollback, and restore methods accept this action explicitly. | | Entity result parsing | `BlockResult` and `ParseResult` expose `getEntityType()` for entity kills and spawns. Their material and block-data getters return `null` for entity rows. | | Tracked containers | Container and inventory lookups include transactions in tracked boats and minecarts. | -| Virtual containers | Adds `logVirtualContainerTransaction` for plugin-owned inventories that have no block or entity. | +| Virtual containers | Adds `logVirtualContainerTransaction` for plugin-owned inventories that have no block or entity, and `registerVirtualContainer` with `VirtualContainerHandler` and `VirtualContainerChange` to roll them back and restore them. | | Pre-log events | Adds `CoreProtectPreLogEvent.Action.ENTITY_SPAWN`, `ENTITY_INTERACTION`, and `VIRTUAL_CONTAINER_TRANSACTION`. | The existing `blockLookup(Block, LookupOptions)` overload retains entity events for compatibility. The new `blockLookup(LookupOptions)` overload returns only block events; use `entityLookup` for entities. @@ -146,6 +146,7 @@ The table below lists the supported methods on `CoreProtectAPI`. Click a method | [`logPlacement(...)`](#block-placement-and-removal) / [`logRemoval(...)`](#block-placement-and-removal) | `boolean` | Queues block changes. | | [`logContainerTransaction(String, Location)`](#container-transactions) | `boolean` | Captures a block inventory before your changes. | | [`logVirtualContainerTransaction(...)`](#virtual-containers) | `boolean` | Queues a change to a plugin-owned inventory. | +| [`registerVirtualContainer(...)`](#rolling-back-virtual-containers) | `boolean` | Lets rollbacks and restores change a type of plugin-owned inventory. | | [`logInteraction(String, Location)`](#block-interactions) | `boolean` | Queues a block interaction. | | [`performPurge(int)`](#purging-history) | `void` | Dispatches a database purge command. | @@ -627,7 +628,23 @@ CoreProtect records the difference between `before` and `after` as items removed The type and id form the key `namespace:type/id`, which can be at most 191 characters. The method returns `false` without logging when the API is disabled, `item-transactions` is disabled in the global configuration, `user` is empty, or the type or id is invalid. -Search the records with `/co lookup container:[/]`. Other lookups do not include them, and they cannot be rolled back or restored yet. +Search the records with `/co lookup container:[/]`, and roll them back or restore them with `/co rollback` or `/co restore` and the same parameter. Other lookups, rollbacks, and restores do not include them, and the legacy `performRollback` and `performRestore` methods cannot select them. + +#### Rolling back virtual containers + +CoreProtect cannot change an inventory that only your plugin knows, so a rollback or restore calls the handler you register for the type: + +`boolean registerVirtualContainer(Plugin plugin, NamespacedKey type, VirtualContainerHandler handler)` + +Registering a type again replaces its handler. It returns `false` when the API is disabled, an argument is `null`, or the type is invalid. Register the handler when your plugin enables; while your plugin is disabled, containers of the type are skipped. + +`VirtualContainerHandler` has one method, `int apply(String id, List changes)`. CoreProtect calls it once per container on the server thread (on Folia, the global region thread), with the changes in the order they must be applied: newest first for a rollback, oldest first for a restore. Apply them in order, stop at the first change that can't be applied, and return how many leading changes you applied. Only those are marked as rolled back or restored; the rest are skipped and reported, and running the command again retries them. When the handler throws or returns a number outside `0` to `changes.size()`, CoreProtect logs it under your plugin's name and marks none of that container's changes, so do not leave a container partly changed in that case. Do not log the changes the handler applies with `logVirtualContainerTransaction`; CoreProtect marks the logged rows instead. + +| `VirtualContainerChange` method | Meaning | +| --- | --- | +| `getItem()` | A copy of the items to add or remove. Logged changes are merged per item, so the amount can exceed the maximum stack size. | +| `isAddition()` | `true` to add the items, `false` to remove them. The direction is already resolved: rolling back a removal adds the items back. | +| `getSlot()` | The slot in the container where the logged change happened, or `-1` when it is unknown: the change was logged before CoreProtect recorded slots, or `before` and `after` were not index-aligned. It is a hint; your handler decides where the items go. | ### Block interactions @@ -962,6 +979,28 @@ backpack.addItem(itemStack); api.logVirtualContainerTransaction("Notch", new NamespacedKey(plugin, "backpack"), backpackId, before, backpack.getContents()); ``` +To let rollbacks and restores change those backpacks, register a handler when your plugin enables. This one stops at the first change that does not fit or whose items are no longer there, and undoes the part of that change it applied: + +```java +api.registerVirtualContainer(plugin, new NamespacedKey(plugin, "backpack"), (id, changes) -> { + Inventory backpack = backpacks.get(id); + if (backpack == null) { + return 0; + } + int applied = 0; + for (VirtualContainerChange change : changes) { + ItemStack[] contents = Arrays.stream(backpack.getContents()).map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new); + boolean complete = change.isAddition() ? backpack.addItem(change.getItem()).isEmpty() : backpack.removeItem(change.getItem()).isEmpty(); + if (!complete) { + backpack.setContents(contents); + break; + } + applied++; + } + return applied; +}); +``` + ### Listen for pre-log events This listener cancels block-placement records attributed to a plugin's preview actor: diff --git a/docs/commands.md b/docs/commands.md index a78147cc6..a295334bf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -57,7 +57,7 @@ Perform a lookup. Nearly all of the parameters are optional. | [`i:`](#iinclude) | Include specific blocks/entities in the lookup. | | [`e:`](#eexclude) | Exclude blocks/entities from the lookup. | | [`f:`](#ffilter) | Include or exclude chat, command, or sign text by prefix. | -| [`container:`](#containertype) | Look up a plugin's virtual containers. | +| [`container:`](#containertype) | Restrict the command to a plugin's virtual containers. | | [`#`](#hashtag) | Add a hashtag to perform additional actions. | #### Pagination @@ -273,13 +273,15 @@ ___ ### `container:` -*Look up items added to or removed from virtual containers: inventories that a plugin keeps without a block or entity, such as backpacks or team storage, and logs through the API.* +*Look up, roll back, or restore items added to or removed from virtual containers: inventories that a plugin keeps without a block or entity, such as backpacks or team storage, and logs through the API.* * Example: `container:myplugin:backpack t:1d` *(every backpack of that plugin)* * Example: `container:myplugin:backpack/1f0c t:1d` *(a single backpack)* * Example: `container:myplugin:backpack/1f0c u:Notch t:7d a:-container` *(items Notch removed from it)* -> Virtual containers have no location, so these lookups are always global and can't be combined with `r:` or `#container`. They accept `u:`, `t:`, `i:`, `e:`, `a:container`, `a:+container`, `a:-container`, `#count`, and `#summary`. Other lookups don't include virtual containers, and they can't be rolled back or restored yet. +> Virtual containers have no location, so these commands are always global and can't be combined with `r:` or `#container`. They accept `u:`, `t:`, `i:`, `e:`, `a:container`, `a:+container`, and `a:-container`, and lookups also accept `#count` and `#summary`. Other lookups, rollbacks, and restores don't include virtual containers. + +> A rollback or restore of a single container, `container:/`, works without `u:`, but one of every container of a type needs `u:`. Neither can be previewed. The plugin that owns the containers applies the changes, newest first for a rollback and oldest first for a restore. Changes that it can't apply, or that belong to a type no enabled plugin handles, are skipped and reported, and running the command again retries them. --- @@ -324,6 +326,10 @@ By default, if no radius is specified, a radius of 10 will be applied, restricti *(rollback griefing Notch did in the last hour ONLY in the Nether)* * `/co rollback u:Notch t:5m a:inventory` *(rollback inventory transactions by Notch in the last 5 minutes)* +* `/co rollback u:Notch t:1h container:myplugin:backpack/1f0c` + *(rollback the items Notch added to or removed from a plugin's backpack in the last hour)* +* `/co rollback t:1h container:myplugin:backpack/1f0c` + *(rollback everything anyone added to or removed from that backpack in the last hour)* * `/co rollback t:15m r:30` *(rollback everything done in the last 15 minutes by anyone within 30 blocks of you)* * `/co rollback t:15m r:#worldedit` diff --git a/lang/en.yml b/lang/en.yml index e7e980d6e..02b961aef 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -201,6 +201,7 @@ ROLLBACK_LENGTH: "Time taken: {0} {second|seconds}." ROLLBACK_MODIFIED: "{Modified|Modifying} {0}." ROLLBACK_RADIUS: "Radius: {0} {block|blocks}." ROLLBACK_SELECTION: "Radius set to \"{0}\"." +ROLLBACK_SKIPPED: "Skipped {0} virtual container {change|changes}. See the console for details." ROLLBACK_STARTED: "{Rollback|Restore|Preview} started on \"{0}\"." ROLLBACK_TIME: "Time range: {0}." ROLLBACK_WORLD_ACTION: "Restricted to {world|action} \"{0}\"." diff --git a/src/main/java/net/coreprotect/database/rollback/Rollback.java b/src/main/java/net/coreprotect/database/rollback/Rollback.java index 4ce53c780..04d26fc92 100644 --- a/src/main/java/net/coreprotect/database/rollback/Rollback.java +++ b/src/main/java/net/coreprotect/database/rollback/Rollback.java @@ -395,7 +395,7 @@ else if (table == InventorySources.ENTITY_CONTAINER) { entitySpawnContext.cancel(); } } - VirtualContainerRollbackHandler.reportSkipped(virtualContainerWork, finalUserString); + int skippedVirtualChanges = VirtualContainerRollbackHandler.reportSkipped(virtualContainerWork, finalUserString); chunkList.clear(); dataList.clear(); @@ -417,6 +417,9 @@ else if (table == InventorySources.ENTITY_CONTAINER) { if (user != null) { RollbackComplete.output(user, location, checkUsers, restrictList, excludeList, excludeUserList, actionList, timeString, chunkCount, totalSeconds, itemCount, blockCount, entityCount, rollbackType, radius, verbose, restrictWorld, preview); + if (skippedVirtualChanges > 0) { + Chat.sendMessage(user, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.ROLLBACK_SKIPPED, Integer.toString(skippedVirtualChanges), (skippedVirtualChanges == 1 ? Selector.FIRST : Selector.SECOND))); + } } list = LookupConverter.convertRawLookup(statement, lookupList); diff --git a/src/main/java/net/coreprotect/language/Language.java b/src/main/java/net/coreprotect/language/Language.java index 7943bb687..267b1fb5a 100644 --- a/src/main/java/net/coreprotect/language/Language.java +++ b/src/main/java/net/coreprotect/language/Language.java @@ -238,6 +238,7 @@ public static void loadPhrases() { phrases.put(Phrase.ROLLBACK_MODIFIED, "{Modified|Modifying} {0}."); phrases.put(Phrase.ROLLBACK_RADIUS, "Radius: {0} {block|blocks}."); phrases.put(Phrase.ROLLBACK_SELECTION, "Radius set to \"{0}\"."); + phrases.put(Phrase.ROLLBACK_SKIPPED, "Skipped {0} virtual container {change|changes}. See the console for details."); phrases.put(Phrase.ROLLBACK_STARTED, "{Rollback|Restore|Preview} started on \"{0}\"."); phrases.put(Phrase.ROLLBACK_TIME, "Time range: {0}."); phrases.put(Phrase.ROLLBACK_WORLD_ACTION, "Restricted to {world|action} \"{0}\"."); diff --git a/src/main/java/net/coreprotect/language/Phrase.java b/src/main/java/net/coreprotect/language/Phrase.java index 562599c46..af7c3f250 100644 --- a/src/main/java/net/coreprotect/language/Phrase.java +++ b/src/main/java/net/coreprotect/language/Phrase.java @@ -209,6 +209,7 @@ public enum Phrase { ROLLBACK_MODIFIED, ROLLBACK_RADIUS, ROLLBACK_SELECTION, + ROLLBACK_SKIPPED, ROLLBACK_STARTED, ROLLBACK_TIME, ROLLBACK_WORLD_ACTION,