diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index b1e093f34..848d53a50 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -22,7 +22,7 @@ All API v12 methods remain available. API v13 adds the following: | Text prefix filters | Adds `textStartsWithAny` and `textStartsWithNone` for chat, command, and sign lookups. | | 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. | +| Tracked containers | Container and inventory lookups include transactions in tracked boats and minecarts, plus chest-equipped donkeys, mules, llamas, and trader llamas. | | Pre-log events | Adds `CoreProtectPreLogEvent.Action.ENTITY_SPAWN` and `ENTITY_INTERACTION`. | The existing `blockLookup(Block, LookupOptions)` overload retains entity events for compatibility. The new `blockLookup(LookupOptions)` overload returns only block events; use `entityLookup` for entities. @@ -338,7 +338,7 @@ The in-game `a:block` filters present placed and destroyed boats/minecarts as bl Searches item additions to and removals from containers. The location/time overload searches an exact location. The options overload also supports radius, world, user, material, action, and pagination filters. -Both methods include tracked boat and minecart container transactions. Their location filters match either the transaction's original location or the entity's persisted current/final location. Typed results for these tracked containers expose the **persisted current/final coordinates and world**. Ordinary block containers expose the transaction location. +Both methods include tracked boat and minecart container transactions, plus transactions for chest-equipped donkeys, mules, llamas, and trader llamas. Their location filters match either the transaction's original location or the entity's persisted current/final location. Typed results for these tracked containers expose the **persisted current/final coordinates and world**. Ordinary block containers expose the transaction location. Tracked positions are checkpointed when transactions are logged and during relevant entity lifecycle changes. Lookups do not wait for a live-entity scan. `containerActions` applies to both block containers and tracked entity containers. @@ -360,7 +360,7 @@ Searches transactions affecting player inventories. The default combines block p Results normalize the action to removal from (`0`) or addition to (`1`) the player's inventory. Use `getTransactionActionId()` and `getSourceId()` together to identify the underlying event. For example, a container removal is an inventory addition. -Tracked boat and minecart transactions follow the location rules described under container lookups and remain part of the public `container` source. +Tracked entity-container transactions follow the location rules described under container lookups and remain part of the public `container` source. ### Chat and command lookups diff --git a/src/main/java/net/coreprotect/consumer/Queue.java b/src/main/java/net/coreprotect/consumer/Queue.java index 41a59c012..ed95c5f1e 100755 --- a/src/main/java/net/coreprotect/consumer/Queue.java +++ b/src/main/java/net/coreprotect/consumer/Queue.java @@ -353,11 +353,43 @@ public static void queueEntityContainerTransaction(String user, java.util.UUID e queueEntityContainerTransaction(user, new EntityContainerTransaction(entityUuid, currentLocation, oldContents, newContents)); } + public static void queueEntityContainerTransaction(String user, Entity entity, ItemStack[] oldContents, ItemStack[] newContents) { + if (entity == null) { + return; + } + Location currentLocation = entity.getLocation(); + EntityInteractionOrigin origin = null; + boolean promotion = false; + if (!EntitySpawnTracking.isTracked(entity)) { + origin = EntitySpawnTracking.getOrCreateInteractionOrigin(entity); + if (origin == null || currentLocation.getWorld() == null) { + return; + } + promotion = EntitySpawnTracking.beginDatabaseIdentityPromotion(entity); + if (!promotion) { + return; + } + } + EntityContainerTransaction transaction = new EntityContainerTransaction(entity.getUniqueId(), origin, currentLocation, oldContents, newContents).withIdentityPromotion(promotion); + queueEntityContainerTransaction(user, transaction); + } + public static void queueEntityContainerTransaction(String user, EntityContainerTransaction transaction) { if (user == null || user.isEmpty() || transaction == null) { + if (transaction != null && transaction.hasIdentityPromotion()) { + EntitySpawnTracking.cancelDatabaseIdentityPromotion(transaction.getEntityUuid(), transaction.getCurrentLocation()); + } return; } - queueStandardData(new Object[] { null, Process.ENTITY_CONTAINER_TRANSACTION, null, 0, null, 0, 0, null }, new String[] { user, null }, transaction, false, Consumer.reserveConsumer()); + boolean queued = false; + try { + queued = queueStandardData(new Object[] { null, Process.ENTITY_CONTAINER_TRANSACTION, null, 0, null, 0, 0, null }, new String[] { user, null }, transaction, false, Consumer.reserveConsumer()); + } + finally { + if (!queued && transaction.hasIdentityPromotion()) { + EntitySpawnTracking.cancelDatabaseIdentityPromotion(transaction.getEntityUuid(), transaction.getCurrentLocation()); + } + } } public static void queueEntityInteraction(String user, Entity entity, EntityInteractionAction action) { diff --git a/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java b/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java index 1e029fc61..7ca2163ad 100644 --- a/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java +++ b/src/main/java/net/coreprotect/consumer/process/ContainerTransactionProcess.java @@ -15,6 +15,8 @@ import net.coreprotect.consumer.Queue; import net.coreprotect.database.ConsumerWriteBatch; import net.coreprotect.database.logger.ContainerLogger; +import net.coreprotect.database.statement.EntityInteractionStatement; +import net.coreprotect.database.statement.EntitySpawnStatement; import net.coreprotect.model.entity.EntityContainerTransaction; import net.coreprotect.model.entity.EntitySpawnIdentity; import net.coreprotect.utility.ErrorReporter; @@ -22,23 +24,53 @@ class ContainerTransactionProcess { - static boolean processEntity(ConsumerWriteBatch preparedStmtContainer, int batchCount, String user, Object object, EntitySpawnIdentity identity) throws Exception { - if (!(object instanceof EntityContainerTransaction) || identity == null) { - return false; + static EntityProcessResult processEntity(ConsumerWriteBatch preparedStmtContainer, int batchCount, String user, Object object, EntitySpawnIdentity identity) throws Exception { + if (!(object instanceof EntityContainerTransaction)) { + return null; } - if (ConfigHandler.databaseType.isColumnar()) { - preparedStmtContainer.executeAtomically("entity_container_transaction", () -> ContainerLogger.logEntity(preparedStmtContainer, batchCount, user, identity, (EntityContainerTransaction) object)); + EntityContainerTransaction transaction = (EntityContainerTransaction) object; + if (identity == null && (!transaction.hasIdentityPromotion() || transaction.getOrigin() == null)) { + return null; + } + + EntitySpawnIdentity[] resolvedIdentity = { identity }; + boolean[] identityActive = { identity != null }; + boolean createdIdentity = identity == null; + if (transaction.hasIdentityPromotion()) { + preparedStmtContainer.executeAtomically("entity_container_transaction", () -> { + if (resolvedIdentity[0] == null) { + resolvedIdentity[0] = EntitySpawnStatement.insertIdentity(preparedStmtContainer, transaction.getTime(), transaction.getEntityUuid(), transaction.getOrigin(), transaction.getCurrentLocation()); + } + identityActive[0] = EntityInteractionStatement.checkpoint(preparedStmtContainer, resolvedIdentity[0], transaction.getCurrentLocation()); + ContainerLogger.logEntity(preparedStmtContainer, batchCount, user, resolvedIdentity[0], transaction); + }); + } + else if (ConfigHandler.databaseType.isColumnar()) { + preparedStmtContainer.executeAtomically("entity_container_transaction", () -> ContainerLogger.logEntity(preparedStmtContainer, batchCount, user, resolvedIdentity[0], transaction)); } else { try { - ContainerLogger.logEntity(preparedStmtContainer, batchCount, user, identity, (EntityContainerTransaction) object); + ContainerLogger.logEntity(preparedStmtContainer, batchCount, user, resolvedIdentity[0], transaction); } catch (Exception e) { ErrorReporter.report(e); } } - return true; + return new EntityProcessResult(resolvedIdentity[0], identityActive[0], createdIdentity); + } + + static final class EntityProcessResult { + + final EntitySpawnIdentity identity; + final boolean identityActive; + final boolean createdIdentity; + + private EntityProcessResult(EntitySpawnIdentity identity, boolean identityActive, boolean createdIdentity) { + this.identity = identity; + this.identityActive = identityActive; + this.createdIdentity = createdIdentity; + } } static void process(ConsumerWriteBatch preparedStmtContainer, ConsumerWriteBatch preparedStmtItems, int batchCount, int processId, int id, Material type, int forceData, String user, Object object) { diff --git a/src/main/java/net/coreprotect/consumer/process/Process.java b/src/main/java/net/coreprotect/consumer/process/Process.java index 2d48c472e..09c7b4292 100755 --- a/src/main/java/net/coreprotect/consumer/process/Process.java +++ b/src/main/java/net/coreprotect/consumer/process/Process.java @@ -330,19 +330,25 @@ else if (action == Process.ENTITY_SPAWN_UPDATE || action == Process.ENTITY_CONTA case Process.ENTITY_CONTAINER_TRANSACTION: EntityContainerTransaction transaction = (EntityContainerTransaction) object; EntitySpawnIdentity identity = entitySpawnIdentities.get(transaction.getEntityUuid()); - boolean processedEntityContainerTransaction; + ContainerTransactionProcess.EntityProcessResult entityContainerResult; try { - processedEntityContainerTransaction = ContainerTransactionProcess.processEntity(writeBatch, i, user, transaction, identity); + entityContainerResult = ContainerTransactionProcess.processEntity(writeBatch, i, user, transaction, identity); } catch (Exception e) { - pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, true)); + pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, false, true)); throw e; } - if (!processedEntityContainerTransaction) { - pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, true)); + if (entityContainerResult == null) { + pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, false, true)); } - else if (ConfigHandler.databaseType.isColumnar()) { - pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, false)); + else { + entitySpawnIdentities.put(transaction.getEntityUuid(), entityContainerResult.identity); + if (entityContainerResult.createdIdentity) { + promotedEntityIdentities.add(transaction.getEntityUuid()); + } + if (ConfigHandler.databaseType.isColumnar() || transaction.hasIdentityPromotion()) { + pendingEntityContainerTransactions.add(new PendingEntityContainerTransaction(user, transaction, entityContainerResult.identityActive, false)); + } } break; case Process.ENTITY_INTERACTION: @@ -739,10 +745,17 @@ private static void retryEntityContainerTransaction(String user, EntityContainer Queue.queueEntityContainerTransaction(user, retry); } else { // only print exception on development branch + cancelEntityContainerPromotion(transaction); ErrorReporter.report(new IllegalStateException("Dropped entity container transaction without tracking row: " + transaction.getEntityUuid()), ConfigHandler.EDITION_BRANCH.contains("-dev")); } } + private static void cancelEntityContainerPromotion(EntityContainerTransaction transaction) { + if (transaction.hasIdentityPromotion()) { + EntitySpawnTracking.cancelDatabaseIdentityPromotion(transaction.getEntityUuid(), transaction.getCurrentLocation()); + } + } + private static void retryEntityInteraction(String user, EntityInteraction interaction) { EntityInteraction retry = interaction.retry(); if (retry != null) { @@ -998,6 +1011,9 @@ else if (action == ITEM_TRANSACTION) { if (object instanceof EntityInteraction) { cancelEntityInteractionPromotion((EntityInteraction) object); } + if (object instanceof EntityContainerTransaction) { + cancelEntityContainerPromotion((EntityContainerTransaction) object); + } EntitySpawnData spawnData = getEntitySpawnUpdate(object); if (spawnData != null) { int trackingRowId = spawnData.getTrackingRowId(); @@ -1117,20 +1133,73 @@ private static void clearConsumerData(int processId, ArrayList consume } private static void completeEntityContainerTransactions(List transactions, TransactionOutcome outcome) { + if (outcome == TransactionOutcome.COMMITTED) { + confirmEntityContainerPromotions(transactions); + } + else if (outcome == TransactionOutcome.DISCARDED) { + verifyEntityContainerPromotions(transactions); + } if (outcome == TransactionOutcome.COMMITTED || outcome == TransactionOutcome.RETRY) { - for (PendingEntityContainerTransaction pending : transactions) { - if (outcome == TransactionOutcome.COMMITTED && !pending.retryRequired) { - continue; - } - try { - retryEntityContainerTransaction(pending.user, pending.transaction); + retryEntityContainerTransactions(transactions, outcome); + } + transactions.clear(); + } + + private static void confirmEntityContainerPromotions(List transactions) { + for (PendingEntityContainerTransaction pending : transactions) { + if (pending.retryRequired || !pending.transaction.hasIdentityPromotion()) { + continue; + } + try { + if (pending.identityActive) { + EntitySpawnTracking.confirmDatabaseIdentity(pending.transaction.getEntityUuid(), pending.transaction.getCurrentLocation()); } - catch (Exception e) { - ErrorReporter.report(e); + else { + EntitySpawnTracking.clearTracking(pending.transaction.getEntityUuid()); } } + catch (Exception e) { + ErrorReporter.report(e); + } + } + } + + private static void verifyEntityContainerPromotions(List transactions) { + Set verifiedIdentities = new HashSet<>(); + for (PendingEntityContainerTransaction pending : transactions) { + if (!pending.transaction.hasIdentityPromotion() || !verifiedIdentities.add(pending.transaction.getEntityUuid())) { + continue; + } + try { + EntitySpawnTracking.verifyPendingDatabaseIdentity(pending.transaction.getEntityUuid(), pending.transaction.getCurrentLocation()); + } + catch (Exception e) { + verifiedIdentities.remove(pending.transaction.getEntityUuid()); + ErrorReporter.report(e); + } + } + for (PendingEntityContainerTransaction pending : transactions) { + try { + cancelEntityContainerPromotion(pending.transaction); + } + catch (Exception e) { + ErrorReporter.report(e); + } + } + } + + private static void retryEntityContainerTransactions(List transactions, TransactionOutcome outcome) { + for (PendingEntityContainerTransaction pending : transactions) { + if (outcome == TransactionOutcome.COMMITTED && !pending.retryRequired) { + continue; + } + try { + retryEntityContainerTransaction(pending.user, pending.transaction); + } + catch (Exception e) { + ErrorReporter.report(e); + } } - transactions.clear(); } private static void completeEntitySpawnLogs(List pendingEntitySpawnLogs, TransactionOutcome outcome) { @@ -1248,11 +1317,13 @@ private static final class PendingEntityContainerTransaction { private final String user; private final EntityContainerTransaction transaction; + private final boolean identityActive; private final boolean retryRequired; - private PendingEntityContainerTransaction(String user, EntityContainerTransaction transaction, boolean retryRequired) { + private PendingEntityContainerTransaction(String user, EntityContainerTransaction transaction, boolean identityActive, boolean retryRequired) { this.user = user; this.transaction = transaction; + this.identityActive = identityActive; this.retryRequired = retryRequired; } } diff --git a/src/main/java/net/coreprotect/database/rollback/EntitySpawnRollbackHandler.java b/src/main/java/net/coreprotect/database/rollback/EntitySpawnRollbackHandler.java index 8c41d5ce6..6e34041d1 100644 --- a/src/main/java/net/coreprotect/database/rollback/EntitySpawnRollbackHandler.java +++ b/src/main/java/net/coreprotect/database/rollback/EntitySpawnRollbackHandler.java @@ -43,6 +43,7 @@ import net.coreprotect.utility.EntitySpawnTracking; import net.coreprotect.utility.EntityUtils; import net.coreprotect.utility.ErrorReporter; +import net.coreprotect.utility.HorseContainerUtils; import net.coreprotect.utility.ItemUtils; import net.coreprotect.utility.MaterialUtils; import net.coreprotect.utility.WorldUtils; @@ -899,7 +900,7 @@ private static void startTransaction(Context context, Work work, Entity entity, } Inventory inventory = ((InventoryHolder) entity).getInventory(); - work.initializeTransactionContents(inventory); + work.initializeTransactionContents(entity, inventory); applyTransactionStep(context, work, entity, inventory, completion); } catch (Exception e) { @@ -937,12 +938,12 @@ private static boolean applyPreparedTransactions(Context context, Work work, Ent } Inventory inventory = ((InventoryHolder) entity).getInventory(); - ItemStack[] currentContents = inventory.getStorageContents(); + ItemStack[] currentContents = HorseContainerUtils.getContents(entity, inventory.getContents()); if (currentContents.length != work.originalTransactionContents.length || inventory.getMaxStackSize() != work.inventoryMaxStackSize || !ItemUtils.compareContainers(work.originalTransactionContents, currentContents)) { context.reportFailure("Restored entity inventory does not match its recorded contents, size, or stack limit", work); return false; } - inventory.setStorageContents(ItemUtils.getContainerState(work.transactionContents)); + HorseContainerUtils.setContents(entity, work.transactionContents); work.transactionContentsApplied = true; return true; } @@ -969,14 +970,14 @@ private static void applyTransactionStep(Context context, Work work, Entity enti scheduleTransactionContinuation(context, work, entity, inventory, completion); return; } - if (context.isCancelled() || !entity.isValid() || !ItemUtils.compareContainers(work.originalTransactionContents, inventory.getStorageContents())) { + if (context.isCancelled() || !entity.isValid() || !ItemUtils.compareContainers(work.originalTransactionContents, HorseContainerUtils.getContents(entity, inventory.getContents()))) { if (!context.isCancelled()) { context.reportFailure("Entity became invalid or its inventory changed during replay", work); } completion.complete(false); return; } - inventory.setStorageContents(ItemUtils.getContainerState(work.transactionContents)); + HorseContainerUtils.setContents(entity, work.transactionContents); work.transactionContentsApplied = true; completion.complete(true); } @@ -1029,7 +1030,7 @@ private static TransactionStep applyTransactionRows(Context context, Work work) try { while (work.transactionIndex < work.transactions.size()) { Object[] row = work.transactions.get(work.transactionIndex); - if (!applyTransaction(context.rollbackType, work.transactionContents, work.inventoryMaxStackSize, row)) { + if (!applyTransaction(context.rollbackType, work.transactionContents, work.inventoryMaxStackSize, work.transactionEntityType, row)) { context.reportFailure("Invalid entity inventory transaction (rowid: " + row[0] + ", material: " + row[6] + ", amount: " + row[11] + ")", work); return TransactionStep.FAILED; } @@ -1079,7 +1080,7 @@ private static void endTransactionStep(int mutations, long nanos) { } } - private static boolean applyTransaction(int rollbackType, ItemStack[] contents, int inventoryMaxStackSize, Object[] row) { + private static boolean applyTransaction(int rollbackType, ItemStack[] contents, int inventoryMaxStackSize, EntityType entityType, Object[] row) { Material rowType = MaterialUtils.getType((Integer) row[6]); int rowAmount = (Integer) row[11]; if (rowType == null || rowAmount <= 0) { @@ -1090,15 +1091,15 @@ private static boolean applyTransaction(int rollbackType, ItemStack[] contents, Object[] populatedStack = RollbackItemHandler.populateItemStack(itemStack, (byte[]) row[12]); int rowAction = (Integer) row[8]; int action = ((rollbackType == 0 && rowAction == 0) || (rollbackType == 1 && rowAction == 1)) ? 1 : 0; - return populatedStack[2] instanceof ItemStack && modifyTransactionContents(contents, (ItemStack) populatedStack[2], action, inventoryMaxStackSize); + return populatedStack[2] instanceof ItemStack && modifyTransactionContents(contents, (ItemStack) populatedStack[2], action, inventoryMaxStackSize, entityType); } - private static boolean modifyTransactionContents(ItemStack[] contents, ItemStack itemStack, int action, int inventoryMaxStackSize) { + private static boolean modifyTransactionContents(ItemStack[] contents, ItemStack itemStack, int action, int inventoryMaxStackSize, EntityType entityType) { int remaining = itemStack.getAmount(); if (action == 0) { for (int index = contents.length - 1; index >= 0 && remaining > 0; index--) { ItemStack current = contents[index]; - if (current == null || !current.isSimilar(itemStack)) { + if (!acceptsTransactionItem(entityType, index, itemStack.getType()) || current == null || !current.isSimilar(itemStack)) { continue; } int removed = Math.min(remaining, current.getAmount()); @@ -1120,23 +1121,25 @@ private static boolean modifyTransactionContents(ItemStack[] contents, ItemStack if (maxStackSize < 1) { maxStackSize = 1; } - for (ItemStack current : contents) { + for (int index = 0; index < contents.length; index++) { + ItemStack current = contents[index]; + int slotMaxStackSize = transactionSlotLimit(entityType, index, maxStackSize); if (remaining <= 0) { return true; } - if (current == null || !current.isSimilar(itemStack) || current.getAmount() >= maxStackSize) { + if (!acceptsTransactionItem(entityType, index, itemStack.getType()) || current == null || !current.isSimilar(itemStack) || current.getAmount() >= slotMaxStackSize) { continue; } - int added = Math.min(remaining, maxStackSize - current.getAmount()); + int added = Math.min(remaining, slotMaxStackSize - current.getAmount()); current.setAmount(current.getAmount() + added); remaining -= added; } for (int index = 0; index < contents.length && remaining > 0; index++) { ItemStack current = contents[index]; - if (current != null && current.getType() != Material.AIR) { + if (!acceptsTransactionItem(entityType, index, itemStack.getType()) || current != null && current.getType() != Material.AIR) { continue; } - int added = Math.min(remaining, maxStackSize); + int added = Math.min(remaining, transactionSlotLimit(entityType, index, maxStackSize)); ItemStack addedItem = itemStack.clone(); addedItem.setAmount(added); contents[index] = addedItem; @@ -1145,6 +1148,21 @@ private static boolean modifyTransactionContents(ItemStack[] contents, ItemStack return true; } + private static boolean acceptsTransactionItem(EntityType type, int slot, Material material) { + if (!HorseContainerUtils.isHorseContainer(type) || slot >= 2) { + return true; + } + if (slot == 0) { + return (type == EntityType.DONKEY || type == EntityType.MULE) && material == Material.SADDLE; + } + String name = material.name(); + return (type == EntityType.LLAMA || type == EntityType.TRADER_LLAMA) && name.endsWith("_CARPET") && !name.equals("MOSS_CARPET") && !name.equals("PALE_MOSS_CARPET"); + } + + private static int transactionSlotLimit(EntityType type, int slot, int storageLimit) { + return HorseContainerUtils.isHorseContainer(type) && slot < 2 ? 1 : storageLimit; + } + private static void scheduleTransactionStart(Context context, Work work, Entity entity, CompletableFuture completion) { if (context.isCancelled()) { completion.complete(false); @@ -1818,6 +1836,7 @@ static final class Work { private int transactionIndex; private int appliedItemCount; private int inventoryMaxStackSize; + private EntityType transactionEntityType; private boolean transactionContentsApplied; private ItemStack[] originalTransactionContents; private ItemStack[] transactionContents; @@ -1871,13 +1890,14 @@ private static int itemCount(List transactions) { return result; } - private void initializeTransactionContents(Inventory inventory) { + private void initializeTransactionContents(Entity entity, Inventory inventory) { if (transactionContents != null) { return; } - originalTransactionContents = ItemUtils.getContainerState(inventory.getStorageContents()); + originalTransactionContents = HorseContainerUtils.getContents(entity, inventory.getContents()); transactionContents = ItemUtils.getContainerState(originalTransactionContents); inventoryMaxStackSize = inventory.getMaxStackSize(); + transactionEntityType = entity.getType(); } private boolean initializeCreatedTransactionContents() { @@ -1899,9 +1919,10 @@ private boolean initializeCreatedTransactionContents() { if (contents == null) { return false; } - originalTransactionContents = ItemUtils.getContainerState(contents); - transactionContents = ItemUtils.getContainerState(contents); + originalTransactionContents = HorseContainerUtils.normalizeContents(type, contents); + transactionContents = ItemUtils.getContainerState(originalTransactionContents); inventoryMaxStackSize = DEFAULT_INVENTORY_MAX_STACK_SIZE; + transactionEntityType = type; return true; } diff --git a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java index 46c6a0629..06bead4e2 100644 --- a/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java +++ b/src/main/java/net/coreprotect/listener/entity/EntityDeathListener.java @@ -79,6 +79,7 @@ import net.coreprotect.consumer.Queue; import net.coreprotect.database.Database; import net.coreprotect.listener.player.EntityInteractionListener; +import net.coreprotect.listener.player.InventoryChangeListener; import net.coreprotect.paper.PaperAdapter; import net.coreprotect.spigot.SpigotAdapter; import net.coreprotect.thread.CacheHandler; @@ -708,6 +709,7 @@ else if (event.getEntity().getLastDamageCause() != null) { return; } + InventoryChangeListener.flushEntityContainer(entity); if (EntitySpawnTracking.isTrackedOrPendingIdentity(entity)) { EntityInteractionListener.flushPendingInteractions(entity); Queue.queueEntitySpawnRemoved(entity); diff --git a/src/main/java/net/coreprotect/listener/entity/TrackedEntityRemoveListener.java b/src/main/java/net/coreprotect/listener/entity/TrackedEntityRemoveListener.java index bc5fdb182..5b15831ce 100644 --- a/src/main/java/net/coreprotect/listener/entity/TrackedEntityRemoveListener.java +++ b/src/main/java/net/coreprotect/listener/entity/TrackedEntityRemoveListener.java @@ -16,6 +16,7 @@ public final class TrackedEntityRemoveListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onEntityRemove(EntityRemoveEvent event) { Entity entity = event.getEntity(); + InventoryChangeListener.flushEntityContainer(entity); switch (event.getCause()) { case UNLOAD: return; @@ -35,7 +36,6 @@ public void onEntityRemove(EntityRemoveEvent event) { } if (EntitySpawnTracking.isTrackedOrPendingIdentity(entity)) { - InventoryChangeListener.flushEntityContainer(entity); EntityInteractionListener.flushPendingInteractions(entity); Queue.queueEntitySpawnRemoved(entity); EntitySpawnTracking.clearTracking(entity.getUniqueId()); diff --git a/src/main/java/net/coreprotect/listener/player/InventoryChangeListener.java b/src/main/java/net/coreprotect/listener/player/InventoryChangeListener.java index 041858168..90dc9adb0 100644 --- a/src/main/java/net/coreprotect/listener/player/InventoryChangeListener.java +++ b/src/main/java/net/coreprotect/listener/player/InventoryChangeListener.java @@ -16,15 +16,18 @@ import org.bukkit.block.BlockState; import org.bukkit.block.DoubleChest; import org.bukkit.block.Hopper; +import org.bukkit.entity.ChestedHorse; import org.bukkit.entity.Entity; import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.inventory.InventoryMoveItemEvent; import org.bukkit.event.inventory.InventoryPickupItemEvent; @@ -45,6 +48,7 @@ import net.coreprotect.utility.EntitySpawnTracking; import net.coreprotect.utility.ErrorReporter; import net.coreprotect.utility.HopperTransactionUtils; +import net.coreprotect.utility.HorseContainerUtils; import net.coreprotect.utility.ItemUtils; import net.coreprotect.utility.Validate; import us.lynuxcraft.deadsilenceiv.advancedchests.AdvancedChestsAPI; @@ -110,6 +114,13 @@ public static void flushPendingContainer(Inventory inventory, Location location) } public static void flushPendingTransactionsForShutdown() { + List pending; + synchronized (pendingEntityTransactions) { + pending = new ArrayList<>(pendingEntityTransactions.values()); + } + for (PendingEntityContainerTransaction transaction : pending) { + flushEntityContainer(transaction.entity); + } HopperPullListener.flushPendingPullsForShutdown(); ContainerTransactionDispatcher.drainForShutdown(); } @@ -273,8 +284,11 @@ static void onInventoryInteractAsync(Player player, Inventory inventory, boolean return; } - Entity entityContainer = getTrackedEntityContainer(PaperAdapter.ADAPTER.getHolder(inventory, false)); + Entity entityContainer = getEntityContainer(PaperAdapter.ADAPTER.getHolder(inventory, false)); if (entityContainer != null) { + if (entityContainer instanceof ChestedHorse) { + return; + } captureEntityContainerTransaction(player.getName(), entityContainer, inventory); return; } @@ -355,6 +369,8 @@ private static void flushEntityContainer(Entity entity, ItemStack[] currentConte return; } + currentContents = HorseContainerUtils.getContents(entity, currentContents); + PendingEntityContainerTransaction pending; synchronized (pendingEntityTransactions) { pending = pendingEntityTransactions.get(entity.getUniqueId()); @@ -377,26 +393,22 @@ private static void captureEntityContainerTransaction(String user, Entity entity return; } - ItemStack[] oldContents = inventory.getContents(); + ItemStack[] oldContents = HorseContainerUtils.getContents(entity, inventory.getContents()); PendingEntityContainerTransaction previous = null; Object scheduleToken = null; synchronized (pendingEntityTransactions) { - PendingEntityContainerTransaction pending = pendingEntityTransactions.get(entity.getUniqueId()); - if (pending == null) { - pending = new PendingEntityContainerTransaction(user, oldContents); - pendingEntityTransactions.put(entity.getUniqueId(), pending); - scheduleToken = pending.scheduleToken; - } - else if (!pending.user.equals(user)) { - previous = pending; - pendingEntityTransactions.put(entity.getUniqueId(), new PendingEntityContainerTransaction(user, oldContents, pending.scheduleToken)); + previous = pendingEntityTransactions.get(entity.getUniqueId()); + if (previous != null && previous.user.equals(user) && !(entity instanceof ChestedHorse)) { + return; } + scheduleToken = previous == null ? new Object() : previous.scheduleToken; + pendingEntityTransactions.put(entity.getUniqueId(), new PendingEntityContainerTransaction(user, entity, oldContents, scheduleToken)); } if (previous != null) { previous.log(entity, oldContents); } - if (scheduleToken != null) { + if (previous == null) { scheduleEntityContainerFlush(entity, scheduleToken); } } @@ -409,11 +421,10 @@ private static void scheduleEntityContainerFlush(Entity entity, Object scheduleT } } catch (Exception e) { - discardEntityContainerSchedule(entity.getUniqueId(), scheduleToken); ErrorReporter.report(e); } }; - Runnable retired = () -> discardEntityContainerSchedule(entity.getUniqueId(), scheduleToken); + Runnable retired = flush; try { if (!PaperAdapter.ADAPTER.executeEntityTask(CoreProtect.getInstance(), entity, flush, retired, 1L)) { if (ConfigHandler.isFolia) { @@ -430,15 +441,6 @@ private static void scheduleEntityContainerFlush(Entity entity, Object scheduleT } } - private static void discardEntityContainerSchedule(UUID uuid, Object scheduleToken) { - synchronized (pendingEntityTransactions) { - PendingEntityContainerTransaction pending = pendingEntityTransactions.get(uuid); - if (pending != null && pending.scheduleToken == scheduleToken) { - pendingEntityTransactions.remove(uuid); - } - } - } - private static boolean recordEntityContainerRemoval(Entity entity, ItemStack item, boolean logRemoval) { synchronized (pendingEntityTransactions) { PendingEntityContainerTransaction pending = pendingEntityTransactions.get(entity.getUniqueId()); @@ -455,7 +457,7 @@ private static boolean recordEntityContainerRemoval(Entity entity, ItemStack ite } private static void queueEntityContainerDelta(String user, Entity entity, ItemStack[] oldContents, ItemStack[] newContents) { - if (!EntitySpawnTracking.isTracked(entity)) { + if (!(entity instanceof ChestedHorse) && !isEntityContainer(entity)) { return; } if (oldContents == null || newContents == null || ItemUtils.compareContainers(oldContents, newContents)) { @@ -463,35 +465,93 @@ private static void queueEntityContainerDelta(String user, Entity entity, ItemSt } Location currentLocation = entity.getLocation(); EntitySpawnTracking.checkpoint(entity, currentLocation); - Queue.queueEntityContainerTransaction(user, entity.getUniqueId(), currentLocation, oldContents, newContents); + Queue.queueEntityContainerTransaction(user, entity, oldContents, newContents); } - private static Entity getTrackedEntityContainer(InventoryHolder holder) { + private static Entity getEntityContainer(InventoryHolder holder) { if (!(holder instanceof Entity)) { return null; } Entity entity = (Entity) holder; - return EntitySpawnTracking.isPlacedEntity(entity) && EntitySpawnTracking.isTracked(entity) ? entity : null; + return isEntityContainer(entity) ? entity : null; + } + + public static boolean isEntityContainer(Entity entity) { + if (!(entity instanceof InventoryHolder)) { + return false; + } + if (EntitySpawnTracking.isPlacedEntity(entity)) { + return EntitySpawnTracking.isTracked(entity); + } + return entity instanceof ChestedHorse && ((ChestedHorse) entity).isCarryingChest(); } private static boolean isSupportedContainer(InventoryHolder holder) { - return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest || getTrackedEntityContainer(holder) != null; + return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest || getEntityContainer(holder) != null; + } + + @EventHandler(priority = EventPriority.MONITOR) + protected void onInventoryClose(InventoryCloseEvent event) { + Inventory inventory = event.getInventory(); + InventoryHolder holder = PaperAdapter.ADAPTER.getHolder(inventory, false); + if (holder instanceof ChestedHorse) { + flushEntityContainer((Entity) holder, inventory.getContents()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + protected void onHorseDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof ChestedHorse) { + // Flush before damage can kill the animal and empty its inventory into drops. + flushEntityContainer(event.getEntity()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + protected void onHorseInventoryClick(InventoryClickEvent event) { + if (!event.isCancelled() && event.getAction() != InventoryAction.NOTHING && event.getWhoClicked() instanceof Player) { + captureHorseTransaction((Player) event.getWhoClicked(), event.getInventory()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + protected void onHorseInventoryDrag(InventoryDragEvent event) { + if (event.isCancelled() || !(event.getWhoClicked() instanceof Player)) { + return; + } + Inventory inventory = event.getInventory(); + Entity entity = getEntityContainer(PaperAdapter.ADAPTER.getHolder(inventory, false)); + if (!(entity instanceof ChestedHorse)) { + return; + } + int viewSize = HorseContainerUtils.getViewSize(inventory); + for (Integer slot : event.getRawSlots()) { + if (slot >= 0 && slot < viewSize) { + captureEntityContainerTransaction(event.getWhoClicked().getName(), entity, inventory); + return; + } + } + } + + private static void captureHorseTransaction(Player player, Inventory inventory) { + Entity entity = getEntityContainer(PaperAdapter.ADAPTER.getHolder(inventory, false)); + if (entity instanceof ChestedHorse) { + captureEntityContainerTransaction(player.getName(), entity, inventory); + } } private static final class PendingEntityContainerTransaction { private final String user; + private final Entity entity; private final ItemStack[] oldContents; private final Object scheduleToken; private final List removedItems = new ArrayList<>(); private final List loggedRemovedItems = new ArrayList<>(); - private PendingEntityContainerTransaction(String user, ItemStack[] oldContents) { - this(user, oldContents, new Object()); - } - - private PendingEntityContainerTransaction(String user, ItemStack[] oldContents, Object scheduleToken) { + private PendingEntityContainerTransaction(String user, Entity entity, ItemStack[] oldContents, Object scheduleToken) { this.user = user; + this.entity = entity; this.oldContents = ItemUtils.getContainerState(oldContents); this.scheduleToken = scheduleToken; } @@ -731,8 +791,8 @@ protected void onInventoryMoveItemEvent(InventoryMoveItemEvent event) { return; } - Entity sourceEntity = getTrackedEntityContainer(sourceHolder); - Entity destinationEntity = getTrackedEntityContainer(destinationHolder); + Entity sourceEntity = getEntityContainer(sourceHolder); + Entity destinationEntity = getEntityContainer(destinationHolder); if (sourceEntity != null || destinationEntity != null) { processEntityInventoryMove(location, sourceHolder, destinationHolder, sourceEntity, destinationEntity, event.getItem(), hopperTransactions); return; @@ -850,7 +910,7 @@ protected void onInventoryPickupItem(InventoryPickupItemEvent event) { } InventoryHolder holder = PaperAdapter.ADAPTER.getHolder(inventory, false); - Entity entityContainer = getTrackedEntityContainer(holder); + Entity entityContainer = getEntityContainer(holder); if (!(holder instanceof Hopper) && (entityContainer == null || !Validate.isHopper(holder))) { return; } diff --git a/src/main/java/net/coreprotect/listener/player/PlayerInteractEntityListener.java b/src/main/java/net/coreprotect/listener/player/PlayerInteractEntityListener.java index cffc33b04..1afd2a920 100644 --- a/src/main/java/net/coreprotect/listener/player/PlayerInteractEntityListener.java +++ b/src/main/java/net/coreprotect/listener/player/PlayerInteractEntityListener.java @@ -16,7 +16,6 @@ import org.bukkit.event.player.PlayerArmorStandManipulateEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.inventory.EquipmentSlot; -import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import net.coreprotect.bukkit.BukkitAdapter; @@ -47,7 +46,7 @@ protected void onPlayerInteractEntity(PlayerInteractEntityEvent event) { Player player = event.getPlayer(); final Entity entity = event.getRightClicked(); // change item in ItemFrame, etc - if (Boolean.TRUE.equals(ConfigHandler.inspecting.get(player.getName())) && EntitySpawnTracking.isPlacedEntity(entity) && entity instanceof InventoryHolder && Config.getConfig(player.getWorld()).ITEM_TRANSACTIONS) { + if (Boolean.TRUE.equals(ConfigHandler.inspecting.get(player.getName())) && InventoryChangeListener.isEntityContainer(entity) && Config.getConfig(player.getWorld()).ITEM_TRANSACTIONS) { String playerUuid = player.getUniqueId().toString(); long now = System.currentTimeMillis(); Object[] previousInspection = PlayerInteractListener.lastInspectorEvent.get(playerUuid); diff --git a/src/main/java/net/coreprotect/model/entity/EntityContainerTransaction.java b/src/main/java/net/coreprotect/model/entity/EntityContainerTransaction.java index 4f81287d5..5215d5829 100644 --- a/src/main/java/net/coreprotect/model/entity/EntityContainerTransaction.java +++ b/src/main/java/net/coreprotect/model/entity/EntityContainerTransaction.java @@ -12,30 +12,44 @@ public final class EntityContainerTransaction { private static final int MAX_IDENTITY_RESOLUTION_ATTEMPTS = 3; private final UUID entityUuid; + private final EntityInteractionOrigin origin; private final Location currentLocation; private final ItemStack[] oldContents; private final ItemStack[] newContents; + private final int time; private final int identityResolutionAttempts; + private final boolean identityPromotion; public EntityContainerTransaction(UUID entityUuid, Location currentLocation, ItemStack[] oldContents, ItemStack[] newContents) { - this(entityUuid, currentLocation, oldContents, newContents, 0); + this(entityUuid, null, currentLocation, oldContents, newContents, (int) (System.currentTimeMillis() / 1000L), 0, false); } - private EntityContainerTransaction(UUID entityUuid, Location currentLocation, ItemStack[] oldContents, ItemStack[] newContents, int identityResolutionAttempts) { + public EntityContainerTransaction(UUID entityUuid, EntityInteractionOrigin origin, Location currentLocation, ItemStack[] oldContents, ItemStack[] newContents) { + this(entityUuid, origin, currentLocation, oldContents, newContents, (int) (System.currentTimeMillis() / 1000L), 0, false); + } + + private EntityContainerTransaction(UUID entityUuid, EntityInteractionOrigin origin, Location currentLocation, ItemStack[] oldContents, ItemStack[] newContents, int time, int identityResolutionAttempts, boolean identityPromotion) { if (entityUuid == null || currentLocation == null || currentLocation.getWorld() == null || oldContents == null || newContents == null) { throw new IllegalArgumentException("Invalid entity container transaction"); } this.entityUuid = entityUuid; + this.origin = origin; this.currentLocation = currentLocation.clone(); this.oldContents = ItemUtils.getContainerState(oldContents); this.newContents = ItemUtils.getContainerState(newContents); + this.time = time; this.identityResolutionAttempts = identityResolutionAttempts; + this.identityPromotion = identityPromotion; } public UUID getEntityUuid() { return entityUuid; } + public EntityInteractionOrigin getOrigin() { + return origin; + } + public Location getCurrentLocation() { return currentLocation.clone(); } @@ -48,11 +62,26 @@ public ItemStack[] getNewContents() { return ItemUtils.getContainerState(newContents); } + public int getTime() { + return time; + } + + public boolean hasIdentityPromotion() { + return identityPromotion; + } + + public EntityContainerTransaction withIdentityPromotion(boolean promotion) { + if (identityPromotion == promotion) { + return this; + } + return new EntityContainerTransaction(entityUuid, origin, currentLocation, oldContents, newContents, time, identityResolutionAttempts, promotion); + } + public EntityContainerTransaction retry() { if (!canRetry()) { return null; } - return new EntityContainerTransaction(entityUuid, currentLocation, oldContents, newContents, identityResolutionAttempts + 1); + return new EntityContainerTransaction(entityUuid, origin, currentLocation, oldContents, newContents, time, identityResolutionAttempts + 1, identityPromotion); } public boolean canRetry() { diff --git a/src/main/java/net/coreprotect/utility/EntitySpawnTracking.java b/src/main/java/net/coreprotect/utility/EntitySpawnTracking.java index ab4a9638b..d2967f423 100644 --- a/src/main/java/net/coreprotect/utility/EntitySpawnTracking.java +++ b/src/main/java/net/coreprotect/utility/EntitySpawnTracking.java @@ -1010,7 +1010,7 @@ else if (isCushion(entity)) { if (entity instanceof InventoryHolder) { Inventory inventory = ((InventoryHolder) entity).getInventory(); inventoryData = new ArrayList<>(inventory.getSize()); - for (ItemStack item : inventory.getContents()) { + for (ItemStack item : HorseContainerUtils.getContents(entity, inventory.getContents())) { inventoryData.add(serializeItem(item)); } } @@ -1102,20 +1102,28 @@ public static void restoreState(Entity entity, List state) { return; } - if (state.size() > 0) { - entity.setCustomName((String) state.get(0)); - } + entity.setCustomName((String) state.get(0)); if (state.size() > 1 && state.get(1) instanceof Boolean) { entity.setCustomNameVisible((Boolean) state.get(1)); } - if (entity instanceof Boat && state.size() > 2 && state.get(2) instanceof String) { + restoreVariantState(entity, state); + restoreInventoryState(entity, state); + restoreEquipmentState(entity, state); + restoreRotationState(entity, state); + } + + private static void restoreVariantState(Entity entity, List state) { + if (state.size() <= 2 || !(state.get(2) instanceof String)) { + return; + } + if (entity instanceof Boat) { try { ((Boat) entity).setWoodType(TreeSpecies.valueOf((String) state.get(2))); } catch (Exception ignored) { } } - else if (isCushion(entity) && state.size() > 2 && state.get(2) instanceof String) { + else if (isCushion(entity)) { DyeColor color = DyeColor.valueOf((String) state.get(2)); if (entity instanceof Colorable) { ((Colorable) entity).setColor(color); @@ -1124,15 +1132,9 @@ else if (isCushion(entity) && state.size() > 2 && state.get(2) instanceof String SpigotAdapter.ADAPTER.setCushionColor(entity, color); } } - if (entity instanceof InventoryHolder && state.size() > 3 && state.get(3) instanceof List) { - Inventory inventory = ((InventoryHolder) entity).getInventory(); - List inventoryData = (List) state.get(3); - ItemStack[] contents = new ItemStack[inventory.getSize()]; - for (int slot = 0; slot < contents.length && slot < inventoryData.size(); slot++) { - contents[slot] = deserializeItem(inventoryData.get(slot)); - } - inventory.setContents(contents); - } + } + + private static void restoreEquipmentState(Entity entity, List state) { if (entity instanceof LivingEntity && state.size() > 4 && state.get(4) instanceof List) { EntityEquipment equipment = ((LivingEntity) entity).getEquipment(); List equipmentData = (List) state.get(4); @@ -1145,6 +1147,9 @@ else if (isCushion(entity) && state.size() > 2 && state.get(2) instanceof String equipment.setHelmet(getItem(equipmentData, 5)); } } + } + + private static void restoreRotationState(Entity entity, List state) { if (isCushion(entity) && state.size() > 5 && state.get(5) instanceof List) { List rotation = (List) state.get(5); if (rotation.size() == 2 && rotation.get(0) instanceof Number && rotation.get(1) instanceof Number) { @@ -1153,6 +1158,23 @@ else if (isCushion(entity) && state.size() > 2 && state.get(2) instanceof String } } + private static void restoreInventoryState(Entity entity, List state) { + if (!(entity instanceof InventoryHolder)) { + return; + } + ItemStack[] contents = deserializeInventoryState(state); + if (contents == null) { + return; + } + if (HorseContainerUtils.isHorseContainer(entity.getType())) { + HorseContainerUtils.setContents(entity, HorseContainerUtils.normalizeContents(entity.getType(), contents)); + } + else { + Inventory inventory = ((InventoryHolder) entity).getInventory(); + inventory.setContents(Arrays.copyOf(contents, inventory.getSize())); + } + } + private static Object serializeItem(ItemStack item) { return item == null || BlockUtils.isAir(item.getType()) ? null : item.serialize(); } diff --git a/src/main/java/net/coreprotect/utility/HorseContainerUtils.java b/src/main/java/net/coreprotect/utility/HorseContainerUtils.java new file mode 100644 index 000000000..cc7e060a7 --- /dev/null +++ b/src/main/java/net/coreprotect/utility/HorseContainerUtils.java @@ -0,0 +1,86 @@ +package net.coreprotect.utility; + +import org.bukkit.entity.ChestedHorse; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.AbstractHorseInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.LlamaInventory; + +public final class HorseContainerUtils { + + private HorseContainerUtils() { + throw new IllegalStateException("Utility class"); + } + + public static boolean isHorseContainer(EntityType type) { + return type == EntityType.DONKEY || type == EntityType.MULE || type == EntityType.LLAMA || type == EntityType.TRADER_LLAMA; + } + + // Chest storage has three rows. Depending on the platform/version, getContents() + // prefixes it with zero, one (saddle), or two (saddle and body) equipment slots. + private static int storageOffset(int size) { + return size % 3; + } + + public static int getViewSize(Inventory inventory) { + // The horse GUI retains both equipment slots even if getSize() omits them. + return inventory.getSize() - storageOffset(inventory.getSize()) + 2; + } + + public static ItemStack[] normalizeContents(EntityType type, ItemStack[] contents) { + if (!isHorseContainer(type)) { + return ItemUtils.getContainerState(contents); + } + int offset = storageOffset(contents.length); + ItemStack[] normalized = new ItemStack[contents.length - offset + 2]; + System.arraycopy(contents, 0, normalized, 0, offset); + System.arraycopy(contents, offset, normalized, 2, contents.length - offset); + return ItemUtils.getContainerState(normalized); + } + + // Snapshots always use saddle/decor at 0/1 and storage from 2, independent + // of the native inventory layout. Read separate equipment through its API. + public static ItemStack[] getContents(Entity entity, ItemStack[] contents) { + ItemStack[] snapshot = normalizeContents(entity.getType(), contents); + if (entity instanceof ChestedHorse) { + AbstractHorseInventory inventory = ((ChestedHorse) entity).getInventory(); + if (inventory instanceof LlamaInventory) { + snapshot[1] = copyItem(((LlamaInventory) inventory).getDecor()); + } + else { + snapshot[0] = copyItem(inventory.getSaddle()); + } + } + return snapshot; + } + + public static void setContents(Entity entity, ItemStack[] contents) { + Inventory inventory = ((InventoryHolder) entity).getInventory(); + if (!(entity instanceof ChestedHorse)) { + inventory.setContents(ItemUtils.getContainerState(contents)); + return; + } + + ItemStack[] nativeContents = inventory.getContents(); + int offset = storageOffset(nativeContents.length); + if (contents.length != nativeContents.length - offset + 2) { + throw new IllegalArgumentException("Horse container storage size changed"); + } + System.arraycopy(contents, 2, nativeContents, offset, contents.length - 2); + inventory.setContents(ItemUtils.getContainerState(nativeContents)); + AbstractHorseInventory horseInventory = ((ChestedHorse) entity).getInventory(); + if (horseInventory instanceof LlamaInventory) { + ((LlamaInventory) horseInventory).setDecor(copyItem(contents[1])); + } + else { + horseInventory.setSaddle(copyItem(contents[0])); + } + } + + private static ItemStack copyItem(ItemStack item) { + return item == null ? null : item.clone(); + } +}