From f7fcfa4b458a707cb80856c17ea1d0e7b1f4a086 Mon Sep 17 00:00:00 2001 From: "d.sudomoin" Date: Mon, 19 Oct 2026 14:38:43 +0300 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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; }