Skip to content
32 changes: 30 additions & 2 deletions docs/api/version/v13.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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. |

Expand Down Expand Up @@ -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. 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.

Search the records with `/co lookup container:<type>[/<id>]`. 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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Perform a lookup. Nearly all of the parameters are optional.
| [`i:<include>`](#iinclude) | Include specific blocks/entities in the lookup. |
| [`e:<exclude>`](#eexclude) | Exclude blocks/entities from the lookup. |
| [`f:<filter>`](#ffilter) | Include or exclude chat, command, or sign text by prefix. |
| [`container:<type>`](#containertype) | Look up a plugin's virtual containers. |
| [`#<hashtag>`](#hashtag) | Add a hashtag to perform additional actions. |

#### Pagination
Expand Down Expand Up @@ -270,6 +271,18 @@ ___

---

### `container:<type>`

*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.

---

### `#<hashtag>`

Add a hashtag to the end of your command to perform additional actions.
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/net/coreprotect/CoreProtectAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/net/coreprotect/command/CommandParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/net/coreprotect/command/LookupCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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:<namespace:type>[/<id>]"));
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 <page>"));
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -325,6 +326,7 @@ else if (pageStart < rows) {
Map<Integer, EntitySpawnRecord> entitySpawnRecords = Collections.emptyMap();
Map<UUID, Location> loadedEntityLocations = Collections.emptyMap();
Set<Integer> entitySpawnRowIds = new HashSet<>();
Set<Integer> 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]));
Expand All @@ -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<Integer, String> virtualContainerKeys = ContainerStatement.loadVirtualKeys(connection, virtualContainerIds);
if (!entitySpawnRowIds.isEmpty()) {
entitySpawnRecords = EntitySpawnStatement.loadLocationRecords(connection, entitySpawnRowIds);
Set<UUID> entitySpawnUuids = new HashSet<>();
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public final class MessageFilterParser {
"block:",
"blocks:",
"c:",
"container:",
"coord:",
"coords:",
"cord:",
Expand Down
Loading