From 9ab0883c9ee36cb27d4b7e3f1a7e430f7969c004 Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Mon, 20 Apr 2026 16:39:30 +0800
Subject: [PATCH 01/29] feat: implement ItemStack request processing system and
inventory improvements
- Add ItemStack request action processors (Craft, Transfer, Consume, etc.)
- Implement ItemStackNetManager for network ID allocation
- Add BundleInventory and HorseInventory support
- Include EnchantmentHelper and TradeRecipeBuildUtils
- Add unit tests for BundleInventory and NetworkMapping
---
src/main/java/cn/nukkit/Player.java | 15 +
src/main/java/cn/nukkit/Server.java | 8 +
.../nukkit/entity/passive/EntityDonkey.java | 5 +
.../entity/passive/EntityHorseBase.java | 45 +-
.../cn/nukkit/inventory/BundleInventory.java | 151 ++++++
.../inventory/CartographyTableInventory.java | 51 +++
.../cn/nukkit/inventory/CraftingManager.java | 23 +-
.../cn/nukkit/inventory/EnchantInventory.java | 76 +++
.../cn/nukkit/inventory/HorseInventory.java | 185 ++++++++
.../cn/nukkit/inventory/InventoryType.java | 4 +-
.../inventory/SmithingTransformRecipe.java | 42 ++
.../nukkit/inventory/SmithingTrimRecipe.java | 43 ++
.../cn/nukkit/inventory/TradeInventory.java | 55 ++-
.../inventory/request/ActionResponse.java | 23 +
.../request/BeaconPaymentActionProcessor.java | 50 ++
.../request/ConsumeActionProcessor.java | 53 +++
.../request/CraftCreativeActionProcessor.java | 63 +++
.../CraftGrindstoneActionProcessor.java | 78 ++++
.../request/CraftLoomActionProcessor.java | 104 +++++
.../CraftNonImplementedActionProcessor.java | 18 +
.../request/CraftRecipeActionProcessor.java | 400 ++++++++++++++++
.../request/CraftRecipeAutoProcessor.java | 112 +++++
.../request/CraftRecipeOptionalProcessor.java | 433 ++++++++++++++++++
.../CraftResultDeprecatedActionProcessor.java | 53 +++
.../request/CreateActionProcessor.java | 77 ++++
.../request/DestroyActionProcessor.java | 64 +++
.../request/DropActionProcessor.java | 65 +++
.../request/InventoryObserverSync.java | 75 +++
.../ItemStackRequestActionProcessor.java | 30 ++
.../request/ItemStackRequestContext.java | 56 +++
.../request/ItemStackRequestHandler.java | 250 ++++++++++
.../LabTableCombineActionProcessor.java | 18 +
.../request/MineBlockActionProcessor.java | 67 +++
.../inventory/request/NetworkMapping.java | 346 ++++++++++++++
.../request/PlaceActionProcessor.java | 18 +
.../request/SwapActionProcessor.java | 60 +++
.../request/TakeActionProcessor.java | 18 +
.../request/TransferItemActionProcessor.java | 183 ++++++++
src/main/java/cn/nukkit/item/ItemBundle.java | 70 ++-
.../cn/nukkit/item/ItemStackNetManager.java | 42 ++
.../item/enchantment/EnchantmentHelper.java | 107 +++++
.../network/process/DataPacketManager.java | 5 +
.../common/ItemStackRequestProcessor.java | 56 +++
.../network/protocol/StartGamePacket.java | 7 +-
.../nukkit/utils/TradeRecipeBuildUtils.java | 40 ++
.../nukkit/inventory/BundleInventoryTest.java | 53 +++
.../inventory/request/NetworkMappingTest.java | 69 +++
47 files changed, 3850 insertions(+), 16 deletions(-)
create mode 100644 src/main/java/cn/nukkit/inventory/BundleInventory.java
create mode 100644 src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
create mode 100644 src/main/java/cn/nukkit/inventory/HorseInventory.java
create mode 100644 src/main/java/cn/nukkit/inventory/SmithingTransformRecipe.java
create mode 100644 src/main/java/cn/nukkit/inventory/SmithingTrimRecipe.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/ActionResponse.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/ItemStackRequestActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/PlaceActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/TakeActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/item/ItemStackNetManager.java
create mode 100644 src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java
create mode 100644 src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
create mode 100644 src/main/java/cn/nukkit/utils/TradeRecipeBuildUtils.java
create mode 100644 src/test/java/cn/nukkit/inventory/BundleInventoryTest.java
create mode 100644 src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java
index cabafa8d1..5eee2182f 100644
--- a/src/main/java/cn/nukkit/Player.java
+++ b/src/main/java/cn/nukkit/Player.java
@@ -39,6 +39,7 @@
import cn.nukkit.form.window.FormWindow;
import cn.nukkit.form.window.FormWindowDialog;
import cn.nukkit.inventory.*;
+import cn.nukkit.inventory.request.ItemStackRequestHandler;
import cn.nukkit.inventory.transaction.*;
import cn.nukkit.inventory.transaction.action.InventoryAction;
import cn.nukkit.inventory.transaction.data.ReleaseItemData;
@@ -3065,6 +3066,7 @@ protected void completeLoginSequence() {
}
startGamePacket.authoritativeMovementMode = this.getAuthoritativeMovementMode();
startGamePacket.isServerAuthoritativeBlockBreaking = this.isServerAuthoritativeBlockBreaking();
+ startGamePacket.isInventoryServerAuthoritative = this.isInventoryServerAuthoritative();
startGamePacket.blockNetworkIdsHashed = GlobalBlockPalette.shouldUseHashedBlockNetworkIds(this.gameVersion);
startGamePacket.playerPropertyData = EntityProperty.getPlayerPropertyCache();
this.forceDataPacket(startGamePacket, null);
@@ -3926,6 +3928,11 @@ public void onCompletion(Server server) {
}
this.forceMovement = null;
}
+
+ // 处理 ItemStackRequest(v1.16.100+ 客户端通过 PlayerAuthInputPacket 发送物品栏操作)
+ if (this.isInventoryServerAuthoritative() && authPacket.getItemStackRequest() != null) {
+ ItemStackRequestHandler.handleRequests(this, List.of(authPacket.getItemStackRequest()));
+ }
break;
case ProtocolInfo.PLAYER_ACTION_PACKET:
PlayerActionPacket playerActionPacket = (PlayerActionPacket) packet;
@@ -7946,6 +7953,14 @@ public boolean isServerAuthoritativeBlockBreaking() {
return this.server.serverAuthoritativeBlockBreaking && this.isMovementServerAuthoritative();
}
+ /**
+ * Check if server authoritative inventory is enabled for this player
+ * @return true if enabled and protocol supports it
+ */
+ public boolean isInventoryServerAuthoritative() {
+ return this.server.serverAuthoritativeInventory && this.protocol >= ProtocolInfo.v1_16_100;
+ }
+
public boolean isEnableNetworkEncryption() {
return protocol >= ProtocolInfo.v1_7_0 && this.server.encryptionEnabled /*&& loginChainData.isXboxAuthed()*/;
}
diff --git a/src/main/java/cn/nukkit/Server.java b/src/main/java/cn/nukkit/Server.java
index c797a7c47..4a35b97c4 100644
--- a/src/main/java/cn/nukkit/Server.java
+++ b/src/main/java/cn/nukkit/Server.java
@@ -530,6 +530,12 @@ public Level remove(@NotNull Object key) {
* Server authority block destruction
*/
public boolean serverAuthoritativeBlockBreaking;
+ /**
+ * Server authoritative inventory mode
+ * When enabled, server has final authority over inventory changes
+ * @since v1.16.100 (protocol 407+)
+ */
+ public boolean serverAuthoritativeInventory;
/**
* Network encryption
*/
@@ -3318,6 +3324,7 @@ private void loadSettings() {
default -> this.serverAuthoritativeMovementMode = 1; // server-auth
}
this.serverAuthoritativeBlockBreaking = this.getPropertyBoolean("server-authoritative-block-breaking", true);
+ this.serverAuthoritativeInventory = this.getPropertyBoolean("server-authoritative-inventory", true);
// === Advanced MOT settings from nukkit-mot.yml ===
ServerConfig config = this.serverConfig;
@@ -3537,6 +3544,7 @@ private static class ServerProperties extends ConfigSection {
put("server-authoritative-movement", "server-auth");
put("server-authoritative-block-breaking", true);
+ put("server-authoritative-inventory", true);
}
}
diff --git a/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java b/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
index c0a210f6a..8edf437bf 100644
--- a/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
+++ b/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
@@ -118,4 +118,9 @@ public void setChested(boolean chested) {
this.chested = chested;
this.setDataFlag(DATA_FLAGS, DATA_FLAG_CHESTED, chested);
}
+
+ @Override
+ protected int getChestSize() {
+ return this.chested ? 15 : 0;
+ }
}
diff --git a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
index 32935a0eb..f2db7b57c 100644
--- a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
+++ b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
@@ -6,6 +6,8 @@
import cn.nukkit.entity.EntityCreature;
import cn.nukkit.entity.EntityRideable;
import cn.nukkit.entity.data.Vector3fEntityData;
+import cn.nukkit.inventory.HorseInventory;
+import cn.nukkit.inventory.InventoryHolder;
import cn.nukkit.item.Item;
import cn.nukkit.level.format.FullChunk;
import cn.nukkit.level.particle.ItemBreakParticle;
@@ -24,9 +26,12 @@
/**
* @author PetteriM1
*/
-public class EntityHorseBase extends EntityWalkingAnimal implements EntityRideable, EntityControllable {
+public class EntityHorseBase extends EntityWalkingAnimal implements EntityRideable, EntityControllable, InventoryHolder {
+
+ private static final String TAG_CHEST_ITEMS = "ChestItems";
private boolean saddled;
+ private HorseInventory horseInventory;
public EntityHorseBase(FullChunk chunk, CompoundTag nbt) {
super(chunk, nbt);
@@ -46,8 +51,18 @@ public int getKillExperience() {
protected void initEntity() {
super.initEntity();
- if (this.namedTag.contains("Saddle")) {
- this.setSaddled(this.namedTag.getBoolean("Saddle"));
+ this.horseInventory = new HorseInventory(this, this.getChestSize());
+ if (this.namedTag.containsList(TAG_CHEST_ITEMS)) {
+ this.horseInventory.loadFromNBT(this.namedTag.getList(TAG_CHEST_ITEMS, CompoundTag.class));
+ }
+
+ boolean hasSaddleItem = !this.horseInventory.getItem(HorseInventory.SLOT_SADDLE).isNull();
+ boolean legacySaddle = this.namedTag.contains("Saddle") && this.namedTag.getBoolean("Saddle");
+ if (hasSaddleItem || legacySaddle) {
+ this.setSaddled(true);
+ if (!hasSaddleItem) {
+ this.horseInventory.applySaddleWithoutSync(Item.get(Item.SADDLE));
+ }
}
}
@@ -55,6 +70,22 @@ protected void initEntity() {
public void saveNBT() {
super.saveNBT();
this.namedTag.putBoolean("Saddle", this.isSaddled());
+ if (this.horseInventory != null) {
+ this.namedTag.putList(TAG_CHEST_ITEMS, this.horseInventory.saveToNBT());
+ }
+ }
+
+ @Override
+ public HorseInventory getInventory() {
+ return this.horseInventory;
+ }
+
+ /**
+ * Number of additional storage slots beyond saddle (0) and armor (1).
+ * Defaults to 0; chested horses (donkey/mule/llama) override.
+ */
+ protected int getChestSize() {
+ return 0;
}
@Override
@@ -118,6 +149,14 @@ public void setSaddled(boolean saddled) {
if (this.canBeSaddled()) {
this.saddled = saddled;
this.setDataFlag(DATA_FLAGS, DATA_FLAG_SADDLED, saddled);
+ if (this.horseInventory != null) {
+ Item current = this.horseInventory.getItem(HorseInventory.SLOT_SADDLE);
+ boolean slotHasSaddle = !current.isNull();
+ if (saddled != slotHasSaddle) {
+ Item target = saddled ? Item.get(Item.SADDLE) : Item.get(Item.AIR);
+ this.horseInventory.applySaddleWithoutSync(target);
+ }
+ }
}
}
diff --git a/src/main/java/cn/nukkit/inventory/BundleInventory.java b/src/main/java/cn/nukkit/inventory/BundleInventory.java
new file mode 100644
index 000000000..e0fb6c169
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/BundleInventory.java
@@ -0,0 +1,151 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.Player;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemBundle;
+import cn.nukkit.nbt.NBTIO;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.nbt.tag.ListTag;
+import cn.nukkit.network.protocol.InventoryContentPacket;
+import cn.nukkit.network.protocol.InventorySlotPacket;
+import cn.nukkit.network.protocol.ProtocolInfo;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Dynamic container backing inventory for bundle items.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public class BundleInventory extends BaseInventory {
+
+ public static final int MAX_FILL = 64;
+ public static final int DYNAMIC_REGISTRY_WINDOW_ID = 125;
+
+ public BundleInventory(ItemBundle holder) {
+ super(holder, InventoryType.CHEST, Map.of(), MAX_FILL, "Bundle");
+ loadFromHolder(holder);
+ }
+
+ @Override
+ public boolean setItem(int index, Item item, boolean send) {
+ int newWeight = getWeight() - getWeight(this.getItemFast(index)) + getWeight(item);
+ if (newWeight > MAX_FILL) {
+ return false;
+ }
+
+ boolean changed = super.setItem(index, item, send);
+ if (changed) {
+ getHolder().saveNBT();
+ }
+ return changed;
+ }
+
+ @Override
+ public boolean clear(int index, boolean send) {
+ boolean changed = super.clear(index, send);
+ if (changed) {
+ getHolder().saveNBT();
+ }
+ return changed;
+ }
+
+ @Override
+ public void sendContents(Player... players) {
+ InventoryContentPacket pk = new InventoryContentPacket();
+ pk.slots = new Item[this.getSize()];
+ for (int i = 0; i < this.getSize(); ++i) {
+ pk.slots[i] = this.getItem(i);
+ }
+ pk.containerNameData = new FullContainerName(ContainerSlotType.DYNAMIC_CONTAINER, getHolder().getBundleId());
+ pk.dynamicContainerSize = this.getSize();
+ pk.storageItem = getHolder().clone();
+
+ for (Player player : players) {
+ if (!player.spawned || player.protocol < ProtocolInfo.v1_21_20) {
+ continue;
+ }
+ pk.inventoryId = DYNAMIC_REGISTRY_WINDOW_ID;
+ player.dataPacket(pk);
+ }
+ }
+
+ @Override
+ public void sendSlot(int index, Player... players) {
+ InventorySlotPacket pk = new InventorySlotPacket();
+ pk.slot = index;
+ pk.item = this.getItem(index);
+ pk.containerNameData = new FullContainerName(ContainerSlotType.DYNAMIC_CONTAINER, getHolder().getBundleId());
+ pk.dynamicContainerSize = this.getSize();
+ pk.storageItem = getHolder().clone();
+
+ for (Player player : players) {
+ if (!player.spawned || player.protocol < ProtocolInfo.v1_21_20) {
+ continue;
+ }
+ pk.inventoryId = DYNAMIC_REGISTRY_WINDOW_ID;
+ player.dataPacket(pk);
+ }
+ }
+
+ public int getWeight() {
+ return getWeight(new HashSet<>());
+ }
+
+ public ItemBundle getHolder() {
+ return (ItemBundle) holder;
+ }
+
+ private void loadFromHolder(ItemBundle holder) {
+ holder.getBundleId();
+ CompoundTag tag = holder.getNamedTag();
+ if (tag == null || !tag.containsList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT)) {
+ return;
+ }
+
+ ListTag items = tag.getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class);
+ for (CompoundTag itemTag : items.getAll()) {
+ int slot = itemTag.getByte("Slot") & 0xFF;
+ if (slot < 0 || slot >= this.getSize()) {
+ continue;
+ }
+
+ Item item = NBTIO.getItemHelper(itemTag);
+ if (!item.isNull()) {
+ this.slots.put(slot, item);
+ }
+ }
+ }
+
+ private int getWeight(Set visitedBundleIds) {
+ int weight = 0;
+ for (Item item : this.slots.values()) {
+ weight += getWeight(item, visitedBundleIds);
+ }
+ return weight;
+ }
+
+ private int getWeight(Item item) {
+ return getWeight(item, new HashSet<>());
+ }
+
+ private int getWeight(Item item, Set visitedBundleIds) {
+ if (item == null || item.isNull() || item.getCount() <= 0) {
+ return 0;
+ }
+
+ if (item instanceof ItemBundle bundle) {
+ int bundleId = bundle.getBundleId();
+ if (!visitedBundleIds.add(bundleId)) {
+ return 0;
+ }
+ return ((BundleInventory) bundle.getInventory()).getWeight(visitedBundleIds) + 4;
+ }
+
+ return Math.max(1, MAX_FILL / Math.max(1, item.getMaxStackSize())) * item.getCount();
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java b/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
new file mode 100644
index 000000000..affe258a5
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
@@ -0,0 +1,51 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.Player;
+import cn.nukkit.item.Item;
+import cn.nukkit.level.Position;
+
+/**
+ * Cartography Table 3-slot UI (input, additional, result). Used by the Server
+ * Authoritative Inventory CraftRecipeOptional flow to resolve map operations such
+ * as copying, scaling, adding a compass, or making a locator map.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public class CartographyTableInventory extends FakeBlockUIComponent {
+
+ public static final int CARTOGRAPHY_INPUT_UI_SLOT = 12;
+ public static final int CARTOGRAPHY_ADDITIONAL_UI_SLOT = 13;
+
+ public CartographyTableInventory(PlayerUIInventory playerUI, Position position) {
+ super(playerUI, InventoryType.CARTOGRAPHY, 2, position);
+ }
+
+ @Override
+ public void onClose(Player who) {
+ super.onClose(who);
+
+ for (int i = 0; i < 2; i++) {
+ Item item = this.getItem(i);
+ if (item.isNull()) {
+ continue;
+ }
+ Item[] drops = who.getInventory().addItem(item);
+ for (Item drop : drops) {
+ if (!who.dropItem(drop)) {
+ this.getHolder().getLevel().dropItem(this.getHolder().add(0.5, 0.5, 0.5), drop);
+ }
+ }
+ this.clear(i);
+ }
+
+ who.resetCraftingGridType();
+ }
+
+ public Item getInput() {
+ return this.getItem(0);
+ }
+
+ public Item getAdditional() {
+ return this.getItem(1);
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/CraftingManager.java b/src/main/java/cn/nukkit/inventory/CraftingManager.java
index 7a48d1a50..9a1c7935d 100644
--- a/src/main/java/cn/nukkit/inventory/CraftingManager.java
+++ b/src/main/java/cn/nukkit/inventory/CraftingManager.java
@@ -93,6 +93,14 @@ public class CraftingManager {
private final Map smithingRecipes = new Object2ObjectOpenHashMap<>();
private final List stonecutterRecipes = new ArrayList<>();
+ /**
+ * Lookup table for recipes by their assigned network ID. Populated for recipes
+ * that carry a networkId (Shaped/Shapeless/Stonecutter/Multi/Smithing). Used by
+ * the Server Authoritative ItemStackRequest flow to resolve a CraftRecipeAction
+ * back to its Recipe instance without iterating the full recipe catalog.
+ */
+ private final Map networkIdRecipes = new Int2ObjectOpenHashMap<>();
+
private final Object2DoubleOpenHashMap recipeXpMap = new Object2DoubleOpenHashMap<>();
private static int RECIPE_COUNT = 0;
@@ -246,7 +254,7 @@ public CraftingManager() {
ingredients.add(ingredientItem);
}
- this.registerSmithingRecipe(new SmithingRecipe(recipeId, 0, ingredients, item));
+ this.registerSmithingRecipe(new SmithingTransformRecipe(recipeId, 0, ingredients, item));
}
this.rebuildPacket();
@@ -1290,6 +1298,7 @@ public void registerShapedRecipe(ShapedRecipe recipe) {
int resultHash = getItemHash(recipe.getResult());
Map map = this.shapedRecipes.computeIfAbsent(resultHash, k -> new HashMap<>());
map.put(getMultiItemHash(new LinkedList<>(recipe.getIngredientsAggregate())), recipe);
+ this.networkIdRecipes.put(recipe.getNetworkId(), recipe);
}
@Deprecated
@@ -1336,6 +1345,7 @@ public void registerShapelessRecipe(ShapelessRecipe recipe) {
int resultHash = getItemHash(recipe.getResult());
Map map = this.shapelessRecipes.computeIfAbsent(resultHash, k -> new HashMap<>());
map.put(hash, recipe);
+ this.networkIdRecipes.put(recipe.getNetworkId(), recipe);
}
@Deprecated
@@ -1357,11 +1367,13 @@ private static int getContainerHash(int ingredientId, int containerId) {
public void registerSmithingRecipe(SmithingRecipe recipe) {
UUID multiItemHash = getMultiItemHash(recipe.getIngredientsAggregate());
this.smithingRecipes.put(multiItemHash, recipe);
+ this.networkIdRecipes.put(recipe.getNetworkId(), recipe);
}
public void registerStonecutterRecipe(StonecutterRecipe recipe) {
recipe.setId(UUID.randomUUID());
this.stonecutterRecipes.add(recipe);
+ this.networkIdRecipes.put(recipe.getNetworkId(), recipe);
}
@Deprecated
@@ -1448,6 +1460,15 @@ private static boolean matchItemsAccumulation(CraftingRecipe recipe, List-
public void registerMultiRecipe(MultiRecipe recipe) {
this.multiRecipes.put(recipe.getId(), recipe);
+ this.networkIdRecipes.put(recipe.getNetworkId(), recipe);
+ }
+
+ /**
+ * Lookup a Recipe by the network ID emitted to the client in CraftingDataPacket.
+ * Returns null if no matching recipe is registered.
+ */
+ public Recipe getRecipeByNetworkId(int networkId) {
+ return this.networkIdRecipes.get(networkId);
}
@Deprecated
diff --git a/src/main/java/cn/nukkit/inventory/EnchantInventory.java b/src/main/java/cn/nukkit/inventory/EnchantInventory.java
index ac1207b5b..1d169eb45 100644
--- a/src/main/java/cn/nukkit/inventory/EnchantInventory.java
+++ b/src/main/java/cn/nukkit/inventory/EnchantInventory.java
@@ -2,7 +2,14 @@
import cn.nukkit.Player;
import cn.nukkit.item.Item;
+import cn.nukkit.item.enchantment.EnchantmentHelper;
import cn.nukkit.level.Position;
+import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
/**
* @author MagicDroidX
@@ -13,6 +20,12 @@ public class EnchantInventory extends FakeBlockUIComponent {
public static final int ENCHANT_INPUT_ITEM_UI_SLOT = 14;
public static final int ENCHANT_REAGENT_UI_SLOT = 15;
+ /**
+ * Enchant option net ids published for the current open session. Cleared on
+ * close to keep {@link PlayerEnchantOptionsPacket#RECIPE_MAP} bounded.
+ */
+ private final Set publishedOptionIds = new HashSet<>();
+
public EnchantInventory(PlayerUIInventory playerUI, Position position) {
super(playerUI, InventoryType.ENCHANT_TABLE, 14, position);
}
@@ -32,10 +45,73 @@ public void onClose(Player who) {
this.clear(i);
}
}
+ releasePublishedOptions();
who.craftingType = Player.CRAFTING_SMALL;
who.resetCraftingGridType();
}
+ @Override
+ public void onSlotChange(int index, Item before, boolean send) {
+ super.onSlotChange(index, before, send);
+ if (index != 0) {
+ return;
+ }
+ Item current = this.getItem(0);
+ if (current.isNull() || current.hasEnchantments()) {
+ sendEmptyOptions();
+ releasePublishedOptions();
+ return;
+ }
+ if (before != null && !before.isNull() && before.equals(current, true, true)) {
+ return;
+ }
+ publishOptions(current);
+ }
+
+ private void publishOptions(Item input) {
+ releasePublishedOptions();
+ long seed = System.nanoTime();
+ List generated = EnchantmentHelper.generateOptions(input, seed);
+ if (generated.isEmpty()) {
+ sendEmptyOptions();
+ return;
+ }
+ List published = new ArrayList<>(generated.size());
+ for (PlayerEnchantOptionsPacket.EnchantOptionData option : generated) {
+ int netId = PlayerEnchantOptionsPacket.assignRecipeId(option);
+ published.add(new PlayerEnchantOptionsPacket.EnchantOptionData(
+ option.getMinLevel(), option.getPrimarySlot(),
+ option.getEnchants0(), option.getEnchants1(), option.getEnchants2(),
+ option.getEnchantName(), netId));
+ publishedOptionIds.add(netId);
+ }
+ PlayerEnchantOptionsPacket pk = new PlayerEnchantOptionsPacket();
+ pk.options.addAll(published);
+ for (Player viewer : this.getViewers()) {
+ viewer.dataPacket(pk);
+ }
+ }
+
+ private void sendEmptyOptions() {
+ if (this.getViewers().isEmpty()) {
+ return;
+ }
+ PlayerEnchantOptionsPacket pk = new PlayerEnchantOptionsPacket();
+ for (Player viewer : this.getViewers()) {
+ viewer.dataPacket(pk);
+ }
+ }
+
+ private void releasePublishedOptions() {
+ if (publishedOptionIds.isEmpty()) {
+ return;
+ }
+ for (Integer id : publishedOptionIds) {
+ PlayerEnchantOptionsPacket.RECIPE_MAP.remove(id.intValue());
+ }
+ publishedOptionIds.clear();
+ }
+
public Item getInputSlot() {
return this.getItem(0);
}
diff --git a/src/main/java/cn/nukkit/inventory/HorseInventory.java b/src/main/java/cn/nukkit/inventory/HorseInventory.java
new file mode 100644
index 000000000..5617c6f72
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/HorseInventory.java
@@ -0,0 +1,185 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.Player;
+import cn.nukkit.entity.passive.EntityHorseBase;
+import cn.nukkit.entity.passive.EntityLlama;
+import cn.nukkit.item.Item;
+import cn.nukkit.nbt.NBTIO;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.nbt.tag.ListTag;
+import cn.nukkit.network.protocol.MobArmorEquipmentPacket;
+
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Inventory backing the horse family's equipment and optional chest storage.
+ *
+ * Slot layout: 0 = saddle, 1 = armor (normal horses), 2..2+chestSize-1 = chest storage.
+ * Llama bodies reject armor in slot 1 (carpet slot wiring is left as follow-up).
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public class HorseInventory extends BaseInventory {
+
+ public static final int SLOT_SADDLE = 0;
+ public static final int SLOT_ARMOR = 1;
+ public static final int SLOT_CHEST_BASE = 2;
+
+ private final int chestSize;
+ private boolean suppressSaddleSync;
+
+ public HorseInventory(EntityHorseBase holder, int chestSize) {
+ super(holder, InventoryType.HORSE, Map.of(), SLOT_CHEST_BASE + Math.max(0, chestSize), "Horse");
+ this.chestSize = Math.max(0, chestSize);
+ }
+
+ @Override
+ public EntityHorseBase getHolder() {
+ return (EntityHorseBase) this.holder;
+ }
+
+ public int getChestSize() {
+ return chestSize;
+ }
+
+ public boolean isSaddleSlot(int slot) {
+ return slot == SLOT_SADDLE;
+ }
+
+ public boolean isArmorSlot(int slot) {
+ return slot == SLOT_ARMOR;
+ }
+
+ public boolean isChestSlot(int slot) {
+ return slot >= SLOT_CHEST_BASE && slot < SLOT_CHEST_BASE + chestSize;
+ }
+
+ @Override
+ public boolean allowedToAdd(Item item) {
+ return true;
+ }
+
+ public boolean isValidForSlot(int slot, Item item) {
+ if (item == null || item.isNull()) {
+ return true;
+ }
+ if (slot == SLOT_SADDLE) {
+ return item.getId() == Item.SADDLE;
+ }
+ if (slot == SLOT_ARMOR) {
+ if (getHolder() instanceof EntityLlama) {
+ return false;
+ }
+ int id = item.getId();
+ return id == Item.LEATHER_HORSE_ARMOR
+ || id == Item.IRON_HORSE_ARMOR
+ || id == Item.GOLD_HORSE_ARMOR
+ || id == Item.DIAMOND_HORSE_ARMOR;
+ }
+ return isChestSlot(slot);
+ }
+
+ @Override
+ public boolean setItem(int index, Item item, boolean send) {
+ if (!isValidForSlot(index, item)) {
+ return false;
+ }
+ return super.setItem(index, item, send);
+ }
+
+ @Override
+ public void onSlotChange(int index, Item before, boolean send) {
+ super.onSlotChange(index, before, send);
+
+ Item now = this.getItem(index);
+ if (index == SLOT_SADDLE) {
+ syncSaddle(!now.isNull());
+ } else if (index == SLOT_ARMOR) {
+ broadcastArmorVisual(now);
+ }
+ }
+
+ private void syncSaddle(boolean saddled) {
+ if (suppressSaddleSync) {
+ return;
+ }
+ EntityHorseBase holder = getHolder();
+ if (holder == null || holder.closed) {
+ return;
+ }
+ if (holder.isSaddled() != saddled) {
+ holder.setSaddled(saddled);
+ }
+ }
+
+ private void broadcastArmorVisual(Item armor) {
+ EntityHorseBase holder = getHolder();
+ if (holder == null || holder.closed) {
+ return;
+ }
+ Item body = armor == null || armor.isNull() ? Item.get(Item.AIR) : armor;
+ MobArmorEquipmentPacket pk = new MobArmorEquipmentPacket();
+ pk.eid = holder.getId();
+ Item air = Item.get(Item.AIR);
+ pk.slots = new Item[]{air, body, air, air};
+
+ Collection viewers = holder.getViewers().values();
+ for (Player viewer : viewers) {
+ viewer.dataPacket(pk);
+ }
+ }
+
+ /**
+ * Sync the saddle slot from the holder's {@link EntityHorseBase#isSaddled()}
+ * without triggering {@link #syncSaddle(boolean)} reentrance. Used when loading
+ * NBT state or handling interact-driven saddle changes.
+ */
+ public void applySaddleWithoutSync(Item saddleItem) {
+ boolean previous = suppressSaddleSync;
+ suppressSaddleSync = true;
+ try {
+ super.setItem(SLOT_SADDLE, saddleItem == null ? Item.get(Item.AIR) : saddleItem, false);
+ } finally {
+ suppressSaddleSync = previous;
+ }
+ }
+
+ public ListTag saveToNBT() {
+ ListTag list = new ListTag<>();
+ for (int slot = 0; slot < this.getSize(); slot++) {
+ Item item = this.getItem(slot);
+ if (item == null || item.isNull()) {
+ continue;
+ }
+ list.add(NBTIO.putItemHelper(item, slot));
+ }
+ return list;
+ }
+
+ public void loadFromNBT(ListTag list) {
+ if (list == null) {
+ return;
+ }
+ boolean previous = suppressSaddleSync;
+ suppressSaddleSync = true;
+ try {
+ for (CompoundTag tag : list.getAll()) {
+ int slot = tag.contains("Slot") ? (tag.getByte("Slot") & 0xFF) : -1;
+ if (slot < 0 || slot >= this.getSize()) {
+ continue;
+ }
+ Item item = NBTIO.getItemHelper(tag);
+ if (item == null || item.isNull()) {
+ continue;
+ }
+ if (!isValidForSlot(slot, item)) {
+ continue;
+ }
+ super.setItem(slot, item, false);
+ }
+ } finally {
+ suppressSaddleSync = previous;
+ }
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/InventoryType.java b/src/main/java/cn/nukkit/inventory/InventoryType.java
index a9ba346e4..b20a962da 100644
--- a/src/main/java/cn/nukkit/inventory/InventoryType.java
+++ b/src/main/java/cn/nukkit/inventory/InventoryType.java
@@ -36,7 +36,9 @@ public enum InventoryType {
BARREL(27, "Barrel", 0),
SMITHING_TABLE(3, "Smithing Table", 33),
GRINDSTONE(3, "Grindstone", 26),
- STONECUTTER(2, "Stonecutter", 29);
+ STONECUTTER(2, "Stonecutter", 29),
+ CARTOGRAPHY(2, "Cartography Table", 32),
+ HORSE(17, "Horse", 12); //1 SADDLE, 1 ARMOR, up to 15 CHEST
private final int size;
private final String title;
diff --git a/src/main/java/cn/nukkit/inventory/SmithingTransformRecipe.java b/src/main/java/cn/nukkit/inventory/SmithingTransformRecipe.java
new file mode 100644
index 000000000..7e16f978c
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/SmithingTransformRecipe.java
@@ -0,0 +1,42 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.inventory.data.RecipeUnlockingRequirement;
+import cn.nukkit.item.Item;
+import lombok.ToString;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Smithing transform recipe: upgrades an equipment item by combining it with a
+ * material and an upgrade template (e.g. Netherite upgrade). Unlike
+ * {@link SmithingTrimRecipe} (cosmetic only), the transform recipe changes the
+ * base item to the recipe's result and preserves the original equipment's NBT
+ * (enchantments, custom name, durability). Exists primarily as a type marker so
+ * the {@code CraftRecipeActionProcessor} can dispatch to
+ * {@code handleSmithingUpgrade} via {@code instanceof}.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@ToString
+public class SmithingTransformRecipe extends SmithingRecipe {
+
+ public SmithingTransformRecipe(String recipeId, int priority, Collection- ingredients, Item result) {
+ super(recipeId, priority, ingredients, result);
+ }
+
+ @Override
+ public RecipeType getType() {
+ return RecipeType.SMITHING_TRANSFORM;
+ }
+
+ @Override
+ public RecipeUnlockingRequirement getRequirement() {
+ return RecipeUnlockingRequirement.ALWAYS_UNLOCKED;
+ }
+
+ @Override
+ public List
- getIngredientsAggregate() {
+ return super.getIngredientsAggregate();
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/SmithingTrimRecipe.java b/src/main/java/cn/nukkit/inventory/SmithingTrimRecipe.java
new file mode 100644
index 000000000..4c9b9162d
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/SmithingTrimRecipe.java
@@ -0,0 +1,43 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.inventory.data.RecipeUnlockingRequirement;
+import cn.nukkit.item.Item;
+import lombok.ToString;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Smithing trim recipe: applies an armor trim pattern + material to an equipment
+ * item using a trim template. Unlike {@link SmithingRecipe} (transform), the trim
+ * recipe does not change the base item — only applies cosmetic trim NBT.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@ToString
+public class SmithingTrimRecipe extends SmithingRecipe {
+
+ public SmithingTrimRecipe(String recipeId, int priority, Item equipment, Item ingredient, Item template) {
+ super(recipeId, priority, Arrays.asList(equipment, ingredient, template), Item.AIR_ITEM.clone());
+ }
+
+ @Override
+ public Item getResult() {
+ return Item.AIR_ITEM.clone();
+ }
+
+ @Override
+ public RecipeType getType() {
+ return RecipeType.SMITHING_TRIM;
+ }
+
+ @Override
+ public RecipeUnlockingRequirement getRequirement() {
+ return RecipeUnlockingRequirement.ALWAYS_UNLOCKED;
+ }
+
+ @Override
+ public List- getIngredientsAggregate() {
+ return super.getIngredientsAggregate();
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/TradeInventory.java b/src/main/java/cn/nukkit/inventory/TradeInventory.java
index 5366870a9..0abffad5d 100644
--- a/src/main/java/cn/nukkit/inventory/TradeInventory.java
+++ b/src/main/java/cn/nukkit/inventory/TradeInventory.java
@@ -6,25 +6,37 @@
import cn.nukkit.nbt.NBTIO;
import cn.nukkit.nbt.tag.CompoundTag;
import cn.nukkit.nbt.tag.ListTag;
+import cn.nukkit.nbt.tag.Tag;
import cn.nukkit.network.protocol.UpdateTradePacket;
+import cn.nukkit.utils.TradeRecipeBuildUtils;
import java.io.IOException;
import java.nio.ByteOrder;
+import java.util.HashSet;
+import java.util.Set;
public class TradeInventory extends BaseInventory {
-
+
public static final int TRADE_INPUT1_UI_SLOT = 4;
public static final int TRADE_INPUT2_UI_SLOT = 5;
-
+
+ /**
+ * Trade recipe network ids allocated for this villager session. Cleared on
+ * close to avoid leaking entries in {@link TradeRecipeBuildUtils#RECIPE_MAP}.
+ */
+ private final Set assignedRecipeIds = new HashSet<>();
+
public TradeInventory(InventoryHolder holder) {
super(holder, InventoryType.TRADING);
}
-
+
@Override
public void onOpen(Player who) {
super.onOpen(who);
EntityVillager villager = this.getHolder();
-
+
+ assignRecipeNetIds(villager.getRecipes());
+
UpdateTradePacket pk = new UpdateTradePacket();
pk.windowId = (byte) who.getWindowId(this);
pk.windowType = (byte) InventoryType.TRADING.getNetworkType();
@@ -49,12 +61,12 @@ public void onOpen(Player who) {
pk.newTradingUi = true;
pk.usingEconomyTrade = true;
-
+
who.dataPacket(pk);
-
+
this.sendContents(who);
}
-
+
@Override
public void onClose(Player who) {
for (int i = 0; i <= 1; i++) {
@@ -66,14 +78,39 @@ public void onClose(Player who) {
}
this.clear(i);
}
-
+
super.onClose(who);
this.getHolder().setTradingPlayer(0L);
+ releaseAssignedRecipeIds();
}
-
+
@Override
public EntityVillager getHolder() {
return (EntityVillager) this.holder;
}
+
+ private void assignRecipeNetIds(ListTag recipes) {
+ if (recipes == null) {
+ return;
+ }
+ releaseAssignedRecipeIds();
+ for (Tag tag : recipes.getAll()) {
+ if (tag instanceof CompoundTag recipe) {
+ int id = TradeRecipeBuildUtils.assignRecipeId(recipe);
+ recipe.putInt("netId", id);
+ assignedRecipeIds.add(id);
+ }
+ }
+ }
+
+ private void releaseAssignedRecipeIds() {
+ if (assignedRecipeIds.isEmpty()) {
+ return;
+ }
+ for (Integer id : assignedRecipeIds) {
+ TradeRecipeBuildUtils.RECIPE_MAP.remove(id.intValue());
+ }
+ assignedRecipeIds.clear();
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/request/ActionResponse.java b/src/main/java/cn/nukkit/inventory/request/ActionResponse.java
new file mode 100644
index 000000000..737cb43a7
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/ActionResponse.java
@@ -0,0 +1,23 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+
+import java.util.Collections;
+import java.util.List;
+
+public record ActionResponse(boolean success, List containers) {
+
+ private static final ActionResponse ERROR = new ActionResponse(false, Collections.emptyList());
+
+ public static ActionResponse error() {
+ return ERROR;
+ }
+
+ public static ActionResponse ok(List containers) {
+ return new ActionResponse(true, containers);
+ }
+
+ public static ActionResponse ok() {
+ return new ActionResponse(true, Collections.emptyList());
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
new file mode 100644
index 000000000..d8a55eeea
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
@@ -0,0 +1,50 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.blockentity.BlockEntityBeacon;
+import cn.nukkit.inventory.BeaconInventory;
+import cn.nukkit.level.Position;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.BeaconPaymentAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.potion.Effect;
+
+public class BeaconPaymentActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.BEACON_PAYMENT;
+ }
+
+ @Override
+ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStackRequestContext context) {
+ if (!(player.getTopWindow().orElse(null) instanceof BeaconInventory beaconInventory)) {
+ return context.error();
+ }
+
+ int primary = action.getPrimaryEffect();
+ int secondary = action.getSecondaryEffect();
+ if (primary != 0 && !isValidEffect(primary)) {
+ return context.error();
+ }
+ if (secondary != 0 && !isValidEffect(secondary)) {
+ return context.error();
+ }
+
+ Position holder = beaconInventory.getHolder();
+ if (holder != null) {
+ if (holder.level.getBlockEntity(holder) instanceof BlockEntityBeacon beacon) {
+ beacon.setPrimaryPower(primary);
+ beacon.setSecondaryPower(secondary);
+ }
+ }
+ return null;
+ }
+
+ private static boolean isValidEffect(int effectId) {
+ try {
+ return Effect.getEffect(effectId) != null;
+ } catch (Exception ex) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
new file mode 100644
index 000000000..223166be3
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
@@ -0,0 +1,53 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ConsumeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+
+import java.util.List;
+
+public class ConsumeActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CONSUME;
+ }
+
+ @Override
+ public ActionResponse handle(ConsumeAction action, Player player, ItemStackRequestContext context) {
+ ItemStackRequestSlotData src = action.getSource();
+ Inventory inventory = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId());
+ if (inventory == null) {
+ return context.error();
+ }
+
+ int slot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
+ int count = action.getCount();
+ if (count <= 0) {
+ return context.error();
+ }
+
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < count) {
+ return context.error();
+ }
+ if (validateStackNetworkId(item.getStackNetId(), src.getStackNetworkId())) {
+ return context.error();
+ }
+
+ if (item.getCount() == count) {
+ inventory.clear(slot, false);
+ } else {
+ Item remaining = item.clone();
+ remaining.setCount(item.getCount() - count);
+ inventory.setItem(slot, remaining, false);
+ }
+
+ ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src);
+ return context.success(List.of(container));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
new file mode 100644
index 000000000..be3343ef5
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
@@ -0,0 +1,63 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftCreativeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+
+import java.util.List;
+
+/**
+ * Handles creative-mode item creation. The client sends this when it wants to
+ * take a stack out of the creative catalog; the server materialises the item
+ * into CREATED_OUTPUT for a subsequent TAKE action to move elsewhere.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public class CraftCreativeActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String CRAFT_CREATIVE_KEY = "craft_creative_key";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_CREATIVE;
+ }
+
+ @Override
+ public ActionResponse handle(CraftCreativeAction action, Player player, ItemStackRequestContext context) {
+ if (!player.isCreative()) {
+ return context.error();
+ }
+
+ // creativeItemNetworkId is 1-based; the catalog is indexed from 0. Use the
+ // player's actual game version so the right catalog is queried for NetEase
+ // and older clients.
+ Item item = Item.getCreativeItem(player.getGameVersion(), action.getCreativeItemNetworkId() - 1);
+ if (item == null || item.isNull()) {
+ return context.error();
+ }
+
+ item = item.clone();
+ item.setCount(item.getMaxStackSize());
+ item.autoAssignStackNetworkId();
+
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, item, false);
+ context.put(CRAFT_CREATIVE_KEY, true);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, item.getCount(), item.getStackNetId(),
+ item.hasCustomName() ? item.getCustomName() : "",
+ item.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
new file mode 100644
index 000000000..a2db886d1
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
@@ -0,0 +1,78 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.event.inventory.GrindItemEvent;
+import cn.nukkit.inventory.GrindstoneInventory;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftGrindstoneAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+
+import java.util.List;
+
+/**
+ * Grindstone disenchant/repair handling. Mirrors the logic used by
+ * GrindstoneTransaction so grindstone operations work through either path.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public class CraftGrindstoneActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String GRINDSTONE_EXP_KEY = "grindstoneExp";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_REPAIR_AND_DISENCHANT;
+ }
+
+ @Override
+ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemStackRequestContext context) {
+ if (!(player.getTopWindow().orElse(null) instanceof GrindstoneInventory grindstone)) {
+ return context.error();
+ }
+
+ Item result = grindstone.getResult();
+ if (result.isNull()) {
+ return context.error();
+ }
+
+ int experience = grindstone.calculateExperience();
+ GrindItemEvent event = new GrindItemEvent(
+ grindstone,
+ grindstone.getEquipment(),
+ result,
+ grindstone.getIngredient(),
+ experience,
+ player
+ );
+ player.getServer().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ return context.error();
+ }
+
+ // Stock vanilla behaviour: grinding emits the stored enchantment XP to the
+ // player. Missing this was a regression from the previous implementation.
+ if (experience > 0) {
+ player.addExperience(experience);
+ }
+
+ Item resultClone = result.clone().autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, resultClone, false);
+ context.put(GRINDSTONE_EXP_KEY, experience);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, resultClone.getCount(), resultClone.getStackNetId(),
+ resultClone.hasCustomName() ? resultClone.getCustomName() : "",
+ resultClone.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
new file mode 100644
index 000000000..8ba97649b
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
@@ -0,0 +1,104 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.Server;
+import cn.nukkit.event.inventory.LoomItemEvent;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.LoomInventory;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemBanner;
+import cn.nukkit.item.ItemDye;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftLoomAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import cn.nukkit.utils.BannerPattern;
+import cn.nukkit.utils.DyeColor;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Loom pattern/color application. Replicates LoomTransaction's logic: takes the
+ * banner + dye (+ optional pattern item) from the LoomInventory and writes the
+ * patterned result to CREATED_OUTPUT. The subsequent CONSUME/TAKE actions
+ * remove the ingredients and deliver the result to the player.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public class CraftLoomActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String LOOM_PATTERN_KEY = "loomPatternId";
+ public static final String LOOM_TIMES_KEY = "loomTimesCrafted";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_LOOM;
+ }
+
+ @Override
+ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackRequestContext context) {
+ context.put(LOOM_PATTERN_KEY, action.getPatternId());
+ context.put(LOOM_TIMES_KEY, action.getTimesCrafted());
+
+ Optional topWindow = player.getTopWindow();
+ if (topWindow.isEmpty() || !(topWindow.get() instanceof LoomInventory loomInventory)) {
+ return context.error();
+ }
+
+ Item banner = loomInventory.getBanner();
+ Item dye = loomInventory.getDye();
+ if (banner == null || banner.isNull() || !(banner instanceof ItemBanner bannerItem)
+ || dye == null || dye.isNull()) {
+ return context.error();
+ }
+
+ BannerPattern.Type patternType = null;
+ String patternId = action.getPatternId();
+ if (patternId != null && !patternId.isBlank()) {
+ patternType = BannerPattern.Type.getByName(patternId);
+ if (patternType == null) {
+ return context.error();
+ }
+ }
+
+ DyeColor dyeColor = DyeColor.BLACK;
+ if (dye instanceof ItemDye itemDye) {
+ dyeColor = itemDye.getDyeColor();
+ }
+
+ int times = Math.max(1, action.getTimesCrafted());
+ ItemBanner result = (ItemBanner) bannerItem.clone();
+ result.setCount(times);
+ if (patternType != null) {
+ result.addPattern(new BannerPattern(patternType, dyeColor));
+ } else {
+ result.setBaseColor(dyeColor);
+ }
+
+ LoomItemEvent event = new LoomItemEvent(loomInventory, result.clone(), player);
+ Server.getInstance().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ return context.error();
+ }
+
+ result.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, result.getCount(), result.getStackNetId(),
+ result.hasCustomName() ? result.getCustomName() : "",
+ result.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java
new file mode 100644
index 000000000..5860d4667
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftNonImplementedAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+
+public class CraftNonImplementedActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_NON_IMPLEMENTED_DEPRECATED;
+ }
+
+ @Override
+ public ActionResponse handle(CraftNonImplementedAction action, Player player, ItemStackRequestContext context) {
+ return context.error();
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
new file mode 100644
index 000000000..52c699266
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
@@ -0,0 +1,400 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.Server;
+import cn.nukkit.entity.passive.EntityVillager;
+import cn.nukkit.event.inventory.CraftItemEvent;
+import cn.nukkit.event.inventory.EnchantItemEvent;
+import cn.nukkit.event.inventory.SmithingTableEvent;
+import cn.nukkit.event.inventory.StonecutterItemEvent;
+import cn.nukkit.inventory.*;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.enchantment.Enchantment;
+import cn.nukkit.nbt.NBTIO;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftRecipeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import cn.nukkit.utils.TradeRecipeBuildUtils;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Resolves the recipe referenced by a CraftRecipeAction and dispatches to one of
+ * three branches depending on the network id range:
+ *
+ * - {@code >= ENCH_RECIPEID}: enchantment option from
+ * {@link PlayerEnchantOptionsPacket#RECIPE_MAP}
+ * - {@code >= TRADE_RECIPEID}: villager trade from
+ * {@link TradeRecipeBuildUtils#RECIPE_MAP}
+ * - otherwise: regular crafting recipe in
+ * {@link cn.nukkit.inventory.CraftingManager}
+ *
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public class CraftRecipeActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String RECIPE_NET_ID_KEY = "recipeNetId";
+ public static final String ENCH_RECIPE_KEY = "enchRecipe";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_RECIPE;
+ }
+
+ @Override
+ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackRequestContext context) {
+ int recipeNetId = action.getRecipeNetworkId();
+ context.put(RECIPE_NET_ID_KEY, recipeNetId);
+
+ // TRADE_RECIPEID (0x20000000) > ENCH_RECIPEID (0x10000000); dispatch in
+ // descending order so a trade id does not get mistaken for an enchant id.
+ if (recipeNetId >= TradeRecipeBuildUtils.TRADE_RECIPEID) {
+ return handleTrade(action, player, context);
+ }
+ if (recipeNetId >= PlayerEnchantOptionsPacket.ENCH_RECIPEID) {
+ return handleEnchant(action, player, context);
+ }
+
+ Recipe recipe = player.getServer().getCraftingManager().getRecipeByNetworkId(recipeNetId);
+ if (recipe == null) {
+ return context.error();
+ }
+
+ // Stonecutter 走独立事件链路(StonecutterItemEvent),不经 CraftItemEvent,
+ // 与旧 StonecutterTransaction 保持语义一致。
+ if (recipe instanceof StonecutterRecipe stonecutterRecipe) {
+ return handleStonecutter(player, stonecutterRecipe, action, context);
+ }
+
+ // Fire CraftItemEvent before applying the recipe so plugins can veto SA
+ // manual crafting. Input items come from the open crafting grid (big
+ // workbench if opened, otherwise the 2x2 personal grid).
+ CraftItemEvent craftEvent = new CraftItemEvent(player, collectCraftingInput(player), recipe);
+ Server.getInstance().getPluginManager().callEvent(craftEvent);
+ if (craftEvent.isCancelled()) {
+ return context.error();
+ }
+
+ context.put(CreateActionProcessor.RECIPE_DATA_KEY, recipe);
+
+ // Smithing dispatch: trim recipes delegate to the inventory's trim logic;
+ // transform recipes preserve the equipment's NBT onto the result.
+ if (recipe instanceof SmithingTrimRecipe) {
+ return handleSmithingTrim(player, context);
+ }
+ if (recipe instanceof SmithingTransformRecipe smithingTransform) {
+ return handleSmithingUpgrade(smithingTransform, player, context);
+ }
+
+ Item recipeResult = recipe instanceof MultiRecipe multi ? multi.getResult() : recipe.getResult();
+ if (recipeResult == null || recipeResult.isNull()) {
+ return null;
+ }
+ int times = Math.max(1, action.getNumberOfRequestedCrafts());
+ Item output = recipeResult.clone();
+ output.setCount(output.getCount() * times);
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+
+ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, ItemStackRequestContext context) {
+ PlayerEnchantOptionsPacket.EnchantOptionData option =
+ PlayerEnchantOptionsPacket.RECIPE_MAP.get(action.getRecipeNetworkId());
+ if (option == null) {
+ log.warn("{}: unknown enchant recipe netId {}", player.getName(), action.getRecipeNetworkId());
+ return context.error();
+ }
+ Inventory inventory = player.getTopWindow().orElse(null);
+ if (!(inventory instanceof EnchantInventory enchantInventory)) {
+ return context.error();
+ }
+ Item first = enchantInventory.getInputSlot();
+ if (first.isNull()) {
+ return context.error();
+ }
+ List enchantments = new ArrayList<>();
+ for (PlayerEnchantOptionsPacket.EnchantData data : option.getEnchants0()) {
+ Enchantment enchantment = Enchantment.getEnchantment(data.getType());
+ if (enchantment != null) {
+ enchantments.add(enchantment.setLevel(data.getLevel()));
+ }
+ }
+ int cost = option.getPrimarySlot() + 1;
+ if (!player.isCreative() && player.getExperienceLevel() < cost) {
+ return context.error();
+ }
+
+ Item output = first.clone();
+ if (output.getId() == Item.BOOK) {
+ output = Item.get(Item.ENCHANTED_BOOK);
+ }
+ output.setCount(1);
+ if (!enchantments.isEmpty()) {
+ output.addEnchantment(enchantments.toArray(Enchantment.EMPTY_ARRAY));
+ }
+ output.autoAssignStackNetworkId();
+
+ EnchantItemEvent event = new EnchantItemEvent(enchantInventory, first.clone(), output, option.getMinLevel(), player);
+ Server.getInstance().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ return context.error();
+ }
+
+ if (!player.isCreative()) {
+ player.setExperience(player.getExperience(), player.getExperienceLevel() - cost);
+ }
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+ PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId());
+ context.put(ENCH_RECIPE_KEY, true);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+
+ private ActionResponse handleTrade(CraftRecipeAction action, Player player, ItemStackRequestContext context) {
+ CompoundTag recipe = TradeRecipeBuildUtils.RECIPE_MAP.get(action.getRecipeNetworkId());
+ if (recipe == null) {
+ log.warn("{}: unknown trade recipe netId {}", player.getName(), action.getRecipeNetworkId());
+ return context.error();
+ }
+ Optional topWindow = player.getTopWindow();
+ if (topWindow.isEmpty() || !(topWindow.get() instanceof TradeInventory tradeInventory)) {
+ return context.error();
+ }
+ int times = Math.max(1, action.getNumberOfRequestedCrafts());
+ int maxUses = recipe.contains("maxUses") ? recipe.getInt("maxUses") : Integer.MAX_VALUE;
+ int uses = recipe.contains("uses") ? recipe.getInt("uses") : 0;
+ if (uses + times > maxUses) {
+ return context.error();
+ }
+
+ Item buyA = tradeInventory.getItem(TradeInventory.TRADE_INPUT1_UI_SLOT);
+ Item buyB = tradeInventory.getItem(TradeInventory.TRADE_INPUT2_UI_SLOT);
+ boolean hasBuyA = recipe.contains("buyA");
+ boolean hasBuyB = recipe.contains("buyB");
+
+ if (hasBuyA && checkTrade(recipe.getCompound("buyA"), buyA, 0)) {
+ return context.error();
+ }
+ if (hasBuyB && checkTrade(recipe.getCompound("buyB"), buyB, 0)) {
+ return context.error();
+ }
+
+ Item output = NBTIO.getItemHelper(recipe.getCompound("sell"));
+ if (output == null || output.isNull()) {
+ return context.error();
+ }
+ output.setCount(output.getCount() * times);
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+
+ recipe.putInt("uses", uses + times);
+ int rewardExp = recipe.contains("rewardExp") ? recipe.getInt("rewardExp") : 0;
+ if (rewardExp > 0) {
+ player.addExperience(rewardExp * times);
+ }
+ EntityVillager villager = tradeInventory.getHolder();
+ if (villager != null) {
+ int traderExp = recipe.contains("traderExp") ? recipe.getInt("traderExp") : 0;
+ if (traderExp > 0) {
+ villager.addExperience(traderExp * times);
+ }
+ }
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+
+ private boolean checkTrade(CompoundTag expected, Item actual, int subtract) {
+ if (actual == null || actual.isNull()) {
+ return true;
+ }
+ int required = Math.max(expected.getByte("Count") - subtract, 1);
+ if (actual.getCount() < required) {
+ return true;
+ }
+ String expectedName = expected.getString("Name");
+ String actualName = actual.getNamespaceId();
+ if (expectedName != null && !expectedName.isEmpty() && !expectedName.equals(actualName)) {
+ return true;
+ }
+ if (expected.contains("Damage") && expected.getShort("Damage") != actual.getDamage()) {
+ return true;
+ }
+ if (expected.contains("tag")) {
+ CompoundTag expectedTag = expected.getCompound("tag");
+ CompoundTag actualTag = actual.getNamedTag();
+ if (actualTag == null || !expectedTag.equals(actualTag)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Collects non-empty items from the player's active crafting grid (big
+ * workbench if one is open, otherwise the personal 2x2 grid). Used as the
+ * {@code input} parameter of {@link CraftItemEvent} so plugin listeners can
+ * inspect what the client intends to consume.
+ */
+ private static Item[] collectCraftingInput(Player player) {
+ Inventory top = player.getTopWindow().orElse(null);
+ CraftingGrid grid = top instanceof CraftingGrid openGrid
+ ? openGrid
+ : player.getUIInventory().getCraftingGrid();
+ int size = grid.getSize();
+ List- items = new ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ Item item = grid.getItem(i);
+ if (item != null && !item.isNull()) {
+ items.add(item.clone());
+ }
+ }
+ return items.toArray(Item.EMPTY_ARRAY);
+ }
+
+ /**
+ * Handles Smithing "transform" recipes (e.g. Netherite upgrade). The result
+ * inherits the original equipment's NBT (enchantments, custom name,
+ * durability) while switching to the recipe's result item type.
+ */
+ private ActionResponse handleSmithingUpgrade(SmithingTransformRecipe recipe, Player player, ItemStackRequestContext context) {
+ Inventory inventory = player.getTopWindow().orElse(null);
+ if (!(inventory instanceof SmithingInventory smithingInventory)) {
+ return context.error();
+ }
+ Item equipment = smithingInventory.getEquipment();
+ Item result = recipe.getResult().clone();
+ if (equipment != null && !equipment.isNull() && equipment.hasCompoundTag()) {
+ result.setCompoundTag(equipment.getCompoundTag());
+ }
+ if (!fireSmithingEvent(smithingInventory, result, player)) {
+ return context.error();
+ }
+ result.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
+ return buildCreatedOutputResponse(context, result);
+ }
+
+ /**
+ * Handles Smithing "trim" recipes: applies a cosmetic trim (pattern +
+ * material) to armor. Reuses {@link SmithingInventory#getTrimOutPutItem()}
+ * which already implements the vanilla trim NBT composition.
+ */
+ private ActionResponse handleSmithingTrim(Player player, ItemStackRequestContext context) {
+ Inventory inventory = player.getTopWindow().orElse(null);
+ if (!(inventory instanceof SmithingInventory smithingInventory)) {
+ return context.error();
+ }
+ Item result = smithingInventory.getTrimOutPutItem();
+ if (result == null || result.isNull()) {
+ return context.error();
+ }
+ if (!fireSmithingEvent(smithingInventory, result, player)) {
+ return context.error();
+ }
+ result.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
+ return buildCreatedOutputResponse(context, result);
+ }
+
+ /**
+ * Mirror {@code SmithingTransaction.execute()}: plugins receive the full set
+ * of input slots + projected output so they can veto smithing-table usage.
+ * Returns {@code false} when the event is cancelled.
+ */
+ private static boolean fireSmithingEvent(SmithingInventory inventory, Item result, Player player) {
+ SmithingTableEvent event = new SmithingTableEvent(
+ inventory,
+ inventory.getEquipment().clone(),
+ result.clone(),
+ inventory.getIngredient().clone(),
+ inventory.getTemplate().clone(),
+ player
+ );
+ Server.getInstance().getPluginManager().callEvent(event);
+ return !event.isCancelled();
+ }
+
+ /**
+ * Resolve the stonecutter recipe against the currently open
+ * {@link StonecutterInventory} and fire {@link StonecutterItemEvent}.
+ * Mirrors the legacy {@code StonecutterTransaction} flow — in particular it
+ * does NOT fire {@link CraftItemEvent}, so plugins listening only to
+ * StonecutterItemEvent behave the same as before.
+ */
+ private ActionResponse handleStonecutter(Player player, StonecutterRecipe recipe, CraftRecipeAction action, ItemStackRequestContext context) {
+ Inventory top = player.getTopWindow().orElse(null);
+ if (!(top instanceof StonecutterInventory stonecutterInventory)) {
+ return context.error();
+ }
+ Item input = stonecutterInventory.getInput().clone();
+ if (input.isNull()) {
+ return context.error();
+ }
+ int times = Math.max(1, action.getNumberOfRequestedCrafts());
+ Item output = recipe.getResult();
+ output.setCount(output.getCount() * times);
+
+ StonecutterItemEvent event = new StonecutterItemEvent(stonecutterInventory, input, output.clone(), player);
+ Server.getInstance().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ return context.error();
+ }
+
+ context.put(CreateActionProcessor.RECIPE_DATA_KEY, recipe);
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+ return buildCreatedOutputResponse(context, output);
+ }
+
+ private ActionResponse buildCreatedOutputResponse(ItemStackRequestContext context, Item output) {
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
new file mode 100644
index 000000000..c850c2847
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
@@ -0,0 +1,112 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.event.inventory.CraftItemEvent;
+import cn.nukkit.inventory.MultiRecipe;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.inventory.Recipe;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.descriptor.ItemDescriptorWithCount;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.AutoCraftRecipeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ConsumeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Auto-craft variant of CraftRecipeAction (triggered by shift-click on recipe
+ * book). Unlike CRAFT_RECIPE, the client ships the concrete ingredient
+ * descriptors it intends to consume, so the server validates against the
+ * resolved recipe and the follow-up CONSUME chain.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public class CraftRecipeAutoProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String TIMES_CRAFTED_KEY = "timesCrafted";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_RECIPE_AUTO;
+ }
+
+ @Override
+ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemStackRequestContext context) {
+ Recipe recipe = player.getServer().getCraftingManager().getRecipeByNetworkId(action.getRecipeNetworkId());
+ if (recipe == null) {
+ return context.error();
+ }
+
+ List ingredients = action.getIngredients();
+ if (ingredients == null) {
+ ingredients = List.of();
+ }
+ Item[] eventItems = ingredients.stream()
+ .map(i -> i.getDescriptor().toItem())
+ .toArray(Item[]::new);
+
+ CraftItemEvent craftItemEvent = new CraftItemEvent(player, eventItems, recipe);
+ player.getServer().getPluginManager().callEvent(craftItemEvent);
+ if (craftItemEvent.isCancelled()) {
+ return context.error();
+ }
+
+ context.put(CraftRecipeActionProcessor.RECIPE_NET_ID_KEY, action.getRecipeNetworkId());
+ context.put(CreateActionProcessor.RECIPE_DATA_KEY, recipe);
+ context.put(TIMES_CRAFTED_KEY, action.getTimesCrafted());
+
+ int consumeActionCountNeeded = 0;
+ for (ItemDescriptorWithCount ingredient : ingredients) {
+ if (ingredient != null && ingredient.getCount() > 0) {
+ consumeActionCountNeeded++;
+ }
+ }
+ List consumeActions = findAllConsumeActions(
+ context.getItemStackRequest().getActions(),
+ context.getCurrentActionIndex() + 1);
+ if (consumeActions.size() < consumeActionCountNeeded) {
+ log.warn("{}: auto-craft consume action count mismatch. expected={} actual={}",
+ player.getName(), consumeActionCountNeeded, consumeActions.size());
+ return context.error();
+ }
+
+ Item recipeResult = recipe instanceof MultiRecipe multi ? multi.getResult() : recipe.getResult();
+ if (recipeResult == null || recipeResult.isNull()) {
+ return null;
+ }
+ int times = Math.max(1, action.getTimesCrafted());
+ Item output = recipeResult.clone();
+ output.setCount(output.getCount() * times);
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+
+ static List findAllConsumeActions(ItemStackRequestAction[] actions, int startIndex) {
+ List found = new ArrayList<>();
+ for (int i = startIndex; i < actions.length; i++) {
+ if (actions[i] instanceof ConsumeAction consume) {
+ found.add(consume);
+ }
+ }
+ return found;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
new file mode 100644
index 000000000..a7b3e823f
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
@@ -0,0 +1,433 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.Server;
+import cn.nukkit.block.Block;
+import cn.nukkit.event.block.AnvilDamageEvent;
+import cn.nukkit.event.block.AnvilDamageEvent.DamageCause;
+import cn.nukkit.event.inventory.RepairItemEvent;
+import cn.nukkit.inventory.*;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.enchantment.Enchantment;
+import cn.nukkit.level.Sound;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.network.protocol.LevelEventPacket;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftRecipeOptionalAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.stream.Stream;
+
+/**
+ * Handles anvil rename/merge/repair and cartography combine operations via the
+ * Server Authoritative "optional" craft recipe action. Replicates Vanilla anvil
+ * cost accounting from the legacy RepairItemTransaction.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public class CraftRecipeOptionalProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String ANVIL_FILTER_INDEX_KEY = "anvilFilterIndex";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_RECIPE_OPTIONAL;
+ }
+
+ @Override
+ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, ItemStackRequestContext context) {
+ Optional topWindow = player.getTopWindow();
+ if (topWindow.isEmpty()) {
+ return context.error();
+ }
+ Inventory inventory = topWindow.get();
+ context.put(ANVIL_FILTER_INDEX_KEY, action.getFilteredStringIndex());
+
+ ItemStackRequest request = context.getItemStackRequest();
+ String filterString = null;
+ String[] filterStrings = request.getFilterStrings();
+ if (filterStrings != null && filterStrings.length > 0) {
+ int index = action.getFilteredStringIndex();
+ if (index >= 0 && index < filterStrings.length) {
+ String candidate = filterStrings[index];
+ if (candidate != null && !candidate.isBlank()) {
+ if (candidate.length() > 64) {
+ return context.error();
+ }
+ filterString = candidate;
+ }
+ }
+ }
+
+ Item result;
+ int levelCost = 0;
+ if (inventory instanceof AnvilInventory anvilInventory) {
+ AnvilResult pair = updateAnvilResult(player, anvilInventory, filterString);
+ if (pair == null || pair.result.isNull()) {
+ return context.error();
+ }
+ result = pair.result;
+ levelCost = pair.levelCost;
+
+ // Mirror legacy RepairItemTransaction: fire RepairItemEvent before any
+ // state mutation so plugins can veto the anvil operation or override
+ // the xp cost via event.setCost(). The event.getCost() result feeds
+ // both exp deduction and the cost-check gate below.
+ Item inputSnapshot = anvilInventory.getInputSlot().clone();
+ Item materialSnapshot = anvilInventory.getMaterialSlot().clone();
+ RepairItemEvent repairEvent = new RepairItemEvent(
+ anvilInventory, inputSnapshot, result.clone(), materialSnapshot, levelCost, player);
+ Server.getInstance().getPluginManager().callEvent(repairEvent);
+ if (repairEvent.isCancelled()) {
+ return context.error();
+ }
+ int finalCost = repairEvent.getCost();
+ if (!player.isCreative() && finalCost > 0) {
+ if (player.getExperienceLevel() < finalCost) {
+ return context.error();
+ }
+ player.setExperience(player.getExperience(), player.getExperienceLevel() - finalCost);
+ }
+
+ applyAnvilDamage(player, anvilInventory);
+ } else if (inventory instanceof CartographyTableInventory cartographyInventory) {
+ result = updateCartographyTableResult(cartographyInventory, filterString);
+ if (result == null || result.isNull()) {
+ return context.error();
+ }
+ } else {
+ return context.error();
+ }
+
+ result.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, result.getCount(), result.getStackNetId(),
+ result.hasCustomName() ? result.getCustomName() : "",
+ result.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+
+ private record AnvilResult(Item result, int levelCost) {}
+
+ /**
+ * Apply the vanilla 12% chance that an anvil loses one durability level on
+ * use. At level 3 the block is destroyed outright. AnvilDamageEvent is fired
+ * so plugins can override or veto the durability change.
+ * Mirrors {@code RepairItemTransaction.execute()} from the legacy path.
+ */
+ private static void applyAnvilDamage(Player player, AnvilInventory anvilInventory) {
+ FakeBlockMenu holder = anvilInventory.getHolder();
+ if (holder == null) {
+ return;
+ }
+ Block block = player.getLevel().getBlock(holder.getFloorX(), holder.getFloorY(), holder.getFloorZ());
+ if (block.getId() != Block.ANVIL) {
+ return;
+ }
+
+ int oldDamage = block.getDamage() >= 8 ? 2 : block.getDamage() >= 4 ? 1 : 0;
+ int newDamage = !player.isCreative() && ThreadLocalRandom.current().nextInt(100) < 12
+ ? oldDamage + 1
+ : oldDamage;
+
+ AnvilDamageEvent event = new AnvilDamageEvent(block, oldDamage, newDamage, DamageCause.USE, player);
+ event.setCancelled(oldDamage == newDamage);
+ Server.getInstance().getPluginManager().callEvent(event);
+
+ if (event.isCancelled()) {
+ player.getLevel().addLevelEvent(block, LevelEventPacket.EVENT_SOUND_ANVIL_USE);
+ return;
+ }
+
+ int finalDamage = event.getNewDamage();
+ if (finalDamage > 2) {
+ player.getLevel().setBlock(block, Block.get(Block.AIR), true);
+ player.getLevel().addLevelEvent(block, LevelEventPacket.EVENT_SOUND_ANVIL_BREAK);
+ } else {
+ if (finalDamage < 0) {
+ finalDamage = 0;
+ }
+ if (finalDamage != oldDamage) {
+ block.setDamage((finalDamage << 2) | (block.getDamage() & 0x3));
+ player.getLevel().setBlock(block, block, true);
+ }
+ player.getLevel().addLevelEvent(block, LevelEventPacket.EVENT_SOUND_ANVIL_USE);
+ }
+ }
+
+ private AnvilResult updateAnvilResult(Player player, AnvilInventory inventory, String filterString) {
+ Item target = inventory.getInputSlot();
+ Item sacrifice = inventory.getMaterialSlot();
+ if (target.isNull() && sacrifice.isNull()) {
+ return null;
+ }
+
+ int extraCost = 0;
+ int costHelper = 0;
+ int repairMaterial = getRepairMaterial(target);
+ Item result = target.clone();
+
+ Set enchantments = new LinkedHashSet<>(Arrays.asList(target.getEnchantments()));
+ if (!sacrifice.isNull()) {
+ boolean enchantedBook = sacrifice.getId() == Item.ENCHANTED_BOOK && sacrifice.getEnchantments().length > 0;
+ int repair;
+ int repair2;
+ int repair3;
+ if (result.getMaxDurability() != -1 && sacrifice.getId() == repairMaterial) {
+ // Anvil - repair via material
+ repair = Math.min(result.getDamage(), result.getMaxDurability() / 4);
+ if (repair <= 0) {
+ return null;
+ }
+ for (repair2 = 0; repair > 0 && repair2 < sacrifice.getCount(); ++repair2) {
+ repair3 = result.getDamage() - repair;
+ result.setDamage(repair3);
+ ++extraCost;
+ repair = Math.min(result.getDamage(), result.getMaxDurability() / 4);
+ }
+ } else {
+ if (!enchantedBook && (result.getId() != sacrifice.getId() || result.getMaxDurability() == -1)) {
+ player.getLevel().addSound(player, Sound.RANDOM_ANVIL_USE, 1f, 1f);
+ return null;
+ }
+
+ if (result.getMaxDurability() != -1 && !enchantedBook) {
+ // Anvil - combine durability from same-type item
+ repair = target.getMaxDurability() - target.getDamage();
+ repair2 = sacrifice.getMaxDurability() - sacrifice.getDamage();
+ repair3 = repair2 + result.getMaxDurability() * 12 / 100;
+ int totalRepair = repair + repair3;
+ int finalDamage = result.getMaxDurability() - totalRepair + 1;
+ if (finalDamage < 0) {
+ finalDamage = 0;
+ }
+ if (finalDamage < result.getDamage()) {
+ result.setDamage(finalDamage);
+ extraCost += 2;
+ }
+ }
+
+ Enchantment[] sacrificeEnchantments = sacrifice.getEnchantments();
+ boolean compatibleFlag = false;
+ boolean incompatibleFlag = false;
+ Iterator it = Arrays.stream(sacrificeEnchantments).iterator();
+
+ iter:
+ while (true) {
+ Enchantment sacrificeEnch;
+ do {
+ if (!it.hasNext()) {
+ if (incompatibleFlag && !compatibleFlag) {
+ return null;
+ }
+ break iter;
+ }
+ sacrificeEnch = it.next();
+ } while (sacrificeEnch == null);
+
+ Enchantment resultEnch = result.getEnchantment(sacrificeEnch.id);
+ int targetLevel = resultEnch != null ? resultEnch.getLevel() : 0;
+ int resultLevel = sacrificeEnch.getLevel();
+ resultLevel = targetLevel == resultLevel ? resultLevel + 1 : Math.max(resultLevel, targetLevel);
+ boolean compatible = sacrificeEnch.canEnchant(target);
+ if (player.isCreative() || target.getId() == Item.ENCHANTED_BOOK) {
+ compatible = true;
+ }
+
+ Iterator targetIt = Stream.of(target.getEnchantments()).iterator();
+ while (targetIt.hasNext()) {
+ Enchantment targetEnch = targetIt.next();
+ if (targetEnch.id != sacrificeEnch.id
+ && (!sacrificeEnch.isCompatibleWith(targetEnch)
+ || !targetEnch.isCompatibleWith(sacrificeEnch))) {
+ compatible = false;
+ ++extraCost;
+ }
+ }
+
+ if (!compatible) {
+ incompatibleFlag = true;
+ } else {
+ compatibleFlag = true;
+ if (resultLevel > sacrificeEnch.getMaxLevel()) {
+ resultLevel = sacrificeEnch.getMaxLevel();
+ }
+ Enchantment used = Enchantment.getEnchantment(sacrificeEnch.getId()).setLevel(resultLevel);
+ enchantments.add(used);
+ int rarity;
+ int weight = sacrificeEnch.getRarity().getWeight();
+ if (weight >= 10) {
+ rarity = 1;
+ } else if (weight >= 5) {
+ rarity = 2;
+ } else if (weight >= 2) {
+ rarity = 4;
+ } else {
+ rarity = 8;
+ }
+ if (enchantedBook) {
+ rarity = Math.max(1, rarity / 2);
+ }
+ extraCost += rarity * Math.max(0, resultLevel - targetLevel);
+ if (target.getCount() > 1) {
+ extraCost = 40;
+ }
+ }
+ }
+ }
+ }
+
+ // Anvil - rename
+ if (filterString == null || filterString.isEmpty()) {
+ player.getLevel().addSound(player, Sound.RANDOM_ANVIL_USE, 1f, 1f);
+ if (target.hasCustomName()) {
+ costHelper = 1;
+ extraCost += costHelper;
+ result.clearCustomName();
+ }
+ } else {
+ if (filterString.length() > 50) {
+ return null;
+ }
+ costHelper = 1;
+ extraCost += costHelper;
+ result.setCustomName(filterString);
+ }
+
+ int levelCost = getRepairCost(result) + (sacrifice.isNull() ? 0 : getRepairCost(sacrifice));
+ levelCost += extraCost;
+ if (extraCost <= 0) {
+ return new AnvilResult(Item.get(Item.AIR), levelCost);
+ }
+
+ if (costHelper == extraCost && costHelper > 0 && levelCost >= 40) {
+ levelCost = 39;
+ }
+ if (levelCost >= 40 && !player.isCreative()) {
+ return new AnvilResult(Item.get(Item.AIR), levelCost);
+ }
+
+ int repairCost = getRepairCost(result);
+ if (!sacrifice.isNull() && repairCost < getRepairCost(sacrifice)) {
+ repairCost = getRepairCost(sacrifice);
+ }
+ if (costHelper != extraCost || costHelper == 0) {
+ repairCost = repairCost * 2 + 1;
+ }
+ CompoundTag namedTag = result.hasCompoundTag() ? result.getNamedTag() : new CompoundTag();
+ namedTag.putInt("RepairCost", repairCost);
+ namedTag.remove("ench");
+ result.setNamedTag(namedTag);
+ if (!enchantments.isEmpty()) {
+ result.addEnchantment(enchantments.toArray(Enchantment.EMPTY_ARRAY));
+ }
+ return new AnvilResult(result, levelCost);
+ }
+
+ private Item updateCartographyTableResult(CartographyTableInventory inventory, String filterString) {
+ Item input = inventory.getInput();
+ Item additional = inventory.getAdditional();
+ if (input.isNull() && additional.isNull()) {
+ return null;
+ }
+
+ Item result = null;
+ int inputId = input.getId();
+ int addId = additional.getId();
+
+ // PAPER alone → EMPTY_MAP
+ if (inputId == Item.PAPER && additional.isNull()) {
+ result = Item.get(Item.EMPTY_MAP);
+ }
+ // Blank/filled map alone → clone (acts as a reset/copy slot)
+ if (result == null && (inputId == Item.EMPTY_MAP || inputId == Item.MAP) && additional.isNull()) {
+ result = input.clone();
+ }
+ // + COMPASS → locator map (damage = 2)
+ if (result == null && (inputId == Item.EMPTY_MAP || inputId == Item.MAP || inputId == Item.PAPER)
+ && addId == Item.COMPASS) {
+ Item base = inputId == Item.PAPER ? Item.get(Item.EMPTY_MAP) : input.clone();
+ base.setDamage(2);
+ result = base;
+ }
+ // + GLASS_PANE → lock map (damage = 6)
+ if (result == null && inputId == Item.MAP && addId == Item.GLASS_PANE) {
+ Item base = input.clone();
+ base.setDamage(6);
+ result = base;
+ }
+ // + EMPTY_MAP → copy
+ if (result == null && inputId == Item.MAP && addId == Item.EMPTY_MAP) {
+ Item base = input.clone();
+ base.setCount(2);
+ result = base;
+ }
+ // + PAPER → scale up (map scaling logic requires ItemFilledMap; fall back to damage bump)
+ if (result == null && inputId == Item.MAP && addId == Item.PAPER) {
+ Item base = input.clone();
+ int scale = Math.min(4, base.getDamage() + 1);
+ base.setDamage(scale);
+ result = base;
+ }
+
+ if (result == null) {
+ return null;
+ }
+ if (filterString != null && !filterString.isEmpty()) {
+ if (filterString.length() > 50) {
+ return null;
+ }
+ result.setCustomName(filterString);
+ } else {
+ result.clearCustomName();
+ }
+ return result;
+ }
+
+ private static int getRepairCost(Item item) {
+ return item.hasCompoundTag() && Objects.requireNonNull(item.getNamedTag()).contains("RepairCost")
+ ? item.getNamedTag().getInt("RepairCost")
+ : 0;
+ }
+
+ private static int getRepairMaterial(Item target) {
+ return switch (target.getId()) {
+ case Item.WOODEN_SWORD, Item.WOODEN_PICKAXE, Item.WOODEN_SHOVEL, Item.WOODEN_AXE, Item.WOODEN_HOE ->
+ Item.PLANKS;
+ case Item.IRON_SWORD, Item.IRON_PICKAXE, Item.IRON_SHOVEL, Item.IRON_AXE, Item.IRON_HOE,
+ Item.IRON_HELMET, Item.IRON_CHESTPLATE, Item.IRON_LEGGINGS, Item.IRON_BOOTS,
+ Item.CHAIN_HELMET, Item.CHAIN_CHESTPLATE, Item.CHAIN_LEGGINGS, Item.CHAIN_BOOTS ->
+ Item.IRON_INGOT;
+ case Item.GOLD_SWORD, Item.GOLD_PICKAXE, Item.GOLD_SHOVEL, Item.GOLD_AXE, Item.GOLD_HOE,
+ Item.GOLD_HELMET, Item.GOLD_CHESTPLATE, Item.GOLD_LEGGINGS, Item.GOLD_BOOTS ->
+ Item.GOLD_INGOT;
+ case Item.DIAMOND_SWORD, Item.DIAMOND_PICKAXE, Item.DIAMOND_SHOVEL, Item.DIAMOND_AXE, Item.DIAMOND_HOE,
+ Item.DIAMOND_HELMET, Item.DIAMOND_CHESTPLATE, Item.DIAMOND_LEGGINGS, Item.DIAMOND_BOOTS ->
+ Item.DIAMOND;
+ case Item.LEATHER_CAP, Item.LEATHER_TUNIC, Item.LEATHER_PANTS, Item.LEATHER_BOOTS ->
+ Item.LEATHER;
+ case Item.STONE_SWORD, Item.STONE_PICKAXE, Item.STONE_SHOVEL, Item.STONE_AXE, Item.STONE_HOE ->
+ Item.COBBLESTONE;
+ case Item.NETHERITE_SWORD, Item.NETHERITE_PICKAXE, Item.NETHERITE_SHOVEL, Item.NETHERITE_AXE, Item.NETHERITE_HOE,
+ Item.NETHERITE_HELMET, Item.NETHERITE_CHESTPLATE, Item.NETHERITE_LEGGINGS, Item.NETHERITE_BOOTS ->
+ Item.NETHERITE_INGOT;
+ case Item.ELYTRA -> Item.PHANTOM_MEMBRANE;
+ default -> Item.AIR;
+ };
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
new file mode 100644
index 000000000..c84395e6a
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
@@ -0,0 +1,53 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.MultiRecipe;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.inventory.Recipe;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+
+import java.util.List;
+
+/**
+ * Legacy craft results pathway. Modern clients still emit this for multi-output
+ * recipes so the output is known before DESTROY actions run. We also use it to
+ * suppress the default creative-destroy response for the subsequent DESTROY.
+ */
+public class CraftResultDeprecatedActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CRAFT_RESULTS_DEPRECATED;
+ }
+
+ @Override
+ public ActionResponse handle(CraftResultsDeprecatedAction action, Player player, ItemStackRequestContext context) {
+ Recipe recipe = context.get(CreateActionProcessor.RECIPE_DATA_KEY);
+ if (recipe instanceof MultiRecipe) {
+ Item[] results = action.getResultItems();
+ if (results != null && results.length > 0) {
+ Item output = results[0].clone();
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+ ItemStackResponseSlot slot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(slot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+ }
+ context.put(DestroyActionProcessor.NO_RESPONSE_DESTROY_KEY, true);
+ return null;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
new file mode 100644
index 000000000..d291c87b1
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
@@ -0,0 +1,77 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.MultiRecipe;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.inventory.Recipe;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftRecipeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CreateAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * CreateAction is sent by the client for multi-output recipes to pick a specific
+ * result slot from the recipe's outputs (typically firework stars, banner
+ * patterns). Resolves the recipe cached by CraftRecipeAction and writes the
+ * chosen result into CREATED_OUTPUT.
+ */
+public class CreateActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String RECIPE_DATA_KEY = "recipe";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.CREATE;
+ }
+
+ @Override
+ public ActionResponse handle(CreateAction action, Player player, ItemStackRequestContext context) {
+ Recipe recipe = context.get(RECIPE_DATA_KEY);
+ if (recipe == null) {
+ // Look back through the request for a CraftRecipeAction
+ Optional cra = Arrays.stream(context.getItemStackRequest().getActions())
+ .filter(a -> a instanceof CraftRecipeAction)
+ .findFirst();
+ if (cra.isEmpty()) {
+ return context.error();
+ }
+ int recipeNetId = ((CraftRecipeAction) cra.get()).getRecipeNetworkId();
+ recipe = player.getServer().getCraftingManager().getRecipeByNetworkId(recipeNetId);
+ if (recipe == null) {
+ return context.error();
+ }
+ }
+
+ List- results = recipe instanceof MultiRecipe multi
+ ? List.of(multi.getResult())
+ : List.of(recipe.getResult());
+ int slot = action.getSlot();
+ if (slot < 0 || slot >= results.size()) {
+ return context.error();
+ }
+
+ Item output = results.get(slot).clone();
+ output.autoAssignStackNetworkId();
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ 0, 0, output.getCount(), output.getStackNetId(),
+ output.hasCustomName() ? output.getCustomName() : "",
+ output.getDamage(), ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.CREATED_OUTPUT,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
new file mode 100644
index 000000000..913283b3f
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
@@ -0,0 +1,64 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.BeaconInventory;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.DestroyAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+
+import java.util.List;
+
+public class DestroyActionProcessor implements ItemStackRequestActionProcessor {
+
+ public static final String NO_RESPONSE_DESTROY_KEY = "noResponseForDestroyAction";
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.DESTROY;
+ }
+
+ @Override
+ public ActionResponse handle(DestroyAction action, Player player, ItemStackRequestContext context) {
+ Boolean suppress = context.get(NO_RESPONSE_DESTROY_KEY);
+ if (suppress != null && suppress) {
+ return null;
+ }
+
+ ItemStackRequestSlotData src = action.getSource();
+ Inventory inventory = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId());
+ if (inventory == null) {
+ return context.error();
+ }
+ if (!player.isCreative() && !(inventory instanceof BeaconInventory)) {
+ return context.error();
+ }
+
+ int slot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
+ int count = action.getCount();
+ if (count <= 0) {
+ return context.error();
+ }
+
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < count) {
+ return context.error();
+ }
+ if (validateStackNetworkId(item.getStackNetId(), src.getStackNetworkId())) {
+ return context.error();
+ }
+
+ if (item.getCount() == count) {
+ inventory.clear(slot, false);
+ } else {
+ Item remaining = item.clone();
+ remaining.setCount(item.getCount() - count);
+ inventory.setItem(slot, remaining, false);
+ }
+
+ ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src);
+ return context.success(List.of(container));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
new file mode 100644
index 000000000..0d0d38680
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
@@ -0,0 +1,65 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.event.player.PlayerDropItemEvent;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.DropAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+
+import java.util.List;
+
+public class DropActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.DROP;
+ }
+
+ @Override
+ public ActionResponse handle(DropAction action, Player player, ItemStackRequestContext context) {
+ ItemStackRequestSlotData src = action.getSource();
+ Inventory inventory = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId());
+ if (inventory == null) {
+ return context.error();
+ }
+
+ int slot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
+ int count = action.getCount();
+ if (count <= 0) {
+ return context.error();
+ }
+
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < count) {
+ return context.error();
+ }
+ if (validateStackNetworkId(item.getStackNetId(), src.getStackNetworkId())) {
+ return context.error();
+ }
+
+ Item dropItem = item.clone();
+ dropItem.setCount(count);
+
+ PlayerDropItemEvent event = new PlayerDropItemEvent(player, dropItem);
+ player.getServer().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ return context.error();
+ }
+
+ if (item.getCount() == count) {
+ inventory.clear(slot, false);
+ } else {
+ Item remaining = item.clone();
+ remaining.setCount(item.getCount() - count);
+ inventory.setItem(slot, remaining, false);
+ }
+
+ player.dropItem(dropItem);
+
+ ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src);
+ return context.success(List.of(container));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
new file mode 100644
index 000000000..440427bd7
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
@@ -0,0 +1,75 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.InventoryHolder;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+public final class InventoryObserverSync {
+
+ private InventoryObserverSync() {
+ }
+
+ public static void syncOtherViewers(Player actor, Inventory inventory) {
+ if (inventory == null) {
+ return;
+ }
+
+ Set viewers = inventory.getViewers();
+ if (viewers == null || viewers.isEmpty()) {
+ return;
+ }
+
+ LinkedHashSet observers = new LinkedHashSet<>();
+ for (Player viewer : viewers) {
+ if (viewer != null && viewer != actor) {
+ observers.add(viewer);
+ }
+ }
+
+ if (!observers.isEmpty()) {
+ inventory.sendContents(observers);
+ }
+ }
+
+ /**
+ * Resend a single slot of an inventory to every player who can observe it:
+ * the actor, the inventory holder (if a Player), and all current viewers.
+ * Used to propagate bundle content changes up to the outer inventory slot so
+ * the freshly serialised storage_item_component_content NBT reaches every
+ * client that sees the bundle item.
+ */
+ public static void resendOuterSlot(Player actor, Inventory outer, int slot) {
+ if (outer == null) {
+ return;
+ }
+ if (slot < 0 || slot >= outer.getSize()) {
+ return;
+ }
+
+ LinkedHashSet targets = new LinkedHashSet<>();
+ if (actor != null) {
+ targets.add(actor);
+ }
+
+ InventoryHolder holder = outer.getHolder();
+ if (holder instanceof Player holderPlayer) {
+ targets.add(holderPlayer);
+ }
+
+ Set viewers = outer.getViewers();
+ if (viewers != null) {
+ for (Player viewer : viewers) {
+ if (viewer != null) {
+ targets.add(viewer);
+ }
+ }
+ }
+
+ if (!targets.isEmpty()) {
+ outer.sendSlot(slot, targets);
+ }
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestActionProcessor.java
new file mode 100644
index 000000000..c145463b4
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestActionProcessor.java
@@ -0,0 +1,30 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import org.jetbrains.annotations.Nullable;
+
+public interface ItemStackRequestActionProcessor {
+
+ ItemStackRequestActionType getType();
+
+ @Nullable
+ ActionResponse handle(T action, Player player, ItemStackRequestContext context);
+
+ /**
+ * Validate that the client-reported stack network id matches the server's
+ * current id for that slot. Returns {@code true} when a mismatch is detected
+ * (i.e. the caller should reject the action).
+ *
+ * Either side being non-positive means "no id to compare against" and skips
+ * validation: {@code serverNetId <= 0} when the server has not allocated a
+ * stackNetId for that slot yet (e.g. items freshly loaded from NBT or
+ * produced by the legacy InventoryTransaction path), and {@code clientNetId
+ * <= 0} when the client defers to server state (typically for follow-up
+ * actions in a chain that share the same slot).
+ */
+ default boolean validateStackNetworkId(int serverNetId, int clientNetId) {
+ return serverNetId > 0 && clientNetId > 0 && serverNetId != clientNetId;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
new file mode 100644
index 000000000..cef5063dd
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
@@ -0,0 +1,56 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Per-request processing context shared across all action processors of a single
+ * ItemStackRequest. Carries the original request, the current action index and an
+ * opaque scratchpad for cross-action state (e.g. the recipe resolved by
+ * CraftRecipeAction that CreateAction / CraftResultsDeprecated will consume).
+ */
+public class ItemStackRequestContext {
+
+ @Getter
+ private final ItemStackRequest itemStackRequest;
+ @Getter
+ @Setter
+ private int currentActionIndex;
+ private final Map extraData = new HashMap<>();
+
+ public ItemStackRequestContext(ItemStackRequest itemStackRequest) {
+ this.itemStackRequest = itemStackRequest;
+ }
+
+ public void put(String key, Object value) {
+ extraData.put(key, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T get(String key) {
+ return (T) extraData.get(key);
+ }
+
+ public boolean has(String key) {
+ return extraData.containsKey(key);
+ }
+
+ public ActionResponse error() {
+ return ActionResponse.error();
+ }
+
+ public ActionResponse success(List containers) {
+ return ActionResponse.ok(containers);
+ }
+
+ public ActionResponse success() {
+ return ActionResponse.ok(Collections.emptyList());
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
new file mode 100644
index 000000000..6c6545315
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
@@ -0,0 +1,250 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.ItemBundle;
+import cn.nukkit.network.protocol.ItemStackResponsePacket;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.*;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponse;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseStatus;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.*;
+
+/**
+ * Central dispatcher for incoming ItemStackRequest payloads. Iterates the action
+ * chain in each request, delegates each action to its registered processor, and
+ * emits a single ItemStackResponsePacket summarising success/failure per request.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public final class ItemStackRequestHandler {
+
+ private static final EnumMap> PROCESSORS =
+ new EnumMap<>(ItemStackRequestActionType.class);
+
+ static {
+ register(new TakeActionProcessor());
+ register(new PlaceActionProcessor());
+ register(new SwapActionProcessor());
+ register(new DropActionProcessor());
+ register(new DestroyActionProcessor());
+ register(new ConsumeActionProcessor());
+ register(new CreateActionProcessor());
+ register(new CraftRecipeActionProcessor());
+ register(new CraftRecipeAutoProcessor());
+ register(new CraftCreativeActionProcessor());
+ register(new CraftRecipeOptionalProcessor());
+ register(new CraftGrindstoneActionProcessor());
+ register(new CraftLoomActionProcessor());
+ register(new CraftResultDeprecatedActionProcessor());
+ register(new CraftNonImplementedActionProcessor());
+ register(new MineBlockActionProcessor());
+ register(new LabTableCombineActionProcessor());
+ register(new BeaconPaymentActionProcessor());
+ }
+
+ public static void register(ItemStackRequestActionProcessor> processor) {
+ PROCESSORS.put(processor.getType(), processor);
+ }
+
+ private ItemStackRequestHandler() {
+ }
+
+ @SuppressWarnings("unchecked")
+ public static void handleRequests(Player player, List requests) {
+ List responses = new ArrayList<>();
+
+ for (ItemStackRequest request : requests) {
+ ItemStackRequestAction[] actions = request.getActions();
+ ItemStackRequestContext context = new ItemStackRequestContext(request);
+ List responseContainers = new ArrayList<>();
+ Set affectedInventories = new LinkedHashSet<>();
+ Set affectedBundleOuters = new LinkedHashSet<>();
+ boolean error = false;
+
+ if (log.isInfoEnabled()) {
+ StringBuilder types = new StringBuilder();
+ for (int i = 0; i < actions.length; i++) {
+ if (i > 0) types.append(',');
+ types.append(actions[i].getType());
+ }
+ log.info("{}: handling item stack request id={} actions=[{}]",
+ player.getName(), request.getRequestId(), types);
+ }
+
+ for (int i = 0; i < actions.length; i++) {
+ ItemStackRequestAction action = actions[i];
+ context.setCurrentActionIndex(i);
+ affectedInventories.addAll(resolveAffectedInventories(player, action));
+ affectedBundleOuters.addAll(resolveAffectedBundleOuters(player, action));
+
+ ItemStackRequestActionProcessor processor =
+ (ItemStackRequestActionProcessor) PROCESSORS.get(action.getType());
+
+ if (processor == null) {
+ log.warn("{}: unhandled item stack request action {}", player.getName(), action.getType());
+ error = true;
+ break;
+ }
+
+ try {
+ ActionResponse response = processor.handle(action, player, context);
+ if (response == null) {
+ continue;
+ }
+ if (!response.success()) {
+ error = true;
+ break;
+ }
+ responseContainers.addAll(response.containers());
+ } catch (Exception e) {
+ log.error("{}: error processing item stack request action {}", player.getName(), action.getType(), e);
+ error = true;
+ break;
+ }
+ }
+
+ syncAffectedInventories(player, affectedInventories);
+ syncAffectedBundleOuters(player, affectedBundleOuters);
+ ItemStackResponseStatus status = error ? ItemStackResponseStatus.ERROR : ItemStackResponseStatus.OK;
+ responses.add(new ItemStackResponse(
+ status,
+ request.getRequestId(),
+ error ? List.of() : compactContainers(responseContainers)
+ ));
+ }
+
+ ItemStackResponsePacket packet = new ItemStackResponsePacket();
+ packet.entries.addAll(responses);
+ packet.protocol = player.protocol;
+ packet.gameVersion = player.getGameVersion();
+ player.dataPacket(packet);
+ }
+
+ private static Set resolveAffectedInventories(Player player, ItemStackRequestAction action) {
+ LinkedHashSet affected = new LinkedHashSet<>();
+
+ try {
+ if (action instanceof TransferItemStackRequestAction transfer) {
+ addAffectedInventory(affected, player, transfer.getSource());
+ addAffectedInventory(affected, player, transfer.getDestination());
+ } else if (action instanceof SwapAction swap) {
+ addAffectedInventory(affected, player, swap.getSource());
+ addAffectedInventory(affected, player, swap.getDestination());
+ } else if (action instanceof DropAction drop) {
+ addAffectedInventory(affected, player, drop.getSource());
+ } else if (action instanceof DestroyAction destroy) {
+ addAffectedInventory(affected, player, destroy.getSource());
+ } else if (action instanceof ConsumeAction consume) {
+ addAffectedInventory(affected, player, consume.getSource());
+ }
+ } catch (Throwable t) {
+ log.debug("{}: failed to resolve affected inventories for action {}", player.getName(), action.getType(), t);
+ }
+
+ return affected;
+ }
+
+ private static void addAffectedInventory(Set affected, Player player, ItemStackRequestSlotData slotData) {
+ Inventory inventory = NetworkMapping.getInventory(player, slotData.getContainer(), slotData.getDynamicId());
+ if (inventory != null) {
+ affected.add(inventory);
+ }
+ }
+
+ private static Set resolveAffectedBundleOuters(Player player, ItemStackRequestAction action) {
+ LinkedHashSet refs = new LinkedHashSet<>();
+
+ try {
+ if (action instanceof TransferItemStackRequestAction transfer) {
+ collectBundleOuter(refs, player, transfer.getSource());
+ collectBundleOuter(refs, player, transfer.getDestination());
+ } else if (action instanceof SwapAction swap) {
+ collectBundleOuter(refs, player, swap.getSource());
+ collectBundleOuter(refs, player, swap.getDestination());
+ } else if (action instanceof DropAction drop) {
+ collectBundleOuter(refs, player, drop.getSource());
+ } else if (action instanceof DestroyAction destroy) {
+ collectBundleOuter(refs, player, destroy.getSource());
+ } else if (action instanceof ConsumeAction consume) {
+ collectBundleOuter(refs, player, consume.getSource());
+ }
+ } catch (Throwable t) {
+ log.debug("{}: failed to resolve bundle outer for action {}", player.getName(), action.getType(), t);
+ }
+
+ return refs;
+ }
+
+ private static void collectBundleOuter(Set refs, Player player, ItemStackRequestSlotData slotData) {
+ if (slotData == null || slotData.getContainer() != ContainerSlotType.DYNAMIC_CONTAINER) {
+ return;
+ }
+ Integer dynamicId = slotData.getDynamicId();
+ if (dynamicId == null) {
+ return;
+ }
+ NetworkMapping.BundleHolderRef ref = NetworkMapping.findBundleHolder(player, dynamicId);
+ if (ref != null) {
+ refs.add(ref);
+ }
+ }
+
+ private static void syncAffectedInventories(Player actor, Set inventories) {
+ for (Inventory inventory : inventories) {
+ try {
+ InventoryObserverSync.syncOtherViewers(actor, inventory);
+ } catch (Throwable t) {
+ log.debug("{}: failed to sync observers for inventory {}", actor.getName(), inventory.getClass().getName(), t);
+ }
+ }
+ }
+
+ private static void syncAffectedBundleOuters(Player actor, Set refs) {
+ for (NetworkMapping.BundleHolderRef ref : refs) {
+ try {
+ // Cascade saveNBT from leaf to root so the root bundle's storage NBT
+ // reflects every nested change before the outer slot is re-sent.
+ List chain = ref.chain();
+ for (int i = chain.size() - 1; i >= 0; i--) {
+ chain.get(i).saveNBT();
+ }
+ InventoryObserverSync.resendOuterSlot(actor, ref.outer(), ref.outerSlot());
+ } catch (Throwable t) {
+ log.debug("{}: failed to sync bundle outer slot", actor.getName(), t);
+ }
+ }
+ }
+
+ private static List compactContainers(List containers) {
+ LinkedHashMap> merged = new LinkedHashMap<>();
+
+ for (ItemStackResponseContainer container : containers) {
+ FullContainerName containerName = container.getContainerName() != null
+ ? container.getContainerName()
+ : new FullContainerName(container.getContainer(), null);
+ LinkedHashMap items = merged.computeIfAbsent(containerName, ignored -> new LinkedHashMap<>());
+ for (ItemStackResponseSlot slot : container.getItems()) {
+ items.put(Objects.hash(slot.getSlot(), slot.getHotbarSlot()), slot);
+ }
+ }
+
+ List compacted = new ArrayList<>(merged.size());
+ for (var entry : merged.entrySet()) {
+ compacted.add(new ItemStackResponseContainer(
+ entry.getKey().getContainer(),
+ new ArrayList<>(entry.getValue().values()),
+ entry.getKey()
+ ));
+ }
+ return compacted;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java
new file mode 100644
index 000000000..46e57f6c7
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.LabTableCombineAction;
+
+public class LabTableCombineActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.LAB_TABLE_COMBINE;
+ }
+
+ @Override
+ public ActionResponse handle(LabTableCombineAction action, Player player, ItemStackRequestContext context) {
+ return context.error();
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java
new file mode 100644
index 000000000..826a533d7
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java
@@ -0,0 +1,67 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.PlayerInventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemDurable;
+import cn.nukkit.network.protocol.InventorySlotPacket;
+import cn.nukkit.network.protocol.types.ContainerIds;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.MineBlockAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+
+import java.util.List;
+
+public class MineBlockActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.MINE_BLOCK;
+ }
+
+ @Override
+ public ActionResponse handle(MineBlockAction action, Player player, ItemStackRequestContext context) {
+ PlayerInventory inventory = player.getInventory();
+ int heldItemIndex = inventory.getHeldItemIndex();
+ if (heldItemIndex != action.getHotbarSlot()) {
+ return context.error();
+ }
+
+ Item itemInHand = inventory.getItem(heldItemIndex);
+ if (validateStackNetworkId(itemInHand.getStackNetId(), action.getStackNetworkId())) {
+ return context.error();
+ }
+
+ // Reconcile client prediction with server-side durability. If the client
+ // predicted a different damage value than what the server holds, force the
+ // authoritative item state back to the client so future actions use the
+ // correct netId + damage.
+ if (!itemInHand.isNull() && itemInHand instanceof ItemDurable && action.getPredictedDurability() != 0) {
+ if (itemInHand.getDamage() != action.getPredictedDurability()) {
+ InventorySlotPacket packet = new InventorySlotPacket();
+ packet.inventoryId = ContainerIds.INVENTORY;
+ packet.slot = heldItemIndex;
+ packet.item = itemInHand;
+ packet.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null);
+ player.dataPacket(packet);
+ }
+ }
+
+ ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
+ heldItemIndex, heldItemIndex,
+ itemInHand.isNull() ? 0 : itemInHand.getCount(),
+ itemInHand.getStackNetId(),
+ itemInHand.hasCustomName() ? itemInHand.getCustomName() : "",
+ itemInHand.getDamage(),
+ ""
+ );
+ return context.success(List.of(new ItemStackResponseContainer(
+ ContainerSlotType.HOTBAR_AND_INVENTORY,
+ List.of(responseSlot),
+ new FullContainerName(ContainerSlotType.HOTBAR_AND_INVENTORY, null)
+ )));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
new file mode 100644
index 000000000..dea762d5f
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
@@ -0,0 +1,346 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.entity.Entity;
+import cn.nukkit.inventory.*;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemBundle;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Translate between the network-level {@link ContainerSlotType} / slot index pair
+ * and the server-side {@link Inventory} / internal slot pair. Used throughout the
+ * ItemStackRequest processor chain to resolve action targets.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+public final class NetworkMapping {
+
+ private NetworkMapping() {
+ }
+
+ /**
+ * Resolve the server-side Inventory referenced by a network-level slot type.
+ * The dynamicId parameter is reserved for DYNAMIC_CONTAINER (bundles); callers
+ * may pass null when the action does not carry one.
+ */
+ @Nullable
+ public static Inventory getInventory(Player player, ContainerSlotType type, @Nullable Integer dynamicId) {
+ PlayerUIInventory ui = player.getUIInventory();
+ return switch (type) {
+ case CURSOR -> ui.getCursorInventory();
+ case CREATED_OUTPUT -> ui; // slot 50 of PlayerUIInventory hosts the created output
+ case CRAFTING_INPUT, CRAFTING_OUTPUT -> {
+ // Follow the window the player currently has open (e.g. a crafting
+ // table) so 3x3 recipes route to BigCraftingGrid; fall back to the
+ // 2x2 personal grid otherwise.
+ yield player.getTopWindow().orElseGet(ui::getCraftingGrid);
+ }
+ case HOTBAR, INVENTORY, HOTBAR_AND_INVENTORY, ARMOR -> player.getInventory();
+ case OFFHAND -> player.getOffhandInventory();
+ case HORSE_EQUIP -> resolveHorseInventory(player);
+ case ANVIL_INPUT, ANVIL_MATERIAL, ANVIL_RESULT -> player.getWindowById(Player.ANVIL_WINDOW_ID);
+ case ENCHANTING_INPUT, ENCHANTING_MATERIAL -> player.getWindowById(Player.ENCHANT_WINDOW_ID);
+ case GRINDSTONE_INPUT, GRINDSTONE_ADDITIONAL, GRINDSTONE_RESULT -> player.getWindowById(Player.GRINDSTONE_WINDOW_ID);
+ case SMITHING_TABLE_INPUT, SMITHING_TABLE_MATERIAL, SMITHING_TABLE_RESULT, SMITHING_TABLE_TEMPLATE ->
+ player.getWindowById(Player.SMITHING_WINDOW_ID);
+ case LOOM_INPUT, LOOM_DYE, LOOM_MATERIAL, LOOM_RESULT -> player.getWindowById(Player.LOOM_WINDOW_ID);
+ case STONECUTTER_INPUT, STONECUTTER_RESULT -> player.getWindowById(Player.STONECUTTER_WINDOW_ID);
+ case CARTOGRAPHY_INPUT, CARTOGRAPHY_ADDITIONAL, CARTOGRAPHY_RESULT ->
+ player.getTopWindow().filter(inv -> inv instanceof CartographyTableInventory).orElse(null);
+ case BEACON_PAYMENT -> player.getWindowById(Player.BEACON_WINDOW_ID);
+ case TRADE_INGREDIENT_1, TRADE_INGREDIENT_2, TRADE_RESULT,
+ TRADE2_INGREDIENT_1, TRADE2_INGREDIENT_2, TRADE2_RESULT ->
+ player.getTopWindow().orElse(null);
+ case FURNACE_FUEL, FURNACE_INGREDIENT, FURNACE_RESULT,
+ BLAST_FURNACE_INGREDIENT, SMOKER_INGREDIENT,
+ BREWING_INPUT, BREWING_RESULT, BREWING_FUEL,
+ SHULKER_BOX, BARREL,
+ LEVEL_ENTITY, CRAFTER_BLOCK_CONTAINER -> player.getTopWindow().orElse(null);
+ case DYNAMIC_CONTAINER -> resolveDynamicContainer(player, dynamicId);
+ default -> null;
+ };
+ }
+
+ /**
+ * Convert a network-level slot index to the server-side internal slot index
+ * for the given container type.
+ *
+ * For a player's main inventory, Bedrock uses the same indices as the
+ * server-side native slot numbering: HOTBAR 0-8, INVENTORY 9-35 and
+ * HOTBAR_AND_INVENTORY 0-35 are all identity mappings — the INVENTORY
+ * values already include the 9-slot hotbar offset, so we must NOT add 9
+ * again (doing so shifts every main-inventory click by 9 slots and makes
+ * equipment / hotbar state diverge from the client).
+ */
+ public static int toInternalSlot(ContainerSlotType type, int networkSlot) {
+ return switch (type) {
+ case HOTBAR, HOTBAR_AND_INVENTORY, INVENTORY -> networkSlot;
+ case ARMOR -> networkSlot + 36;
+ case CURSOR, OFFHAND, BEACON_PAYMENT -> 0;
+ case CREATED_OUTPUT -> 50;
+ // Villager trade inventory places the two input slots at fixed
+ // physical indices; map both the 1.16+ TRADE2_* and legacy TRADE_*
+ // aliases to the same slots.
+ case TRADE_INGREDIENT_1, TRADE2_INGREDIENT_1 -> TradeInventory.TRADE_INPUT1_UI_SLOT;
+ case TRADE_INGREDIENT_2, TRADE2_INGREDIENT_2 -> TradeInventory.TRADE_INPUT2_UI_SLOT;
+ default -> networkSlot;
+ };
+ }
+
+ /**
+ * Convert a server-side internal slot index back to the network-level slot
+ * index. Used when constructing ItemStackResponseSlot entries.
+ */
+ public static int toNetworkSlot(Inventory inventory, int internalSlot) {
+ if (inventory instanceof PlayerInventory) {
+ // HOTBAR (0-8) and INVENTORY (9-35) are identity-mapped on the wire
+ // — see toInternalSlot. Armor slots (36-39) are published through
+ // MobArmorEquipment / the ARMOR container at indices 0-3.
+ if (internalSlot < 36) {
+ return internalSlot;
+ }
+ return internalSlot - 36;
+ }
+ return internalSlot;
+ }
+
+ /**
+ * Derive the network-level ContainerSlotType for a given inventory + internal
+ * slot. Used by the ItemStackRequest response path to label the slots the
+ * server is echoing back.
+ */
+ public static ContainerSlotType getSlotType(Inventory inventory, int internalSlot) {
+ if (inventory instanceof PlayerCursorInventory) {
+ return ContainerSlotType.CURSOR;
+ }
+ if (inventory instanceof PlayerInventory) {
+ if (internalSlot < 9) {
+ return ContainerSlotType.HOTBAR;
+ }
+ if (internalSlot < 36) {
+ return ContainerSlotType.INVENTORY;
+ }
+ return ContainerSlotType.ARMOR;
+ }
+ if (inventory instanceof PlayerUIInventory) {
+ return internalSlot == 50 ? ContainerSlotType.CREATED_OUTPUT : ContainerSlotType.CURSOR;
+ }
+ if (inventory instanceof AnvilInventory) {
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.ANVIL_INPUT;
+ case 1 -> ContainerSlotType.ANVIL_MATERIAL;
+ default -> ContainerSlotType.ANVIL_RESULT;
+ };
+ }
+ if (inventory instanceof EnchantInventory) {
+ return internalSlot == 0 ? ContainerSlotType.ENCHANTING_INPUT : ContainerSlotType.ENCHANTING_MATERIAL;
+ }
+ if (inventory instanceof GrindstoneInventory) {
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.GRINDSTONE_INPUT;
+ case 1 -> ContainerSlotType.GRINDSTONE_ADDITIONAL;
+ default -> ContainerSlotType.GRINDSTONE_RESULT;
+ };
+ }
+ if (inventory instanceof SmithingInventory) {
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.SMITHING_TABLE_INPUT;
+ case 1 -> ContainerSlotType.SMITHING_TABLE_MATERIAL;
+ case 2 -> ContainerSlotType.SMITHING_TABLE_TEMPLATE;
+ default -> ContainerSlotType.SMITHING_TABLE_RESULT;
+ };
+ }
+ if (inventory instanceof LoomInventory) {
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.LOOM_INPUT;
+ case 1 -> ContainerSlotType.LOOM_DYE;
+ case 2 -> ContainerSlotType.LOOM_MATERIAL;
+ default -> ContainerSlotType.LOOM_RESULT;
+ };
+ }
+ if (inventory instanceof StonecutterInventory) {
+ return internalSlot == 0 ? ContainerSlotType.STONECUTTER_INPUT : ContainerSlotType.STONECUTTER_RESULT;
+ }
+ if (inventory instanceof CartographyTableInventory) {
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.CARTOGRAPHY_INPUT;
+ case 1 -> ContainerSlotType.CARTOGRAPHY_ADDITIONAL;
+ default -> ContainerSlotType.CARTOGRAPHY_RESULT;
+ };
+ }
+ if (inventory instanceof BeaconInventory) {
+ return ContainerSlotType.BEACON_PAYMENT;
+ }
+ if (inventory instanceof CraftingGrid) {
+ return internalSlot == 0 ? ContainerSlotType.CRAFTING_OUTPUT : ContainerSlotType.CRAFTING_INPUT;
+ }
+ if (inventory instanceof BundleInventory) {
+ return ContainerSlotType.DYNAMIC_CONTAINER;
+ }
+ if (inventory instanceof HorseInventory) {
+ return internalSlot <= HorseInventory.SLOT_ARMOR
+ ? ContainerSlotType.HORSE_EQUIP
+ : ContainerSlotType.HOTBAR_AND_INVENTORY;
+ }
+ return ContainerSlotType.HOTBAR_AND_INVENTORY;
+ }
+
+ @Nullable
+ private static Inventory resolveHorseInventory(Player player) {
+ Inventory topWindow = player.getTopWindow().orElse(null);
+ if (topWindow instanceof HorseInventory) {
+ return topWindow;
+ }
+
+ Entity riding = player.getRiding();
+ if (riding instanceof InventoryHolder inventoryHolder) {
+ Inventory inv = inventoryHolder.getInventory();
+ if (inv instanceof HorseInventory) {
+ return inv;
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ private static Inventory resolveDynamicContainer(Player player, @Nullable Integer dynamicId) {
+ if (dynamicId == null) {
+ return null;
+ }
+
+ LinkedHashSet inventories = new LinkedHashSet<>();
+ player.getTopWindow().ifPresent(inventories::add);
+ inventories.add(player.getInventory());
+ inventories.add(player.getOffhandInventory());
+ inventories.add(player.getCursorInventory());
+ inventories.add(player.getCraftingGrid());
+
+ Set visitedBundleIds = new LinkedHashSet<>();
+ for (Inventory inventory : inventories) {
+ Inventory resolved = findBundleInventory(inventory, dynamicId, visitedBundleIds);
+ if (resolved != null) {
+ return resolved;
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ private static Inventory findBundleInventory(@Nullable Inventory inventory, int dynamicId, Set visitedBundleIds) {
+ if (inventory == null) {
+ return null;
+ }
+
+ for (Item item : inventory.getContents().values()) {
+ if (!(item instanceof ItemBundle bundle)) {
+ continue;
+ }
+
+ int bundleId = bundle.getBundleId();
+ if (!visitedBundleIds.add(bundleId)) {
+ continue;
+ }
+ if (bundleId == dynamicId) {
+ return bundle.getInventory();
+ }
+
+ Inventory nested = findBundleInventory(bundle.getInventory(), dynamicId, visitedBundleIds);
+ if (nested != null) {
+ return nested;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Result of {@link #findBundleHolder}. {@code outer}/{@code outerSlot} point to
+ * the non-bundle inventory + slot index that holds the root {@link ItemBundle};
+ * {@code chain} lists the bundles traversed from root to the target bundle
+ * (inclusive). Used by the ItemStackRequest path to cascade saveNBT and re-send
+ * the outer slot to viewers.
+ */
+ public record BundleHolderRef(Inventory outer, int outerSlot, List chain) {
+ }
+
+ /**
+ * Locate the non-bundle inventory holding the bundle with the given dynamic id.
+ * Search order mirrors {@link #resolveDynamicContainer}: topWindow, player
+ * inventory, offhand, cursor, crafting grid.
+ */
+ @Nullable
+ public static BundleHolderRef findBundleHolder(Player player, int dynamicId) {
+ LinkedHashSet inventories = new LinkedHashSet<>();
+ player.getTopWindow().ifPresent(inventories::add);
+ inventories.add(player.getInventory());
+ inventories.add(player.getOffhandInventory());
+ inventories.add(player.getCursorInventory());
+ inventories.add(player.getCraftingGrid());
+
+ Set visitedBundleIds = new LinkedHashSet<>();
+ for (Inventory inventory : inventories) {
+ BundleHolderRef ref = findBundleHolder(inventory, dynamicId, visitedBundleIds, new ArrayList<>());
+ if (ref != null) {
+ return ref;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ private static BundleHolderRef findBundleHolder(
+ @Nullable Inventory inventory,
+ int dynamicId,
+ Set visitedBundleIds,
+ List chainSoFar
+ ) {
+ if (inventory == null) {
+ return null;
+ }
+
+ for (var entry : inventory.getContents().entrySet()) {
+ Item item = entry.getValue();
+ if (!(item instanceof ItemBundle bundle)) {
+ continue;
+ }
+
+ int bundleId = bundle.getBundleId();
+ if (!visitedBundleIds.add(bundleId)) {
+ continue;
+ }
+
+ // Build a chain that includes this bundle; used for saveNBT cascade.
+ List nextChain = new ArrayList<>(chainSoFar.size() + 1);
+ nextChain.addAll(chainSoFar);
+ nextChain.add(bundle);
+
+ if (bundleId == dynamicId) {
+ // Intermediate result: outer here may be a BundleInventory (nested
+ // case); the caller at the top level will replace it with the real
+ // outer inventory before returning.
+ return new BundleHolderRef(inventory, entry.getKey(), nextChain);
+ }
+
+ BundleHolderRef nested = findBundleHolder(bundle.getInventory(), dynamicId, visitedBundleIds, nextChain);
+ if (nested != null) {
+ // At the top level (first non-bundle inventory), replace the inner
+ // outer with the real non-bundle outer + its slot index.
+ if (chainSoFar.isEmpty()) {
+ return new BundleHolderRef(inventory, entry.getKey(), nested.chain());
+ }
+ return nested;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/PlaceActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/PlaceActionProcessor.java
new file mode 100644
index 000000000..8f205e6c2
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/PlaceActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.PlaceAction;
+
+public class PlaceActionProcessor extends TransferItemActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.PLACE;
+ }
+
+ @Override
+ public ActionResponse handle(PlaceAction action, Player player, ItemStackRequestContext context) {
+ return doTransfer(action, player, context);
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
new file mode 100644
index 000000000..e2b6037ec
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
@@ -0,0 +1,60 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.SwapAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+
+import java.util.List;
+
+public class SwapActionProcessor implements ItemStackRequestActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.SWAP;
+ }
+
+ @Override
+ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestContext context) {
+ ItemStackRequestSlotData src = action.getSource();
+ ItemStackRequestSlotData dst = action.getDestination();
+
+ Inventory srcInv = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId());
+ Inventory dstInv = NetworkMapping.getInventory(player, dst.getContainer(), dst.getDynamicId());
+ if (srcInv == null || dstInv == null) {
+ return context.error();
+ }
+
+ int srcSlot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
+ int dstSlot = NetworkMapping.toInternalSlot(dst.getContainer(), dst.getSlot());
+
+ Item sourceItem = srcInv.getItem(srcSlot);
+ Item destItem = dstInv.getItem(dstSlot);
+
+ if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) {
+ return context.error();
+ }
+ if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) {
+ return context.error();
+ }
+
+ // Fire InventoryClickEvent for both slots before mutation, matching the
+ // legacy InventoryTransaction path (one event per swapped slot).
+ if (!TransferItemActionProcessor.fireClickEvent(player, srcInv, srcSlot, sourceItem, destItem)) {
+ return context.error();
+ }
+ if (!TransferItemActionProcessor.fireClickEvent(player, dstInv, dstSlot, destItem, sourceItem)) {
+ return context.error();
+ }
+
+ srcInv.setItem(srcSlot, destItem.clone(), false);
+ dstInv.setItem(dstSlot, sourceItem.clone(), false);
+
+ ItemStackResponseContainer srcResp = TransferItemActionProcessor.buildContainer(srcInv, srcSlot, src);
+ ItemStackResponseContainer dstResp = TransferItemActionProcessor.buildContainer(dstInv, dstSlot, dst);
+ return context.success(List.of(srcResp, dstResp));
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/TakeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TakeActionProcessor.java
new file mode 100644
index 000000000..a1cb8551b
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/TakeActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.TakeAction;
+
+public class TakeActionProcessor extends TransferItemActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.TAKE;
+ }
+
+ @Override
+ public ActionResponse handle(TakeAction action, Player player, ItemStackRequestContext context) {
+ return doTransfer(action, player, context);
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
new file mode 100644
index 000000000..928d680ff
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
@@ -0,0 +1,183 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.Server;
+import cn.nukkit.event.inventory.InventoryClickEvent;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.TransferItemStackRequestAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Base handler for TAKE and PLACE actions — both structurally a partial/full
+ * transfer between two slots. Centralises the count math, stack merging rules
+ * and response container construction.
+ *
+ * Adapted from PowerNukkitX (PowerNukkitX)
+ */
+@Log4j2
+public abstract class TransferItemActionProcessor
+ implements ItemStackRequestActionProcessor {
+
+ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestContext context) {
+ ItemStackRequestSlotData src = action.getSource();
+ ItemStackRequestSlotData dst = action.getDestination();
+
+ Inventory srcInv = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId());
+ Inventory dstInv = NetworkMapping.getInventory(player, dst.getContainer(), dst.getDynamicId());
+
+ int srcSlot = srcInv == null ? -1 : NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
+ int dstSlot = dstInv == null ? -1 : NetworkMapping.toInternalSlot(dst.getContainer(), dst.getSlot());
+ int count = action.getCount();
+
+ log.info("{}: {} src={}[net={}->int={},netId={}] dst={}[net={}->int={},netId={}] count={} srcItem={} dstItem={}",
+ player.getName(), getType(),
+ src.getContainer(), src.getSlot(), srcSlot, src.getStackNetworkId(),
+ dst.getContainer(), dst.getSlot(), dstSlot, dst.getStackNetworkId(),
+ count,
+ srcInv == null ? "null-inv" : srcInv.getItem(srcSlot),
+ dstInv == null ? "null-inv" : dstInv.getItem(dstSlot));
+
+ if (srcInv == null || dstInv == null) {
+ log.info("{}: transfer rejected - inventory missing src={}({}) dst={}({})",
+ player.getName(), src.getContainer(), srcInv, dst.getContainer(), dstInv);
+ return context.error();
+ }
+
+ if (count <= 0) {
+ log.info("{}: transfer rejected - non-positive count {}", player.getName(), count);
+ return context.error();
+ }
+
+ Item sourceItem = srcInv.getItem(srcSlot);
+ if (sourceItem.isNull() || sourceItem.getCount() < count) {
+ log.info("{}: transfer rejected - src invalid (slot {} item={} count needed {})",
+ player.getName(), srcSlot, sourceItem, count);
+ return context.error();
+ }
+ if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) {
+ log.info("{}: transfer rejected - src stackNetId mismatch server={} client={}",
+ player.getName(), sourceItem.getStackNetId(), src.getStackNetworkId());
+ return context.error();
+ }
+
+ Item destItem = dstInv.getItem(dstSlot);
+ if (!destItem.isNull() && !destItem.equals(sourceItem, true, true)) {
+ log.info("{}: transfer rejected - dst item differs (dst {} vs src {})",
+ player.getName(), destItem, sourceItem);
+ return context.error();
+ }
+ if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) {
+ log.info("{}: transfer rejected - dst stackNetId mismatch server={} client={}",
+ player.getName(), destItem.getStackNetId(), dst.getStackNetworkId());
+ return context.error();
+ }
+
+ int destCount = destItem.isNull() ? 0 : destItem.getCount();
+ if (destCount + count > sourceItem.getMaxStackSize()) {
+ log.info("{}: transfer rejected - would overflow max stack (destCount={} + count={} > max={})",
+ player.getName(), destCount, count, sourceItem.getMaxStackSize());
+ return context.error();
+ }
+
+ // Equipment containers (OFFHAND/ARMOR) must emit network packets so other
+ // players see MobEquipment/MobArmor updates; other containers can stay
+ // quiet to avoid double-send with the ItemStackResponse echo.
+ boolean sendSource = isEquipmentSlot(src.getContainer());
+ boolean sendDest = isEquipmentSlot(dst.getContainer());
+
+ Item newDest;
+ if (destItem.isNull()) {
+ newDest = sourceItem.clone();
+ newDest.setCount(count);
+ newDest.autoAssignStackNetworkId();
+ } else {
+ newDest = destItem.clone();
+ newDest.setCount(destCount + count);
+ }
+
+ Item newSrc;
+ if (sourceItem.getCount() == count) {
+ newSrc = Item.get(Item.AIR);
+ } else {
+ newSrc = sourceItem.clone();
+ newSrc.setCount(sourceItem.getCount() - count);
+ }
+
+ // Fire InventoryClickEvent for each affected slot (matches legacy
+ // InventoryTransaction.java:260). Only holder-is-Player inventories
+ // trigger the event, as in the legacy path.
+ if (!fireClickEvent(player, srcInv, srcSlot, sourceItem, newSrc)) {
+ return context.error();
+ }
+ if (!fireClickEvent(player, dstInv, dstSlot, destItem, newDest)) {
+ return context.error();
+ }
+
+ dstInv.setItem(dstSlot, newDest, sendDest);
+
+ if (sourceItem.getCount() == count) {
+ srcInv.clear(srcSlot, sendSource);
+ } else {
+ srcInv.setItem(srcSlot, newSrc, sendSource);
+ }
+
+ List containers = new ArrayList<>();
+ containers.add(buildContainer(srcInv, srcSlot, src));
+ if (src.getContainer() != dst.getContainer() || src.getSlot() != dst.getSlot()) {
+ containers.add(buildContainer(dstInv, dstSlot, dst));
+ }
+ return context.success(containers);
+ }
+
+ private static boolean isEquipmentSlot(ContainerSlotType type) {
+ return type == ContainerSlotType.OFFHAND || type == ContainerSlotType.ARMOR;
+ }
+
+ /**
+ * Fire {@link InventoryClickEvent} for a slot that is about to change. Only
+ * inventories whose holder is a {@link Player} emit the event, mirroring the
+ * legacy {@code InventoryTransaction.callExecuteEvent} check. Returns
+ * {@code false} when a plugin cancelled the event — callers should abort
+ * and return an error response.
+ */
+ static boolean fireClickEvent(Player actor, Inventory inventory, int slot, Item sourceItem, Item heldItem) {
+ if (!(inventory.getHolder() instanceof Player)) {
+ return true;
+ }
+ InventoryClickEvent event = new InventoryClickEvent(
+ actor, inventory, slot, sourceItem.clone(), heldItem.clone());
+ Server.getInstance().getPluginManager().callEvent(event);
+ return !event.isCancelled();
+ }
+
+ static ItemStackResponseContainer buildContainer(Inventory inv, int internalSlot, ItemStackRequestSlotData slotData) {
+ Item current = inv.getItem(internalSlot);
+ int networkSlot = slotData.getSlot();
+ int hotbarSlot = (slotData.getContainer() == ContainerSlotType.HOTBAR
+ || slotData.getContainer() == ContainerSlotType.HOTBAR_AND_INVENTORY) ? networkSlot : 0;
+ ItemStackResponseSlot slot = new ItemStackResponseSlot(
+ networkSlot,
+ hotbarSlot,
+ current.isNull() ? 0 : current.getCount(),
+ current.getStackNetId(),
+ current.hasCustomName() ? current.getCustomName() : "",
+ current.getDamage(),
+ ""
+ );
+ FullContainerName name = new FullContainerName(slotData.getContainer(), slotData.getDynamicId());
+ return new ItemStackResponseContainer(slotData.getContainer(), List.of(slot), name);
+ }
+
+ @Override
+ public abstract ItemStackRequestActionType getType();
+}
diff --git a/src/main/java/cn/nukkit/item/ItemBundle.java b/src/main/java/cn/nukkit/item/ItemBundle.java
index 5c3e4638e..ed006b939 100644
--- a/src/main/java/cn/nukkit/item/ItemBundle.java
+++ b/src/main/java/cn/nukkit/item/ItemBundle.java
@@ -1,11 +1,26 @@
package cn.nukkit.item;
import cn.nukkit.GameVersion;
+import cn.nukkit.inventory.BundleInventory;
+import cn.nukkit.inventory.InventoryHolder;
+import cn.nukkit.nbt.NBTIO;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.nbt.tag.ListTag;
import cn.nukkit.network.protocol.ProtocolInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
-public class ItemBundle extends StringItemBase {
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class ItemBundle extends StringItemBase implements InventoryHolder {
+
+ public static final String TAG_BUNDLE_ID = "bundle_id";
+ public static final String TAG_STORAGE_ITEM_COMPONENT_CONTENT = "storage_item_component_content";
+
+ private static final AtomicInteger NEXT_BUNDLE_ID = new AtomicInteger(1);
+
+ private BundleInventory inventory;
public ItemBundle() {
super(BUNDLE, "Bundle");
@@ -24,4 +39,57 @@ public boolean isSupportedOn(GameVersion protocolId) {
public int getMaxStackSize() {
return 1;
}
+
+ public int getBundleId() {
+ return ensureBundleTag().getInt(TAG_BUNDLE_ID);
+ }
+
+ @Override
+ public BundleInventory getInventory() {
+ if (this.inventory == null) {
+ ensureBundleTag();
+ this.inventory = new BundleInventory(this);
+ }
+ return this.inventory;
+ }
+
+ public void saveNBT() {
+ CompoundTag tag = ensureBundleTag();
+ ListTag storedItems = new ListTag<>(TAG_STORAGE_ITEM_COMPONENT_CONTENT);
+ for (Map.Entry entry : this.getInventory().getContents().entrySet()) {
+ Item item = entry.getValue();
+ if (item != null && !item.isNull()) {
+ storedItems.add(NBTIO.putItemHelper(item, entry.getKey()));
+ }
+ }
+ tag.putList(storedItems);
+ this.setNamedTag(tag);
+ }
+
+ @Override
+ public ItemBundle clone() {
+ ItemBundle cloned = (ItemBundle) super.clone();
+ cloned.inventory = null;
+ return cloned;
+ }
+
+ private CompoundTag ensureBundleTag() {
+ CompoundTag tag = this.hasCompoundTag() ? this.getNamedTag() : new CompoundTag();
+ boolean dirty = !this.hasCompoundTag();
+
+ if (!tag.contains(TAG_BUNDLE_ID)) {
+ tag.putInt(TAG_BUNDLE_ID, NEXT_BUNDLE_ID.getAndIncrement());
+ dirty = true;
+ } else {
+ NEXT_BUNDLE_ID.updateAndGet(current -> Math.max(current, tag.getInt(TAG_BUNDLE_ID) + 1));
+ }
+ if (!tag.containsList(TAG_STORAGE_ITEM_COMPONENT_CONTENT)) {
+ tag.putList(new ListTag<>(TAG_STORAGE_ITEM_COMPONENT_CONTENT));
+ dirty = true;
+ }
+ if (dirty) {
+ this.setNamedTag(tag);
+ }
+ return tag;
+ }
}
diff --git a/src/main/java/cn/nukkit/item/ItemStackNetManager.java b/src/main/java/cn/nukkit/item/ItemStackNetManager.java
new file mode 100644
index 000000000..de70d2f6d
--- /dev/null
+++ b/src/main/java/cn/nukkit/item/ItemStackNetManager.java
@@ -0,0 +1,42 @@
+package cn.nukkit.item;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Allocator for {@link Item#getStackNetId() stack network ids}. Used by the
+ * Server Authoritative Inventory (ItemStackRequest) flow to track a specific
+ * item stack across client-server round trips. Ids are positive integers and
+ * wrap back to 1 when {@link Integer#MAX_VALUE} is reached; 0 is reserved for
+ * "not tracked". Unrelated to block / item runtime ids.
+ */
+public final class ItemStackNetManager {
+
+ private static final AtomicInteger COUNTER = new AtomicInteger(1);
+
+ private ItemStackNetManager() {
+ }
+
+ /**
+ * Allocates the next stack network id. Thread-safe; the counter wraps to 1
+ * after reaching {@link Integer#MAX_VALUE} so the return value is always
+ * positive.
+ *
+ * @return the freshly allocated stack network id (always {@code > 0})
+ */
+ public static int allocate() {
+ int next = COUNTER.getAndIncrement();
+ if (next == Integer.MAX_VALUE) {
+ COUNTER.set(1);
+ }
+ return next;
+ }
+
+ /**
+ * Resets the allocator back to 1. Intended for tests; production code must
+ * not call this because previously-issued ids may still be referenced by
+ * in-flight {@code ItemStackRequest} sessions.
+ */
+ public static void reset() {
+ COUNTER.set(1);
+ }
+}
diff --git a/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java
new file mode 100644
index 000000000..f2e333d73
--- /dev/null
+++ b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java
@@ -0,0 +1,107 @@
+package cn.nukkit.item.enchantment;
+
+import cn.nukkit.item.Item;
+import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * Minimal helper that produces the three enchantment options displayed in the
+ * Bedrock enchanting table UI. Used by the Server Authoritative Inventory flow:
+ * the generated options are assigned synthetic recipe ids and echoed back via
+ * {@link cn.nukkit.inventory.request.CraftRecipeActionProcessor} when the
+ * player picks one.
+ *
+ * This is a simplified implementation focused on keeping the SA path functional;
+ * full parity with vanilla's bookshelf-weighted RNG is staged for a follow-up
+ * pass.
+ */
+public final class EnchantmentHelper {
+
+ private static final String[] OPTION_NAMES = {
+ "ancient", "arcane", "bane", "breath", "cold", "conjuring",
+ "craft", "enchant", "free", "greater", "lesser", "light",
+ "magic", "marvel", "mirror", "mystery", "primal", "rune",
+ "secret", "shadow", "spell", "temporal"
+ };
+
+ private EnchantmentHelper() {
+ }
+
+ public static List generateOptions(Item input, long seed) {
+ if (input == null || input.isNull() || input.hasEnchantments()) {
+ return Collections.emptyList();
+ }
+ Random random = new Random(seed);
+ int[] requiredLevels = {
+ Math.max(1, random.nextInt(8) + 1),
+ Math.max(1, random.nextInt(15) + 5),
+ Math.max(1, random.nextInt(30) + 10)
+ };
+ List options = new ArrayList<>(3);
+ for (int i = 0; i < 3; i++) {
+ options.add(createOption(random, input, requiredLevels[i], i));
+ }
+ return options;
+ }
+
+ private static PlayerEnchantOptionsPacket.EnchantOptionData createOption(Random random, Item input, int requiredLevel, int slot) {
+ int enchantability = Math.max(1, input.getEnchantAbility());
+ int power = requiredLevel + random.nextInt(enchantability >> 1 > 0 ? (enchantability >> 1) + 1 : 2);
+ List chosen = new ArrayList<>();
+ List applicable = filterApplicable(input, power);
+ if (!applicable.isEmpty()) {
+ Enchantment first = applicable.get(random.nextInt(applicable.size()));
+ chosen.add(new PlayerEnchantOptionsPacket.EnchantData(first.getId(), first.getLevel()));
+
+ int remainingPower = power / 2;
+ while (remainingPower > 0 && !applicable.isEmpty() && random.nextInt(50) <= remainingPower) {
+ Enchantment pick = applicable.get(random.nextInt(applicable.size()));
+ boolean compatible = true;
+ for (PlayerEnchantOptionsPacket.EnchantData picked : chosen) {
+ if (picked.getType() == pick.getId()) {
+ compatible = false;
+ break;
+ }
+ Enchantment other = Enchantment.getEnchantment(picked.getType());
+ if (other != null && !other.isCompatibleWith(pick)) {
+ compatible = false;
+ break;
+ }
+ }
+ if (compatible) {
+ chosen.add(new PlayerEnchantOptionsPacket.EnchantData(pick.getId(), pick.getLevel()));
+ }
+ remainingPower /= 2;
+ }
+ }
+
+ String name = OPTION_NAMES[random.nextInt(OPTION_NAMES.length)];
+ int netId = 0;
+ PlayerEnchantOptionsPacket.EnchantOptionData option = new PlayerEnchantOptionsPacket.EnchantOptionData(
+ requiredLevel, slot, chosen,
+ Collections.emptyList(), Collections.emptyList(),
+ name, netId
+ );
+ return option;
+ }
+
+ private static List filterApplicable(Item input, int power) {
+ List result = new ArrayList<>();
+ for (Enchantment enchantment : Enchantment.getEnchantments()) {
+ if (enchantment == null || !enchantment.canEnchant(input)) {
+ continue;
+ }
+ for (int lvl = enchantment.getMaxLevel(); lvl > 0; lvl--) {
+ if (power >= enchantment.getMinEnchantAbility(lvl) && power <= enchantment.getMaxEnchantAbility(lvl)) {
+ result.add(enchantment.clone().setLevel(lvl));
+ break;
+ }
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/cn/nukkit/network/process/DataPacketManager.java b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
index be2218264..9022308a8 100644
--- a/src/main/java/cn/nukkit/network/process/DataPacketManager.java
+++ b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
@@ -221,6 +221,11 @@ public static void registerDefaultProcessors() {
FilterTextProcessor_v422.INSTANCE
);
+ registerProcessor(
+ ProtocolInfo.v1_16_100,
+ ItemStackRequestProcessor.INSTANCE
+ );
+
registerProcessor(
ProtocolInfo.v1_19_0,
RequestAbilityProcessor_v527.INSTANCE
diff --git a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
new file mode 100644
index 000000000..d55969128
--- /dev/null
+++ b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
@@ -0,0 +1,56 @@
+package cn.nukkit.network.process.processor.common;
+
+import cn.nukkit.Player;
+import cn.nukkit.PlayerHandle;
+import cn.nukkit.inventory.request.ItemStackRequestHandler;
+import cn.nukkit.network.process.DataPacketProcessor;
+import cn.nukkit.network.protocol.DataPacket;
+import cn.nukkit.network.protocol.ItemStackRequestPacket;
+import cn.nukkit.network.protocol.ProtocolInfo;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Processor for ItemStackRequestPacket
+ * Handles server-authoritative inventory requests from clients
+ *
+ * @author Nukkit-MOT Team
+ * @since v1.16.100 (protocol 407+)
+ */
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ItemStackRequestProcessor extends DataPacketProcessor {
+
+ public static final ItemStackRequestProcessor INSTANCE = new ItemStackRequestProcessor();
+
+ @Override
+ public void handle(@NotNull PlayerHandle playerHandle, @NotNull ItemStackRequestPacket pk) {
+ Player player = playerHandle.player;
+
+ // Only process if server authoritative inventory is enabled
+ if (!player.isInventoryServerAuthoritative()) {
+ return;
+ }
+
+ // Handle the requests
+ if (!pk.getRequests().isEmpty()) {
+ ItemStackRequestHandler.handleRequests(player, pk.getRequests());
+ }
+ }
+
+ @Override
+ public int getPacketId() {
+ return ProtocolInfo.toNewProtocolID(ProtocolInfo.ITEM_STACK_REQUEST_PACKET);
+ }
+
+ @Override
+ public Class extends DataPacket> getPacketClass() {
+ return ItemStackRequestPacket.class;
+ }
+
+ @Override
+ public boolean isSupported(int protocol) {
+ // ItemStackRequest was introduced in v1.16.100
+ return protocol >= ProtocolInfo.v1_16_100;
+ }
+}
diff --git a/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java b/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java
index 17ecf7657..190bb08d0 100644
--- a/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java
+++ b/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java
@@ -123,6 +123,11 @@ public byte pid() {
public AuthoritativeMovementMode authoritativeMovementMode;
public int rewindHistorySize;
public boolean isServerAuthoritativeBlockBreaking;
+ /**
+ * Server authoritative inventory mode
+ * @since v1.16.100 (protocol 407+)
+ */
+ public boolean isInventoryServerAuthoritative;
public long currentTick;
public int enchantmentSeed;
public Collection blockDefinitions = CustomBlockManager.get().getBlockDefinitions();
@@ -396,7 +401,7 @@ public void encode() {
if (protocol == 354 && version != null && version.startsWith("1.11.4")) {
this.putBoolean(this.isOnlySpawningV1Villagers);
} else if (protocol >= ProtocolInfo.v1_16_0) {
- this.putBoolean(false); // isInventoryServerAuthoritative
+ this.putBoolean(this.isInventoryServerAuthoritative);
if (protocol >= ProtocolInfo.v1_16_230_50) {
this.putString(""); // serverEngine
if (protocol >= ProtocolInfo.v1_18_0) {
diff --git a/src/main/java/cn/nukkit/utils/TradeRecipeBuildUtils.java b/src/main/java/cn/nukkit/utils/TradeRecipeBuildUtils.java
new file mode 100644
index 000000000..4f73affc0
--- /dev/null
+++ b/src/main/java/cn/nukkit/utils/TradeRecipeBuildUtils.java
@@ -0,0 +1,40 @@
+package cn.nukkit.utils;
+
+import cn.nukkit.nbt.tag.CompoundTag;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Central registry for villager trade recipes addressable by a synthetic network
+ * ID. The Server Authoritative Inventory ItemStackRequest CraftRecipe flow uses
+ * this to resolve a selected trade option back to its recipe payload.
+ */
+public final class TradeRecipeBuildUtils {
+
+ /**
+ * Base recipe ID for trade recipes. Values in [TRADE_RECIPEID, ENCH_RECIPEID)
+ * are treated as trade recipes by the ItemStackRequest CraftRecipe flow.
+ */
+ public static final int TRADE_RECIPEID = 0x20000000;
+
+ private static final AtomicInteger COUNTER = new AtomicInteger(0);
+
+ public static final Int2ObjectMap RECIPE_MAP = new Int2ObjectOpenHashMap<>();
+
+ private TradeRecipeBuildUtils() {
+ }
+
+ /**
+ * Allocate a new trade recipe ID, register the given recipe payload under it,
+ * and return the new ID. The caller should attach the returned ID to the
+ * UpdateTradePacket payload so the client echoes it in subsequent
+ * CraftRecipeActions.
+ */
+ public static int assignRecipeId(CompoundTag recipe) {
+ int id = TRADE_RECIPEID + COUNTER.incrementAndGet();
+ RECIPE_MAP.put(id, recipe);
+ return id;
+ }
+}
diff --git a/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java
new file mode 100644
index 000000000..4623b183d
--- /dev/null
+++ b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java
@@ -0,0 +1,53 @@
+package cn.nukkit.inventory;
+
+import cn.nukkit.MockServer;
+import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemBundle;
+import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.nbt.tag.ListTag;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BundleInventoryTest {
+
+ @BeforeAll
+ static void init() {
+ MockServer.init();
+ }
+
+ @BeforeEach
+ void resetServer() {
+ MockServer.reset();
+ }
+
+ @Test
+ void saveNbtKeepsBundleContentsAndCloneDoesNotShareInventoryInstance() {
+ ItemBundle bundle = new ItemBundle();
+ int bundleId = bundle.getBundleId();
+ BundleInventory inventory = bundle.getInventory();
+
+ assertTrue(inventory.setItem(0, Item.get(Item.STONE, 0, 16), false));
+
+ ListTag storedItems = bundle.getNamedTag()
+ .getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class);
+ assertEquals(1, storedItems.size());
+
+ ItemBundle clone = bundle.clone();
+ assertEquals(bundleId, clone.getBundleId());
+ assertNotSame(inventory, clone.getInventory());
+ assertEquals(Item.STONE, clone.getInventory().getItem(0).getId());
+ assertEquals(16, clone.getInventory().getItem(0).getCount());
+ }
+
+ @Test
+ void rejectsItemsThatWouldOverfillTheBundle() {
+ ItemBundle bundle = new ItemBundle();
+ BundleInventory inventory = bundle.getInventory();
+
+ assertFalse(inventory.setItem(0, Item.get(Item.DIRT, 0, 65), false));
+ assertTrue(inventory.isEmpty());
+ }
+}
diff --git a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
new file mode 100644
index 000000000..5a2fef48e
--- /dev/null
+++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
@@ -0,0 +1,69 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.MockServer;
+import cn.nukkit.Player;
+import cn.nukkit.inventory.*;
+import cn.nukkit.item.ItemBundle;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class NetworkMappingTest {
+
+ @BeforeAll
+ static void init() {
+ MockServer.init();
+ }
+
+ @BeforeEach
+ void resetServer() {
+ MockServer.reset();
+ }
+
+ @Test
+ void levelEntityAndCrafterContainerResolveToTopWindow() {
+ Player player = Mockito.mock(Player.class);
+ Inventory topWindow = Mockito.mock(Inventory.class);
+
+ Mockito.when(player.getTopWindow()).thenReturn(Optional.of(topWindow));
+
+ assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null));
+ assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null));
+ }
+
+ @Test
+ void dynamicContainerCanResolveNestedBundleFromAccessibleInventories() {
+ Player player = Mockito.mock(Player.class);
+ PlayerInventory inventory = Mockito.mock(PlayerInventory.class);
+ PlayerOffhandInventory offhand = Mockito.mock(PlayerOffhandInventory.class);
+ PlayerCursorInventory cursor = Mockito.mock(PlayerCursorInventory.class);
+ CraftingGrid craftingGrid = Mockito.mock(CraftingGrid.class);
+
+ ItemBundle outerBundle = new ItemBundle();
+ ItemBundle innerBundle = new ItemBundle();
+ outerBundle.getInventory().setItem(0, innerBundle, false);
+
+ Mockito.when(player.getTopWindow()).thenReturn(Optional.empty());
+ Mockito.when(player.getInventory()).thenReturn(inventory);
+ Mockito.when(player.getOffhandInventory()).thenReturn(offhand);
+ Mockito.when(player.getCursorInventory()).thenReturn(cursor);
+ Mockito.when(player.getCraftingGrid()).thenReturn(craftingGrid);
+ Mockito.when(inventory.getContents()).thenReturn(Map.of());
+ Mockito.when(offhand.getContents()).thenReturn(Map.of(0, outerBundle));
+ Mockito.when(cursor.getContents()).thenReturn(Map.of());
+ Mockito.when(craftingGrid.getContents()).thenReturn(Map.of());
+
+ Inventory resolved = NetworkMapping.getInventory(player, ContainerSlotType.DYNAMIC_CONTAINER, innerBundle.getBundleId());
+ BundleInventory bundleInventory = assertInstanceOf(BundleInventory.class, resolved);
+
+ assertEquals(innerBundle.getBundleId(), bundleInventory.getHolder().getBundleId());
+ assertEquals(innerBundle.getInventory().getContents(), bundleInventory.getContents());
+ }
+}
From d5c2ebe594e63f12d030775b01272d871a793dc2 Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Fri, 24 Apr 2026 18:17:22 +0800
Subject: [PATCH 02/29] fix: improve item stack request and inventory
transaction handling
Add inventory transaction packet processing on the player path.\nImprove item stack request rollback, container mapping and transfer events.
---
src/main/java/cn/nukkit/Player.java | 1318 +++++++++--------
.../ItemStackRequestActionEvent.java | 52 +
.../event/player/PlayerTransferItemEvent.java | 68 +
.../cn/nukkit/inventory/BaseInventory.java | 38 +-
.../inventory/EntityArmorInventory.java | 3 +
.../inventory/FakeBlockUIComponent.java | 4 +
.../java/cn/nukkit/inventory/Inventory.java | 6 +
.../cn/nukkit/inventory/PlayerInventory.java | 9 +
.../inventory/PlayerOffhandInventory.java | 3 +
.../nukkit/inventory/PlayerUIComponent.java | 7 +
.../nukkit/inventory/PlayerUIInventory.java | 16 +-
.../request/BeaconPaymentActionProcessor.java | 18 +-
.../request/ConsumeActionProcessor.java | 10 +-
.../request/CraftCreativeActionProcessor.java | 3 +-
.../CraftGrindstoneActionProcessor.java | 2 +-
.../request/CraftRecipeActionProcessor.java | 85 +-
.../request/CraftRecipeAutoProcessor.java | 70 +-
.../request/CraftRecipeOptionalProcessor.java | 10 +-
.../CraftResultDeprecatedActionProcessor.java | 6 +
.../request/CreateActionProcessor.java | 15 +-
.../request/DestroyActionProcessor.java | 10 +-
.../request/DropActionProcessor.java | 10 +-
.../request/InventoryObserverSync.java | 10 +-
.../request/ItemStackRequestContext.java | 21 +
.../request/ItemStackRequestHandler.java | 132 +-
.../inventory/request/NetworkMapping.java | 54 +-
.../PlaceInItemContainerActionProcessor.java | 18 +
.../request/SwapActionProcessor.java | 16 +-
.../TakeFromItemContainerActionProcessor.java | 18 +
.../request/TransferItemActionProcessor.java | 129 +-
src/main/java/cn/nukkit/item/Item.java | 6 +-
.../network/process/DataPacketManager.java | 1 +
.../common/InventoryTransactionProcessor.java | 43 +
.../common/ItemStackRequestProcessor.java | 3 +-
34 files changed, 1487 insertions(+), 727 deletions(-)
create mode 100644 src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
create mode 100644 src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/PlaceInItemContainerActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/inventory/request/TakeFromItemContainerActionProcessor.java
create mode 100644 src/main/java/cn/nukkit/network/process/processor/common/InventoryTransactionProcessor.java
diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java
index 5eee2182f..a33a50374 100644
--- a/src/main/java/cn/nukkit/Player.java
+++ b/src/main/java/cn/nukkit/Player.java
@@ -68,6 +68,9 @@
import cn.nukkit.network.process.DataPacketManager;
import cn.nukkit.network.protocol.*;
import cn.nukkit.network.protocol.types.*;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
import cn.nukkit.network.protocol.types.debugshape.DebugShape;
import cn.nukkit.network.session.NetworkPlayerSession;
import cn.nukkit.network.session.NetworkPlayerSession.ImmediatePacketMode;
@@ -367,6 +370,8 @@ public class Player extends EntityHuman implements CommandSender, InventoryHolde
private double lastRightClickTime = 0.0;
private long lastClickAirTime = 0;
private BlockVector3 lastRightClickPos = null;
+ private final IntOpenHashSet processedItemStackRequestIds = new IntOpenHashSet();
+ private int processedItemStackRequestTick = Integer.MIN_VALUE;
public EntityFishingHook fishing = null;
public boolean formOpen;
public boolean locallyInitialized;
@@ -3931,7 +3936,7 @@ public void onCompletion(Server server) {
// 处理 ItemStackRequest(v1.16.100+ 客户端通过 PlayerAuthInputPacket 发送物品栏操作)
if (this.isInventoryServerAuthoritative() && authPacket.getItemStackRequest() != null) {
- ItemStackRequestHandler.handleRequests(this, List.of(authPacket.getItemStackRequest()));
+ this.handleItemStackRequests(List.of(authPacket.getItemStackRequest()));
}
break;
case ProtocolInfo.PLAYER_ACTION_PACKET:
@@ -4486,762 +4491,778 @@ public void onCompletion(Server server) {
}
}
break;
- case ProtocolInfo.INVENTORY_TRANSACTION_PACKET:
- if (!this.spawned || !this.isAlive()) {
- log.debug("Player {} sent inventory transaction packet while not spawned or not alive", this.username);
- break;
- }
+ default:
+ break;
+ }
+ }
- // 旁观者模式检查:如果启用了客户端旁观模式,则完全阻止交互(不触发事件)
- // 如果未启用,则允许触发事件但不允许实际操作(假旁观模式,类似创造模式)
- // Spectator mode check: If client spectator mode is enabled, completely block interaction (no event trigger)
- // If not enabled, allow event trigger but prevent actual operation (fake spectator mode, similar to creative mode)
- if (this.isSpectator() && this.server.useClientSpectator) {
- this.needSendInventory = true;
+ public void handleItemStackRequests(List requests) {
+ if (!this.isInventoryServerAuthoritative() || requests.isEmpty()) {
+ return;
+ }
+
+ int currentTick = this.server.getTick();
+ if (this.processedItemStackRequestTick != currentTick) {
+ this.processedItemStackRequestTick = currentTick;
+ this.processedItemStackRequestIds.clear();
+ }
+
+ List pendingRequests = new ArrayList<>(requests.size());
+ for (ItemStackRequest request : requests) {
+ if (this.processedItemStackRequestIds.add(request.getRequestId())) {
+ pendingRequests.add(request);
+ }
+ }
+
+ if (!pendingRequests.isEmpty()) {
+ ItemStackRequestHandler.handleRequests(this, pendingRequests);
+ }
+ }
+
+ public void handleInventoryTransactionPacket(InventoryTransactionPacket transactionPacket) {
+ if (!this.spawned || !this.isAlive()) {
+ log.debug("Player {} sent inventory transaction packet while not spawned or not alive", this.username);
+ return;
+ }
+
+ // 旁观者模式检查:如果启用了客户端旁观模式,则完全阻止交互(不触发事件)
+ // 如果未启用,则允许触发事件但不允许实际操作(假旁观模式,类似创造模式)
+ // Spectator mode check: If client spectator mode is enabled, completely block interaction (no event trigger)
+ // If not enabled, allow event trigger but prevent actual operation (fake spectator mode, similar to creative mode)
+ if (this.isSpectator() && this.server.useClientSpectator) {
+ this.needSendInventory = true;
+ return;
+ }
+
+ PlayerInventory inventory = this.inventory;
+ Level level = this.level;
+ Server server = this.server;
+ BlockFace face;
+ Block block;
+ Item item;
+
+ // Nasty hack because the client won't change the right packet in survival when creating netherite stuff,
+ // so we are emulating what Mojang should be sending
+ if (getWindowById(SMITHING_WINDOW_ID) instanceof SmithingInventory smithingInventory
+ && transactionPacket.transactionType == InventoryTransactionPacket.TYPE_MISMATCH
+ && !smithingInventory.getResult().isNull()) {
+ InventoryTransactionPacket fixedPacket = new InventoryTransactionPacket();
+ fixedPacket.isRepairItemPart = true;
+ fixedPacket.actions = new NetworkInventoryAction[8];
+
+ Item fromIngredient = smithingInventory.getIngredient().clone();
+ Item toIngredient = fromIngredient.decrement(1);
+
+ Item fromEquipment = smithingInventory.getEquipment().clone();
+ Item toEquipment = fromEquipment.decrement(1);
+
+ Item fromTemplate = smithingInventory.getTemplate().clone();
+ Item toTemplate = fromTemplate.decrement(1);
+
+ Item fromResult = Item.get(Item.AIR);
+ Item toResult = smithingInventory.getResult().clone();
+
+ NetworkInventoryAction action = new NetworkInventoryAction();
+ action.windowId = ContainerIds.UI;
+ action.inventorySlot = SmithingInventory.SMITHING_INGREDIENT_UI_SLOT;
+ action.oldItem = fromIngredient.clone();
+ action.newItem = toIngredient.clone();
+ fixedPacket.actions[0] = action;
+
+ action = new NetworkInventoryAction();
+ action.windowId = ContainerIds.UI;
+ action.inventorySlot = SmithingInventory.SMITHING_EQUIPMENT_UI_SLOT;
+ action.oldItem = fromEquipment.clone();
+ action.newItem = toEquipment.clone();
+ fixedPacket.actions[1] = action;
+
+ action = new NetworkInventoryAction();
+ action.windowId = ContainerIds.UI;
+ action.inventorySlot = SmithingInventory.SMITHING_TEMPLATE_UI_SLOT;
+ action.oldItem = fromTemplate.clone();
+ action.newItem = toTemplate.clone();
+ fixedPacket.actions[2] = action;
+
+ int emptyPlayerSlot = -1;
+ for (int slot = 0; slot < inventory.getSize(); slot++) {
+ if (inventory.getItem(slot).isNull()) {
+ emptyPlayerSlot = slot;
break;
}
+ }
+ if (emptyPlayerSlot == -1) {
+ this.needSendInventory = true;
+ return;
+ } else {
+ action = new NetworkInventoryAction();
+ action.windowId = ContainerIds.INVENTORY;
+ action.inventorySlot = emptyPlayerSlot;
+ action.oldItem = Item.get(Item.AIR);
+ action.newItem = toResult.clone();
+ fixedPacket.actions[3] = action;
+
+ action = new NetworkInventoryAction();
+ action.sourceType = NetworkInventoryAction.SOURCE_TODO;
+ action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_RESULT;
+ action.inventorySlot = 2;
+ action.oldItem = toResult.clone();
+ action.newItem = fromResult.clone();
+ fixedPacket.actions[4] = action;
+
+ action = new NetworkInventoryAction();
+ action.sourceType = NetworkInventoryAction.SOURCE_TODO;
+ action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_INPUT;
+ action.inventorySlot = 0;
+ action.oldItem = toEquipment.clone();
+ action.newItem = fromEquipment.clone();
+ fixedPacket.actions[5] = action;
+
+ action = new NetworkInventoryAction();
+ action.sourceType = NetworkInventoryAction.SOURCE_TODO;
+ action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_MATERIAL;
+ action.inventorySlot = 1;
+ action.oldItem = toIngredient.clone();
+ action.newItem = fromIngredient.clone();
+ fixedPacket.actions[6] = action;
+
+ action = new NetworkInventoryAction();
+ action.sourceType = NetworkInventoryAction.SOURCE_TODO;
+ action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_MATERIAL;
+ action.inventorySlot = 3;
+ action.oldItem = toTemplate.clone();
+ action.newItem = fromTemplate.clone();
+ fixedPacket.actions[7] = action;
+
+ transactionPacket = fixedPacket;
+ }
+ }
+
+ List actions = new ArrayList<>();
+ for (NetworkInventoryAction networkInventoryAction : transactionPacket.actions) {
+ InventoryAction a = networkInventoryAction.createInventoryAction(this);
+
+ if (a == null) {
+ this.getServer().getLogger().debug("Unmatched inventory action from " + this.username + ": " + networkInventoryAction);
+ this.needSendInventory = true;
+ return;
+ }
- InventoryTransactionPacket transactionPacket = (InventoryTransactionPacket) packet;
- // Nasty hack because the client won't change the right packet in survival when creating netherite stuff,
- // so we are emulating what Mojang should be sending
- if (getWindowById(SMITHING_WINDOW_ID) instanceof SmithingInventory smithingInventory
- && transactionPacket.transactionType == InventoryTransactionPacket.TYPE_MISMATCH
- && !smithingInventory.getResult().isNull()) {
- InventoryTransactionPacket fixedPacket = new InventoryTransactionPacket();
- fixedPacket.isRepairItemPart = true;
- fixedPacket.actions = new NetworkInventoryAction[8];
-
- Item fromIngredient = smithingInventory.getIngredient().clone();
- Item toIngredient = fromIngredient.decrement(1);
-
- Item fromEquipment = smithingInventory.getEquipment().clone();
- Item toEquipment = fromEquipment.decrement(1);
-
- Item fromTemplate = smithingInventory.getTemplate().clone();
- Item toTemplate = fromTemplate.decrement(1);
-
- Item fromResult = Item.get(Item.AIR);
- Item toResult = smithingInventory.getResult().clone();
-
- NetworkInventoryAction action = new NetworkInventoryAction();
- action.windowId = ContainerIds.UI;
- action.inventorySlot = SmithingInventory.SMITHING_INGREDIENT_UI_SLOT;
- action.oldItem = fromIngredient.clone();
- action.newItem = toIngredient.clone();
- fixedPacket.actions[0] = action;
-
- action = new NetworkInventoryAction();
- action.windowId = ContainerIds.UI;
- action.inventorySlot = SmithingInventory.SMITHING_EQUIPMENT_UI_SLOT;
- action.oldItem = fromEquipment.clone();
- action.newItem = toEquipment.clone();
- fixedPacket.actions[1] = action;
-
-
- action = new NetworkInventoryAction();
- action.windowId = ContainerIds.UI;
- action.inventorySlot = SmithingInventory.SMITHING_TEMPLATE_UI_SLOT;
- action.oldItem = fromTemplate.clone();
- action.newItem = toTemplate.clone();
- fixedPacket.actions[2] = action;
-
- int emptyPlayerSlot = -1;
- for (int slot = 0; slot < inventory.getSize(); slot++) {
- if (inventory.getItem(slot).isNull()) {
- emptyPlayerSlot = slot;
- break;
- }
- }
- if (emptyPlayerSlot == -1) {
- this.needSendInventory = true;
- return;
- } else {
- action = new NetworkInventoryAction();
- action.windowId = ContainerIds.INVENTORY;
- action.inventorySlot = emptyPlayerSlot; // Cursor
- action.oldItem = Item.get(Item.AIR);
- action.newItem = toResult.clone();
- fixedPacket.actions[3] = action;
-
- action = new NetworkInventoryAction();
- action.sourceType = NetworkInventoryAction.SOURCE_TODO;
- action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_RESULT;
- action.inventorySlot = 2; // result
- action.oldItem = toResult.clone();
- action.newItem = fromResult.clone();
- fixedPacket.actions[4] = action;
-
- action = new NetworkInventoryAction();
- action.sourceType = NetworkInventoryAction.SOURCE_TODO;
- action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_INPUT;
- action.inventorySlot = 0; // equipment
- action.oldItem = toEquipment.clone();
- action.newItem = fromEquipment.clone();
- fixedPacket.actions[5] = action;
-
- action = new NetworkInventoryAction();
- action.sourceType = NetworkInventoryAction.SOURCE_TODO;
- action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_MATERIAL;
- action.inventorySlot = 1; // material
- action.oldItem = toIngredient.clone();
- action.newItem = fromIngredient.clone();
- fixedPacket.actions[6] = action;
-
- action = new NetworkInventoryAction();
- action.sourceType = NetworkInventoryAction.SOURCE_TODO;
- action.windowId = NetworkInventoryAction.SOURCE_TYPE_ANVIL_MATERIAL;
- action.inventorySlot = 3; // template
- action.oldItem = toTemplate.clone();
- action.newItem = fromTemplate.clone();
- fixedPacket.actions[7] = action;
-
- transactionPacket = fixedPacket;
+ actions.add(a);
+ }
+
+ if (transactionPacket.isCraftingPart) {
+ if (LoomTransaction.isIn(actions)) {
+ if (this.loomTransaction == null) {
+ this.loomTransaction = new LoomTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.loomTransaction.addAction(action);
}
}
-
- List actions = new ArrayList<>();
- for (NetworkInventoryAction networkInventoryAction : transactionPacket.actions) {
- InventoryAction a = networkInventoryAction.createInventoryAction(this);
-
- if (a == null) {
- this.getServer().getLogger().debug("Unmatched inventory action from " + this.username + ": " + networkInventoryAction);
- this.needSendInventory = true;
- break packetswitch;
+ if (this.loomTransaction.canExecute()) {
+ if (this.loomTransaction.execute()) {
+ level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_LOOM_USE);
}
-
- actions.add(a);
}
+ this.loomTransaction = null;
+ return;
+ }
- if (transactionPacket.isCraftingPart) {
- if (LoomTransaction.isIn(actions)) {
- if (this.loomTransaction == null) {
- this.loomTransaction = new LoomTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.loomTransaction.addAction(action);
- }
- }
- if (this.loomTransaction.canExecute()) {
- if (this.loomTransaction.execute()) {
- level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_LOOM_USE);
- }
- }
- this.loomTransaction = null; // Must be here or stuff will break
- return;
+ if (StonecutterTransaction.isIn(actions)) {
+ if (this.stonecutterTransaction == null) {
+ this.stonecutterTransaction = new StonecutterTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.stonecutterTransaction.addAction(action);
}
-
- if (StonecutterTransaction.isIn(actions)) {
- if (this.stonecutterTransaction == null) {
- this.stonecutterTransaction = new StonecutterTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.stonecutterTransaction.addAction(action);
- }
- }
- if (this.stonecutterTransaction.canExecute()) {
- if (this.stonecutterTransaction.execute()) {
- level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_STONECUTTER_USE);
- }
- this.stonecutterTransaction = null;
- } else if (this.stonecutterTransaction.getActionList().size() >= 4) {
- // 切石机操作最多 4 个 action,超过说明数据已损坏
- this.setNeedSendInventory(true);
- this.stonecutterTransaction = null;
- }
- return;
+ }
+ if (this.stonecutterTransaction.canExecute()) {
+ if (this.stonecutterTransaction.execute()) {
+ level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_STONECUTTER_USE);
}
+ this.stonecutterTransaction = null;
+ } else if (this.stonecutterTransaction.getActionList().size() >= 4) {
+ this.setNeedSendInventory(true);
+ this.stonecutterTransaction = null;
+ }
+ return;
+ }
- if (this.craftingTransaction == null) {
- this.craftingTransaction = new CraftingTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.craftingTransaction.addAction(action);
- }
- }
+ if (this.craftingTransaction == null) {
+ this.craftingTransaction = new CraftingTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.craftingTransaction.addAction(action);
+ }
+ }
- if (this.craftingTransaction.getPrimaryOutput() != null && this.craftingTransaction.canExecute()) {
- try {
- this.craftingTransaction.execute();
- } catch (Exception e) {
- this.server.getLogger().debug("Executing crafting transaction failed");
- }
- this.craftingTransaction = null;
+ if (this.craftingTransaction.getPrimaryOutput() != null && this.craftingTransaction.canExecute()) {
+ try {
+ this.craftingTransaction.execute();
+ } catch (Exception e) {
+ this.server.getLogger().debug("Executing crafting transaction failed");
+ }
+ this.craftingTransaction = null;
+ }
+ return;
+ } else if (this.protocol >= ProtocolInfo.v1_16_0 && transactionPacket.isEnchantingPart) {
+ if (this.enchantTransaction == null) {
+ this.enchantTransaction = new EnchantTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.enchantTransaction.addAction(action);
+ }
+ }
+ if (this.enchantTransaction.canExecute()) {
+ this.enchantTransaction.execute();
+ this.enchantTransaction = null;
+ }
+ return;
+ } else if (this.protocol >= ProtocolInfo.v1_16_0 && transactionPacket.isRepairItemPart) {
+ Sound sound = null;
+ if (SmithingTransaction.isIn(actions)) {
+ if (this.smithingTransaction == null) {
+ this.smithingTransaction = new SmithingTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.smithingTransaction.addAction(action);
}
- return;
- } else if (this.protocol >= ProtocolInfo.v1_16_0 && transactionPacket.isEnchantingPart) {
- if (this.enchantTransaction == null) {
- this.enchantTransaction = new EnchantTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.enchantTransaction.addAction(action);
+ }
+ if (this.smithingTransaction.canExecute()) {
+ try {
+ if (this.smithingTransaction.execute()) {
+ sound = Sound.SMITHING_TABLE_USE;
}
+ } finally {
+ this.smithingTransaction = null;
}
- if (this.enchantTransaction.canExecute()) {
- this.enchantTransaction.execute();
- this.enchantTransaction = null;
- }
- return;
- } else if (this.protocol >= ProtocolInfo.v1_16_0 && transactionPacket.isRepairItemPart) {
- Sound sound = null;
- if (SmithingTransaction.isIn(actions)) {
- if (this.smithingTransaction == null) {
- this.smithingTransaction = new SmithingTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.smithingTransaction.addAction(action);
- }
- }
- if (this.smithingTransaction.canExecute()) {
- try {
- if (this.smithingTransaction.execute()) {
- sound = Sound.SMITHING_TABLE_USE;
- }
- } finally {
- this.smithingTransaction = null;
- }
- }
- } else if (GrindstoneTransaction.isIn(actions)) {
- if (this.grindstoneTransaction == null) {
- this.grindstoneTransaction = new GrindstoneTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.grindstoneTransaction.addAction(action);
- }
- }
- if (this.grindstoneTransaction.canExecute()) {
- if (this.grindstoneTransaction.execute()) {
- Collection players = level.getChunkPlayers(getChunkX(), getChunkZ()).values();
- players.remove(this);
- if (!players.isEmpty()) {
- level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_GRINDSTONE_USE);
- }
- int exp = this.grindstoneTransaction.getExperienceDropped();
- if (exp > 0) {
- Inventory grindstoneInv = this.getWindowById(Player.GRINDSTONE_WINDOW_ID);
- if (grindstoneInv instanceof GrindstoneInventory gInv) {
- Position grindstonePos = gInv.getHolder();
- level.dropExpOrb(grindstonePos.add(0.5, 0.5, 0.5), exp);
- }
- }
- }
- this.grindstoneTransaction = null;
- }
- } else {
- if (this.repairItemTransaction == null) {
- this.repairItemTransaction = new RepairItemTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.repairItemTransaction.addAction(action);
- }
- }
- if (this.repairItemTransaction.canExecute()) {
- this.repairItemTransaction.execute();
- this.repairItemTransaction = null;
- }
+ }
+ } else if (GrindstoneTransaction.isIn(actions)) {
+ if (this.grindstoneTransaction == null) {
+ this.grindstoneTransaction = new GrindstoneTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.grindstoneTransaction.addAction(action);
}
-
- if (sound != null) {
+ }
+ if (this.grindstoneTransaction.canExecute()) {
+ if (this.grindstoneTransaction.execute()) {
Collection players = level.getChunkPlayers(getChunkX(), getChunkZ()).values();
players.remove(this);
if (!players.isEmpty()) {
- level.addSound(this, sound, 1f, 1f, players);
- }
- }
- return;
- } else if (transactionPacket.isTradeItemPart) {
- if (this.tradingTransaction == null) {
- this.tradingTransaction = new TradingTransaction(this, actions);
- } else {
- for (InventoryAction action : actions) {
- this.tradingTransaction.addAction(action);
+ level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_GRINDSTONE_USE);
}
- }
- if (this.tradingTransaction.canExecute()) {
- this.tradingTransaction.execute();
-
- for (Inventory inventory : this.tradingTransaction.getInventories()) {
-
- if (inventory instanceof TradeInventory tradeInventory) {
- EntityVillager ent = tradeInventory.getHolder();
- ent.namedTag.putBoolean("traded", true);
- for (Tag tag : ent.getRecipes().getAll()) {
- CompoundTag ta = (CompoundTag) tag;
- if (ta.getCompound("buyA").getShort("id") == tradeInventory.getItem(0).getId()) {
- int tradeXP = ta.getInt("traderExp");
- this.addExperience(ta.getByte("rewardExp"));
- ent.addExperience(tradeXP);
- this.level.addSound(this, Sound.RANDOM_ORB, 0,3f, this);
- }
- }
+ int exp = this.grindstoneTransaction.getExperienceDropped();
+ if (exp > 0) {
+ Inventory grindstoneInv = this.getWindowById(Player.GRINDSTONE_WINDOW_ID);
+ if (grindstoneInv instanceof GrindstoneInventory gInv) {
+ Position grindstonePos = gInv.getHolder();
+ level.dropExpOrb(grindstonePos.add(0.5, 0.5, 0.5), exp);
}
}
-
- this.tradingTransaction = null;
}
- return;
- } else if (this.craftingTransaction != null) {
- if (!handleQuickCraft(transactionPacket, actions, this.craftingTransaction)) this.craftingTransaction = null;
- return;
- } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.enchantTransaction != null) {
- if (!handleQuickCraft(transactionPacket, actions, this.enchantTransaction)) this.enchantTransaction = null;
- return;
- } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.repairItemTransaction != null) {
- if (!handleQuickCraft(transactionPacket, actions, this.repairItemTransaction)) this.repairItemTransaction = null;
- return;
- } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.smithingTransaction != null) {
- if (!handleQuickCraft(transactionPacket, actions, this.smithingTransaction)) this.smithingTransaction = null;
- return;
- } else if (this.grindstoneTransaction != null) {
- if (!handleQuickCraft(transactionPacket, actions, this.grindstoneTransaction)) this.grindstoneTransaction = null;
- return;
+ this.grindstoneTransaction = null;
}
+ } else {
+ if (this.repairItemTransaction == null) {
+ this.repairItemTransaction = new RepairItemTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.repairItemTransaction.addAction(action);
+ }
+ }
+ if (this.repairItemTransaction.canExecute()) {
+ this.repairItemTransaction.execute();
+ this.repairItemTransaction = null;
+ }
+ }
- switch (transactionPacket.transactionType) {
- case InventoryTransactionPacket.TYPE_NORMAL:
- InventoryTransaction transaction = new InventoryTransaction(this, actions);
-
- if (!transaction.execute()) {
- this.server.getLogger().debug("Failed to execute inventory transaction from " + this.username + " with actions: " + Arrays.toString(transactionPacket.actions));
- failedTransactions++;
- if (failedTransactions > 15) { //撤回合成事件时,如果玩家点的太快会到12
- this.close("", "Too many failed inventory transactions");
+ if (sound != null) {
+ Collection players = level.getChunkPlayers(getChunkX(), getChunkZ()).values();
+ players.remove(this);
+ if (!players.isEmpty()) {
+ level.addSound(this, sound, 1f, 1f, players);
+ }
+ }
+ return;
+ } else if (transactionPacket.isTradeItemPart) {
+ if (this.tradingTransaction == null) {
+ this.tradingTransaction = new TradingTransaction(this, actions);
+ } else {
+ for (InventoryAction action : actions) {
+ this.tradingTransaction.addAction(action);
+ }
+ }
+ if (this.tradingTransaction.canExecute()) {
+ this.tradingTransaction.execute();
+
+ for (Inventory tradeInventoryView : this.tradingTransaction.getInventories()) {
+ if (tradeInventoryView instanceof TradeInventory tradeInventory) {
+ EntityVillager ent = tradeInventory.getHolder();
+ ent.namedTag.putBoolean("traded", true);
+ for (Tag tag : ent.getRecipes().getAll()) {
+ CompoundTag ta = (CompoundTag) tag;
+ if (ta.getCompound("buyA").getShort("id") == tradeInventory.getItem(0).getId()) {
+ int tradeXP = ta.getInt("traderExp");
+ this.addExperience(ta.getByte("rewardExp"));
+ ent.addExperience(tradeXP);
+ this.level.addSound(this, Sound.RANDOM_ORB, 0, 3f, this);
}
- break packetswitch;
}
+ }
+ }
- break packetswitch;
- case InventoryTransactionPacket.TYPE_MISMATCH:
- if (transactionPacket.actions.length > 0) {
- this.server.getLogger().debug("Expected 0 actions for mismatch, got " + transactionPacket.actions.length + ", " + Arrays.toString(transactionPacket.actions));
- }
+ this.tradingTransaction = null;
+ }
+ return;
+ } else if (this.craftingTransaction != null) {
+ if (!handleQuickCraft(transactionPacket, actions, this.craftingTransaction)) {
+ this.craftingTransaction = null;
+ }
+ return;
+ } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.enchantTransaction != null) {
+ if (!handleQuickCraft(transactionPacket, actions, this.enchantTransaction)) {
+ this.enchantTransaction = null;
+ }
+ return;
+ } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.repairItemTransaction != null) {
+ if (!handleQuickCraft(transactionPacket, actions, this.repairItemTransaction)) {
+ this.repairItemTransaction = null;
+ }
+ return;
+ } else if (this.protocol >= ProtocolInfo.v1_16_0 && this.smithingTransaction != null) {
+ if (!handleQuickCraft(transactionPacket, actions, this.smithingTransaction)) {
+ this.smithingTransaction = null;
+ }
+ return;
+ } else if (this.grindstoneTransaction != null) {
+ if (!handleQuickCraft(transactionPacket, actions, this.grindstoneTransaction)) {
+ this.grindstoneTransaction = null;
+ }
+ return;
+ }
+
+ boolean resyncInventoryAfterLegacyTransaction = false;
+ try {
+ transactionSwitch:
+ switch (transactionPacket.transactionType) {
+ case InventoryTransactionPacket.TYPE_NORMAL:
+ if (this.isInventoryServerAuthoritative()) {
+ this.server.getLogger().debug(this.username + ": dropping legacy InventoryTransaction TYPE_NORMAL while SAI is enabled");
this.needSendInventory = true;
- break packetswitch;
- case InventoryTransactionPacket.TYPE_USE_ITEM:
- UseItemData useItemData;
- BlockVector3 blockVector;
-
- try {
- useItemData = (UseItemData) transactionPacket.transactionData;
- blockVector = useItemData.blockPos;
- face = useItemData.face;
- } catch (Exception ignored) {
- break packetswitch;
- }
+ break;
+ }
- if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) {
- inventory.equipItem(useItemData.hotbarSlot);
+ InventoryTransaction transaction = new InventoryTransaction(this, actions);
+ if (!transaction.execute()) {
+ this.server.getLogger().debug("Failed to execute inventory transaction from " + this.username + " with actions: " + Arrays.toString(transactionPacket.actions));
+ failedTransactions++;
+ if (failedTransactions > 15) {
+ this.close("", "Too many failed inventory transactions");
}
+ }
+ break;
+ case InventoryTransactionPacket.TYPE_MISMATCH:
+ if (transactionPacket.actions.length > 0) {
+ this.server.getLogger().debug("Expected 0 actions for mismatch, got " + transactionPacket.actions.length + ", " + Arrays.toString(transactionPacket.actions));
+ }
+ this.needSendInventory = true;
+ break;
+ case InventoryTransactionPacket.TYPE_USE_ITEM:
+ resyncInventoryAfterLegacyTransaction = this.isInventoryServerAuthoritative();
+ UseItemData useItemData;
+ BlockVector3 blockVector;
- switch (useItemData.actionType) {
- case InventoryTransactionPacket.USE_ITEM_ACTION_CLICK_BLOCK:
- boolean spamming = !server.doNotLimitInteractions
- && lastRightClickPos != null
- && System.currentTimeMillis() - lastRightClickTime < 100.0
- && blockVector.distanceSquared(lastRightClickPos) < 0.00001;
-
- lastRightClickPos = blockVector;
- lastRightClickTime = System.currentTimeMillis();
-
- // Hack: Fix client spamming right clicks
- // 假旁观模式下也需要防止重复点击,避免事件被多次触发
- // Fake spectator mode also needs to prevent duplicate clicks to avoid multiple event triggers
- if (spamming && (this.getInventory().getItemInHandFast().getBlockId() == BlockID.AIR
- || (this.isSpectator() && !this.server.useClientSpectator))) {
- return;
- }
+ try {
+ useItemData = (UseItemData) transactionPacket.transactionData;
+ blockVector = useItemData.blockPos;
+ face = useItemData.face;
+ } catch (Exception ignored) {
+ break;
+ }
- this.setUsingItem(false);
+ if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) {
+ inventory.equipItem(useItemData.hotbarSlot);
+ }
- if (!(this.distance(blockVector.asVector3()) > (this.isCreative() ? 13 : 7))) {
- // 创造模式或假旁观模式(未启用客户端旁观):允许触发事件但不消耗物品
- // Creative mode or fake spectator mode (client spectator not enabled): Allow event trigger but don't consume items
- if (this.isCreative() || (this.isSpectator() && !this.server.useClientSpectator)) {
- if (this.level.useItemOn(blockVector.asVector3(), inventory.getItemInHand(), face, useItemData.clickPos.x, useItemData.clickPos.y, useItemData.clickPos.z, this) != null) {
- break packetswitch;
- }
- } else {
- Item serverItem = inventory.getItemInHand();
- Item clientItem = useItemData.itemInHand;
-
- // 默认严格检查(equals 不检查数量,所以客户端预测消耗减少数量的情况会自动通过)
- boolean canProceed = serverItem.equals(clientItem);
-
- // 特殊情况:客户端预测完全消耗(变成空气)
- // 条件:服务端是可激活物品且数量为1,客户端是空气
- if (!canProceed && clientItem.isNull()
- && serverItem.getCount() == 1
- && serverItem.canBeActivated()) {
- canProceed = true;
- }
+ switch (useItemData.actionType) {
+ case InventoryTransactionPacket.USE_ITEM_ACTION_CLICK_BLOCK:
+ boolean spamming = !server.doNotLimitInteractions
+ && lastRightClickPos != null
+ && System.currentTimeMillis() - lastRightClickTime < 100.0
+ && blockVector.distanceSquared(lastRightClickPos) < 0.00001;
+
+ lastRightClickPos = blockVector;
+ lastRightClickTime = System.currentTimeMillis();
+
+ if (spamming && (this.getInventory().getItemInHandFast().getBlockId() == BlockID.AIR
+ || (this.isSpectator() && !this.server.useClientSpectator))) {
+ return;
+ }
+
+ this.setUsingItem(false);
+
+ if (!(this.distance(blockVector.asVector3()) > (this.isCreative() ? 13 : 7))) {
+ if (this.isCreative() || (this.isSpectator() && !this.server.useClientSpectator)) {
+ if (this.level.useItemOn(blockVector.asVector3(), inventory.getItemInHand(), face, useItemData.clickPos.x, useItemData.clickPos.y, useItemData.clickPos.z, this) != null) {
+ break transactionSwitch;
+ }
+ } else {
+ Item serverItem = inventory.getItemInHand();
+ Item clientItem = useItemData.itemInHand;
+
+ boolean canProceed = serverItem.equals(clientItem);
+ if (!canProceed && clientItem.isNull()
+ && serverItem.getCount() == 1
+ && serverItem.canBeActivated()) {
+ canProceed = true;
+ }
- if (canProceed) {
- Item i = serverItem;
- Item oldItem = i.clone();
- if ((i = this.level.useItemOn(blockVector.asVector3(), i, face, useItemData.clickPos.x, useItemData.clickPos.y, useItemData.clickPos.z, this)) != null) {
- if (!i.equals(oldItem) || i.getCount() != oldItem.getCount()) {
- if (oldItem.getId() == i.getId() || i.getId() == 0) {
- inventory.setItemInHand(i);
- } else {
- server.getLogger().debug("Tried to set item " + i.getId() + " but " + this.username + " had item " + oldItem.getId() + " in their hand slot");
- }
- inventory.sendHeldItem(this.getViewers().values());
+ if (canProceed) {
+ Item i = serverItem;
+ Item oldItem = i.clone();
+ if ((i = this.level.useItemOn(blockVector.asVector3(), i, face, useItemData.clickPos.x, useItemData.clickPos.y, useItemData.clickPos.z, this)) != null) {
+ if (!i.equals(oldItem) || i.getCount() != oldItem.getCount()) {
+ if (oldItem.getId() == i.getId() || i.getId() == 0) {
+ inventory.setItemInHand(i);
+ } else {
+ server.getLogger().debug("Tried to set item " + i.getId() + " but " + this.username + " had item " + oldItem.getId() + " in their hand slot");
}
- break packetswitch;
- } else {
- // useItemOn 返回 null(如事件被取消),需要重同步物品到客户端
- this.needSendHeldItem = true;
+ inventory.sendHeldItem(this.getViewers().values());
}
+ break transactionSwitch;
} else {
this.needSendHeldItem = true;
}
+ } else {
+ this.needSendHeldItem = true;
}
}
+ }
- if (blockVector.distanceSquared(this) > 10000) {
- break packetswitch;
- }
-
- Block target = this.level.getBlock(blockVector.asVector3());
- block = target.getSide(face);
-
- this.level.sendBlocks(new Player[]{this}, new Block[]{target, block}, UpdateBlockPacket.FLAG_NOGRAPHIC);
- this.level.sendBlocks(new Player[]{this}, new Block[]{target.getLevelBlockAtLayer(1), block.getLevelBlockAtLayer(1)}, UpdateBlockPacket.FLAG_NOGRAPHIC, 1);
-
- if (target instanceof BlockDoor) {
- BlockDoor door = (BlockDoor) target;
+ if (blockVector.distanceSquared(this) > 10000) {
+ break;
+ }
- Block part;
+ Block target = this.level.getBlock(blockVector.asVector3());
+ block = target.getSide(face);
- if ((door.getDamage() & 0x08) > 0) {
- part = target.down();
+ this.level.sendBlocks(new Player[]{this}, new Block[]{target, block}, UpdateBlockPacket.FLAG_NOGRAPHIC);
+ this.level.sendBlocks(new Player[]{this}, new Block[]{target.getLevelBlockAtLayer(1), block.getLevelBlockAtLayer(1)}, UpdateBlockPacket.FLAG_NOGRAPHIC, 1);
- if (part.getId() == target.getId()) {
- target = part;
- this.level.sendBlocks(new Player[]{this}, new Block[]{target}, UpdateBlockPacket.FLAG_NOGRAPHIC);
- this.level.sendBlocks(new Player[]{this}, new Block[]{target.getLevelBlockAtLayer(1)}, UpdateBlockPacket.FLAG_NOGRAPHIC, 1);
- }
- }
+ if (target instanceof BlockDoor door && (door.getDamage() & 0x08) > 0) {
+ Block part = target.down();
+ if (part.getId() == target.getId()) {
+ target = part;
+ this.level.sendBlocks(new Player[]{this}, new Block[]{target}, UpdateBlockPacket.FLAG_NOGRAPHIC);
+ this.level.sendBlocks(new Player[]{this}, new Block[]{target.getLevelBlockAtLayer(1)}, UpdateBlockPacket.FLAG_NOGRAPHIC, 1);
}
- break packetswitch;
- case InventoryTransactionPacket.USE_ITEM_ACTION_BREAK_BLOCK:
- if (!this.spawned || !this.isAlive()) {
- break packetswitch;
- }
-
- this.resetCraftingGridType();
+ }
+ break;
+ case InventoryTransactionPacket.USE_ITEM_ACTION_BREAK_BLOCK:
+ if (!this.spawned || !this.isAlive()) {
+ break;
+ }
- Item i = this.getInventory().getItemInHand();
+ this.resetCraftingGridType();
- Item oldItem = i.clone();
+ Item i = this.getInventory().getItemInHand();
+ Item oldItem = i.clone();
- if (this.canInteract(blockVector.add(0.5, 0.5, 0.5), this.isCreative() ? 13 : 7) && (i = this.level.useBreakOn(blockVector.asVector3(), face, i, this, true)) != null) {
- if (this.isSurvival() || this.isAdventure()) {
- this.foodData.updateFoodExpLevel(0.005);
- if (!i.equals(oldItem) || i.getCount() != oldItem.getCount()) {
- if (oldItem.getId() == i.getId() || i.getId() == 0) {
- inventory.setItemInHand(i);
- } else {
- server.getLogger().debug("Tried to set item " + i.getId() + " but " + this.username + " had item " + oldItem.getId() + " in their hand slot");
- }
- inventory.sendHeldItem(this.getViewers().values());
+ if (this.canInteract(blockVector.add(0.5, 0.5, 0.5), this.isCreative() ? 13 : 7)
+ && (i = this.level.useBreakOn(blockVector.asVector3(), face, i, this, true)) != null) {
+ if (this.isSurvival() || this.isAdventure()) {
+ this.foodData.updateFoodExpLevel(0.005);
+ if (!i.equals(oldItem) || i.getCount() != oldItem.getCount()) {
+ if (oldItem.getId() == i.getId() || i.getId() == 0) {
+ inventory.setItemInHand(i);
+ } else {
+ server.getLogger().debug("Tried to set item " + i.getId() + " but " + this.username + " had item " + oldItem.getId() + " in their hand slot");
}
+ inventory.sendHeldItem(this.getViewers().values());
}
- break packetswitch;
}
+ break;
+ }
- inventory.sendContents(this);
- inventory.sendHeldItem(this);
+ inventory.sendContents(this);
+ inventory.sendHeldItem(this);
- if (blockVector.distanceSquared(this) < 10000) {
- target = this.level.getBlock(blockVector.asVector3());
- this.level.sendBlocks(new Player[]{this}, new Block[]{target}, UpdateBlockPacket.FLAG_ALL_PRIORITY);
+ if (blockVector.distanceSquared(this) < 10000) {
+ target = this.level.getBlock(blockVector.asVector3());
+ this.level.sendBlocks(new Player[]{this}, new Block[]{target}, UpdateBlockPacket.FLAG_ALL_PRIORITY);
- BlockEntity blockEntity = this.level.getBlockEntity(blockVector.asVector3());
- if (blockEntity instanceof BlockEntitySpawnable) {
- ((BlockEntitySpawnable) blockEntity).spawnTo(this);
- }
+ BlockEntity blockEntity = this.level.getBlockEntity(blockVector.asVector3());
+ if (blockEntity instanceof BlockEntitySpawnable) {
+ ((BlockEntitySpawnable) blockEntity).spawnTo(this);
}
+ }
+ break;
+ case InventoryTransactionPacket.USE_ITEM_ACTION_CLICK_AIR:
+ long currentClickAirTime = System.currentTimeMillis();
+ if (!server.doNotLimitInteractions && (currentClickAirTime - lastClickAirTime) < 100) {
+ return;
+ }
+ lastClickAirTime = currentClickAirTime;
- break packetswitch;
- case InventoryTransactionPacket.USE_ITEM_ACTION_CLICK_AIR:
- // 防止右键点击空气的重复触发
- // Prevent duplicate triggers of right-click on air
- long currentClickAirTime = System.currentTimeMillis();
- if (!server.doNotLimitInteractions && (currentClickAirTime - lastClickAirTime) < 100) {
- return;
- }
- lastClickAirTime = currentClickAirTime;
+ Vector3 directionVector = this.getDirectionVector();
+ if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) {
+ inventory.equipItem(useItemData.hotbarSlot);
+ }
- Vector3 directionVector = this.getDirectionVector();
+ item = this.inventory.getItemInHand();
+ if (item instanceof ItemCrossbow && !item.onClickAir(this, directionVector)) {
+ return;
+ }
- if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) {
- inventory.equipItem(useItemData.hotbarSlot);
- }
+ if (!item.equalsFast(useItemData.itemInHand)) {
+ this.needSendHeldItem = true;
+ break;
+ }
+
+ PlayerInteractEvent interactEvent = new PlayerInteractEvent(this, item, directionVector, face, Action.RIGHT_CLICK_AIR);
+ this.server.getPluginManager().callEvent(interactEvent);
- item = this.inventory.getItemInHand();
+ if (interactEvent.isCancelled()) {
+ this.needSendHeldItem = true;
+ break;
+ }
- if (item instanceof ItemCrossbow) {
- if (!item.onClickAir(this, directionVector)) {
- return; // Shoot
+ if (item.onClickAir(this, directionVector)) {
+ if (this.isSurvival() || this.isAdventure()) {
+ if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
+ this.inventory.setItemInHand(item);
+ } else {
+ server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
}
}
- if (!item.equalsFast(useItemData.itemInHand)) {
- this.needSendHeldItem = true;
- break packetswitch;
+ if (!this.isUsingItem()) {
+ this.setUsingItem(item.canRelease());
+ break;
}
- PlayerInteractEvent interactEvent = new PlayerInteractEvent(this, item, directionVector, face, Action.RIGHT_CLICK_AIR);
-
- this.server.getPluginManager().callEvent(interactEvent);
-
- if (interactEvent.isCancelled()) {
- this.needSendHeldItem = true;
- break packetswitch;
+ int ticksUsed = this.server.getTick() - this.startAction;
+ this.setUsingItem(false);
+ if (!item.onUse(this, ticksUsed)) {
+ this.inventory.sendContents(this);
}
+ }
+ break;
+ default:
+ break;
+ }
+ break;
+ case InventoryTransactionPacket.TYPE_USE_ITEM_ON_ENTITY:
+ resyncInventoryAfterLegacyTransaction = this.isInventoryServerAuthoritative();
+ UseItemOnEntityData useItemOnEntityData = (UseItemOnEntityData) transactionPacket.transactionData;
- if (item.onClickAir(this, directionVector)) {
- // 假旁观模式:不消耗物品,类似创造模式
- // Fake spectator mode: Don't consume items, similar to creative mode
- if (this.isSurvival() || this.isAdventure()) {
- if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
- this.inventory.setItemInHand(item);
- } else {
- server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
- }
- }
+ Entity target = this.level.getEntity(useItemOnEntityData.entityRuntimeId);
+ if (target == null) {
+ return;
+ }
- if (!this.isUsingItem()) {
- this.setUsingItem(/*true*/ item.canRelease());
- break packetswitch;
- }
+ if (inventory.getHeldItemIndex() != useItemOnEntityData.hotbarSlot) {
+ inventory.equipItem(useItemOnEntityData.hotbarSlot);
+ }
- // Used item
- int ticksUsed = this.server.getTick() - this.startAction;
- this.setUsingItem(false);
- if (!item.onUse(this, ticksUsed)) {
- this.inventory.sendContents(this);
- }
- }
+ if (!useItemOnEntityData.itemInHand.equalsFast(this.inventory.getItemInHand())) {
+ this.inventory.sendHeldItem(this);
+ }
- break packetswitch;
- default:
- break;
- }
- break;
- case InventoryTransactionPacket.TYPE_USE_ITEM_ON_ENTITY:
- UseItemOnEntityData useItemOnEntityData = (UseItemOnEntityData) transactionPacket.transactionData;
+ item = this.inventory.getItemInHand();
- Entity target = this.level.getEntity(useItemOnEntityData.entityRuntimeId);
- if (target == null) {
- return;
- }
+ switch (useItemOnEntityData.actionType) {
+ case InventoryTransactionPacket.USE_ITEM_ON_ENTITY_ACTION_INTERACT:
+ if (this.distanceSquared(target) > 256) {
+ this.getServer().getLogger().debug(username + ": target entity is too far away");
+ return;
+ }
- if (inventory.getHeldItemIndex() != useItemOnEntityData.hotbarSlot) {
- inventory.equipItem(useItemOnEntityData.hotbarSlot);
- }
+ this.breakingBlock = null;
+ this.setUsingItem(false);
- if (!useItemOnEntityData.itemInHand.equalsFast(this.inventory.getItemInHand())) {
- this.inventory.sendHeldItem(this);
- }
+ PlayerInteractEntityEvent playerInteractEntityEvent = new PlayerInteractEntityEvent(this, target, item, useItemOnEntityData.clickPos);
+ if (this.isSpectator()) {
+ playerInteractEntityEvent.setCancelled();
+ }
+ getServer().getPluginManager().callEvent(playerInteractEntityEvent);
- item = this.inventory.getItemInHand();
+ if (playerInteractEntityEvent.isCancelled()) {
+ break;
+ }
- switch (useItemOnEntityData.actionType) {
- case InventoryTransactionPacket.USE_ITEM_ON_ENTITY_ACTION_INTERACT:
- if (this.distanceSquared(target) > 256) { // TODO: Note entity scale
- this.getServer().getLogger().debug(username + ": target entity is too far away");
- return;
+ if (target.onInteract(this, item, useItemOnEntityData.clickPos) && (this.isSurvival() || this.isAdventure())) {
+ if (item.isTool()) {
+ if (item.useOn(target) && item.getDamage() >= item.getMaxDurability()) {
+ level.addSoundToViewers(this, Sound.RANDOM_BREAK);
+ level.addParticle(new ItemBreakParticle(this, item));
+ item = new ItemBlock(Block.get(BlockID.AIR));
+ }
+ } else if (item.count > 1) {
+ item.count--;
+ } else {
+ item = new ItemBlock(Block.get(BlockID.AIR));
}
- this.breakingBlock = null;
-
- this.setUsingItem(false);
-
- PlayerInteractEntityEvent playerInteractEntityEvent = new PlayerInteractEntityEvent(this, target, item, useItemOnEntityData.clickPos);
- if (this.isSpectator()) playerInteractEntityEvent.setCancelled();
- getServer().getPluginManager().callEvent(playerInteractEntityEvent);
-
- if (playerInteractEntityEvent.isCancelled()) {
- break;
+ if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
+ this.inventory.setItemInHand(item);
+ } else {
+ server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
}
+ }
+ break;
+ case InventoryTransactionPacket.USE_ITEM_ON_ENTITY_ACTION_ATTACK:
+ if (target.getId() == this.getId()) {
+ this.kick(PlayerKickEvent.Reason.INVALID_PVP, "Tried to attack invalid player");
+ return;
+ }
- if (target.onInteract(this, item, useItemOnEntityData.clickPos) && (this.isSurvival() || this.isAdventure())) {
- if (item.isTool()) {
- if (item.useOn(target) && item.getDamage() >= item.getMaxDurability()) {
- level.addSoundToViewers(this, Sound.RANDOM_BREAK);
- level.addParticle(new ItemBreakParticle(this, item));
- item = new ItemBlock(Block.get(BlockID.AIR));
- }
- } else {
- if (item.count > 1) {
- item.count--;
- } else {
- item = new ItemBlock(Block.get(BlockID.AIR));
- }
- }
-
- if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
- this.inventory.setItemInHand(item);
- } else {
- server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
- }
- }
+ if (!this.canInteractEntity(target, isCreative() ? 8 : 5)) {
break;
- case InventoryTransactionPacket.USE_ITEM_ON_ENTITY_ACTION_ATTACK:
- if (target.getId() == this.getId()) {
- this.kick(PlayerKickEvent.Reason.INVALID_PVP, "Tried to attack invalid player");
- return;
- }
-
- if (!this.canInteractEntity(target, isCreative() ? 8 : 5)) {
+ } else if (target instanceof Player playerTarget) {
+ if ((playerTarget.gamemode & 0x01) > 0 || !this.server.pvpEnabled) {
break;
- } else if (target instanceof Player) {
- if ((((Player) target).gamemode & 0x01) > 0) {
- break;
- } else if (!this.server.pvpEnabled) {
- break;
- }
}
+ }
- this.breakingBlock = null;
+ this.breakingBlock = null;
+ this.setUsingItem(false);
- this.setUsingItem(false);
+ if (this.sleeping != null) {
+ this.getServer().getLogger().debug(username + ": USE_ITEM_ON_ENTITY_ACTION_ATTACK while sleeping");
+ return;
+ }
- if (this.sleeping != null) {
- this.getServer().getLogger().debug(username + ": USE_ITEM_ON_ENTITY_ACTION_ATTACK while sleeping");
- return;
- }
+ if (this.inventoryOpen) {
+ this.getServer().getLogger().debug(username + ": USE_ITEM_ON_ENTITY_ACTION_ATTACK while viewing inventory");
+ return;
+ }
- if (this.inventoryOpen) {
- this.getServer().getLogger().debug(username + ": USE_ITEM_ON_ENTITY_ACTION_ATTACK while viewing inventory");
- return;
- }
+ Enchantment[] enchantments = item.getEnchantments();
+ float itemDamage = item.getAttackDamage(this);
+ for (Enchantment enchantment : enchantments) {
+ itemDamage += enchantment.getDamageBonus(target, this);
+ }
- Enchantment[] enchantments = item.getEnchantments();
+ Map damage = new EnumMap<>(DamageModifier.class);
+ damage.put(DamageModifier.BASE, itemDamage);
- float itemDamage = item.getAttackDamage(this);
- for (Enchantment enchantment : enchantments) {
- itemDamage += enchantment.getDamageBonus(target, this);
- }
+ float knockBack = 0.3f;
+ Enchantment knockBackEnchantment = item.getEnchantment(Enchantment.ID_KNOCKBACK);
+ if (knockBackEnchantment != null) {
+ knockBack += knockBackEnchantment.getLevel() * 0.1f;
+ }
- Map damage = new EnumMap<>(DamageModifier.class);
- damage.put(DamageModifier.BASE, itemDamage);
+ EntityDamageByEntityEvent entityDamageByEntityEvent = new EntityDamageByEntityEvent(this, target, DamageCause.ENTITY_ATTACK, damage, knockBack, enchantments);
+ entityDamageByEntityEvent.setBreakShield(item.canBreakShield());
+ if (this.isSpectator()) {
+ entityDamageByEntityEvent.setCancelled();
+ }
+ if ((target instanceof Player) && !this.level.getGameRules().getBoolean(GameRule.PVP)) {
+ entityDamageByEntityEvent.setCancelled();
+ }
- float knockBack = 0.3f;
- Enchantment knockBackEnchantment = item.getEnchantment(Enchantment.ID_KNOCKBACK);
- if (knockBackEnchantment != null) {
- knockBack += knockBackEnchantment.getLevel() * 0.1f;
+ if (!target.attack(entityDamageByEntityEvent)) {
+ if (item.isTool() && !this.isCreative()) {
+ this.inventory.sendContents(this);
}
+ break;
+ }
- EntityDamageByEntityEvent entityDamageByEntityEvent = new EntityDamageByEntityEvent(this, target, DamageCause.ENTITY_ATTACK, damage, knockBack, enchantments);
- entityDamageByEntityEvent.setBreakShield(item.canBreakShield());
- if (this.isSpectator()) entityDamageByEntityEvent.setCancelled();
- if ((target instanceof Player) && !this.level.getGameRules().getBoolean(GameRule.PVP)) {
- entityDamageByEntityEvent.setCancelled();
+ for (Enchantment enchantment : item.getEnchantments()) {
+ enchantment.doPostAttack(this, target);
+ }
+
+ if (item.isTool() && !this.isCreative()) {
+ if (item.useOn(target) && item.getDamage() >= item.getMaxDurability()) {
+ level.addSoundToViewers(this, Sound.RANDOM_BREAK);
+ level.addParticle(new ItemBreakParticle(this, item));
+ this.inventory.setItemInHand(Item.get(0));
+ } else if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
+ this.inventory.setItemInHand(item);
+ } else {
+ server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
}
+ }
+ return;
+ default:
+ break;
+ }
+ break;
+ case InventoryTransactionPacket.TYPE_RELEASE_ITEM:
+ resyncInventoryAfterLegacyTransaction = this.isInventoryServerAuthoritative();
+ if (this.isSpectator()) {
+ this.needSendInventory = true;
+ break;
+ }
- if (!target.attack(entityDamageByEntityEvent)) {
- if (item.isTool() && !this.isCreative()) {
+ ReleaseItemData releaseItemData = (ReleaseItemData) transactionPacket.transactionData;
+ try {
+ switch (releaseItemData.actionType) {
+ case InventoryTransactionPacket.RELEASE_ITEM_ACTION_RELEASE:
+ if (this.isUsingItem()) {
+ item = this.inventory.getItemInHand();
+ int ticksUsed = this.server.getTick() - this.startAction;
+ if (!item.onRelease(this, ticksUsed)) {
this.inventory.sendContents(this);
}
- break;
+ this.setUsingItem(false);
+ } else {
+ this.inventory.sendContents(this);
}
-
- for (Enchantment enchantment : item.getEnchantments()) {
- enchantment.doPostAttack(this, target);
+ return;
+ case InventoryTransactionPacket.RELEASE_ITEM_ACTION_CONSUME:
+ if (this.protocol >= 388) {
+ break;
}
- if (item.isTool() && !this.isCreative()) {
- if (item.useOn(target) && item.getDamage() >= item.getMaxDurability()) {
- level.addSoundToViewers(this, Sound.RANDOM_BREAK);
- level.addParticle(new ItemBreakParticle(this, item));
- this.inventory.setItemInHand(Item.get(0));
- } else {
- if (item.getId() == 0 || this.inventory.getItemInHandFast().getId() == item.getId()) {
- this.inventory.setItemInHand(item);
- } else {
- server.getLogger().debug("Tried to set item " + item.getId() + " but " + this.username + " had item " + this.inventory.getItemInHandFast().getId() + " in their hand slot");
- }
+ Item itemInHand = this.inventory.getItemInHand();
+ PlayerItemConsumeEvent consumeEvent = new PlayerItemConsumeEvent(this, itemInHand);
+ if (itemInHand.getId() == Item.POTION) {
+ this.server.getPluginManager().callEvent(consumeEvent);
+ if (consumeEvent.isCancelled()) {
+ this.inventory.sendContents(this);
+ break;
}
- }
- return;
- default:
- break;
- }
- break;
- case InventoryTransactionPacket.TYPE_RELEASE_ITEM:
- if (this.isSpectator()) {
- this.needSendInventory = true;
- break packetswitch;
- }
- ReleaseItemData releaseItemData = (ReleaseItemData) transactionPacket.transactionData;
-
- try {
- switch (releaseItemData.actionType) {
- case InventoryTransactionPacket.RELEASE_ITEM_ACTION_RELEASE:
- if (this.isUsingItem()) {
- item = this.inventory.getItemInHand();
- int ticksUsed = this.server.getTick() - this.startAction;
- if (!item.onRelease(this, ticksUsed)) {
- this.inventory.sendContents(this);
- }
- this.setUsingItem(false);
- } else {
+ Potion potion = Potion.getPotion(itemInHand.getDamage());
+ if (this.gamemode == SURVIVAL || this.gamemode == ADVENTURE) {
+ this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex());
+ this.inventory.addItem(new ItemGlassBottle());
+ }
+ if (potion != null) {
+ potion.applyPotion(this);
+ }
+ } else {
+ this.server.getPluginManager().callEvent(consumeEvent);
+ if (consumeEvent.isCancelled()) {
this.inventory.sendContents(this);
+ break;
}
- return;
- case InventoryTransactionPacket.RELEASE_ITEM_ACTION_CONSUME:
- if (this.protocol >= 388)
- break; // Usage of potions on 1.13 and later is handled at ItemPotion#onUse
- Item itemInHand = this.inventory.getItemInHand();
- PlayerItemConsumeEvent consumeEvent = new PlayerItemConsumeEvent(this, itemInHand);
-
- if (itemInHand.getId() == Item.POTION) {
- this.server.getPluginManager().callEvent(consumeEvent);
- if (consumeEvent.isCancelled()) {
- this.inventory.sendContents(this);
- break;
- }
- Potion potion = Potion.getPotion(itemInHand.getDamage());
-
- if (this.gamemode == SURVIVAL || this.gamemode == ADVENTURE) {
- this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex());
- this.inventory.addItem(new ItemGlassBottle());
- }
-
- if (potion != null) {
- potion.applyPotion(this);
- }
- } else { // Food
- this.server.getPluginManager().callEvent(consumeEvent);
- if (consumeEvent.isCancelled()) {
- this.inventory.sendContents(this);
- break;
- }
- Food food = Food.getByRelative(itemInHand);
- if (food != null && food.eatenBy(this)) {
- this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex());
- }
+ Food food = Food.getByRelative(itemInHand);
+ if (food != null && food.eatenBy(this)) {
+ this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex());
}
- return;
- default:
- this.getServer().getLogger().debug(username + ": unknown release item action type: " + releaseItemData.actionType);
- break;
- }
- } finally {
- this.setUsingItem(false);
+ }
+ return;
+ default:
+ this.getServer().getLogger().debug(username + ": unknown release item action type: " + releaseItemData.actionType);
+ break;
}
- break;
- default:
- this.inventory.sendContents(this);
- break;
- }
- break;
- default:
- break;
+ } finally {
+ this.setUsingItem(false);
+ }
+ break;
+ default:
+ this.inventory.sendContents(this);
+ break;
+ }
+ } finally {
+ if (resyncInventoryAfterLegacyTransaction) {
+ this.needSendInventory = true;
+ }
}
}
@@ -8102,6 +8123,7 @@ private void syncHeldItem() {
pk.slot = this.inventory.getHeldItemIndex();
pk.item = this.inventory.getItem(pk.slot);
pk.inventoryId = ContainerIds.INVENTORY;
+ pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null);
this.dataPacket(pk);
}
diff --git a/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java b/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
new file mode 100644
index 000000000..5179b57c2
--- /dev/null
+++ b/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
@@ -0,0 +1,52 @@
+package cn.nukkit.event.inventory;
+
+import cn.nukkit.Player;
+import cn.nukkit.event.Cancellable;
+import cn.nukkit.event.HandlerList;
+import cn.nukkit.inventory.request.ActionResponse;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
+
+/**
+ * Called before an ItemStackRequest action is processed.
+ * Allows plugins to cancel or provide a custom response.
+ */
+public class ItemStackRequestActionEvent extends InventoryEvent implements Cancellable {
+
+ private static final HandlerList handlers = new HandlerList();
+
+ public static HandlerList getHandlers() {
+ return handlers;
+ }
+
+ private final Player player;
+ private final ItemStackRequestAction action;
+ private final int actionIndex;
+ private ActionResponse response;
+
+ public ItemStackRequestActionEvent(Player player, ItemStackRequestAction action, int actionIndex) {
+ super(null);
+ this.player = player;
+ this.action = action;
+ this.actionIndex = actionIndex;
+ }
+
+ public Player getPlayer() {
+ return player;
+ }
+
+ public ItemStackRequestAction getAction() {
+ return action;
+ }
+
+ public int getActionIndex() {
+ return actionIndex;
+ }
+
+ public void setResponse(ActionResponse response) {
+ this.response = response;
+ }
+
+ public ActionResponse getResponse() {
+ return response;
+ }
+}
diff --git a/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java b/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java
new file mode 100644
index 000000000..b4c762464
--- /dev/null
+++ b/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java
@@ -0,0 +1,68 @@
+package cn.nukkit.event.player;
+
+import cn.nukkit.Player;
+import cn.nukkit.event.Cancellable;
+import cn.nukkit.event.HandlerList;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
+
+/**
+ * Called when a player transfers an item between inventories via SAI.
+ */
+public class PlayerTransferItemEvent extends PlayerEvent implements Cancellable {
+
+ private static final HandlerList handlers = new HandlerList();
+
+ public static HandlerList getHandlers() {
+ return handlers;
+ }
+
+ private final Inventory sourceInventory;
+ private final int sourceSlot;
+ private final Inventory destinationInventory;
+ private final int destinationSlot;
+ private final Item sourceItem;
+ private final Item destinationItem;
+ private final int count;
+
+ public PlayerTransferItemEvent(Player player, Inventory sourceInventory, int sourceSlot,
+ Inventory destinationInventory, int destinationSlot,
+ Item sourceItem, Item destinationItem, int count) {
+ this.player = player;
+ this.sourceInventory = sourceInventory;
+ this.sourceSlot = sourceSlot;
+ this.destinationInventory = destinationInventory;
+ this.destinationSlot = destinationSlot;
+ this.sourceItem = sourceItem;
+ this.destinationItem = destinationItem;
+ this.count = count;
+ }
+
+ public Inventory getSourceInventory() {
+ return sourceInventory;
+ }
+
+ public int getSourceSlot() {
+ return sourceSlot;
+ }
+
+ public Inventory getDestinationInventory() {
+ return destinationInventory;
+ }
+
+ public int getDestinationSlot() {
+ return destinationSlot;
+ }
+
+ public Item getSourceItem() {
+ return sourceItem;
+ }
+
+ public Item getDestinationItem() {
+ return destinationItem;
+ }
+
+ public int getCount() {
+ return count;
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/BaseInventory.java b/src/main/java/cn/nukkit/inventory/BaseInventory.java
index 59c836463..96fa7a316 100644
--- a/src/main/java/cn/nukkit/inventory/BaseInventory.java
+++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java
@@ -17,6 +17,7 @@
import cn.nukkit.network.protocol.v113.ContainerSetSlotPacketV113;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;
+import org.jetbrains.annotations.ApiStatus;
import java.util.*;
@@ -106,12 +107,37 @@ public String getTitle() {
@Override
public Item getItem(int index) {
- return this.slots.containsKey(index) ? this.slots.get(index).clone() : new ItemBlock(Block.get(BlockID.AIR), null, 0);
+ Item original = this.slots.get(index);
+ if (original != null) {
+ // Ensure every non-empty stack carries a valid stackNetworkId for SAI.
+ if (!original.isNull() && original.getStackNetId() == 0) {
+ original.autoAssignStackNetworkId();
+ }
+ return original.clone();
+ }
+ return new ItemBlock(Block.get(BlockID.AIR), null, 0);
+ }
+
+ @Override
+ @ApiStatus.Internal
+ public Item getUnclonedItem(int index) {
+ Item item = this.slots.get(index);
+ if (item != null) {
+ if (!item.isNull() && item.getStackNetId() == 0) {
+ item.autoAssignStackNetworkId();
+ }
+ return item;
+ }
+ return new ItemBlock(Block.get(BlockID.AIR), null, 0);
}
@Override
public Item getItemFast(int index) {
- return this.slots.getOrDefault(index, air);
+ Item item = this.slots.getOrDefault(index, air);
+ if (item != air && !item.isNull() && item.getStackNetId() == 0) {
+ item.autoAssignStackNetworkId();
+ }
+ return item;
}
@Override
@@ -174,6 +200,14 @@ public boolean setItem(int index, Item item, boolean send) {
((BlockEntity) holder).setDirty();
}
+ // Server-Authoritative Inventory requires every non-empty stack to carry a
+ // positive stackNetworkId. Items created before SAI (e.g. loaded from NBT or
+ // spawned by plugins) may still have id==0, which Bedrock clients interpret as
+ // "empty slot" in ItemStackResponse packets and causes cursor/inventory desync.
+ if (!item.isNull() && item.getStackNetId() == 0) {
+ item.autoAssignStackNetworkId();
+ }
+
Item old = this.getItem(index);
this.slots.put(index, item.clone());
this.onSlotChange(index, old, send);
diff --git a/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java b/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
index e3ae56c03..83791b120 100644
--- a/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
+++ b/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
@@ -6,6 +6,8 @@
import cn.nukkit.network.protocol.InventoryContentPacket;
import cn.nukkit.network.protocol.InventorySlotPacket;
import cn.nukkit.network.protocol.MobArmorEquipmentPacket;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import java.util.HashSet;
import java.util.Set;
@@ -97,6 +99,7 @@ public void sendSlot(int index, Player player) {
inventorySlotPacket.inventoryId = player.getWindowId(this);
inventorySlotPacket.slot = index;
inventorySlotPacket.item = this.getItem(index);
+ inventorySlotPacket.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, null);
player.dataPacket(inventorySlotPacket);
} else {
MobArmorEquipmentPacket mobArmorEquipmentPacket = new MobArmorEquipmentPacket();
diff --git a/src/main/java/cn/nukkit/inventory/FakeBlockUIComponent.java b/src/main/java/cn/nukkit/inventory/FakeBlockUIComponent.java
index 66768130d..b1410f9c9 100644
--- a/src/main/java/cn/nukkit/inventory/FakeBlockUIComponent.java
+++ b/src/main/java/cn/nukkit/inventory/FakeBlockUIComponent.java
@@ -25,6 +25,10 @@ public FakeBlockMenu getHolder() {
return (FakeBlockMenu) this.holder;
}
+ public InventoryType getFakeBlockType() {
+ return this.type;
+ }
+
@Override
public boolean open(Player who) {
InventoryOpenEvent ev = new InventoryOpenEvent(this, who);
diff --git a/src/main/java/cn/nukkit/inventory/Inventory.java b/src/main/java/cn/nukkit/inventory/Inventory.java
index c966b192c..84ee1e20e 100644
--- a/src/main/java/cn/nukkit/inventory/Inventory.java
+++ b/src/main/java/cn/nukkit/inventory/Inventory.java
@@ -2,6 +2,7 @@
import cn.nukkit.Player;
import cn.nukkit.item.Item;
+import org.jetbrains.annotations.ApiStatus;
import java.util.Collection;
import java.util.Map;
@@ -27,6 +28,11 @@ public interface Inventory {
Item getItem(int index);
+ @ApiStatus.Internal
+ default Item getUnclonedItem(int index) {
+ return getItem(index);
+ }
+
default Item getItemFast(int index) {
return getItem(index);
}
diff --git a/src/main/java/cn/nukkit/inventory/PlayerInventory.java b/src/main/java/cn/nukkit/inventory/PlayerInventory.java
index 847ff8963..c1d201e86 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerInventory.java
@@ -15,7 +15,9 @@
import cn.nukkit.item.ItemMap;
import cn.nukkit.network.protocol.*;
import cn.nukkit.network.protocol.types.ContainerIds;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.ContainerType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.v113.ContainerSetContentPacketV113;
import cn.nukkit.network.protocol.v113.ContainerSetSlotPacketV113;
@@ -549,6 +551,13 @@ public void sendSlot(int index, Player... players) {
InventorySlotPacket pk = new InventorySlotPacket();
pk.slot = index;
pk.item = this.getItem(index).clone();
+ if (index < 9) {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null);
+ } else if (index < 36) {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.INVENTORY, null);
+ } else {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, null);
+ }
ContainerSetSlotPacketV113 pk2 = new ContainerSetSlotPacketV113();
pk2.slot = index;
diff --git a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
index 289c79d03..84cf0e8c5 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
@@ -13,6 +13,8 @@
import cn.nukkit.network.protocol.LevelSoundEventPacket;
import cn.nukkit.network.protocol.MobEquipmentPacket;
import cn.nukkit.network.protocol.types.ContainerIds;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
public class PlayerOffhandInventory extends BaseInventory {
@@ -73,6 +75,7 @@ public void sendSlot(int index, Player... players) {
InventorySlotPacket pk2 = new InventorySlotPacket();
pk2.inventoryId = ContainerIds.OFFHAND;
pk2.item = item;
+ pk2.containerNameData = new FullContainerName(ContainerSlotType.OFFHAND, null);
player.dataPacket(pk2);
} else {
player.dataPacket(pk);
diff --git a/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java b/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java
index cc88b104d..2c7a857d6 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java
@@ -2,6 +2,7 @@
import cn.nukkit.Player;
import cn.nukkit.item.Item;
+import org.jetbrains.annotations.ApiStatus;
import java.util.*;
@@ -46,6 +47,12 @@ public Item getItem(int index) {
return this.playerUI.getItem(index + this.offset);
}
+ @Override
+ @ApiStatus.Internal
+ public Item getUnclonedItem(int index) {
+ return this.playerUI.getUnclonedItem(index + this.offset);
+ }
+
@Override
public Item getItemFast(int index) {
return this.playerUI.getItemFast(index + this.offset);
diff --git a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
index 293a45435..9bf99e5b9 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
@@ -6,6 +6,8 @@
import cn.nukkit.network.protocol.InventorySlotPacket;
import cn.nukkit.network.protocol.ProtocolInfo;
import cn.nukkit.network.protocol.types.ContainerIds;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import java.util.HashMap;
@@ -60,6 +62,16 @@ public void sendSlot(int index, Player... target) {
pk.slot = index;
pk.item = this.getItem(index);
+ // v1.21.30+ requires the correct container type in InventorySlotPacket,
+ // otherwise the client ignores the update (default is ANVIL_INPUT).
+ if (index == 0) {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.CURSOR, null);
+ } else if (index == PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT) {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null);
+ } else {
+ pk.containerNameData = new FullContainerName(ContainerSlotType.CRAFTING_INPUT, null);
+ }
+
for (Player p : target) {
if (p == this.getHolder()) {
pk.inventoryId = ContainerIds.UI;
@@ -112,9 +124,9 @@ public void sendContents(Player... target) {
p.dataPacket(pk);
}
}
- /*if (p.protocol >= ProtocolInfo.v1_16_0) {
+ if (p.protocol >= ProtocolInfo.v1_16_0) {
p.dataPacket(pk);
- }*/
+ }
//https://github.com/CloudburstMC/Nukkit/commit/f96ce6eb90d47ab99ced368dd7129601f14c0b2b
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
index d8a55eeea..6920d5a1d 100644
--- a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
@@ -3,6 +3,7 @@
import cn.nukkit.Player;
import cn.nukkit.blockentity.BlockEntityBeacon;
import cn.nukkit.inventory.BeaconInventory;
+import cn.nukkit.item.ItemID;
import cn.nukkit.level.Position;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.BeaconPaymentAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
@@ -20,6 +21,9 @@ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStac
if (!(player.getTopWindow().orElse(null) instanceof BeaconInventory beaconInventory)) {
return context.error();
}
+ if (!isValidPayment(beaconInventory.getItem(0).getId())) {
+ return context.error();
+ }
int primary = action.getPrimaryEffect();
int secondary = action.getSecondaryEffect();
@@ -33,8 +37,10 @@ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStac
Position holder = beaconInventory.getHolder();
if (holder != null) {
if (holder.level.getBlockEntity(holder) instanceof BlockEntityBeacon beacon) {
- beacon.setPrimaryPower(primary);
- beacon.setSecondaryPower(secondary);
+ context.onCommit(() -> {
+ beacon.setPrimaryPower(primary);
+ beacon.setSecondaryPower(secondary);
+ });
}
}
return null;
@@ -47,4 +53,12 @@ private static boolean isValidEffect(int effectId) {
return false;
}
}
+
+ private static boolean isValidPayment(int itemId) {
+ return itemId == ItemID.NETHERITE_INGOT
+ || itemId == ItemID.EMERALD
+ || itemId == ItemID.DIAMOND
+ || itemId == ItemID.GOLD_INGOT
+ || itemId == ItemID.IRON_INGOT;
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
index 223166be3..055928973 100644
--- a/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
@@ -31,7 +31,7 @@ public ActionResponse handle(ConsumeAction action, Player player, ItemStackReque
return context.error();
}
- Item item = inventory.getItem(slot);
+ Item item = inventory.getUnclonedItem(slot);
if (item.isNull() || item.getCount() < count) {
return context.error();
}
@@ -40,11 +40,15 @@ public ActionResponse handle(ConsumeAction action, Player player, ItemStackReque
}
if (item.getCount() == count) {
- inventory.clear(slot, false);
+ if (!inventory.clear(slot, false)) {
+ return context.error();
+ }
} else {
Item remaining = item.clone();
remaining.setCount(item.getCount() - count);
- inventory.setItem(slot, remaining, false);
+ if (!inventory.setItem(slot, remaining, false)) {
+ return context.error();
+ }
}
ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
index be3343ef5..4682e78ba 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
@@ -43,7 +43,8 @@ public ActionResponse handle(CraftCreativeAction action, Player player, ItemStac
}
item = item.clone();
- item.setCount(item.getMaxStackSize());
+ int requestedCount = Math.max(1, action.getNumberOfRequestedCrafts());
+ item.setCount(Math.min(item.getMaxStackSize(), requestedCount));
item.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, item, false);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
index a2db886d1..c0edf27ae 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
@@ -57,7 +57,7 @@ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemSt
// Stock vanilla behaviour: grinding emits the stored enchantment XP to the
// player. Missing this was a regression from the previous implementation.
if (experience > 0) {
- player.addExperience(experience);
+ context.onCommit(() -> player.addExperience(experience));
}
Item resultClone = result.clone().autoAssignStackNetworkId();
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
index 52c699266..434a17d73 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
@@ -16,6 +16,9 @@
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftRecipeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CreateAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
@@ -87,6 +90,13 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
context.put(CreateActionProcessor.RECIPE_DATA_KEY, recipe);
+ if (recipe instanceof MultiRecipe) {
+ if (!hasFollowupOutputAction(context.getItemStackRequest().getActions(), context.getCurrentActionIndex() + 1)) {
+ return context.error();
+ }
+ return context.success();
+ }
+
// Smithing dispatch: trim recipes delegate to the inventory's trim logic;
// transform recipes preserve the equipment's NBT onto the result.
if (recipe instanceof SmithingTrimRecipe) {
@@ -101,6 +111,9 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
return null;
}
int times = Math.max(1, action.getNumberOfRequestedCrafts());
+ if (!validateCraftingRecipe(player, recipe, recipeResult, times)) {
+ return context.error();
+ }
Item output = recipeResult.clone();
output.setCount(output.getCount() * times);
output.autoAssignStackNetworkId();
@@ -162,10 +175,10 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
}
if (!player.isCreative()) {
- player.setExperience(player.getExperience(), player.getExperienceLevel() - cost);
+ context.onCommit(() -> player.setExperience(player.getExperience(), player.getExperienceLevel() - cost));
}
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
- PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId());
+ context.onCommit(() -> PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId()));
context.put(ENCH_RECIPE_KEY, true);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
@@ -197,8 +210,8 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
return context.error();
}
- Item buyA = tradeInventory.getItem(TradeInventory.TRADE_INPUT1_UI_SLOT);
- Item buyB = tradeInventory.getItem(TradeInventory.TRADE_INPUT2_UI_SLOT);
+ Item buyA = tradeInventory.getUnclonedItem(TradeInventory.TRADE_INPUT1_UI_SLOT);
+ Item buyB = tradeInventory.getUnclonedItem(TradeInventory.TRADE_INPUT2_UI_SLOT);
boolean hasBuyA = recipe.contains("buyA");
boolean hasBuyB = recipe.contains("buyB");
@@ -217,18 +230,18 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
output.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
- recipe.putInt("uses", uses + times);
int rewardExp = recipe.contains("rewardExp") ? recipe.getInt("rewardExp") : 0;
- if (rewardExp > 0) {
- player.addExperience(rewardExp * times);
- }
EntityVillager villager = tradeInventory.getHolder();
- if (villager != null) {
- int traderExp = recipe.contains("traderExp") ? recipe.getInt("traderExp") : 0;
- if (traderExp > 0) {
+ int traderExp = recipe.contains("traderExp") ? recipe.getInt("traderExp") : 0;
+ context.onCommit(() -> {
+ recipe.putInt("uses", uses + times);
+ if (rewardExp > 0) {
+ player.addExperience(rewardExp * times);
+ }
+ if (villager != null && traderExp > 0) {
villager.addExperience(traderExp * times);
}
- }
+ });
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
0, 0, output.getCount(), output.getStackNetId(),
@@ -275,6 +288,11 @@ private boolean checkTrade(CompoundTag expected, Item actual, int subtract) {
* inspect what the client intends to consume.
*/
private static Item[] collectCraftingInput(Player player) {
+ List- items = collectCraftingInputList(player);
+ return items.toArray(Item.EMPTY_ARRAY);
+ }
+
+ static List
- collectCraftingInputList(Player player) {
Inventory top = player.getTopWindow().orElse(null);
CraftingGrid grid = top instanceof CraftingGrid openGrid
? openGrid
@@ -282,12 +300,51 @@ private static Item[] collectCraftingInput(Player player) {
int size = grid.getSize();
List
- items = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
- Item item = grid.getItem(i);
+ Item item = grid.getUnclonedItem(i);
if (item != null && !item.isNull()) {
items.add(item.clone());
}
}
- return items.toArray(Item.EMPTY_ARRAY);
+ return items;
+ }
+
+ static boolean validateCraftingRecipe(Player player, Recipe recipe, Item output, int multiplier) {
+ List
- inputs = collectCraftingInputList(player);
+ if (recipe instanceof MultiRecipe multiRecipe) {
+ return multiRecipe.canExecute(player, output.clone(), inputs);
+ }
+ if (!(recipe instanceof CraftingRecipe craftingRecipe)) {
+ return true;
+ }
+
+ Item primaryOutput = output.clone();
+ primaryOutput.setCount(primaryOutput.getCount() * Math.max(1, multiplier));
+ List
- extraOutputs = scaleItems(craftingRecipe.getExtraResults(), Math.max(1, multiplier));
+ Recipe matched = player.getServer().getCraftingManager().matchRecipe(inputs, primaryOutput, extraOutputs);
+ return matched == recipe;
+ }
+
+ static List
- scaleItems(List
- items, int multiplier) {
+ List
- scaled = new ArrayList<>(items.size());
+ for (Item item : items) {
+ if (item == null || item.isNull()) {
+ continue;
+ }
+ Item clone = item.clone();
+ clone.setCount(clone.getCount() * multiplier);
+ scaled.add(clone);
+ }
+ return scaled;
+ }
+
+ private static boolean hasFollowupOutputAction(ItemStackRequestAction[] actions, int startIndex) {
+ for (int i = startIndex; i < actions.length; i++) {
+ ItemStackRequestAction action = actions[i];
+ if (action instanceof CreateAction || action instanceof CraftResultsDeprecatedAction) {
+ return true;
+ }
+ }
+ return false;
}
/**
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
index c850c2847..f9a851a29 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
@@ -11,6 +11,7 @@
import cn.nukkit.network.protocol.types.inventory.descriptor.ItemDescriptorWithCount;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.AutoCraftRecipeAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ConsumeAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
@@ -78,11 +79,32 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt
return context.error();
}
- Item recipeResult = recipe instanceof MultiRecipe multi ? multi.getResult() : recipe.getResult();
+ if (!validateConsumePlan(player, ingredients, consumeActions)) {
+ return context.error();
+ }
+
+ if (recipe instanceof MultiRecipe multiRecipe) {
+ CraftResultsDeprecatedAction resultsAction = findCraftResultsAction(
+ context.getItemStackRequest().getActions(), context.getCurrentActionIndex() + 1);
+ if (resultsAction == null || resultsAction.getResultItems() == null || resultsAction.getResultItems().length == 0) {
+ return context.error();
+ }
+ Item output = resultsAction.getResultItems()[0];
+ if (output == null || output.isNull() || !CraftRecipeActionProcessor.validateCraftingRecipe(player, multiRecipe, output, 1)) {
+ return context.error();
+ }
+ context.put(CraftResultDeprecatedActionProcessor.MULTI_RESULTS_KEY, List.of(resultsAction.getResultItems()));
+ return context.success();
+ }
+
+ Item recipeResult = recipe.getResult();
if (recipeResult == null || recipeResult.isNull()) {
return null;
}
int times = Math.max(1, action.getTimesCrafted());
+ if (!CraftRecipeActionProcessor.validateCraftingRecipe(player, recipe, recipeResult, times)) {
+ return context.error();
+ }
Item output = recipeResult.clone();
output.setCount(output.getCount() * times);
output.autoAssignStackNetworkId();
@@ -109,4 +131,50 @@ static List findAllConsumeActions(ItemStackRequestAction[] action
}
return found;
}
+
+ private static CraftResultsDeprecatedAction findCraftResultsAction(ItemStackRequestAction[] actions, int startIndex) {
+ for (int i = startIndex; i < actions.length; i++) {
+ if (actions[i] instanceof CraftResultsDeprecatedAction craftResults) {
+ return craftResults;
+ }
+ }
+ return null;
+ }
+
+ private static boolean validateConsumePlan(Player player, List ingredients, List consumeActions) {
+ List
- expected = new ArrayList<>();
+ for (ItemDescriptorWithCount ingredient : ingredients) {
+ if (ingredient == null || ingredient.getCount() <= 0) {
+ continue;
+ }
+ Item expectedItem = ingredient.getDescriptor().toItem();
+ if (expectedItem == null || expectedItem.isNull()) {
+ return false;
+ }
+ expectedItem.setCount(ingredient.getCount());
+ expected.add(expectedItem);
+ }
+
+ List
- actual = new ArrayList<>();
+ for (ConsumeAction consume : consumeActions) {
+ if (consume.getCount() <= 0) {
+ return false;
+ }
+ var source = consume.getSource();
+ var inventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId());
+ if (inventory == null) {
+ return false;
+ }
+ int slot = NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot());
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < consume.getCount()) {
+ return false;
+ }
+ Item consumed = item.clone();
+ consumed.setCount(consume.getCount());
+ actual.add(consumed);
+ }
+
+ return Recipe.matchItemList(actual, expected);
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
index a7b3e823f..7798c9c2c 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
@@ -94,10 +94,14 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It
if (player.getExperienceLevel() < finalCost) {
return context.error();
}
- player.setExperience(player.getExperience(), player.getExperienceLevel() - finalCost);
}
-
- applyAnvilDamage(player, anvilInventory);
+ final int commitCost = finalCost;
+ context.onCommit(() -> {
+ if (!player.isCreative() && commitCost > 0) {
+ player.setExperience(player.getExperience(), player.getExperienceLevel() - commitCost);
+ }
+ applyAnvilDamage(player, anvilInventory);
+ });
} else if (inventory instanceof CartographyTableInventory cartographyInventory) {
result = updateCartographyTableResult(cartographyInventory, filterString);
if (result == null || result.isNull()) {
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
index c84395e6a..f4e748dae 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java
@@ -21,6 +21,8 @@
*/
public class CraftResultDeprecatedActionProcessor implements ItemStackRequestActionProcessor {
+ public static final String MULTI_RESULTS_KEY = "multiResults";
+
@Override
public ItemStackRequestActionType getType() {
return ItemStackRequestActionType.CRAFT_RESULTS_DEPRECATED;
@@ -33,6 +35,10 @@ public ActionResponse handle(CraftResultsDeprecatedAction action, Player player,
Item[] results = action.getResultItems();
if (results != null && results.length > 0) {
Item output = results[0].clone();
+ if (!CraftRecipeActionProcessor.validateCraftingRecipe(player, recipe, output, 1)) {
+ return context.error();
+ }
+ context.put(MULTI_RESULTS_KEY, List.of(results));
output.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
ItemStackResponseSlot slot = new ItemStackResponseSlot(
diff --git a/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
index d291c87b1..da11715d2 100644
--- a/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java
@@ -51,15 +51,24 @@ public ActionResponse handle(CreateAction action, Player player, ItemStackReques
}
}
- List
- results = recipe instanceof MultiRecipe multi
- ? List.of(multi.getResult())
- : List.of(recipe.getResult());
+ List
- results;
+ if (recipe instanceof MultiRecipe) {
+ results = context.get(CraftResultDeprecatedActionProcessor.MULTI_RESULTS_KEY);
+ if (results == null || results.isEmpty()) {
+ return context.error();
+ }
+ } else {
+ results = List.of(recipe.getResult());
+ }
int slot = action.getSlot();
if (slot < 0 || slot >= results.size()) {
return context.error();
}
Item output = results.get(slot).clone();
+ if (!CraftRecipeActionProcessor.validateCraftingRecipe(player, recipe, output, 1)) {
+ return context.error();
+ }
output.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
diff --git a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
index 913283b3f..bad44ab8a 100644
--- a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
@@ -42,7 +42,7 @@ public ActionResponse handle(DestroyAction action, Player player, ItemStackReque
return context.error();
}
- Item item = inventory.getItem(slot);
+ Item item = inventory.getUnclonedItem(slot);
if (item.isNull() || item.getCount() < count) {
return context.error();
}
@@ -51,11 +51,15 @@ public ActionResponse handle(DestroyAction action, Player player, ItemStackReque
}
if (item.getCount() == count) {
- inventory.clear(slot, false);
+ if (!inventory.clear(slot, false)) {
+ return context.error();
+ }
} else {
Item remaining = item.clone();
remaining.setCount(item.getCount() - count);
- inventory.setItem(slot, remaining, false);
+ if (!inventory.setItem(slot, remaining, false)) {
+ return context.error();
+ }
}
ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src);
diff --git a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
index 0d0d38680..acc9ca9d6 100644
--- a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
@@ -32,7 +32,7 @@ public ActionResponse handle(DropAction action, Player player, ItemStackRequestC
return context.error();
}
- Item item = inventory.getItem(slot);
+ Item item = inventory.getUnclonedItem(slot);
if (item.isNull() || item.getCount() < count) {
return context.error();
}
@@ -50,11 +50,15 @@ public ActionResponse handle(DropAction action, Player player, ItemStackRequestC
}
if (item.getCount() == count) {
- inventory.clear(slot, false);
+ if (!inventory.clear(slot, false)) {
+ return context.error();
+ }
} else {
Item remaining = item.clone();
remaining.setCount(item.getCount() - count);
- inventory.setItem(slot, remaining, false);
+ if (!inventory.setItem(slot, remaining, false)) {
+ return context.error();
+ }
}
player.dropItem(dropItem);
diff --git a/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
index 440427bd7..55e83ec51 100644
--- a/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
+++ b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java
@@ -3,6 +3,7 @@
import cn.nukkit.Player;
import cn.nukkit.inventory.Inventory;
import cn.nukkit.inventory.InventoryHolder;
+import cn.nukkit.inventory.PlayerInventory;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -30,7 +31,14 @@ public static void syncOtherViewers(Player actor, Inventory inventory) {
}
if (!observers.isEmpty()) {
- inventory.sendContents(observers);
+ if (inventory instanceof PlayerInventory playerInventory) {
+ Player[] targets = observers.toArray(Player.EMPTY_ARRAY);
+ playerInventory.sendContents(targets);
+ playerInventory.sendArmorContents(targets);
+ playerInventory.sendHeldItem(targets);
+ } else {
+ inventory.sendContents(observers);
+ }
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
index cef5063dd..211f63d32 100644
--- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
@@ -7,6 +7,7 @@
import java.util.Collections;
import java.util.HashMap;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -24,6 +25,7 @@ public class ItemStackRequestContext {
@Setter
private int currentActionIndex;
private final Map extraData = new HashMap<>();
+ private final List commitActions = new ArrayList<>();
public ItemStackRequestContext(ItemStackRequest itemStackRequest) {
this.itemStackRequest = itemStackRequest;
@@ -42,6 +44,25 @@ public boolean has(String key) {
return extraData.containsKey(key);
}
+ public void onCommit(Runnable action) {
+ if (action != null) {
+ commitActions.add(action);
+ }
+ }
+
+ public boolean commit() {
+ try {
+ for (Runnable action : commitActions) {
+ action.run();
+ }
+ commitActions.clear();
+ return true;
+ } catch (Throwable t) {
+ commitActions.clear();
+ return false;
+ }
+ }
+
public ActionResponse error() {
return ActionResponse.error();
}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
index 6c6545315..b627fe4cb 100644
--- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
@@ -1,8 +1,15 @@
package cn.nukkit.inventory.request;
import cn.nukkit.Player;
+import cn.nukkit.blockentity.BlockEntity;
+import cn.nukkit.event.inventory.ItemStackRequestActionEvent;
+import cn.nukkit.inventory.BaseInventory;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.PlayerInventory;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.inventory.PlayerUIInventory;
import cn.nukkit.item.ItemBundle;
+import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.ItemStackResponsePacket;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
@@ -33,6 +40,8 @@ public final class ItemStackRequestHandler {
static {
register(new TakeActionProcessor());
register(new PlaceActionProcessor());
+ register(new TakeFromItemContainerActionProcessor());
+ register(new PlaceInItemContainerActionProcessor());
register(new SwapActionProcessor());
register(new DropActionProcessor());
register(new DestroyActionProcessor());
@@ -68,6 +77,7 @@ public static void handleRequests(Player player, List requests
List responseContainers = new ArrayList<>();
Set affectedInventories = new LinkedHashSet<>();
Set affectedBundleOuters = new LinkedHashSet<>();
+ LinkedHashMap> snapshots = new LinkedHashMap<>();
boolean error = false;
if (log.isInfoEnabled()) {
@@ -85,6 +95,7 @@ public static void handleRequests(Player player, List requests
context.setCurrentActionIndex(i);
affectedInventories.addAll(resolveAffectedInventories(player, action));
affectedBundleOuters.addAll(resolveAffectedBundleOuters(player, action));
+ captureSnapshots(snapshots, affectedInventories);
ItemStackRequestActionProcessor processor =
(ItemStackRequestActionProcessor) PROCESSORS.get(action.getType());
@@ -96,6 +107,21 @@ public static void handleRequests(Player player, List requests
}
try {
+ ItemStackRequestActionEvent event = new ItemStackRequestActionEvent(player, action, i);
+ player.getServer().getPluginManager().callEvent(event);
+ if (event.isCancelled()) {
+ error = true;
+ break;
+ }
+ if (event.getResponse() != null) {
+ if (!event.getResponse().success()) {
+ error = true;
+ break;
+ }
+ responseContainers.addAll(event.getResponse().containers());
+ continue;
+ }
+
ActionResponse response = processor.handle(action, player, context);
if (response == null) {
continue;
@@ -112,6 +138,15 @@ public static void handleRequests(Player player, List requests
}
}
+ if (!error && !context.commit()) {
+ error = true;
+ }
+
+ if (error) {
+ rollbackSnapshots(snapshots);
+ resyncActor(player, snapshots.keySet());
+ }
+
syncAffectedInventories(player, affectedInventories);
syncAffectedBundleOuters(player, affectedBundleOuters);
ItemStackResponseStatus status = error ? ItemStackResponseStatus.ERROR : ItemStackResponseStatus.OK;
@@ -145,6 +180,8 @@ private static Set resolveAffectedInventories(Player player, ItemStac
addAffectedInventory(affected, player, destroy.getSource());
} else if (action instanceof ConsumeAction consume) {
addAffectedInventory(affected, player, consume.getSource());
+ } else if (writesCreatedOutput(action)) {
+ affected.add(player.getUIInventory());
}
} catch (Throwable t) {
log.debug("{}: failed to resolve affected inventories for action {}", player.getName(), action.getType(), t);
@@ -155,8 +192,90 @@ private static Set resolveAffectedInventories(Player player, ItemStac
private static void addAffectedInventory(Set affected, Player player, ItemStackRequestSlotData slotData) {
Inventory inventory = NetworkMapping.getInventory(player, slotData.getContainer(), slotData.getDynamicId());
- if (inventory != null) {
- affected.add(inventory);
+ Inventory canonical = canonicalizeInventory(inventory);
+ if (canonical != null) {
+ affected.add(canonical);
+ }
+ }
+
+ private static boolean writesCreatedOutput(ItemStackRequestAction action) {
+ return action instanceof CreateAction
+ || action instanceof CraftRecipeAction
+ || action instanceof AutoCraftRecipeAction
+ || action instanceof CraftCreativeAction
+ || action instanceof CraftRecipeOptionalAction
+ || action instanceof CraftGrindstoneAction
+ || action instanceof CraftLoomAction
+ || action instanceof CraftResultsDeprecatedAction;
+ }
+
+ private static Inventory canonicalizeInventory(Inventory inventory) {
+ if (inventory instanceof PlayerUIComponent component && component.getHolder() instanceof Player player) {
+ return player.getUIInventory();
+ }
+ return inventory;
+ }
+
+ private static void captureSnapshots(Map> snapshots, Set inventories) {
+ for (Inventory inventory : inventories) {
+ Inventory canonical = canonicalizeInventory(inventory);
+ if (canonical != null && !snapshots.containsKey(canonical)) {
+ snapshots.put(canonical, copyContents(canonical));
+ }
+ }
+ }
+
+ private static Map copyContents(Inventory inventory) {
+ LinkedHashMap snapshot = new LinkedHashMap<>();
+ for (var entry : inventory.getContents().entrySet()) {
+ Item item = entry.getValue();
+ if (item != null && !item.isNull() && item.getCount() > 0) {
+ snapshot.put(entry.getKey(), item.clone());
+ }
+ }
+ return snapshot;
+ }
+
+ private static void rollbackSnapshots(Map> snapshots) {
+ for (var entry : snapshots.entrySet()) {
+ restoreInventory(entry.getKey(), entry.getValue());
+ }
+ }
+
+ private static void restoreInventory(Inventory inventory, Map snapshot) {
+ Inventory canonical = canonicalizeInventory(inventory);
+ if (!(canonical instanceof BaseInventory baseInventory)) {
+ return;
+ }
+
+ for (int slot : new ArrayList<>(baseInventory.slots.keySet())) {
+ if (!snapshot.containsKey(slot)) {
+ baseInventory.clear(slot, false);
+ }
+ }
+
+ for (var entry : snapshot.entrySet()) {
+ Item item = entry.getValue();
+ if (item != null && !item.isNull() && item.getCount() > 0) {
+ baseInventory.setItem(entry.getKey(), item.clone(), false);
+ } else {
+ baseInventory.clear(entry.getKey(), false);
+ }
+ }
+ }
+
+ private static void resyncActor(Player actor, Collection inventories) {
+ actor.getCursorInventory().sendContents(actor);
+ actor.sendAllInventories();
+ actor.getInventory().sendHeldItem(actor);
+ actor.getInventory().sendArmorContents(actor);
+ actor.getOffhandInventory().sendContents(actor);
+
+ for (Inventory inventory : inventories) {
+ if (inventory == null || inventory instanceof PlayerInventory || inventory instanceof PlayerUIInventory) {
+ continue;
+ }
+ inventory.sendContents(actor);
}
}
@@ -225,15 +344,18 @@ private static void syncAffectedBundleOuters(Player actor, Set compactContainers(List containers) {
- LinkedHashMap> merged = new LinkedHashMap<>();
+ LinkedHashMap> merged = new LinkedHashMap<>();
for (ItemStackResponseContainer container : containers) {
FullContainerName containerName = container.getContainerName() != null
? container.getContainerName()
: new FullContainerName(container.getContainer(), null);
- LinkedHashMap items = merged.computeIfAbsent(containerName, ignored -> new LinkedHashMap<>());
+ LinkedHashMap items = merged.computeIfAbsent(containerName, ignored -> new LinkedHashMap<>());
for (ItemStackResponseSlot slot : container.getItems()) {
- items.put(Objects.hash(slot.getSlot(), slot.getHotbarSlot()), slot);
+ // 用 (slot<<32)|hotbarSlot 作为组合 key,避免 Objects.hash 碰撞导致
+ // 同一容器不同 slot 的响应被相互覆盖(这会让客户端丢失更新并回滚对应 slot)。
+ long key = ((long) slot.getSlot() << 32) | (slot.getHotbarSlot() & 0xFFFFFFFFL);
+ items.put(key, slot);
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
index dea762d5f..f55e0e5bb 100644
--- a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
+++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
@@ -32,6 +32,11 @@ private NetworkMapping() {
*/
@Nullable
public static Inventory getInventory(Player player, ContainerSlotType type, @Nullable Integer dynamicId) {
+ Inventory topWindow = player.getTopWindow().orElse(null);
+ if (topWindow instanceof FakeBlockUIComponent fakeUI && isSlotTypeCompatibleWithFakeUI(fakeUI, type)) {
+ return fakeUI;
+ }
+
PlayerUIInventory ui = player.getUIInventory();
return switch (type) {
case CURSOR -> ui.getCursorInventory();
@@ -40,7 +45,7 @@ public static Inventory getInventory(Player player, ContainerSlotType type, @Nul
// Follow the window the player currently has open (e.g. a crafting
// table) so 3x3 recipes route to BigCraftingGrid; fall back to the
// 2x2 personal grid otherwise.
- yield player.getTopWindow().orElseGet(ui::getCraftingGrid);
+ yield topWindow != null ? topWindow : ui.getCraftingGrid();
}
case HOTBAR, INVENTORY, HOTBAR_AND_INVENTORY, ARMOR -> player.getInventory();
case OFFHAND -> player.getOffhandInventory();
@@ -53,21 +58,53 @@ public static Inventory getInventory(Player player, ContainerSlotType type, @Nul
case LOOM_INPUT, LOOM_DYE, LOOM_MATERIAL, LOOM_RESULT -> player.getWindowById(Player.LOOM_WINDOW_ID);
case STONECUTTER_INPUT, STONECUTTER_RESULT -> player.getWindowById(Player.STONECUTTER_WINDOW_ID);
case CARTOGRAPHY_INPUT, CARTOGRAPHY_ADDITIONAL, CARTOGRAPHY_RESULT ->
- player.getTopWindow().filter(inv -> inv instanceof CartographyTableInventory).orElse(null);
+ topWindow instanceof CartographyTableInventory ? topWindow : null;
case BEACON_PAYMENT -> player.getWindowById(Player.BEACON_WINDOW_ID);
+ case COMPOUND_CREATOR_INPUT, COMPOUND_CREATOR_OUTPUT,
+ ELEMENT_CONSTRUCTOR_OUTPUT,
+ MATERIAL_REDUCER_INPUT, MATERIAL_REDUCER_OUTPUT,
+ LAB_TABLE_INPUT -> topWindow;
case TRADE_INGREDIENT_1, TRADE_INGREDIENT_2, TRADE_RESULT,
TRADE2_INGREDIENT_1, TRADE2_INGREDIENT_2, TRADE2_RESULT ->
- player.getTopWindow().orElse(null);
+ topWindow;
case FURNACE_FUEL, FURNACE_INGREDIENT, FURNACE_RESULT,
BLAST_FURNACE_INGREDIENT, SMOKER_INGREDIENT,
BREWING_INPUT, BREWING_RESULT, BREWING_FUEL,
SHULKER_BOX, BARREL,
- LEVEL_ENTITY, CRAFTER_BLOCK_CONTAINER -> player.getTopWindow().orElse(null);
+ LEVEL_ENTITY, CRAFTER_BLOCK_CONTAINER -> topWindow;
case DYNAMIC_CONTAINER -> resolveDynamicContainer(player, dynamicId);
default -> null;
};
}
+ private static boolean isSlotTypeCompatibleWithFakeUI(FakeBlockUIComponent fakeUI, ContainerSlotType type) {
+ return switch (fakeUI.getFakeBlockType()) {
+ case ANVIL -> type == ContainerSlotType.ANVIL_INPUT
+ || type == ContainerSlotType.ANVIL_MATERIAL
+ || type == ContainerSlotType.ANVIL_RESULT;
+ case ENCHANT_TABLE -> type == ContainerSlotType.ENCHANTING_INPUT
+ || type == ContainerSlotType.ENCHANTING_MATERIAL;
+ case BEACON -> type == ContainerSlotType.BEACON_PAYMENT;
+ case LOOM -> type == ContainerSlotType.LOOM_INPUT
+ || type == ContainerSlotType.LOOM_DYE
+ || type == ContainerSlotType.LOOM_MATERIAL
+ || type == ContainerSlotType.LOOM_RESULT;
+ case SMITHING_TABLE -> type == ContainerSlotType.SMITHING_TABLE_INPUT
+ || type == ContainerSlotType.SMITHING_TABLE_MATERIAL
+ || type == ContainerSlotType.SMITHING_TABLE_RESULT
+ || type == ContainerSlotType.SMITHING_TABLE_TEMPLATE;
+ case GRINDSTONE -> type == ContainerSlotType.GRINDSTONE_INPUT
+ || type == ContainerSlotType.GRINDSTONE_ADDITIONAL
+ || type == ContainerSlotType.GRINDSTONE_RESULT;
+ case STONECUTTER -> type == ContainerSlotType.STONECUTTER_INPUT
+ || type == ContainerSlotType.STONECUTTER_RESULT;
+ case CARTOGRAPHY -> type == ContainerSlotType.CARTOGRAPHY_INPUT
+ || type == ContainerSlotType.CARTOGRAPHY_ADDITIONAL
+ || type == ContainerSlotType.CARTOGRAPHY_RESULT;
+ default -> false;
+ };
+ }
+
/**
* Convert a network-level slot index to the server-side internal slot index
* for the given container type.
@@ -130,7 +167,11 @@ public static ContainerSlotType getSlotType(Inventory inventory, int internalSlo
return ContainerSlotType.ARMOR;
}
if (inventory instanceof PlayerUIInventory) {
- return internalSlot == 50 ? ContainerSlotType.CREATED_OUTPUT : ContainerSlotType.CURSOR;
+ return switch (internalSlot) {
+ case 0 -> ContainerSlotType.CURSOR;
+ case 50 -> ContainerSlotType.CREATED_OUTPUT;
+ default -> ContainerSlotType.CRAFTING_INPUT;
+ };
}
if (inventory instanceof AnvilInventory) {
return switch (internalSlot) {
@@ -196,6 +237,9 @@ public static ContainerSlotType getSlotType(Inventory inventory, int internalSlo
private static Inventory resolveHorseInventory(Player player) {
Inventory topWindow = player.getTopWindow().orElse(null);
if (topWindow instanceof HorseInventory) {
+ // Explicit horse windows resolve here even when the player is not
+ // currently mounted; the riding lookup below is only the fallback
+ // for the current mount-backed interaction flow.
return topWindow;
}
diff --git a/src/main/java/cn/nukkit/inventory/request/PlaceInItemContainerActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/PlaceInItemContainerActionProcessor.java
new file mode 100644
index 000000000..85ee4d754
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/PlaceInItemContainerActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.PlaceInItemContainerAction;
+
+public class PlaceInItemContainerActionProcessor extends TransferItemActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.PLACE_IN_ITEM_CONTAINER;
+ }
+
+ @Override
+ public ActionResponse handle(PlaceInItemContainerAction action, Player player, ItemStackRequestContext context) {
+ return doTransfer(action, player, context);
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
index e2b6037ec..84033d3ac 100644
--- a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
@@ -31,8 +31,8 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC
int srcSlot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot());
int dstSlot = NetworkMapping.toInternalSlot(dst.getContainer(), dst.getSlot());
- Item sourceItem = srcInv.getItem(srcSlot);
- Item destItem = dstInv.getItem(dstSlot);
+ Item sourceItem = srcInv.getUnclonedItem(srcSlot);
+ Item destItem = dstInv.getUnclonedItem(dstSlot);
if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) {
return context.error();
@@ -50,8 +50,16 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC
return context.error();
}
- srcInv.setItem(srcSlot, destItem.clone(), false);
- dstInv.setItem(dstSlot, sourceItem.clone(), false);
+ Item originalSource = sourceItem.clone();
+ Item originalDest = destItem.clone();
+
+ if (!srcInv.setItem(srcSlot, destItem.clone(), false)) {
+ return context.error();
+ }
+ if (!dstInv.setItem(dstSlot, sourceItem.clone(), false)) {
+ srcInv.setItem(srcSlot, originalSource, false);
+ return context.error();
+ }
ItemStackResponseContainer srcResp = TransferItemActionProcessor.buildContainer(srcInv, srcSlot, src);
ItemStackResponseContainer dstResp = TransferItemActionProcessor.buildContainer(dstInv, dstSlot, dst);
diff --git a/src/main/java/cn/nukkit/inventory/request/TakeFromItemContainerActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TakeFromItemContainerActionProcessor.java
new file mode 100644
index 000000000..8a1e61785
--- /dev/null
+++ b/src/main/java/cn/nukkit/inventory/request/TakeFromItemContainerActionProcessor.java
@@ -0,0 +1,18 @@
+package cn.nukkit.inventory.request;
+
+import cn.nukkit.Player;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.TakeFromItemContainerAction;
+
+public class TakeFromItemContainerActionProcessor extends TransferItemActionProcessor {
+
+ @Override
+ public ItemStackRequestActionType getType() {
+ return ItemStackRequestActionType.TAKE_FROM_ITEM_CONTAINER;
+ }
+
+ @Override
+ public ActionResponse handle(TakeFromItemContainerAction action, Player player, ItemStackRequestContext context) {
+ return doTransfer(action, player, context);
+ }
+}
diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
index 928d680ff..f4d1747dd 100644
--- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
@@ -3,7 +3,11 @@
import cn.nukkit.Player;
import cn.nukkit.Server;
import cn.nukkit.event.inventory.InventoryClickEvent;
+import cn.nukkit.event.player.PlayerTransferItemEvent;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.PlayerInventory;
+import cn.nukkit.inventory.PlayerUIComponent;
+import cn.nukkit.inventory.PlayerUIInventory;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
@@ -39,13 +43,15 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
int dstSlot = dstInv == null ? -1 : NetworkMapping.toInternalSlot(dst.getContainer(), dst.getSlot());
int count = action.getCount();
- log.info("{}: {} src={}[net={}->int={},netId={}] dst={}[net={}->int={},netId={}] count={} srcItem={} dstItem={}",
- player.getName(), getType(),
- src.getContainer(), src.getSlot(), srcSlot, src.getStackNetworkId(),
- dst.getContainer(), dst.getSlot(), dstSlot, dst.getStackNetworkId(),
- count,
- srcInv == null ? "null-inv" : srcInv.getItem(srcSlot),
- dstInv == null ? "null-inv" : dstInv.getItem(dstSlot));
+ if (log.isInfoEnabled()) {
+ log.info("{}: {} src={}[net={}->int={},netId={}] dst={}[net={}->int={},netId={}] count={} srcItem={} dstItem={}",
+ player.getName(), getType(),
+ src.getContainer(), src.getSlot(), srcSlot, src.getStackNetworkId(),
+ dst.getContainer(), dst.getSlot(), dstSlot, dst.getStackNetworkId(),
+ count,
+ srcInv == null ? "null-inv" : srcInv.getUnclonedItem(srcSlot),
+ dstInv == null ? "null-inv" : dstInv.getUnclonedItem(dstSlot));
+ }
if (srcInv == null || dstInv == null) {
log.info("{}: transfer rejected - inventory missing src={}({}) dst={}({})",
@@ -58,7 +64,7 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
return context.error();
}
- Item sourceItem = srcInv.getItem(srcSlot);
+ Item sourceItem = srcInv.getUnclonedItem(srcSlot);
if (sourceItem.isNull() || sourceItem.getCount() < count) {
log.info("{}: transfer rejected - src invalid (slot {} item={} count needed {})",
player.getName(), srcSlot, sourceItem, count);
@@ -70,7 +76,7 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
return context.error();
}
- Item destItem = dstInv.getItem(dstSlot);
+ Item destItem = dstInv.getUnclonedItem(dstSlot);
if (!destItem.isNull() && !destItem.equals(sourceItem, true, true)) {
log.info("{}: transfer rejected - dst item differs (dst {} vs src {})",
player.getName(), destItem, sourceItem);
@@ -89,30 +95,51 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
return context.error();
}
+ if (!isSlotCompatible(dstInv, dstSlot, sourceItem)) {
+ log.info("{}: transfer rejected - item {} cannot be placed in slot {} of {}",
+ player.getName(), sourceItem, dstSlot, dstInv.getClass().getSimpleName());
+ return context.error();
+ }
+
// Equipment containers (OFFHAND/ARMOR) must emit network packets so other
// players see MobEquipment/MobArmor updates; other containers can stay
// quiet to avoid double-send with the ItemStackResponse echo.
boolean sendSource = isEquipmentSlot(src.getContainer());
boolean sendDest = isEquipmentSlot(dst.getContainer());
+ boolean fullTransfer = sourceItem.getCount() == count;
+ boolean srcIsCreatedOutput = isCreatedOutput(srcInv, srcSlot);
+
+ // stackNetId allocation strategy aligned with Allay/PNX:
+ // - full transfer + empty dst : keep source stackId (whole stack moves)
+ // - any transfer + non-empty dst: keep dest stackId (merge into existing)
+ // - partial transfer + empty dst: assign new stackId (split creates new stack)
+ // - CREATED_OUTPUT full transfer: assign new stackId so every creative take
+ // is an independent stack (prevents id reuse across creative sessions).
Item newDest;
if (destItem.isNull()) {
newDest = sourceItem.clone();
newDest.setCount(count);
- newDest.autoAssignStackNetworkId();
+ if (!fullTransfer || srcIsCreatedOutput) {
+ newDest.autoAssignStackNetworkId();
+ }
} else {
newDest = destItem.clone();
newDest.setCount(destCount + count);
}
Item newSrc;
- if (sourceItem.getCount() == count) {
+ if (fullTransfer) {
newSrc = Item.get(Item.AIR);
} else {
newSrc = sourceItem.clone();
newSrc.setCount(sourceItem.getCount() - count);
}
+ if (!fireTransferEvent(player, srcInv, srcSlot, dstInv, dstSlot, sourceItem, destItem, count)) {
+ return context.error();
+ }
+
// Fire InventoryClickEvent for each affected slot (matches legacy
// InventoryTransaction.java:260). Only holder-is-Player inventories
// trigger the event, as in the legacy path.
@@ -123,12 +150,32 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
return context.error();
}
- dstInv.setItem(dstSlot, newDest, sendDest);
+ // 原子性写入:先 dst,再 src;任一失败则回滚已写入的另一端。
+ // PlayerInventory.setItem 可能在 EntityInventoryChangeEvent/EntityArmorChangeEvent
+ // 被插件取消时返回 false —— 需要把 dst 恢复到原状态避免出现悬挂写入。
+ Item originalDestItem = destItem.clone();
+ Item originalSourceItem = sourceItem.clone();
- if (sourceItem.getCount() == count) {
- srcInv.clear(srcSlot, sendSource);
- } else {
- srcInv.setItem(srcSlot, newSrc, sendSource);
+ if (!dstInv.setItem(dstSlot, newDest, sendDest)) {
+ return context.error();
+ }
+
+ boolean srcOk = fullTransfer
+ ? srcInv.clear(srcSlot, sendSource)
+ : srcInv.setItem(srcSlot, newSrc, sendSource);
+ if (!srcOk) {
+ if (originalDestItem.isNull()) {
+ dstInv.clear(dstSlot, sendDest);
+ } else {
+ dstInv.setItem(dstSlot, originalDestItem, sendDest);
+ }
+ return context.error();
+ }
+
+ // 防御:如果 src 的底层 setItem 不知为何把原物品写没了(如插件篡改为 AIR),
+ // 也要把 src 恢复,避免客户端回滚看到不一致状态。
+ if (srcInv.getItem(srcSlot).isNull() && !fullTransfer) {
+ srcInv.setItem(srcSlot, originalSourceItem, sendSource);
}
List containers = new ArrayList<>();
@@ -143,6 +190,31 @@ private static boolean isEquipmentSlot(ContainerSlotType type) {
return type == ContainerSlotType.OFFHAND || type == ContainerSlotType.ARMOR;
}
+ /**
+ * Check whether {@code item} is allowed in the given {@code slot} of
+ * {@code inventory}. ARMOR slots reject non-matching equipment; all other
+ * inventories accept any item.
+ */
+ private static boolean isSlotCompatible(Inventory inventory, int slot, Item item) {
+ if (inventory instanceof PlayerInventory playerInv) {
+ int size = playerInv.getSize();
+ if (slot == size) {
+ return item.canBePutInHelmetSlot();
+ } else if (slot == size + 1) {
+ return item.isChestplate();
+ } else if (slot == size + 2) {
+ return item.isLeggings();
+ } else if (slot == size + 3) {
+ return item.isBoots();
+ }
+ }
+ return true;
+ }
+
+ private static boolean isCreatedOutput(Inventory inventory, int slot) {
+ return inventory instanceof PlayerUIInventory && slot == PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT;
+ }
+
/**
* Fire {@link InventoryClickEvent} for a slot that is about to change. Only
* inventories whose holder is a {@link Player} emit the event, mirroring the
@@ -160,14 +232,31 @@ static boolean fireClickEvent(Player actor, Inventory inventory, int slot, Item
return !event.isCancelled();
}
+ static boolean fireTransferEvent(Player actor, Inventory sourceInventory, int sourceSlot,
+ Inventory destinationInventory, int destinationSlot,
+ Item sourceItem, Item destinationItem, int count) {
+ PlayerTransferItemEvent event = new PlayerTransferItemEvent(
+ actor,
+ sourceInventory,
+ sourceSlot,
+ destinationInventory,
+ destinationSlot,
+ sourceItem.clone(),
+ destinationItem.clone(),
+ count
+ );
+ Server.getInstance().getPluginManager().callEvent(event);
+ return !event.isCancelled();
+ }
+
static ItemStackResponseContainer buildContainer(Inventory inv, int internalSlot, ItemStackRequestSlotData slotData) {
- Item current = inv.getItem(internalSlot);
+ Item current = inv.getUnclonedItem(internalSlot);
int networkSlot = slotData.getSlot();
- int hotbarSlot = (slotData.getContainer() == ContainerSlotType.HOTBAR
- || slotData.getContainer() == ContainerSlotType.HOTBAR_AND_INVENTORY) ? networkSlot : 0;
+ // hotbarSlot 与 slot 保持一致,与 Allay/PNX 对齐。部分客户端对"非 HOTBAR 容器
+ // 填 hotbarSlot=0"会误判需要刷新热栏槽 0 造成视觉错位。
ItemStackResponseSlot slot = new ItemStackResponseSlot(
networkSlot,
- hotbarSlot,
+ networkSlot,
current.isNull() ? 0 : current.getCount(),
current.getStackNetId(),
current.hasCustomName() ? current.getCustomName() : "",
diff --git a/src/main/java/cn/nukkit/item/Item.java b/src/main/java/cn/nukkit/item/Item.java
index 54deab949..49f403b47 100644
--- a/src/main/java/cn/nukkit/item/Item.java
+++ b/src/main/java/cn/nukkit/item/Item.java
@@ -44,7 +44,6 @@
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.*;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -81,7 +80,6 @@ public class Item implements Cloneable, BlockID, ItemID, ItemNamespaceId, Protoc
private static final HashMap> CUSTOM_ITEMS = new HashMap<>();
private static final HashMap CUSTOM_ITEM_DEFINITIONS = new HashMap<>();
- private static final AtomicInteger STACK_NETWORK_ID_COUNTER = new AtomicInteger(0);
/**
* 存储需要在 initCreativeItems 后重新添加的创造物品
* Stores creative items that need to be re-added after initCreativeItems
@@ -1908,7 +1906,7 @@ public boolean isUsingStackNetId() {
}
/**
- * Allocates a fresh positive stack network id from Item's internal counter
+ * Allocates a fresh positive stack network id from ItemStackNetManager
* and assigns it to this item. Call this whenever a new, distinct stack is
* produced server-side (for example, the output of a crafting / enchanting
* / grindstone operation) so the client can reference it in subsequent
@@ -1917,7 +1915,7 @@ public boolean isUsingStackNetId() {
* @return this item for chaining
*/
public Item autoAssignStackNetworkId() {
- this.stackNetId = STACK_NETWORK_ID_COUNTER.updateAndGet(current -> current == Integer.MAX_VALUE ? 1 : current + 1);
+ this.stackNetId = ItemStackNetManager.allocate();
return this;
}
diff --git a/src/main/java/cn/nukkit/network/process/DataPacketManager.java b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
index 9022308a8..74f7cc88d 100644
--- a/src/main/java/cn/nukkit/network/process/DataPacketManager.java
+++ b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
@@ -170,6 +170,7 @@ public static void registerDefaultProcessors() {
ClientToServerHandshakeProcessor.INSTANCE,
EmotePacketProcessor.INSTANCE,
ItemFrameDropItemProcessor.INSTANCE,
+ InventoryTransactionProcessor.INSTANCE,
LevelSoundEventProcessor.INSTANCE,
LevelSoundEventProcessorV1.INSTANCE,
LevelSoundEventProcessorV2.INSTANCE,
diff --git a/src/main/java/cn/nukkit/network/process/processor/common/InventoryTransactionProcessor.java b/src/main/java/cn/nukkit/network/process/processor/common/InventoryTransactionProcessor.java
new file mode 100644
index 000000000..73fb9d991
--- /dev/null
+++ b/src/main/java/cn/nukkit/network/process/processor/common/InventoryTransactionProcessor.java
@@ -0,0 +1,43 @@
+package cn.nukkit.network.process.processor.common;
+
+import cn.nukkit.Player;
+import cn.nukkit.PlayerHandle;
+import cn.nukkit.network.process.DataPacketProcessor;
+import cn.nukkit.network.protocol.DataPacket;
+import cn.nukkit.network.protocol.InventoryTransactionPacket;
+import cn.nukkit.network.protocol.ProtocolInfo;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.jetbrains.annotations.NotNull;
+
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class InventoryTransactionProcessor extends DataPacketProcessor {
+
+ public static final InventoryTransactionProcessor INSTANCE = new InventoryTransactionProcessor();
+
+ @Override
+ public void handle(@NotNull PlayerHandle playerHandle, @NotNull InventoryTransactionPacket pk) {
+ Player player = playerHandle.player;
+
+ if (!player.isAlive() || !player.spawned) {
+ return;
+ }
+
+ player.handleInventoryTransactionPacket(pk);
+ }
+
+ @Override
+ public int getPacketId() {
+ return ProtocolInfo.toNewProtocolID(ProtocolInfo.INVENTORY_TRANSACTION_PACKET);
+ }
+
+ @Override
+ public Class extends DataPacket> getPacketClass() {
+ return InventoryTransactionPacket.class;
+ }
+
+ @Override
+ public boolean isSupported(int protocol) {
+ return true;
+ }
+}
diff --git a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
index d55969128..e3f3c553f 100644
--- a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
+++ b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
@@ -2,7 +2,6 @@
import cn.nukkit.Player;
import cn.nukkit.PlayerHandle;
-import cn.nukkit.inventory.request.ItemStackRequestHandler;
import cn.nukkit.network.process.DataPacketProcessor;
import cn.nukkit.network.protocol.DataPacket;
import cn.nukkit.network.protocol.ItemStackRequestPacket;
@@ -34,7 +33,7 @@ public void handle(@NotNull PlayerHandle playerHandle, @NotNull ItemStackRequest
// Handle the requests
if (!pk.getRequests().isEmpty()) {
- ItemStackRequestHandler.handleRequests(player, pk.getRequests());
+ player.handleItemStackRequests(pk.getRequests());
}
}
From 7d7415e6b85c8c382cd408309dd7b541ec896c3e Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Fri, 24 Apr 2026 19:48:13 +0800
Subject: [PATCH 03/29] fix: plugin event compatibility under SAI inventory
mode
---
.../request/ConsumeActionProcessor.java | 23 ++++++-
.../request/DestroyActionProcessor.java | 23 ++++++-
.../request/DropActionProcessor.java | 19 ++++++
.../request/ItemStackRequestContext.java | 18 +++--
.../request/ItemStackRequestHandler.java | 17 +++--
.../request/SwapActionProcessor.java | 13 ++++
.../request/TransferItemActionProcessor.java | 68 +++++++++++++++++++
7 files changed, 161 insertions(+), 20 deletions(-)
diff --git a/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
index 055928973..ca42d0f38 100644
--- a/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java
@@ -2,12 +2,15 @@
import cn.nukkit.Player;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ConsumeAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import java.util.ArrayList;
import java.util.List;
public class ConsumeActionProcessor implements ItemStackRequestActionProcessor {
@@ -39,14 +42,28 @@ public ActionResponse handle(ConsumeAction action, Player player, ItemStackReque
return context.error();
}
+ Item targetItem;
+ if (item.getCount() == count) {
+ targetItem = Item.get(Item.AIR);
+ } else {
+ targetItem = item.clone();
+ targetItem.setCount(item.getCount() - count);
+ }
+
+ // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent
+ List transactionActions = new ArrayList<>();
+ transactionActions.add(new SlotChangeAction(inventory, slot, item, targetItem));
+ var transaction = new TransferItemActionProcessor.EventOnlyInventoryTransaction(player, transactionActions, context);
+ if (!transaction.execute()) {
+ return context.error();
+ }
+
if (item.getCount() == count) {
if (!inventory.clear(slot, false)) {
return context.error();
}
} else {
- Item remaining = item.clone();
- remaining.setCount(item.getCount() - count);
- if (!inventory.setItem(slot, remaining, false)) {
+ if (!inventory.setItem(slot, targetItem, false)) {
return context.error();
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
index bad44ab8a..2c7400a6b 100644
--- a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java
@@ -3,12 +3,15 @@
import cn.nukkit.Player;
import cn.nukkit.inventory.BeaconInventory;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.DestroyAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import java.util.ArrayList;
import java.util.List;
public class DestroyActionProcessor implements ItemStackRequestActionProcessor {
@@ -50,14 +53,28 @@ public ActionResponse handle(DestroyAction action, Player player, ItemStackReque
return context.error();
}
+ Item targetItem;
+ if (item.getCount() == count) {
+ targetItem = Item.get(Item.AIR);
+ } else {
+ targetItem = item.clone();
+ targetItem.setCount(item.getCount() - count);
+ }
+
+ // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent
+ List transactionActions = new ArrayList<>();
+ transactionActions.add(new SlotChangeAction(inventory, slot, item, targetItem));
+ var transaction = new TransferItemActionProcessor.EventOnlyInventoryTransaction(player, transactionActions, context);
+ if (!transaction.execute()) {
+ return context.error();
+ }
+
if (item.getCount() == count) {
if (!inventory.clear(slot, false)) {
return context.error();
}
} else {
- Item remaining = item.clone();
- remaining.setCount(item.getCount() - count);
- if (!inventory.setItem(slot, remaining, false)) {
+ if (!inventory.setItem(slot, targetItem, false)) {
return context.error();
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
index acc9ca9d6..8a520ed22 100644
--- a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java
@@ -3,12 +3,15 @@
import cn.nukkit.Player;
import cn.nukkit.event.player.PlayerDropItemEvent;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.DropAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import java.util.ArrayList;
import java.util.List;
public class DropActionProcessor implements ItemStackRequestActionProcessor {
@@ -49,6 +52,22 @@ public ActionResponse handle(DropAction action, Player player, ItemStackRequestC
return context.error();
}
+ // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent,
+ // 但 SAI 路径默认不触发。
+ List transactionActions = new ArrayList<>();
+ Item targetItem;
+ if (item.getCount() == count) {
+ targetItem = Item.get(Item.AIR);
+ } else {
+ targetItem = item.clone();
+ targetItem.setCount(item.getCount() - count);
+ }
+ transactionActions.add(new SlotChangeAction(inventory, slot, item, targetItem));
+ var transaction = new TransferItemActionProcessor.EventOnlyInventoryTransaction(player, transactionActions, context);
+ if (!transaction.execute()) {
+ return context.error();
+ }
+
if (item.getCount() == count) {
if (!inventory.clear(slot, false)) {
return context.error();
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
index 211f63d32..33e11629b 100644
--- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java
@@ -1,15 +1,12 @@
package cn.nukkit.inventory.request;
+import cn.nukkit.inventory.Inventory;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
import lombok.Getter;
import lombok.Setter;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
/**
* Per-request processing context shared across all action processors of a single
@@ -26,6 +23,7 @@ public class ItemStackRequestContext {
private int currentActionIndex;
private final Map extraData = new HashMap<>();
private final List commitActions = new ArrayList<>();
+ private final Set pluginModifiedInventories = new LinkedHashSet<>();
public ItemStackRequestContext(ItemStackRequest itemStackRequest) {
this.itemStackRequest = itemStackRequest;
@@ -63,6 +61,16 @@ public boolean commit() {
}
}
+ public void addPluginModifiedInventory(Inventory inventory) {
+ if (inventory != null) {
+ this.pluginModifiedInventories.add(inventory);
+ }
+ }
+
+ public Set getPluginModifiedInventories() {
+ return this.pluginModifiedInventories;
+ }
+
public ActionResponse error() {
return ActionResponse.error();
}
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
index b627fe4cb..90e929c6f 100644
--- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
@@ -1,15 +1,10 @@
package cn.nukkit.inventory.request;
import cn.nukkit.Player;
-import cn.nukkit.blockentity.BlockEntity;
import cn.nukkit.event.inventory.ItemStackRequestActionEvent;
-import cn.nukkit.inventory.BaseInventory;
-import cn.nukkit.inventory.Inventory;
-import cn.nukkit.inventory.PlayerInventory;
-import cn.nukkit.inventory.PlayerUIComponent;
-import cn.nukkit.inventory.PlayerUIInventory;
-import cn.nukkit.item.ItemBundle;
+import cn.nukkit.inventory.*;
import cn.nukkit.item.Item;
+import cn.nukkit.item.ItemBundle;
import cn.nukkit.network.protocol.ItemStackResponsePacket;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
@@ -143,7 +138,7 @@ public static void handleRequests(Player player, List requests
}
if (error) {
- rollbackSnapshots(snapshots);
+ rollbackSnapshots(snapshots, context.getPluginModifiedInventories());
resyncActor(player, snapshots.keySet());
}
@@ -236,8 +231,12 @@ private static Map copyContents(Inventory inventory) {
return snapshot;
}
- private static void rollbackSnapshots(Map> snapshots) {
+ private static void rollbackSnapshots(Map> snapshots, Set pluginModifiedInventories) {
for (var entry : snapshots.entrySet()) {
+ Inventory canonical = canonicalizeInventory(entry.getKey());
+ if (canonical != null && pluginModifiedInventories.contains(canonical)) {
+ continue;
+ }
restoreInventory(entry.getKey(), entry.getValue());
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
index 84033d3ac..2c979d989 100644
--- a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
@@ -2,12 +2,15 @@
import cn.nukkit.Player;
import cn.nukkit.inventory.Inventory;
+import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.SwapAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
+import java.util.ArrayList;
import java.util.List;
public class SwapActionProcessor implements ItemStackRequestActionProcessor {
@@ -50,6 +53,16 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC
return context.error();
}
+ // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent,
+ // 但 SAI 路径默认不触发。
+ List transactionActions = new ArrayList<>();
+ transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, destItem));
+ transactionActions.add(new SlotChangeAction(dstInv, dstSlot, destItem, sourceItem));
+ var transaction = new TransferItemActionProcessor.EventOnlyInventoryTransaction(player, transactionActions, context);
+ if (!transaction.execute()) {
+ return context.error();
+ }
+
Item originalSource = sourceItem.clone();
Item originalDest = destItem.clone();
diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
index f4d1747dd..73c099095 100644
--- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
@@ -3,11 +3,15 @@
import cn.nukkit.Player;
import cn.nukkit.Server;
import cn.nukkit.event.inventory.InventoryClickEvent;
+import cn.nukkit.event.inventory.InventoryTransactionEvent;
import cn.nukkit.event.player.PlayerTransferItemEvent;
import cn.nukkit.inventory.Inventory;
import cn.nukkit.inventory.PlayerInventory;
import cn.nukkit.inventory.PlayerUIComponent;
import cn.nukkit.inventory.PlayerUIInventory;
+import cn.nukkit.inventory.transaction.InventoryTransaction;
+import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.item.Item;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
@@ -150,6 +154,18 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
return context.error();
}
+ // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent,
+ // 但 SAI 路径默认不触发。为保持与旧插件的兼容性,在此处补发事件。
+ List transactionActions = new ArrayList<>();
+ transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, newSrc));
+ if (srcInv != dstInv || srcSlot != dstSlot) {
+ transactionActions.add(new SlotChangeAction(dstInv, dstSlot, destItem, newDest));
+ }
+ InventoryTransaction transaction = new EventOnlyInventoryTransaction(player, transactionActions, context);
+ if (!transaction.execute()) {
+ return context.error();
+ }
+
// 原子性写入:先 dst,再 src;任一失败则回滚已写入的另一端。
// PlayerInventory.setItem 可能在 EntityInventoryChangeEvent/EntityArmorChangeEvent
// 被插件取消时返回 false —— 需要把 dst 恢复到原状态避免出现悬挂写入。
@@ -249,6 +265,58 @@ static boolean fireTransferEvent(Player actor, Inventory sourceInventory, int so
return !event.isCancelled();
}
+ /**
+ * InventoryTransaction subclass used solely to emit
+ * {@link InventoryTransactionEvent} for server-authoritative item stack
+ * requests that involve block-entity containers (e.g. chests). It does
+ * not execute any actions – the real mutation is already performed
+ * by {@link #doTransfer} – so calling {@link #execute} only fires the
+ * event and returns whether a plugin cancelled it.
+ */
+ static class EventOnlyInventoryTransaction extends InventoryTransaction {
+ private final ItemStackRequestContext context;
+
+ public EventOnlyInventoryTransaction(Player source, List actions, ItemStackRequestContext context) {
+ super(source, actions, false);
+ this.context = context;
+ init(source, actions);
+ }
+
+ @Override
+ protected void init(Player source, List actions) {
+ this.source = source;
+ for (InventoryAction action : actions) {
+ this.actions.add(action);
+ if (action instanceof SlotChangeAction slotChange) {
+ this.inventories.add(slotChange.getInventory());
+ }
+ }
+ }
+
+ @Override
+ protected boolean callExecuteEvent() {
+ InventoryTransactionEvent ev = new InventoryTransactionEvent(this);
+ this.source.getServer().getPluginManager().callEvent(ev);
+ return !ev.isCancelled();
+ }
+
+ @Override
+ public boolean execute() {
+ if (!callExecuteEvent()) {
+ if (context != null) {
+ for (InventoryAction action : this.actions) {
+ if (action instanceof SlotChangeAction slotChange) {
+ context.addPluginModifiedInventory(slotChange.getInventory());
+ }
+ }
+ }
+ return false;
+ }
+ this.hasExecuted = true;
+ return true;
+ }
+ }
+
static ItemStackResponseContainer buildContainer(Inventory inv, int internalSlot, ItemStackRequestSlotData slotData) {
Item current = inv.getUnclonedItem(internalSlot);
int networkSlot = slotData.getSlot();
From ebb395ad1d7e3dbe6c3bc9ff2fa25db217a0ca4f Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Fri, 24 Apr 2026 19:57:35 +0800
Subject: [PATCH 04/29] remove debug logs
---
.../request/ItemStackRequestHandler.java | 10 --------
.../request/TransferItemActionProcessor.java | 25 -------------------
2 files changed, 35 deletions(-)
diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
index 90e929c6f..925e376d1 100644
--- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
+++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java
@@ -75,16 +75,6 @@ public static void handleRequests(Player player, List requests
LinkedHashMap> snapshots = new LinkedHashMap<>();
boolean error = false;
- if (log.isInfoEnabled()) {
- StringBuilder types = new StringBuilder();
- for (int i = 0; i < actions.length; i++) {
- if (i > 0) types.append(',');
- types.append(actions[i].getType());
- }
- log.info("{}: handling item stack request id={} actions=[{}]",
- player.getName(), request.getRequestId(), types);
- }
-
for (int i = 0; i < actions.length; i++) {
ItemStackRequestAction action = actions[i];
context.setCurrentActionIndex(i);
diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
index 73c099095..c6fd48e84 100644
--- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
@@ -47,61 +47,36 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon
int dstSlot = dstInv == null ? -1 : NetworkMapping.toInternalSlot(dst.getContainer(), dst.getSlot());
int count = action.getCount();
- if (log.isInfoEnabled()) {
- log.info("{}: {} src={}[net={}->int={},netId={}] dst={}[net={}->int={},netId={}] count={} srcItem={} dstItem={}",
- player.getName(), getType(),
- src.getContainer(), src.getSlot(), srcSlot, src.getStackNetworkId(),
- dst.getContainer(), dst.getSlot(), dstSlot, dst.getStackNetworkId(),
- count,
- srcInv == null ? "null-inv" : srcInv.getUnclonedItem(srcSlot),
- dstInv == null ? "null-inv" : dstInv.getUnclonedItem(dstSlot));
- }
-
if (srcInv == null || dstInv == null) {
- log.info("{}: transfer rejected - inventory missing src={}({}) dst={}({})",
- player.getName(), src.getContainer(), srcInv, dst.getContainer(), dstInv);
return context.error();
}
if (count <= 0) {
- log.info("{}: transfer rejected - non-positive count {}", player.getName(), count);
return context.error();
}
Item sourceItem = srcInv.getUnclonedItem(srcSlot);
if (sourceItem.isNull() || sourceItem.getCount() < count) {
- log.info("{}: transfer rejected - src invalid (slot {} item={} count needed {})",
- player.getName(), srcSlot, sourceItem, count);
return context.error();
}
if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) {
- log.info("{}: transfer rejected - src stackNetId mismatch server={} client={}",
- player.getName(), sourceItem.getStackNetId(), src.getStackNetworkId());
return context.error();
}
Item destItem = dstInv.getUnclonedItem(dstSlot);
if (!destItem.isNull() && !destItem.equals(sourceItem, true, true)) {
- log.info("{}: transfer rejected - dst item differs (dst {} vs src {})",
- player.getName(), destItem, sourceItem);
return context.error();
}
if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) {
- log.info("{}: transfer rejected - dst stackNetId mismatch server={} client={}",
- player.getName(), destItem.getStackNetId(), dst.getStackNetworkId());
return context.error();
}
int destCount = destItem.isNull() ? 0 : destItem.getCount();
if (destCount + count > sourceItem.getMaxStackSize()) {
- log.info("{}: transfer rejected - would overflow max stack (destCount={} + count={} > max={})",
- player.getName(), destCount, count, sourceItem.getMaxStackSize());
return context.error();
}
if (!isSlotCompatible(dstInv, dstSlot, sourceItem)) {
- log.info("{}: transfer rejected - item {} cannot be placed in slot {} of {}",
- player.getName(), sourceItem, dstSlot, dstInv.getClass().getSimpleName());
return context.error();
}
From 6a46be1e7cb8a7a9004fa7571276b772e074f1a8 Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Fri, 24 Apr 2026 21:55:16 +0800
Subject: [PATCH 05/29] fix: SA inventory validation holes
---
src/main/java/cn/nukkit/Player.java | 6 +-
.../request/CraftRecipeActionProcessor.java | 76 ++++++++++++++++---
.../common/ItemStackRequestProcessor.java | 4 +
3 files changed, 74 insertions(+), 12 deletions(-)
diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java
index a33a50374..9e47a8ed1 100644
--- a/src/main/java/cn/nukkit/Player.java
+++ b/src/main/java/cn/nukkit/Player.java
@@ -68,10 +68,10 @@
import cn.nukkit.network.process.DataPacketManager;
import cn.nukkit.network.protocol.*;
import cn.nukkit.network.protocol.types.*;
+import cn.nukkit.network.protocol.types.debugshape.DebugShape;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest;
-import cn.nukkit.network.protocol.types.debugshape.DebugShape;
import cn.nukkit.network.session.NetworkPlayerSession;
import cn.nukkit.network.session.NetworkPlayerSession.ImmediatePacketMode;
import cn.nukkit.network.session.login.SessionLoginPhase;
@@ -4497,6 +4497,10 @@ public void onCompletion(Server server) {
}
public void handleItemStackRequests(List requests) {
+ if (!this.spawned || !this.isAlive()) {
+ log.debug("Player {} sent item stack request packet while not spawned or not alive", this.username);
+ return;
+ }
if (!this.isInventoryServerAuthoritative() || requests.isEmpty()) {
return;
}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
index 434a17d73..32f190c11 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
@@ -15,11 +15,7 @@
import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftRecipeAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CreateAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.*;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
import cn.nukkit.utils.TradeRecipeBuildUtils;
@@ -114,6 +110,9 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
if (!validateCraftingRecipe(player, recipe, recipeResult, times)) {
return context.error();
}
+ if (recipe instanceof CraftingRecipe craftingRecipe && !validateCraftingConsumePlan(player, craftingRecipe, times, context)) {
+ return context.error();
+ }
Item output = recipeResult.clone();
output.setCount(output.getCount() * times);
output.autoAssignStackNetworkId();
@@ -154,8 +153,14 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
}
}
int cost = option.getPrimarySlot() + 1;
- if (!player.isCreative() && player.getExperienceLevel() < cost) {
- return context.error();
+ if (!player.isCreative()) {
+ if (player.getExperienceLevel() < cost) {
+ return context.error();
+ }
+ Item reagent = enchantInventory.getReagentSlot();
+ if (reagent.isNull() || reagent.getCount() < cost || !reagent.equals(Item.get(Item.DYE, 4), true, false)) {
+ return context.error();
+ }
}
Item output = first.clone();
@@ -176,6 +181,17 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
if (!player.isCreative()) {
context.onCommit(() -> player.setExperience(player.getExperience(), player.getExperienceLevel() - cost));
+ context.onCommit(() -> {
+ Item reagent = enchantInventory.getReagentSlot();
+ if (!reagent.isNull() && reagent.equals(Item.get(Item.DYE, 4), true, false) && reagent.getCount() >= cost) {
+ if (reagent.getCount() > cost) {
+ reagent.setCount(reagent.getCount() - cost);
+ enchantInventory.setItem(1, reagent, false);
+ } else {
+ enchantInventory.clear(1, false);
+ }
+ }
+ });
}
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
context.onCommit(() -> PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId()));
@@ -215,10 +231,10 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
boolean hasBuyA = recipe.contains("buyA");
boolean hasBuyB = recipe.contains("buyB");
- if (hasBuyA && checkTrade(recipe.getCompound("buyA"), buyA, 0)) {
+ if (hasBuyA && checkTrade(recipe.getCompound("buyA"), buyA, times)) {
return context.error();
}
- if (hasBuyB && checkTrade(recipe.getCompound("buyB"), buyB, 0)) {
+ if (hasBuyB && checkTrade(recipe.getCompound("buyB"), buyB, times)) {
return context.error();
}
@@ -255,11 +271,11 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
)));
}
- private boolean checkTrade(CompoundTag expected, Item actual, int subtract) {
+ private boolean checkTrade(CompoundTag expected, Item actual, int multiplier) {
if (actual == null || actual.isNull()) {
return true;
}
- int required = Math.max(expected.getByte("Count") - subtract, 1);
+ int required = Math.max(expected.getByte("Count") * Math.max(1, multiplier), 1);
if (actual.getCount() < required) {
return true;
}
@@ -337,6 +353,44 @@ static List
- scaleItems(List
- items, int multiplier) {
return scaled;
}
+ private static boolean validateCraftingConsumePlan(Player player, CraftingRecipe recipe, int times, ItemStackRequestContext context) {
+ List
- expected = new ArrayList<>();
+ for (Item ingredient : recipe.getIngredientsAggregate()) {
+ if (ingredient == null || ingredient.isNull()) {
+ continue;
+ }
+ Item expectedItem = ingredient.clone();
+ expectedItem.setCount(expectedItem.getCount() * times);
+ expected.add(expectedItem);
+ }
+
+ List consumeActions = CraftRecipeAutoProcessor.findAllConsumeActions(
+ context.getItemStackRequest().getActions(),
+ context.getCurrentActionIndex() + 1);
+
+ List
- actual = new ArrayList<>();
+ for (ConsumeAction consume : consumeActions) {
+ if (consume.getCount() <= 0) {
+ return false;
+ }
+ var source = consume.getSource();
+ var inventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId());
+ if (inventory == null) {
+ return false;
+ }
+ int slot = NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot());
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < consume.getCount()) {
+ return false;
+ }
+ Item consumed = item.clone();
+ consumed.setCount(consume.getCount());
+ actual.add(consumed);
+ }
+
+ return Recipe.matchItemList(actual, expected);
+ }
+
private static boolean hasFollowupOutputAction(ItemStackRequestAction[] actions, int startIndex) {
for (int i = startIndex; i < actions.length; i++) {
ItemStackRequestAction action = actions[i];
diff --git a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
index e3f3c553f..2a34d3026 100644
--- a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
+++ b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
@@ -26,6 +26,10 @@ public class ItemStackRequestProcessor extends DataPacketProcessor
Date: Sat, 25 Apr 2026 16:04:32 +0800
Subject: [PATCH 06/29] fix
---
src/main/java/cn/nukkit/Player.java | 4 +
.../nukkit/blockentity/BlockEntityBeacon.java | 16 ++-
.../nukkit/entity/passive/EntityDonkey.java | 8 +-
.../entity/passive/EntityHorseBase.java | 6 +
.../inventory/CartographyTableInventory.java | 2 +-
.../cn/nukkit/inventory/HorseInventory.java | 16 ++-
.../nukkit/inventory/PlayerUIInventory.java | 36 ++++-
.../request/BeaconPaymentActionProcessor.java | 40 ++----
.../CraftGrindstoneActionProcessor.java | 21 ++-
.../request/CraftLoomActionProcessor.java | 8 ++
.../request/CraftRecipeActionProcessor.java | 135 ++++++++++++++----
.../request/CraftRecipeOptionalProcessor.java | 20 ++-
.../inventory/request/NetworkMapping.java | 36 ++++-
.../request/SwapActionProcessor.java | 7 +
.../request/TransferItemActionProcessor.java | 24 +++-
src/main/java/cn/nukkit/item/ItemBundle.java | 9 ++
.../network/process/DataPacketManager.java | 2 +-
.../common/ItemStackRequestProcessor.java | 4 +-
.../network/protocol/CraftingDataPacket.java | 3 +-
.../inventory/request/NetworkMappingTest.java | 26 ++++
20 files changed, 328 insertions(+), 95 deletions(-)
diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java
index 9e47a8ed1..d741fa461 100644
--- a/src/main/java/cn/nukkit/Player.java
+++ b/src/main/java/cn/nukkit/Player.java
@@ -4501,6 +4501,10 @@ public void handleItemStackRequests(List requests) {
log.debug("Player {} sent item stack request packet while not spawned or not alive", this.username);
return;
}
+ if (this.isSpectator() && this.server.useClientSpectator) {
+ this.needSendInventory = true;
+ return;
+ }
if (!this.isInventoryServerAuthoritative() || requests.isEmpty()) {
return;
}
diff --git a/src/main/java/cn/nukkit/blockentity/BlockEntityBeacon.java b/src/main/java/cn/nukkit/blockentity/BlockEntityBeacon.java
index 0921e90ff..c6f1e4b3e 100644
--- a/src/main/java/cn/nukkit/blockentity/BlockEntityBeacon.java
+++ b/src/main/java/cn/nukkit/blockentity/BlockEntityBeacon.java
@@ -242,7 +242,15 @@ public void setSecondaryPower(int power) {
}
private static final IntSet ALLOWED_EFFECTS = new IntOpenHashSet(new int[]{Effect.SPEED, Effect.HASTE, Effect.DAMAGE_RESISTANCE, Effect.JUMP, Effect.STRENGTH, Effect.REGENERATION});
- private static final IntSet ITEMS = new IntOpenHashSet(new int[]{Item.AIR, ItemID.NETHERITE_INGOT, ItemID.EMERALD, ItemID.DIAMOND, ItemID.GOLD_INGOT, ItemID. IRON_INGOT});
+ private static final IntSet PAYMENT_ITEMS = new IntOpenHashSet(new int[]{ItemID.NETHERITE_INGOT, ItemID.EMERALD, ItemID.DIAMOND, ItemID.GOLD_INGOT, ItemID.IRON_INGOT});
+
+ public static boolean isAllowedEffect(int effectId) {
+ return effectId == 0 || ALLOWED_EFFECTS.contains(effectId);
+ }
+
+ public static boolean isPaymentItem(int itemId) {
+ return PAYMENT_ITEMS.contains(itemId);
+ }
@Override
public boolean updateCompoundTag(CompoundTag nbt, Player player) {
@@ -262,7 +270,7 @@ public boolean updateCompoundTag(CompoundTag nbt, Player player) {
}
int material = beaconInventory.useMaterial();
- if (!ITEMS.contains(material)) {
+ if (!isPaymentItem(material)) {
Server.getInstance().getLogger().debug(player.getName() + " tried to set effect but there's no payment in beacon inventory");
return false;
}
@@ -273,14 +281,14 @@ public boolean updateCompoundTag(CompoundTag nbt, Player player) {
}
int primary = nbt.getInt("primary");
- if (ALLOWED_EFFECTS.contains(primary)) {
+ if (isAllowedEffect(primary)) {
this.setPrimaryPower(primary);
} else {
Server.getInstance().getLogger().debug(player.getName() + " tried to set an invalid primary effect to a beacon: " + primary);
}
int secondary = nbt.getInt("secondary");
- if (ALLOWED_EFFECTS.contains(secondary)) {
+ if (isAllowedEffect(secondary)) {
this.setSecondaryPower(secondary);
} else {
Server.getInstance().getLogger().debug(player.getName() + " tried to set an invalid secondary effect to a beacon: " + secondary);
diff --git a/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java b/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
index 8edf437bf..85eb4bd9e 100644
--- a/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
+++ b/src/main/java/cn/nukkit/entity/passive/EntityDonkey.java
@@ -48,11 +48,11 @@ public float getHeight() {
public void initEntity() {
this.setMaxHealth(15);
- super.initEntity();
-
if (this.namedTag.contains("ChestedHorse")) {
this.setChested(this.namedTag.getBoolean("ChestedHorse"));
}
+
+ super.initEntity();
}
@Override
@@ -115,8 +115,12 @@ public boolean isChested() {
}
public void setChested(boolean chested) {
+ boolean changed = this.chested != chested;
this.chested = chested;
this.setDataFlag(DATA_FLAGS, DATA_FLAG_CHESTED, chested);
+ if (changed) {
+ this.syncHorseInventoryChestSize();
+ }
}
@Override
diff --git a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
index f2db7b57c..609568ac9 100644
--- a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
+++ b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java
@@ -80,6 +80,12 @@ public HorseInventory getInventory() {
return this.horseInventory;
}
+ protected void syncHorseInventoryChestSize() {
+ if (this.horseInventory != null) {
+ this.horseInventory.setChestSize(this.getChestSize());
+ }
+ }
+
/**
* Number of additional storage slots beyond saddle (0) and armor (1).
* Defaults to 0; chested horses (donkey/mule/llama) override.
diff --git a/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java b/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
index affe258a5..cea07f9d8 100644
--- a/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
+++ b/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java
@@ -17,7 +17,7 @@ public class CartographyTableInventory extends FakeBlockUIComponent {
public static final int CARTOGRAPHY_ADDITIONAL_UI_SLOT = 13;
public CartographyTableInventory(PlayerUIInventory playerUI, Position position) {
- super(playerUI, InventoryType.CARTOGRAPHY, 2, position);
+ super(playerUI, InventoryType.CARTOGRAPHY, CARTOGRAPHY_INPUT_UI_SLOT, position);
}
@Override
diff --git a/src/main/java/cn/nukkit/inventory/HorseInventory.java b/src/main/java/cn/nukkit/inventory/HorseInventory.java
index 5617c6f72..92ac4e4db 100644
--- a/src/main/java/cn/nukkit/inventory/HorseInventory.java
+++ b/src/main/java/cn/nukkit/inventory/HorseInventory.java
@@ -26,7 +26,7 @@ public class HorseInventory extends BaseInventory {
public static final int SLOT_ARMOR = 1;
public static final int SLOT_CHEST_BASE = 2;
- private final int chestSize;
+ private int chestSize;
private boolean suppressSaddleSync;
public HorseInventory(EntityHorseBase holder, int chestSize) {
@@ -43,6 +43,20 @@ public int getChestSize() {
return chestSize;
}
+ public void setChestSize(int chestSize) {
+ int normalized = Math.max(0, chestSize);
+ if (this.chestSize == normalized) {
+ return;
+ }
+ int oldSize = this.getSize();
+ this.chestSize = normalized;
+ this.setSize(SLOT_CHEST_BASE + normalized);
+ for (int slot = this.getSize(); slot < oldSize; slot++) {
+ this.clear(slot, false);
+ }
+ this.sendContents(this.viewers.toArray(Player.EMPTY_ARRAY));
+ }
+
public boolean isSaddleSlot(int slot) {
return slot == SLOT_SADDLE;
}
diff --git a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
index 9bf99e5b9..1d9a270b4 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
@@ -64,13 +64,7 @@ public void sendSlot(int index, Player... target) {
// v1.21.30+ requires the correct container type in InventorySlotPacket,
// otherwise the client ignores the update (default is ANVIL_INPUT).
- if (index == 0) {
- pk.containerNameData = new FullContainerName(ContainerSlotType.CURSOR, null);
- } else if (index == PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT) {
- pk.containerNameData = new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null);
- } else {
- pk.containerNameData = new FullContainerName(ContainerSlotType.CRAFTING_INPUT, null);
- }
+ pk.containerNameData = new FullContainerName(resolveUISlotType(index), null);
for (Player p : target) {
if (p == this.getHolder()) {
@@ -131,6 +125,34 @@ public void sendContents(Player... target) {
}
}
+ private static ContainerSlotType resolveUISlotType(int slot) {
+ if (slot == 0) {
+ return ContainerSlotType.CURSOR;
+ }
+ if (slot == PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT) {
+ return ContainerSlotType.CREATED_OUTPUT;
+ }
+ return switch (slot) {
+ case 1 -> ContainerSlotType.ANVIL_INPUT;
+ case 2 -> ContainerSlotType.ANVIL_MATERIAL;
+ case 3 -> ContainerSlotType.STONECUTTER_INPUT;
+ case 9 -> ContainerSlotType.LOOM_INPUT;
+ case 10 -> ContainerSlotType.LOOM_DYE;
+ case 11 -> ContainerSlotType.LOOM_MATERIAL;
+ case 12 -> ContainerSlotType.CARTOGRAPHY_INPUT;
+ case 13 -> ContainerSlotType.CARTOGRAPHY_ADDITIONAL;
+ case 14 -> ContainerSlotType.ENCHANTING_INPUT;
+ case 15 -> ContainerSlotType.ENCHANTING_MATERIAL;
+ case 16 -> ContainerSlotType.GRINDSTONE_INPUT;
+ case 17 -> ContainerSlotType.GRINDSTONE_ADDITIONAL;
+ case 27 -> ContainerSlotType.BEACON_PAYMENT;
+ case 51 -> ContainerSlotType.SMITHING_TABLE_INPUT;
+ case 52 -> ContainerSlotType.SMITHING_TABLE_MATERIAL;
+ case 53 -> ContainerSlotType.SMITHING_TABLE_TEMPLATE;
+ default -> ContainerSlotType.CRAFTING_INPUT;
+ };
+ }
+
@Override
public int getSize() {
return 51;
diff --git a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
index 6920d5a1d..54b5cc0fe 100644
--- a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
@@ -3,11 +3,9 @@
import cn.nukkit.Player;
import cn.nukkit.blockentity.BlockEntityBeacon;
import cn.nukkit.inventory.BeaconInventory;
-import cn.nukkit.item.ItemID;
import cn.nukkit.level.Position;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.BeaconPaymentAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
-import cn.nukkit.potion.Effect;
public class BeaconPaymentActionProcessor implements ItemStackRequestActionProcessor {
@@ -21,44 +19,30 @@ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStac
if (!(player.getTopWindow().orElse(null) instanceof BeaconInventory beaconInventory)) {
return context.error();
}
- if (!isValidPayment(beaconInventory.getItem(0).getId())) {
+ if (!BlockEntityBeacon.isPaymentItem(beaconInventory.getItem(0).getId())) {
return context.error();
}
int primary = action.getPrimaryEffect();
int secondary = action.getSecondaryEffect();
- if (primary != 0 && !isValidEffect(primary)) {
+ if (!BlockEntityBeacon.isAllowedEffect(primary)) {
return context.error();
}
- if (secondary != 0 && !isValidEffect(secondary)) {
+ if (!BlockEntityBeacon.isAllowedEffect(secondary)) {
return context.error();
}
Position holder = beaconInventory.getHolder();
- if (holder != null) {
- if (holder.level.getBlockEntity(holder) instanceof BlockEntityBeacon beacon) {
- context.onCommit(() -> {
- beacon.setPrimaryPower(primary);
- beacon.setSecondaryPower(secondary);
- });
- }
+ if (holder == null || !(holder.level.getBlockEntity(holder) instanceof BlockEntityBeacon beacon)) {
+ return context.error();
}
- return null;
- }
-
- private static boolean isValidEffect(int effectId) {
- try {
- return Effect.getEffect(effectId) != null;
- } catch (Exception ex) {
- return false;
+ if (beacon.getPowerLevel() < 1) {
+ return context.error();
}
- }
-
- private static boolean isValidPayment(int itemId) {
- return itemId == ItemID.NETHERITE_INGOT
- || itemId == ItemID.EMERALD
- || itemId == ItemID.DIAMOND
- || itemId == ItemID.GOLD_INGOT
- || itemId == ItemID.IRON_INGOT;
+ context.onCommit(() -> {
+ beacon.setPrimaryPower(primary);
+ beacon.setSecondaryPower(secondary);
+ });
+ return null;
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
index c0edf27ae..49fadcc1d 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
@@ -5,6 +5,7 @@
import cn.nukkit.inventory.GrindstoneInventory;
import cn.nukkit.inventory.PlayerUIComponent;
import cn.nukkit.item.Item;
+import cn.nukkit.level.Position;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftGrindstoneAction;
@@ -12,6 +13,7 @@
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
+import java.util.ArrayList;
import java.util.List;
/**
@@ -54,15 +56,24 @@ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemSt
return context.error();
}
- // Stock vanilla behaviour: grinding emits the stored enchantment XP to the
- // player. Missing this was a regression from the previous implementation.
- if (experience > 0) {
- context.onCommit(() -> player.addExperience(experience));
+ List
- expectedConsumes = new ArrayList<>(2);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, grindstone.getEquipment(), 1);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, grindstone.getIngredient(), 1);
+ if (!CraftRecipeActionProcessor.validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
+
+ int experienceDropped = event.getExperienceDropped();
+ if (experienceDropped > 0) {
+ context.onCommit(() -> {
+ Position pos = grindstone.getHolder();
+ player.getLevel().dropExpOrb(pos.add(0.5, 0.5, 0.5), experienceDropped);
+ });
}
Item resultClone = result.clone().autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, resultClone, false);
- context.put(GRINDSTONE_EXP_KEY, experience);
+ context.put(GRINDSTONE_EXP_KEY, experienceDropped);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
0, 0, resultClone.getCount(), resultClone.getStackNetId(),
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
index 8ba97649b..c983497bc 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
@@ -19,6 +19,7 @@
import cn.nukkit.utils.DyeColor;
import lombok.extern.log4j.Log4j2;
+import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -87,6 +88,13 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq
return context.error();
}
+ List
- expectedConsumes = new ArrayList<>(2);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, banner, times);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, dye, times);
+ if (!CraftRecipeActionProcessor.validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
+
result.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
index 32f190c11..f6f4a2a6d 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
@@ -12,6 +12,7 @@
import cn.nukkit.item.enchantment.Enchantment;
import cn.nukkit.nbt.NBTIO;
import cn.nukkit.nbt.tag.CompoundTag;
+import cn.nukkit.network.protocol.CraftingDataPacket;
import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket;
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
@@ -63,6 +64,9 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
if (recipeNetId >= PlayerEnchantOptionsPacket.ENCH_RECIPEID) {
return handleEnchant(action, player, context);
}
+ if (recipeNetId == CraftingDataPacket.SMITHING_ARMOR_TRIM_NETWORK_ID) {
+ return handleSmithingTrim(player, context);
+ }
Recipe recipe = player.getServer().getCraftingManager().getRecipeByNetworkId(recipeNetId);
if (recipe == null) {
@@ -161,6 +165,12 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
if (reagent.isNull() || reagent.getCount() < cost || !reagent.equals(Item.get(Item.DYE, 4), true, false)) {
return context.error();
}
+ List
- expectedConsumes = new ArrayList<>(2);
+ addExpectedConsumeItem(expectedConsumes, first, 1);
+ addExpectedConsumeItem(expectedConsumes, reagent, cost);
+ if (!validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
}
Item output = first.clone();
@@ -173,34 +183,26 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
}
output.autoAssignStackNetworkId();
- EnchantItemEvent event = new EnchantItemEvent(enchantInventory, first.clone(), output, option.getMinLevel(), player);
+ EnchantItemEvent event = new EnchantItemEvent(enchantInventory, first.clone(), output, cost, player);
Server.getInstance().getPluginManager().callEvent(event);
if (event.isCancelled()) {
return context.error();
}
+ Item finalOutput = event.getNewItem();
+ int finalCost = event.getXpCost();
+
if (!player.isCreative()) {
- context.onCommit(() -> player.setExperience(player.getExperience(), player.getExperienceLevel() - cost));
- context.onCommit(() -> {
- Item reagent = enchantInventory.getReagentSlot();
- if (!reagent.isNull() && reagent.equals(Item.get(Item.DYE, 4), true, false) && reagent.getCount() >= cost) {
- if (reagent.getCount() > cost) {
- reagent.setCount(reagent.getCount() - cost);
- enchantInventory.setItem(1, reagent, false);
- } else {
- enchantInventory.clear(1, false);
- }
- }
- });
+ context.onCommit(() -> player.setExperience(player.getExperience(), player.getExperienceLevel() - finalCost));
}
- player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
+ player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, finalOutput, false);
context.onCommit(() -> PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId()));
context.put(ENCH_RECIPE_KEY, true);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, output.getCount(), output.getStackNetId(),
- output.hasCustomName() ? output.getCustomName() : "",
- output.getDamage(), ""
+ 0, 0, finalOutput.getCount(), finalOutput.getStackNetId(),
+ finalOutput.hasCustomName() ? finalOutput.getCustomName() : "",
+ finalOutput.getDamage(), ""
);
return context.success(List.of(new ItemStackResponseContainer(
ContainerSlotType.CREATED_OUTPUT,
@@ -226,8 +228,8 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
return context.error();
}
- Item buyA = tradeInventory.getUnclonedItem(TradeInventory.TRADE_INPUT1_UI_SLOT);
- Item buyB = tradeInventory.getUnclonedItem(TradeInventory.TRADE_INPUT2_UI_SLOT);
+ Item buyA = tradeInventory.getUnclonedItem(0);
+ Item buyB = tradeInventory.getUnclonedItem(1);
boolean hasBuyA = recipe.contains("buyA");
boolean hasBuyB = recipe.contains("buyB");
@@ -238,6 +240,17 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
return context.error();
}
+ List
- expectedConsumes = new ArrayList<>(2);
+ if (hasBuyA) {
+ addExpectedTradeConsumeItem(expectedConsumes, recipe.getCompound("buyA"), times);
+ }
+ if (hasBuyB) {
+ addExpectedTradeConsumeItem(expectedConsumes, recipe.getCompound("buyB"), times);
+ }
+ if (!validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
+
Item output = NBTIO.getItemHelper(recipe.getCompound("sell"));
if (output == null || output.isNull()) {
return context.error();
@@ -254,8 +267,11 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
if (rewardExp > 0) {
player.addExperience(rewardExp * times);
}
- if (villager != null && traderExp > 0) {
- villager.addExperience(traderExp * times);
+ if (villager != null) {
+ villager.namedTag.putBoolean("traded", true);
+ if (traderExp > 0) {
+ villager.addExperience(traderExp * times);
+ }
}
});
@@ -297,6 +313,14 @@ private boolean checkTrade(CompoundTag expected, Item actual, int multiplier) {
return false;
}
+ private static void addExpectedTradeConsumeItem(List
- expectedConsumes, CompoundTag tag, int times) {
+ Item item = NBTIO.getItemHelper(tag);
+ if (item == null || item.isNull()) {
+ return;
+ }
+ addExpectedConsumeItem(expectedConsumes, item, Math.max(1, item.getCount()) * Math.max(1, times));
+ }
+
/**
* Collects non-empty items from the player's active crafting grid (big
* workbench if one is open, otherwise the personal 2x2 grid). Used as the
@@ -356,12 +380,31 @@ static List
- scaleItems(List
- items, int multiplier) {
private static boolean validateCraftingConsumePlan(Player player, CraftingRecipe recipe, int times, ItemStackRequestContext context) {
List
- expected = new ArrayList<>();
for (Item ingredient : recipe.getIngredientsAggregate()) {
- if (ingredient == null || ingredient.isNull()) {
+ addExpectedConsumeItem(expected, ingredient, ingredient == null ? 0 : ingredient.getCount() * times);
+ }
+
+ return validateExpectedConsumePlan(player, expected, context);
+ }
+
+ static void addExpectedConsumeItem(List
- expected, Item item, int count) {
+ if (item == null || item.isNull() || count <= 0) {
+ return;
+ }
+ Item expectedItem = item.clone();
+ expectedItem.setCount(count);
+ expected.add(expectedItem);
+ }
+
+ static boolean validateExpectedConsumePlan(Player player, List
- expected, ItemStackRequestContext context) {
+ List
- expectedConsumes = new ArrayList<>(expected.size());
+ for (Item item : expected) {
+ if (item == null || item.isNull() || item.getCount() <= 0) {
continue;
}
- Item expectedItem = ingredient.clone();
- expectedItem.setCount(expectedItem.getCount() * times);
- expected.add(expectedItem);
+ expectedConsumes.add(item.clone());
+ }
+ if (expectedConsumes.isEmpty()) {
+ return true;
}
List consumeActions = CraftRecipeAutoProcessor.findAllConsumeActions(
@@ -388,7 +431,7 @@ private static boolean validateCraftingConsumePlan(Player player, CraftingRecipe
actual.add(consumed);
}
- return Recipe.matchItemList(actual, expected);
+ return Recipe.matchItemList(actual, expectedConsumes);
}
private static boolean hasFollowupOutputAction(ItemStackRequestAction[] actions, int startIndex) {
@@ -412,9 +455,19 @@ private ActionResponse handleSmithingUpgrade(SmithingTransformRecipe recipe, Pla
return context.error();
}
Item equipment = smithingInventory.getEquipment();
- Item result = recipe.getResult().clone();
- if (equipment != null && !equipment.isNull() && equipment.hasCompoundTag()) {
- result.setCompoundTag(equipment.getCompoundTag());
+ Item ingredient = smithingInventory.getIngredient();
+ Item template = smithingInventory.getTemplate();
+ SmithingRecipe matchedRecipe = player.getServer().getCraftingManager()
+ .matchSmithingRecipe(new ArrayList<>(List.of(equipment, ingredient, template)));
+ if (matchedRecipe != recipe) {
+ return context.error();
+ }
+ Item result = recipe.getFinalResult(equipment, template);
+ if (result == null || result.isNull()) {
+ return context.error();
+ }
+ if (!validateSmithingConsumePlan(player, context, equipment, ingredient, template)) {
+ return context.error();
}
if (!fireSmithingEvent(smithingInventory, result, player)) {
return context.error();
@@ -438,6 +491,10 @@ private ActionResponse handleSmithingTrim(Player player, ItemStackRequestContext
if (result == null || result.isNull()) {
return context.error();
}
+ if (!validateSmithingConsumePlan(player, context,
+ smithingInventory.getEquipment(), smithingInventory.getIngredient(), smithingInventory.getTemplate())) {
+ return context.error();
+ }
if (!fireSmithingEvent(smithingInventory, result, player)) {
return context.error();
}
@@ -446,6 +503,15 @@ private ActionResponse handleSmithingTrim(Player player, ItemStackRequestContext
return buildCreatedOutputResponse(context, result);
}
+ private static boolean validateSmithingConsumePlan(Player player, ItemStackRequestContext context,
+ Item equipment, Item ingredient, Item template) {
+ List
- expectedConsumes = new ArrayList<>(3);
+ addExpectedConsumeItem(expectedConsumes, equipment, 1);
+ addExpectedConsumeItem(expectedConsumes, ingredient, 1);
+ addExpectedConsumeItem(expectedConsumes, template, 1);
+ return validateExpectedConsumePlan(player, expectedConsumes, context);
+ }
+
/**
* Mirror {@code SmithingTransaction.execute()}: plugins receive the full set
* of input slots + projected output so they can veto smithing-table usage.
@@ -481,9 +547,20 @@ private ActionResponse handleStonecutter(Player player, StonecutterRecipe recipe
return context.error();
}
int times = Math.max(1, action.getNumberOfRequestedCrafts());
+ Item ingredient = recipe.getIngredient();
+ if (!ingredient.equals(input, ingredient.hasMeta(), false)
+ || input.getCount() < ingredient.getCount() * times) {
+ return context.error();
+ }
Item output = recipe.getResult();
output.setCount(output.getCount() * times);
+ List
- expectedConsumes = new ArrayList<>(1);
+ addExpectedConsumeItem(expectedConsumes, ingredient, ingredient.getCount() * times);
+ if (!validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
+
StonecutterItemEvent event = new StonecutterItemEvent(stonecutterInventory, input, output.clone(), player);
Server.getInstance().getPluginManager().callEvent(event);
if (event.isCancelled()) {
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
index 7798c9c2c..b6f6c3f11 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java
@@ -69,6 +69,7 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It
Item result;
int levelCost = 0;
+ List
- expectedConsumes = new ArrayList<>(2);
if (inventory instanceof AnvilInventory anvilInventory) {
AnvilResult pair = updateAnvilResult(player, anvilInventory, filterString);
if (pair == null || pair.result.isNull()) {
@@ -76,6 +77,8 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It
}
result = pair.result;
levelCost = pair.levelCost;
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, anvilInventory.getInputSlot(), 1);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, anvilInventory.getMaterialSlot(), pair.materialCost);
// Mirror legacy RepairItemTransaction: fire RepairItemEvent before any
// state mutation so plugins can veto the anvil operation or override
@@ -107,10 +110,16 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It
if (result == null || result.isNull()) {
return context.error();
}
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, cartographyInventory.getInput(), 1);
+ CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, cartographyInventory.getAdditional(), 1);
} else {
return context.error();
}
+ if (!CraftRecipeActionProcessor.validateExpectedConsumePlan(player, expectedConsumes, context)) {
+ return context.error();
+ }
+
result.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
@@ -126,7 +135,7 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It
)));
}
- private record AnvilResult(Item result, int levelCost) {}
+ private record AnvilResult(Item result, int levelCost, int materialCost) {}
/**
* Apply the vanilla 12% chance that an anvil loses one durability level on
@@ -185,6 +194,7 @@ private AnvilResult updateAnvilResult(Player player, AnvilInventory inventory, S
int costHelper = 0;
int repairMaterial = getRepairMaterial(target);
Item result = target.clone();
+ int materialCost = 0;
Set enchantments = new LinkedHashSet<>(Arrays.asList(target.getEnchantments()));
if (!sacrifice.isNull()) {
@@ -204,11 +214,13 @@ private AnvilResult updateAnvilResult(Player player, AnvilInventory inventory, S
++extraCost;
repair = Math.min(result.getDamage(), result.getMaxDurability() / 4);
}
+ materialCost = repair2;
} else {
if (!enchantedBook && (result.getId() != sacrifice.getId() || result.getMaxDurability() == -1)) {
player.getLevel().addSound(player, Sound.RANDOM_ANVIL_USE, 1f, 1f);
return null;
}
+ materialCost = 1;
if (result.getMaxDurability() != -1 && !enchantedBook) {
// Anvil - combine durability from same-type item
@@ -316,14 +328,14 @@ private AnvilResult updateAnvilResult(Player player, AnvilInventory inventory, S
int levelCost = getRepairCost(result) + (sacrifice.isNull() ? 0 : getRepairCost(sacrifice));
levelCost += extraCost;
if (extraCost <= 0) {
- return new AnvilResult(Item.get(Item.AIR), levelCost);
+ return new AnvilResult(Item.get(Item.AIR), levelCost, materialCost);
}
if (costHelper == extraCost && costHelper > 0 && levelCost >= 40) {
levelCost = 39;
}
if (levelCost >= 40 && !player.isCreative()) {
- return new AnvilResult(Item.get(Item.AIR), levelCost);
+ return new AnvilResult(Item.get(Item.AIR), levelCost, materialCost);
}
int repairCost = getRepairCost(result);
@@ -340,7 +352,7 @@ private AnvilResult updateAnvilResult(Player player, AnvilInventory inventory, S
if (!enchantments.isEmpty()) {
result.addEnchantment(enchantments.toArray(Enchantment.EMPTY_ARRAY));
}
- return new AnvilResult(result, levelCost);
+ return new AnvilResult(result, levelCost, materialCost);
}
private Item updateCartographyTableResult(CartographyTableInventory inventory, String filterString) {
diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
index f55e0e5bb..142658857 100644
--- a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
+++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java
@@ -42,10 +42,10 @@ public static Inventory getInventory(Player player, ContainerSlotType type, @Nul
case CURSOR -> ui.getCursorInventory();
case CREATED_OUTPUT -> ui; // slot 50 of PlayerUIInventory hosts the created output
case CRAFTING_INPUT, CRAFTING_OUTPUT -> {
- // Follow the window the player currently has open (e.g. a crafting
- // table) so 3x3 recipes route to BigCraftingGrid; fall back to the
- // 2x2 personal grid otherwise.
- yield topWindow != null ? topWindow : ui.getCraftingGrid();
+ // Crafting tables are tracked through Player.craftingGrid rather
+ // than a normal top window. Only use topWindow when it is already a
+ // crafting-grid view; otherwise return the player's active grid.
+ yield topWindow instanceof CraftingGrid ? topWindow : player.getCraftingGrid();
}
case HOTBAR, INVENTORY, HOTBAR_AND_INVENTORY, ARMOR -> player.getInventory();
case OFFHAND -> player.getOffhandInventory();
@@ -122,15 +122,39 @@ public static int toInternalSlot(ContainerSlotType type, int networkSlot) {
case ARMOR -> networkSlot + 36;
case CURSOR, OFFHAND, BEACON_PAYMENT -> 0;
case CREATED_OUTPUT -> 50;
+ case CRAFTING_INPUT, CRAFTING_OUTPUT -> {
+ if (networkSlot >= 32 && networkSlot <= 40) {
+ yield networkSlot - 32;
+ }
+ if (networkSlot >= 28 && networkSlot <= 31) {
+ yield networkSlot - 28;
+ }
+ yield networkSlot;
+ }
+ case ANVIL_INPUT, ANVIL_MATERIAL, ANVIL_RESULT -> mapRange(networkSlot, 1, 3);
+ case STONECUTTER_INPUT, STONECUTTER_RESULT -> mapRange(networkSlot, 3, 4);
+ case LOOM_INPUT, LOOM_DYE, LOOM_MATERIAL, LOOM_RESULT -> mapRange(networkSlot, 9, 12);
+ case CARTOGRAPHY_INPUT, CARTOGRAPHY_ADDITIONAL, CARTOGRAPHY_RESULT -> mapRange(networkSlot, 12, 14);
+ case ENCHANTING_INPUT, ENCHANTING_MATERIAL -> mapRange(networkSlot, 14, 15);
+ case GRINDSTONE_INPUT, GRINDSTONE_ADDITIONAL, GRINDSTONE_RESULT -> mapRange(networkSlot, 16, 18);
+ case SMITHING_TABLE_INPUT, SMITHING_TABLE_MATERIAL, SMITHING_TABLE_TEMPLATE, SMITHING_TABLE_RESULT ->
+ mapRange(networkSlot, 51, 54);
// Villager trade inventory places the two input slots at fixed
// physical indices; map both the 1.16+ TRADE2_* and legacy TRADE_*
// aliases to the same slots.
- case TRADE_INGREDIENT_1, TRADE2_INGREDIENT_1 -> TradeInventory.TRADE_INPUT1_UI_SLOT;
- case TRADE_INGREDIENT_2, TRADE2_INGREDIENT_2 -> TradeInventory.TRADE_INPUT2_UI_SLOT;
+ case TRADE_INGREDIENT_1, TRADE2_INGREDIENT_1 -> 0;
+ case TRADE_INGREDIENT_2, TRADE2_INGREDIENT_2 -> 1;
default -> networkSlot;
};
}
+ private static int mapRange(int networkSlot, int firstNetworkSlot, int lastNetworkSlot) {
+ if (networkSlot >= firstNetworkSlot && networkSlot <= lastNetworkSlot) {
+ return networkSlot - firstNetworkSlot;
+ }
+ return networkSlot;
+ }
+
/**
* Convert a server-side internal slot index back to the network-level slot
* index. Used when constructing ItemStackResponseSlot entries.
diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
index 2c979d989..3f1a85959 100644
--- a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java
@@ -44,6 +44,13 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC
return context.error();
}
+ if (!TransferItemActionProcessor.isSlotCompatible(srcInv, srcSlot, destItem)) {
+ return context.error();
+ }
+ if (!TransferItemActionProcessor.isSlotCompatible(dstInv, dstSlot, sourceItem)) {
+ return context.error();
+ }
+
// Fire InventoryClickEvent for both slots before mutation, matching the
// legacy InventoryTransaction path (one event per swapped slot).
if (!TransferItemActionProcessor.fireClickEvent(player, srcInv, srcSlot, sourceItem, destItem)) {
diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
index c6fd48e84..e1d36bcaf 100644
--- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java
@@ -22,8 +22,7 @@
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
import lombok.extern.log4j.Log4j2;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
/**
* Base handler for TAKE and PLACE actions — both structurally a partial/full
@@ -186,7 +185,7 @@ private static boolean isEquipmentSlot(ContainerSlotType type) {
* {@code inventory}. ARMOR slots reject non-matching equipment; all other
* inventories accept any item.
*/
- private static boolean isSlotCompatible(Inventory inventory, int slot, Item item) {
+ static boolean isSlotCompatible(Inventory inventory, int slot, Item item) {
if (inventory instanceof PlayerInventory playerInv) {
int size = playerInv.getSize();
if (slot == size) {
@@ -277,11 +276,28 @@ protected boolean callExecuteEvent() {
@Override
public boolean execute() {
+ // Snapshot affected slots before firing the event so we can tell
+ // whether a plugin actually mutated the inventory when cancelling.
+ Map> preStates = new HashMap<>();
+ for (InventoryAction action : this.actions) {
+ if (action instanceof SlotChangeAction slotChange) {
+ Inventory inv = slotChange.getInventory();
+ int slot = slotChange.getSlot();
+ preStates.computeIfAbsent(inv, k -> new HashMap<>()).put(slot, inv.getItem(slot).clone());
+ }
+ }
+
if (!callExecuteEvent()) {
if (context != null) {
for (InventoryAction action : this.actions) {
if (action instanceof SlotChangeAction slotChange) {
- context.addPluginModifiedInventory(slotChange.getInventory());
+ Inventory inv = slotChange.getInventory();
+ int slot = slotChange.getSlot();
+ Item before = preStates.getOrDefault(inv, Collections.emptyMap()).get(slot);
+ Item after = inv.getItem(slot);
+ if (before == null || after == null || !before.equals(after, true, true)) {
+ context.addPluginModifiedInventory(inv);
+ }
}
}
}
diff --git a/src/main/java/cn/nukkit/item/ItemBundle.java b/src/main/java/cn/nukkit/item/ItemBundle.java
index ed006b939..035cad52f 100644
--- a/src/main/java/cn/nukkit/item/ItemBundle.java
+++ b/src/main/java/cn/nukkit/item/ItemBundle.java
@@ -66,6 +66,15 @@ public void saveNBT() {
this.setNamedTag(tag);
}
+ @Override
+ public Item setNamedTag(CompoundTag tag) {
+ super.setNamedTag(tag);
+ if (tag != null && tag.contains(TAG_BUNDLE_ID)) {
+ NEXT_BUNDLE_ID.updateAndGet(current -> Math.max(current, tag.getInt(TAG_BUNDLE_ID) + 1));
+ }
+ return this;
+ }
+
@Override
public ItemBundle clone() {
ItemBundle cloned = (ItemBundle) super.clone();
diff --git a/src/main/java/cn/nukkit/network/process/DataPacketManager.java b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
index 74f7cc88d..3a24dd3be 100644
--- a/src/main/java/cn/nukkit/network/process/DataPacketManager.java
+++ b/src/main/java/cn/nukkit/network/process/DataPacketManager.java
@@ -223,7 +223,7 @@ public static void registerDefaultProcessors() {
);
registerProcessor(
- ProtocolInfo.v1_16_100,
+ ProtocolInfo.v1_16_0,
ItemStackRequestProcessor.INSTANCE
);
diff --git a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
index 2a34d3026..ab05f8023 100644
--- a/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
+++ b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java
@@ -15,7 +15,7 @@
* Handles server-authoritative inventory requests from clients
*
* @author Nukkit-MOT Team
- * @since v1.16.100 (protocol 407+)
+ * @since v1.16.100 (protocol 419+)
*/
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ItemStackRequestProcessor extends DataPacketProcessor {
@@ -53,7 +53,7 @@ public Class extends DataPacket> getPacketClass() {
@Override
public boolean isSupported(int protocol) {
- // ItemStackRequest was introduced in v1.16.100
+ // Protocols before v419 use the old ItemStackResponse success boolean.
return protocol >= ProtocolInfo.v1_16_100;
}
}
diff --git a/src/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java b/src/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java
index 444c1d76e..31f7ee0cd 100644
--- a/src/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java
+++ b/src/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java
@@ -29,6 +29,7 @@ public class CraftingDataPacket extends DataPacket {
public static final String CRAFTING_TAG_BLAST_FURNACE = "blast_furnace";
public static final String CRAFTING_TAG_SMOKER = "smoker";
public static final String CRAFTING_TAG_SMITHING_TABLE = "smithing_table";
+ public static final int SMITHING_ARMOR_TRIM_NETWORK_ID = 1;
private List entries = new ArrayList<>();
private final List stonecutterEntries = new ArrayList<>();
@@ -254,7 +255,7 @@ public void encode() {
this.putRecipeIngredient(protocol, "minecraft:trimmable_armors", 1);
this.putRecipeIngredient(protocol, "minecraft:trim_materials", 1);
this.putString(CRAFTING_TAG_SMITHING_TABLE);
- this.putUnsignedVarInt(1); // Network ID (hardcoded in CraftingManager)
+ this.putUnsignedVarInt(SMITHING_ARMOR_TRIM_NETWORK_ID); // Reserved by CraftingManager
}
if (protocol >= 388) {
diff --git a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
index 5a2fef48e..17e2df3f2 100644
--- a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
+++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java
@@ -38,6 +38,32 @@ void levelEntityAndCrafterContainerResolveToTopWindow() {
assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null));
}
+ @Test
+ void craftingInputResolvesToCurrentPlayerCraftingGrid() {
+ Player player = Mockito.mock(Player.class);
+ Inventory unrelatedTopWindow = Mockito.mock(Inventory.class);
+ CraftingGrid activeGrid = Mockito.mock(CraftingGrid.class);
+
+ Mockito.when(player.getTopWindow()).thenReturn(Optional.of(unrelatedTopWindow));
+ Mockito.when(player.getCraftingGrid()).thenReturn(activeGrid);
+
+ assertSame(activeGrid, NetworkMapping.getInventory(player, ContainerSlotType.CRAFTING_INPUT, null));
+ }
+
+ @Test
+ void uiNetworkSlotsMapToComponentLocalSlots() {
+ assertEquals(0, NetworkMapping.toInternalSlot(ContainerSlotType.CRAFTING_INPUT, 28));
+ assertEquals(3, NetworkMapping.toInternalSlot(ContainerSlotType.CRAFTING_INPUT, 31));
+ assertEquals(0, NetworkMapping.toInternalSlot(ContainerSlotType.CRAFTING_INPUT, 32));
+ assertEquals(8, NetworkMapping.toInternalSlot(ContainerSlotType.CRAFTING_INPUT, 40));
+ assertEquals(0, NetworkMapping.toInternalSlot(ContainerSlotType.ENCHANTING_INPUT, 14));
+ assertEquals(1, NetworkMapping.toInternalSlot(ContainerSlotType.ENCHANTING_MATERIAL, 15));
+ assertEquals(0, NetworkMapping.toInternalSlot(ContainerSlotType.SMITHING_TABLE_INPUT, 51));
+ assertEquals(1, NetworkMapping.toInternalSlot(ContainerSlotType.SMITHING_TABLE_MATERIAL, 52));
+ assertEquals(2, NetworkMapping.toInternalSlot(ContainerSlotType.SMITHING_TABLE_TEMPLATE, 53));
+ assertEquals(0, NetworkMapping.toInternalSlot(ContainerSlotType.STONECUTTER_INPUT, 3));
+ }
+
@Test
void dynamicContainerCanResolveNestedBundleFromAccessibleInventories() {
Player player = Mockito.mock(Player.class);
From 615ade892aaa4dec29573977353471e6b315ac82 Mon Sep 17 00:00:00 2001
From: LT_Name <572413378@qq.com>
Date: Sun, 26 Apr 2026 11:33:56 +0800
Subject: [PATCH 07/29] fix
---
src/main/java/cn/nukkit/Player.java | 99 ++-
.../ItemStackRequestActionEvent.java | 4 +-
.../cn/nukkit/inventory/BaseInventory.java | 64 ++
.../inventory/DoubleChestInventory.java | 23 +-
.../cn/nukkit/inventory/EnchantInventory.java | 10 +
.../inventory/EntityArmorInventory.java | 9 +-
.../cn/nukkit/inventory/PlayerInventory.java | 28 +-
.../inventory/PlayerOffhandInventory.java | 4 +-
.../nukkit/inventory/PlayerUIInventory.java | 1 +
.../cn/nukkit/inventory/TradeInventory.java | 18 +
.../request/BeaconPaymentActionProcessor.java | 45 ++
.../request/CraftCreativeActionProcessor.java | 22 +-
.../CraftGrindstoneActionProcessor.java | 4 +-
.../request/CraftLoomActionProcessor.java | 4 +-
.../request/CraftRecipeActionProcessor.java | 157 ++++-
.../request/CraftRecipeAutoProcessor.java | 127 +++-
.../request/CraftRecipeOptionalProcessor.java | 4 +-
.../CraftResultDeprecatedActionProcessor.java | 4 +-
.../request/CreateActionProcessor.java | 4 +-
.../request/DestroyActionProcessor.java | 8 +-
.../request/DropActionProcessor.java | 3 +-
.../request/ItemStackRequestHandler.java | 23 +-
.../request/MineBlockActionProcessor.java | 4 +-
.../request/TransferItemActionProcessor.java | 102 +++-
.../item/enchantment/EnchantmentHelper.java | 5 +-
.../descriptor/ComplexAliasDescriptor.java | 9 +
.../descriptor/ItemTagDescriptor.java | 9 +
.../PlayerInventoryTransactionTest.java | 71 +++
.../InventoryServerAuthoritativeSyncTest.java | 167 ++++++
.../ItemStackRequestProcessorTest.java | 566 ++++++++++++++++++
.../enchantment/EnchantmentHelperTest.java | 35 ++
31 files changed, 1506 insertions(+), 127 deletions(-)
create mode 100644 src/test/java/cn/nukkit/PlayerInventoryTransactionTest.java
create mode 100644 src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java
create mode 100644 src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java
create mode 100644 src/test/java/cn/nukkit/item/enchantment/EnchantmentHelperTest.java
diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java
index f0812bfc8..303958664 100644
--- a/src/main/java/cn/nukkit/Player.java
+++ b/src/main/java/cn/nukkit/Player.java
@@ -41,7 +41,9 @@
import cn.nukkit.inventory.*;
import cn.nukkit.inventory.request.ItemStackRequestHandler;
import cn.nukkit.inventory.transaction.*;
+import cn.nukkit.inventory.transaction.action.DropItemAction;
import cn.nukkit.inventory.transaction.action.InventoryAction;
+import cn.nukkit.inventory.transaction.action.SlotChangeAction;
import cn.nukkit.inventory.transaction.data.ReleaseItemData;
import cn.nukkit.inventory.transaction.data.UseItemData;
import cn.nukkit.inventory.transaction.data.UseItemOnEntityData;
@@ -3557,12 +3559,13 @@ public void onCompletion(Server server) {
}
break;
case ProtocolInfo.PLAYER_AUTH_INPUT_PACKET:
+ PlayerAuthInputPacket authPacket = (PlayerAuthInputPacket) packet;
+ this.handleAuthInputItemStackRequest(authPacket);
+
if (!this.isMovementServerAuthoritative()) {
return;
}
- PlayerAuthInputPacket authPacket = (PlayerAuthInputPacket) packet;
-
if (!authPacket.getBlockActionData().isEmpty()) {
for (PlayerBlockActionData action : authPacket.getBlockActionData().values()) {
BlockVector3 blockPos = action.getPosition();
@@ -3937,10 +3940,6 @@ public void onCompletion(Server server) {
this.forceMovement = null;
}
- // 处理 ItemStackRequest(v1.16.100+ 客户端通过 PlayerAuthInputPacket 发送物品栏操作)
- if (this.isInventoryServerAuthoritative() && authPacket.getItemStackRequest() != null) {
- this.handleItemStackRequests(List.of(authPacket.getItemStackRequest()));
- }
break;
case ProtocolInfo.PLAYER_ACTION_PACKET:
PlayerActionPacket playerActionPacket = (PlayerActionPacket) packet;
@@ -4530,6 +4529,12 @@ public void handleItemStackRequests(List requests) {
}
}
+ private void handleAuthInputItemStackRequest(PlayerAuthInputPacket authPacket) {
+ if (authPacket.getItemStackRequest() != null) {
+ this.handleItemStackRequests(List.of(authPacket.getItemStackRequest()));
+ }
+ }
+
public void handleInventoryTransactionPacket(InventoryTransactionPacket transactionPacket) {
if (!this.spawned || !this.isAlive()) {
log.debug("Player {} sent inventory transaction packet while not spawned or not alive", this.username);
@@ -4661,6 +4666,24 @@ public void handleInventoryTransactionPacket(InventoryTransactionPacket transact
actions.add(a);
}
+ if (this.isInventoryServerAuthoritative()
+ && transactionPacket.transactionType == InventoryTransactionPacket.TYPE_NORMAL
+ && isServerAuthoritativeLegacyDropTransaction(transactionPacket, actions)) {
+ InventoryTransaction transaction = new InventoryTransaction(this, actions);
+ if (!transaction.execute()) {
+ this.server.getLogger().debug("Failed to execute SAI legacy drop transaction from " + this.username
+ + " with actions: " + Arrays.toString(transactionPacket.actions));
+ this.needSendInventory = true;
+ }
+ return;
+ }
+
+ if (this.shouldRejectLegacyInventoryUiTransaction(transactionPacket)) {
+ this.server.getLogger().debug(this.username + ": dropping legacy InventoryTransaction UI transaction while SAI is enabled");
+ this.needSendInventory = true;
+ return;
+ }
+
if (transactionPacket.isCraftingPart) {
if (LoomTransaction.isIn(actions)) {
if (this.loomTransaction == null) {
@@ -5277,6 +5300,63 @@ public void handleInventoryTransactionPacket(InventoryTransactionPacket transact
}
}
+ private boolean shouldRejectLegacyInventoryUiTransaction(InventoryTransactionPacket packet) {
+ return this.isInventoryServerAuthoritative()
+ && (packet.transactionType == InventoryTransactionPacket.TYPE_NORMAL
+ || packet.isCraftingPart
+ || packet.isEnchantingPart
+ || packet.isRepairItemPart
+ || packet.isTradeItemPart);
+ }
+
+ static boolean isServerAuthoritativeLegacyDropTransaction(InventoryTransactionPacket packet, List actions) {
+ if (packet.transactionType != InventoryTransactionPacket.TYPE_NORMAL
+ || packet.actions == null
+ || packet.actions.length != 2
+ || actions.size() != 2) {
+ return false;
+ }
+ if (!(actions.get(0) instanceof DropItemAction) || !(actions.get(1) instanceof SlotChangeAction slotChange)) {
+ return false;
+ }
+
+ NetworkInventoryAction worldAction = packet.actions[0];
+ NetworkInventoryAction containerAction = packet.actions[1];
+ if (worldAction.sourceType != NetworkInventoryAction.SOURCE_WORLD
+ || worldAction.inventorySlot != InventoryTransactionPacket.ACTION_MAGIC_SLOT_DROP_ITEM
+ || containerAction.sourceType != NetworkInventoryAction.SOURCE_CONTAINER
+ || containerAction.windowId != ContainerIds.INVENTORY
+ || slotChange.getSlot() != containerAction.inventorySlot
+ || !(slotChange.getInventory() instanceof PlayerInventory)) {
+ return false;
+ }
+
+ Item droppedItem = worldAction.newItem;
+ Item oldItem = containerAction.oldItem;
+ Item newItem = containerAction.newItem;
+ if (droppedItem == null || droppedItem.isNull()
+ || oldItem == null || oldItem.isNull()
+ || newItem == null) {
+ return false;
+ }
+ int droppedCount = droppedItem.getCount();
+ if (droppedCount <= 0 || oldItem.getCount() < droppedCount) {
+ return false;
+ }
+ if (!slotChange.getSourceItem().equalsExact(oldItem) || !slotChange.getTargetItem().equalsExact(newItem)) {
+ return false;
+ }
+
+ Item expectedDrop = oldItem.clone();
+ expectedDrop.setCount(droppedCount);
+ Item expectedRemainder = oldItem.clone();
+ expectedRemainder.setCount(oldItem.getCount() - droppedCount);
+ if (expectedRemainder.getCount() <= 0) {
+ expectedRemainder = Item.get(Item.AIR);
+ }
+ return droppedItem.equalsExact(expectedDrop) && newItem.equalsExact(expectedRemainder);
+ }
+
private boolean handleQuickCraft(InventoryTransactionPacket packet, List actions, InventoryTransaction transaction) {
if (transaction.checkForItemPart(actions)) {
for (InventoryAction action : actions) {
@@ -6202,6 +6282,7 @@ public void kill() {
int id = this.getWindowId(this.getOffhandInventory());
if (id != -1) {
pk.inventoryId = id;
+ pk.containerNameData = new FullContainerName(ContainerSlotType.OFFHAND, ContainerIds.INVENTORY);
this.dataPacket(pk);
}
}
@@ -6213,6 +6294,10 @@ public void kill() {
pk.slot = entry.getKey();
pk.item = Item.AIR_ITEM;
pk.inventoryId = id;
+ pk.containerNameData = new FullContainerName(
+ pk.slot < 9 ? ContainerSlotType.HOTBAR : ContainerSlotType.INVENTORY,
+ id
+ );
this.dataPacket(pk);
}
}
@@ -8134,7 +8219,7 @@ private void syncHeldItem() {
pk.slot = this.inventory.getHeldItemIndex();
pk.item = this.inventory.getItem(pk.slot);
pk.inventoryId = ContainerIds.INVENTORY;
- pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null);
+ pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, ContainerIds.INVENTORY);
this.dataPacket(pk);
}
diff --git a/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java b/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
index 5179b57c2..69dd95cdb 100644
--- a/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
+++ b/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java
@@ -2,6 +2,7 @@
import cn.nukkit.Player;
import cn.nukkit.event.Cancellable;
+import cn.nukkit.event.Event;
import cn.nukkit.event.HandlerList;
import cn.nukkit.inventory.request.ActionResponse;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
@@ -10,7 +11,7 @@
* Called before an ItemStackRequest action is processed.
* Allows plugins to cancel or provide a custom response.
*/
-public class ItemStackRequestActionEvent extends InventoryEvent implements Cancellable {
+public class ItemStackRequestActionEvent extends Event implements Cancellable {
private static final HandlerList handlers = new HandlerList();
@@ -24,7 +25,6 @@ public static HandlerList getHandlers() {
private ActionResponse response;
public ItemStackRequestActionEvent(Player player, ItemStackRequestAction action, int actionIndex) {
- super(null);
this.player = player;
this.action = action;
this.actionIndex = actionIndex;
diff --git a/src/main/java/cn/nukkit/inventory/BaseInventory.java b/src/main/java/cn/nukkit/inventory/BaseInventory.java
index 96fa7a316..74bb60861 100644
--- a/src/main/java/cn/nukkit/inventory/BaseInventory.java
+++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java
@@ -13,6 +13,8 @@
import cn.nukkit.network.protocol.InventoryContentPacket;
import cn.nukkit.network.protocol.InventorySlotPacket;
import cn.nukkit.network.protocol.ProtocolInfo;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.v113.ContainerSetContentPacketV113;
import cn.nukkit.network.protocol.v113.ContainerSetSlotPacketV113;
import it.unimi.dsi.fastutil.ints.IntArrayList;
@@ -544,6 +546,7 @@ public void sendContents(Player... players) {
continue;
}
pk.inventoryId = id;
+ pk.containerNameData = this.resolveFullContainerName(0, id);
player.dataPacket(pk);
}
}
@@ -617,6 +620,7 @@ private void sendSlotTo(int index, Player player) {
return;
}
pk.inventoryId = id;
+ pk.containerNameData = this.resolveFullContainerName(index, id);
player.dataPacket(pk);
} else {
ContainerSetSlotPacketV113 pk = new ContainerSetSlotPacketV113();
@@ -651,6 +655,7 @@ public void sendSlot(int index, Player... players) {
pk.inventoryId = id;
pk2.windowid = id;
if (player.protocol >= ProtocolInfo.v1_2_0) {
+ pk.containerNameData = this.resolveFullContainerName(index, id);
player.dataPacket(pk);
} else {
player.dataPacket(pk2);
@@ -667,4 +672,63 @@ public void sendSlot(int index, Collection players) {
public InventoryType getType() {
return type;
}
+
+ protected FullContainerName resolveFullContainerName(int index) {
+ return new FullContainerName(resolveContainerSlotType(index), null);
+ }
+
+ protected FullContainerName resolveFullContainerName(int index, int dynamicId) {
+ return new FullContainerName(resolveContainerSlotType(index), dynamicId);
+ }
+
+ protected ContainerSlotType resolveContainerSlotType(int index) {
+ return switch (this.type) {
+ case PLAYER -> {
+ if (index < 9) {
+ yield ContainerSlotType.HOTBAR;
+ }
+ if (index < 36) {
+ yield ContainerSlotType.INVENTORY;
+ }
+ yield ContainerSlotType.ARMOR;
+ }
+ case OFFHAND -> ContainerSlotType.OFFHAND;
+ case ENTITY_ARMOR -> ContainerSlotType.ARMOR;
+ case BARREL -> ContainerSlotType.BARREL;
+ case SHULKER_BOX -> ContainerSlotType.SHULKER_BOX;
+ case FURNACE -> switch (index) {
+ case 0 -> ContainerSlotType.FURNACE_INGREDIENT;
+ case 1 -> ContainerSlotType.FURNACE_FUEL;
+ default -> ContainerSlotType.FURNACE_RESULT;
+ };
+ case BLAST_FURNACE -> switch (index) {
+ case 0 -> ContainerSlotType.BLAST_FURNACE_INGREDIENT;
+ case 1 -> ContainerSlotType.FURNACE_FUEL;
+ default -> ContainerSlotType.FURNACE_RESULT;
+ };
+ case SMOKER -> switch (index) {
+ case 0 -> ContainerSlotType.SMOKER_INGREDIENT;
+ case 1 -> ContainerSlotType.FURNACE_FUEL;
+ default -> ContainerSlotType.FURNACE_RESULT;
+ };
+ case BREWING_STAND -> {
+ if (index == 0) {
+ yield ContainerSlotType.BREWING_INPUT;
+ }
+ if (index == 4) {
+ yield ContainerSlotType.BREWING_FUEL;
+ }
+ yield ContainerSlotType.BREWING_RESULT;
+ }
+ case BEACON -> ContainerSlotType.BEACON_PAYMENT;
+ case TRADING -> switch (index) {
+ case 0 -> ContainerSlotType.TRADE2_INGREDIENT_1;
+ case 1 -> ContainerSlotType.TRADE2_INGREDIENT_2;
+ default -> ContainerSlotType.TRADE2_RESULT;
+ };
+ case HORSE -> index <= HorseInventory.SLOT_ARMOR ? ContainerSlotType.HORSE_EQUIP : ContainerSlotType.LEVEL_ENTITY;
+ case UI -> ContainerSlotType.CRAFTING_INPUT;
+ default -> ContainerSlotType.LEVEL_ENTITY;
+ };
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java
index e48f104f9..f998accc4 100644
--- a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java
+++ b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java
@@ -7,6 +7,9 @@
import cn.nukkit.network.protocol.BlockEventPacket;
import cn.nukkit.network.protocol.InventorySlotPacket;
import cn.nukkit.network.protocol.LevelSoundEventPacket;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.FullContainerName;
+import org.jetbrains.annotations.ApiStatus;
import java.util.HashMap;
import java.util.Map;
@@ -59,17 +62,30 @@ public BlockEntityChest getHolder() {
@Override
public Item getItem(int index) {
- return index < this.left.getSize() ? this.left.getItem(index) : this.right.getItem(index - this.right.getSize());
+ return index < this.left.getSize() ? this.left.getItem(index) : this.right.getItem(index - this.left.getSize());
+ }
+
+ @Override
+ @ApiStatus.Internal
+ public Item getUnclonedItem(int index) {
+ return index < this.left.getSize()
+ ? this.left.getUnclonedItem(index)
+ : this.right.getUnclonedItem(index - this.left.getSize());
}
@Override
public boolean setItem(int index, Item item, boolean send) {
- return index < this.left.getSize() ? this.left.setItem(index, item, send) : this.right.setItem(index - this.right.getSize(), item, send);
+ return index < this.left.getSize() ? this.left.setItem(index, item, send) : this.right.setItem(index - this.left.getSize(), item, send);
}
@Override
public boolean clear(int index) {
- return index < this.left.getSize() ? this.left.clear(index) : this.right.clear(index - this.right.getSize());
+ return this.clear(index, true);
+ }
+
+ @Override
+ public boolean clear(int index, boolean send) {
+ return index < this.left.getSize() ? this.left.clear(index, send) : this.right.clear(index - this.left.getSize(), send);
}
@Override
@@ -188,6 +204,7 @@ public void sendSlot(Inventory inv, int index, Player... players) {
continue;
}
pk.inventoryId = id;
+ pk.containerNameData = new FullContainerName(ContainerSlotType.LEVEL_ENTITY, id);
player.dataPacket(pk);
}
}
diff --git a/src/main/java/cn/nukkit/inventory/EnchantInventory.java b/src/main/java/cn/nukkit/inventory/EnchantInventory.java
index 1d169eb45..62c49641f 100644
--- a/src/main/java/cn/nukkit/inventory/EnchantInventory.java
+++ b/src/main/java/cn/nukkit/inventory/EnchantInventory.java
@@ -112,6 +112,16 @@ private void releasePublishedOptions() {
publishedOptionIds.clear();
}
+ public boolean hasPublishedOption(int recipeNetId) {
+ return publishedOptionIds.contains(recipeNetId);
+ }
+
+ public void releasePublishedOption(int recipeNetId) {
+ if (publishedOptionIds.remove(recipeNetId)) {
+ PlayerEnchantOptionsPacket.RECIPE_MAP.remove(recipeNetId);
+ }
+ }
+
public Item getInputSlot() {
return this.getItem(0);
}
diff --git a/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java b/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
index 83791b120..47f5dfe4b 100644
--- a/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
+++ b/src/main/java/cn/nukkit/inventory/EntityArmorInventory.java
@@ -96,10 +96,11 @@ public void sendSlot(int index, Player... players) {
public void sendSlot(int index, Player player) {
if (player == this.holder) {
InventorySlotPacket inventorySlotPacket = new InventorySlotPacket();
- inventorySlotPacket.inventoryId = player.getWindowId(this);
+ int id = player.getWindowId(this);
+ inventorySlotPacket.inventoryId = id;
inventorySlotPacket.slot = index;
inventorySlotPacket.item = this.getItem(index);
- inventorySlotPacket.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, null);
+ inventorySlotPacket.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, id);
player.dataPacket(inventorySlotPacket);
} else {
MobArmorEquipmentPacket mobArmorEquipmentPacket = new MobArmorEquipmentPacket();
@@ -121,8 +122,10 @@ public void sendContents(Player... players) {
public void sendContents(Player player) {
if (player == this.holder) {
InventoryContentPacket inventoryContentPacket = new InventoryContentPacket();
- inventoryContentPacket.inventoryId = player.getWindowId(this);
+ int id = player.getWindowId(this);
+ inventoryContentPacket.inventoryId = id;
inventoryContentPacket.slots = new Item[]{this.getHelmet(), this.getChestplate(), this.getLeggings(), this.getBoots()};
+ inventoryContentPacket.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, id);
player.dataPacket(inventoryContentPacket);
} else {
MobArmorEquipmentPacket mobArmorEquipmentPacket = new MobArmorEquipmentPacket();
diff --git a/src/main/java/cn/nukkit/inventory/PlayerInventory.java b/src/main/java/cn/nukkit/inventory/PlayerInventory.java
index c1d201e86..a0830b3b8 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerInventory.java
@@ -383,8 +383,10 @@ public void sendArmorContents(Player[] players) {
if (player.equals(this.getHolder())) {
if (player.protocol >= ProtocolInfo.v1_2_0) {
InventoryContentPacket pk2 = new InventoryContentPacket();
- pk2.inventoryId = InventoryContentPacket.SPECIAL_ARMOR;
+ int id = InventoryContentPacket.SPECIAL_ARMOR;
+ pk2.inventoryId = id;
pk2.slots = armor;
+ pk2.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, id);
player.dataPacket(pk2);
} else {
ContainerSetContentPacketV113 pk2 = new ContainerSetContentPacketV113();
@@ -448,9 +450,11 @@ public void sendArmorSlot(int index, Player[] players) {
if (player.equals(this.getHolder())) {
if (player.protocol >= ProtocolInfo.v1_2_0) {
InventorySlotPacket pk2 = new InventorySlotPacket();
- pk2.inventoryId = InventoryContentPacket.SPECIAL_ARMOR;
+ int id = InventoryContentPacket.SPECIAL_ARMOR;
+ pk2.inventoryId = id;
pk2.slot = index - this.getSize();
pk2.item = this.getItem(index);
+ pk2.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, id);
player.dataPacket(pk2);
} else {
ContainerSetSlotPacketV113 pk3 = new ContainerSetSlotPacketV113();
@@ -525,6 +529,7 @@ public void sendContents(Player[] players) {
continue;
}
pk.inventoryId = id;
+ pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR_AND_INVENTORY, id);
player.dataPacket(pk.clone());
}
}
@@ -551,13 +556,6 @@ public void sendSlot(int index, Player... players) {
InventorySlotPacket pk = new InventorySlotPacket();
pk.slot = index;
pk.item = this.getItem(index).clone();
- if (index < 9) {
- pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null);
- } else if (index < 36) {
- pk.containerNameData = new FullContainerName(ContainerSlotType.INVENTORY, null);
- } else {
- pk.containerNameData = new FullContainerName(ContainerSlotType.ARMOR, null);
- }
ContainerSetSlotPacketV113 pk2 = new ContainerSetSlotPacketV113();
pk2.slot = index;
@@ -566,6 +564,7 @@ public void sendSlot(int index, Player... players) {
for (Player player : players) {
if (player.equals(this.getHolder())) {
pk.inventoryId = ContainerIds.INVENTORY;
+ pk.containerNameData = resolvePlayerSlotContainerName(index, ContainerIds.INVENTORY);
pk2.windowid = 0;
if (player.protocol >= ProtocolInfo.v1_2_0) {
player.dataPacket(pk);
@@ -579,6 +578,7 @@ public void sendSlot(int index, Player... players) {
continue;
}
pk.inventoryId = id;
+ pk.containerNameData = resolvePlayerSlotContainerName(index, id);
pk2.windowid = id;
if (player.protocol >= ProtocolInfo.v1_2_0) {
player.dataPacket(pk.clone());
@@ -589,6 +589,16 @@ public void sendSlot(int index, Player... players) {
}
}
+ private FullContainerName resolvePlayerSlotContainerName(int index, int dynamicId) {
+ if (index < 9) {
+ return new FullContainerName(ContainerSlotType.HOTBAR, dynamicId);
+ }
+ if (index < 36) {
+ return new FullContainerName(ContainerSlotType.INVENTORY, dynamicId);
+ }
+ return new FullContainerName(ContainerSlotType.ARMOR, dynamicId);
+ }
+
public void sendCreativeContents() {
if (!(this.getHolder() instanceof Player)) {
return;
diff --git a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
index 84cf0e8c5..8d982219c 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java
@@ -17,6 +17,7 @@
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
public class PlayerOffhandInventory extends BaseInventory {
+ private static final int OFFHAND_CONTAINER_DYNAMIC_ID = 0;
/**
* Items that can be put to offhand inventory on Bedrock Edition
@@ -58,6 +59,7 @@ public void sendContents(Player... players) {
InventoryContentPacket pk2 = new InventoryContentPacket();
pk2.inventoryId = ContainerIds.OFFHAND;
pk2.slots = new Item[]{item};
+ pk2.containerNameData = new FullContainerName(ContainerSlotType.OFFHAND, OFFHAND_CONTAINER_DYNAMIC_ID);
player.dataPacket(pk2);
} else {
player.dataPacket(pk);
@@ -75,7 +77,7 @@ public void sendSlot(int index, Player... players) {
InventorySlotPacket pk2 = new InventorySlotPacket();
pk2.inventoryId = ContainerIds.OFFHAND;
pk2.item = item;
- pk2.containerNameData = new FullContainerName(ContainerSlotType.OFFHAND, null);
+ pk2.containerNameData = new FullContainerName(ContainerSlotType.OFFHAND, OFFHAND_CONTAINER_DYNAMIC_ID);
player.dataPacket(pk2);
} else {
player.dataPacket(pk);
diff --git a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
index 1d9a270b4..c1a0ceca8 100644
--- a/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
+++ b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java
@@ -99,6 +99,7 @@ public void sendContents(Player... target) {
for (int i = 0; i < this.getSize(); ++i) {
pk.slots[i] = this.getItem(i);
}
+ pk.containerNameData = new FullContainerName(ContainerSlotType.CRAFTING_INPUT, null);
for (Player p : target) {
if (p == this.getHolder()) {
diff --git a/src/main/java/cn/nukkit/inventory/TradeInventory.java b/src/main/java/cn/nukkit/inventory/TradeInventory.java
index 0abffad5d..cefa3f3ce 100644
--- a/src/main/java/cn/nukkit/inventory/TradeInventory.java
+++ b/src/main/java/cn/nukkit/inventory/TradeInventory.java
@@ -113,4 +113,22 @@ private void releaseAssignedRecipeIds() {
}
assignedRecipeIds.clear();
}
+
+ public CompoundTag getAssignedRecipe(int recipeNetId) {
+ if (!assignedRecipeIds.contains(recipeNetId)) {
+ return null;
+ }
+ ListTag recipes = this.getHolder().getRecipes();
+ if (recipes == null) {
+ return null;
+ }
+ for (Tag tag : recipes.getAll()) {
+ if (tag instanceof CompoundTag recipe
+ && recipe.contains("netId")
+ && recipe.getInt("netId") == recipeNetId) {
+ return recipe;
+ }
+ }
+ return null;
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
index 54b5cc0fe..0495c3a53 100644
--- a/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java
@@ -3,8 +3,14 @@
import cn.nukkit.Player;
import cn.nukkit.blockentity.BlockEntityBeacon;
import cn.nukkit.inventory.BeaconInventory;
+import cn.nukkit.inventory.Inventory;
+import cn.nukkit.item.Item;
import cn.nukkit.level.Position;
+import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequestSlotData;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.BeaconPaymentAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.DestroyAction;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
public class BeaconPaymentActionProcessor implements ItemStackRequestActionProcessor {
@@ -22,6 +28,9 @@ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStac
if (!BlockEntityBeacon.isPaymentItem(beaconInventory.getItem(0).getId())) {
return context.error();
}
+ if (!hasValidPaymentDestroyAction(player, beaconInventory, context)) {
+ return context.error();
+ }
int primary = action.getPrimaryEffect();
int secondary = action.getSecondaryEffect();
@@ -45,4 +54,40 @@ public ActionResponse handle(BeaconPaymentAction action, Player player, ItemStac
});
return null;
}
+
+ static boolean hasValidPaymentDestroyAction(Player player, BeaconInventory beaconInventory, ItemStackRequestContext context) {
+ ItemStackRequestAction[] actions = context.getItemStackRequest().getActions();
+ int destroyIndex = context.getCurrentActionIndex() + 1;
+ if (destroyIndex >= actions.length || !(actions[destroyIndex] instanceof DestroyAction destroy)) {
+ return false;
+ }
+ if (destroy.getCount() != 1) {
+ return false;
+ }
+
+ ItemStackRequestSlotData source = destroy.getSource();
+ if (source == null) {
+ return false;
+ }
+ if (source.getContainer() != ContainerSlotType.BEACON_PAYMENT) {
+ return false;
+ }
+ Inventory destroyInventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId());
+ if (destroyInventory != beaconInventory) {
+ return false;
+ }
+ if (NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot()) != 0) {
+ return false;
+ }
+
+ Item payment = beaconInventory.getItem(0);
+ return !payment.isNull()
+ && payment.getCount() >= destroy.getCount()
+ && BlockEntityBeacon.isPaymentItem(payment.getId())
+ && !hasStackNetworkIdMismatch(payment.getStackNetId(), source.getStackNetworkId());
+ }
+
+ private static boolean hasStackNetworkIdMismatch(int serverNetId, int clientNetId) {
+ return serverNetId > 0 && clientNetId > 0 && serverNetId != clientNetId;
+ }
}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
index 4682e78ba..62e5d3f52 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java
@@ -3,14 +3,8 @@
import cn.nukkit.Player;
import cn.nukkit.inventory.PlayerUIComponent;
import cn.nukkit.item.Item;
-import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
-import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftCreativeAction;
import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
-import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
-import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
-
-import java.util.List;
/**
* Handles creative-mode item creation. The client sends this when it wants to
@@ -43,22 +37,14 @@ public ActionResponse handle(CraftCreativeAction action, Player player, ItemStac
}
item = item.clone();
- int requestedCount = Math.max(1, action.getNumberOfRequestedCrafts());
+ int requestedCount = action.getNumberOfRequestedCrafts() <= 0
+ ? item.getMaxStackSize()
+ : action.getNumberOfRequestedCrafts();
item.setCount(Math.min(item.getMaxStackSize(), requestedCount));
item.autoAssignStackNetworkId();
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, item, false);
context.put(CRAFT_CREATIVE_KEY, true);
-
- ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, item.getCount(), item.getStackNetId(),
- item.hasCustomName() ? item.getCustomName() : "",
- item.getDamage(), ""
- );
- return context.success(List.of(new ItemStackResponseContainer(
- ContainerSlotType.CREATED_OUTPUT,
- List.of(responseSlot),
- new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null)
- )));
+ return null;
}
}
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
index 49fadcc1d..abfb844ee 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java
@@ -76,7 +76,9 @@ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemSt
context.put(GRINDSTONE_EXP_KEY, experienceDropped);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, resultClone.getCount(), resultClone.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ resultClone.getCount(), resultClone.getStackNetId(),
resultClone.hasCustomName() ? resultClone.getCustomName() : "",
resultClone.getDamage(), ""
);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
index c983497bc..ef412a92b 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java
@@ -99,7 +99,9 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, result, false);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, result.getCount(), result.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ result.getCount(), result.getStackNetId(),
result.hasCustomName() ? result.getCustomName() : "",
result.getDamage(), ""
);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
index f6f4a2a6d..97d8af14d 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java
@@ -22,9 +22,7 @@
import cn.nukkit.utils.TradeRecipeBuildUtils;
import lombok.extern.log4j.Log4j2;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
/**
* Resolves the recipe referenced by a CraftRecipeAction and dispatches to one of
@@ -90,8 +88,22 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
context.put(CreateActionProcessor.RECIPE_DATA_KEY, recipe);
- if (recipe instanceof MultiRecipe) {
- if (!hasFollowupOutputAction(context.getItemStackRequest().getActions(), context.getCurrentActionIndex() + 1)) {
+ if (recipe instanceof MultiRecipe multiRecipe) {
+ CraftResultsDeprecatedAction resultsAction = findCraftResultsAction(
+ context.getItemStackRequest().getActions(),
+ context.getCurrentActionIndex() + 1
+ );
+ if (resultsAction == null || resultsAction.getResultItems() == null || resultsAction.getResultItems().length == 0) {
+ return context.error();
+ }
+ Item output = resultsAction.getResultItems()[0];
+ if (output == null || output.isNull()) {
+ return context.error();
+ }
+ if (!validateCraftingRecipe(player, multiRecipe, output, 1)) {
+ return context.error();
+ }
+ if (!validateMultiRecipeConsumePlan(player, multiRecipe, output, context)) {
return context.error();
}
return context.success();
@@ -123,7 +135,9 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, output.getCount(), output.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ output.getCount(), output.getStackNetId(),
output.hasCustomName() ? output.getCustomName() : "",
output.getDamage(), ""
);
@@ -135,16 +149,21 @@ public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackR
}
private ActionResponse handleEnchant(CraftRecipeAction action, Player player, ItemStackRequestContext context) {
+ Inventory inventory = player.getTopWindow().orElse(null);
+ if (!(inventory instanceof EnchantInventory enchantInventory)) {
+ return context.error();
+ }
+ if (!enchantInventory.hasPublishedOption(action.getRecipeNetworkId())) {
+ log.warn("{}: enchant recipe netId {} is not published for the current window",
+ player.getName(), action.getRecipeNetworkId());
+ return context.error();
+ }
PlayerEnchantOptionsPacket.EnchantOptionData option =
PlayerEnchantOptionsPacket.RECIPE_MAP.get(action.getRecipeNetworkId());
if (option == null) {
log.warn("{}: unknown enchant recipe netId {}", player.getName(), action.getRecipeNetworkId());
return context.error();
}
- Inventory inventory = player.getTopWindow().orElse(null);
- if (!(inventory instanceof EnchantInventory enchantInventory)) {
- return context.error();
- }
Item first = enchantInventory.getInputSlot();
if (first.isNull()) {
return context.error();
@@ -153,12 +172,18 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
for (PlayerEnchantOptionsPacket.EnchantData data : option.getEnchants0()) {
Enchantment enchantment = Enchantment.getEnchantment(data.getType());
if (enchantment != null) {
+ if (enchantment.isTreasure() || enchantment.isCurse()) {
+ return context.error();
+ }
+ if (!isApplicableEnchant(enchantment, first)) {
+ return context.error();
+ }
enchantments.add(enchantment.setLevel(data.getLevel()));
}
}
int cost = option.getPrimarySlot() + 1;
if (!player.isCreative()) {
- if (player.getExperienceLevel() < cost) {
+ if (player.getExperienceLevel() < cost || player.getExperienceLevel() < option.getMinLevel()) {
return context.error();
}
Item reagent = enchantInventory.getReagentSlot();
@@ -196,11 +221,13 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
context.onCommit(() -> player.setExperience(player.getExperience(), player.getExperienceLevel() - finalCost));
}
player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, finalOutput, false);
- context.onCommit(() -> PlayerEnchantOptionsPacket.RECIPE_MAP.remove(action.getRecipeNetworkId()));
+ context.onCommit(() -> enchantInventory.releasePublishedOption(action.getRecipeNetworkId()));
context.put(ENCH_RECIPE_KEY, true);
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, finalOutput.getCount(), finalOutput.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ finalOutput.getCount(), finalOutput.getStackNetId(),
finalOutput.hasCustomName() ? finalOutput.getCustomName() : "",
finalOutput.getDamage(), ""
);
@@ -211,16 +238,21 @@ private ActionResponse handleEnchant(CraftRecipeAction action, Player player, It
)));
}
+ private static boolean isApplicableEnchant(Enchantment enchantment, Item input) {
+ return input.getId() == Item.BOOK || enchantment.canEnchant(input);
+ }
+
private ActionResponse handleTrade(CraftRecipeAction action, Player player, ItemStackRequestContext context) {
- CompoundTag recipe = TradeRecipeBuildUtils.RECIPE_MAP.get(action.getRecipeNetworkId());
- if (recipe == null) {
- log.warn("{}: unknown trade recipe netId {}", player.getName(), action.getRecipeNetworkId());
- return context.error();
- }
Optional topWindow = player.getTopWindow();
if (topWindow.isEmpty() || !(topWindow.get() instanceof TradeInventory tradeInventory)) {
return context.error();
}
+ CompoundTag recipe = tradeInventory.getAssignedRecipe(action.getRecipeNetworkId());
+ if (recipe == null) {
+ log.warn("{}: trade recipe netId {} is not assigned to the current villager",
+ player.getName(), action.getRecipeNetworkId());
+ return context.error();
+ }
int times = Math.max(1, action.getNumberOfRequestedCrafts());
int maxUses = recipe.contains("maxUses") ? recipe.getInt("maxUses") : Integer.MAX_VALUE;
int uses = recipe.contains("uses") ? recipe.getInt("uses") : 0;
@@ -276,7 +308,9 @@ private ActionResponse handleTrade(CraftRecipeAction action, Player player, Item
});
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, output.getCount(), output.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ output.getCount(), output.getStackNetId(),
output.hasCustomName() ? output.getCustomName() : "",
output.getDamage(), ""
);
@@ -333,10 +367,7 @@ private static Item[] collectCraftingInput(Player player) {
}
static List
- collectCraftingInputList(Player player) {
- Inventory top = player.getTopWindow().orElse(null);
- CraftingGrid grid = top instanceof CraftingGrid openGrid
- ? openGrid
- : player.getUIInventory().getCraftingGrid();
+ CraftingGrid grid = getActiveCraftingGrid(player);
int size = grid.getSize();
List
- items = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
@@ -348,6 +379,15 @@ static List
- collectCraftingInputList(Player player) {
return items;
}
+ private static CraftingGrid getActiveCraftingGrid(Player player) {
+ Inventory top = player.getTopWindow().orElse(null);
+ if (top instanceof CraftingGrid openGrid) {
+ return openGrid;
+ }
+ CraftingGrid grid = player.getCraftingGrid();
+ return grid != null ? grid : player.getUIInventory().getCraftingGrid();
+ }
+
static boolean validateCraftingRecipe(Player player, Recipe recipe, Item output, int multiplier) {
List
- inputs = collectCraftingInputList(player);
if (recipe instanceof MultiRecipe multiRecipe) {
@@ -386,6 +426,64 @@ private static boolean validateCraftingConsumePlan(Player player, CraftingRecipe
return validateExpectedConsumePlan(player, expected, context);
}
+ static boolean validateMultiRecipeConsumePlan(Player player, MultiRecipe recipe, Item output, ItemStackRequestContext context) {
+ CraftingGrid grid = getActiveCraftingGrid(player);
+ Set occupiedSlots = new HashSet<>();
+ for (int slot = 0; slot < grid.getSize(); slot++) {
+ Item item = grid.getUnclonedItem(slot);
+ if (item != null && !item.isNull()) {
+ occupiedSlots.add(slot);
+ }
+ }
+ if (occupiedSlots.isEmpty()) {
+ return false;
+ }
+
+ List consumeActions = CraftRecipeAutoProcessor.findAllConsumeActions(
+ context.getItemStackRequest().getActions(),
+ context.getCurrentActionIndex() + 1);
+ if (consumeActions.size() != occupiedSlots.size()) {
+ return false;
+ }
+
+ Set consumedSlots = new HashSet<>();
+ List
- consumedItems = new ArrayList<>(consumeActions.size());
+ for (ConsumeAction consume : consumeActions) {
+ if (consume.getCount() <= 0) {
+ return false;
+ }
+ var source = consume.getSource();
+ if (source == null) {
+ return false;
+ }
+ var inventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId());
+ if (inventory != grid) {
+ return false;
+ }
+ int slot = NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot());
+ if (!occupiedSlots.contains(slot) || !consumedSlots.add(slot)) {
+ return false;
+ }
+ Item item = inventory.getItem(slot);
+ if (item.isNull() || item.getCount() < consume.getCount()) {
+ return false;
+ }
+ if (hasStackNetworkIdMismatch(item.getStackNetId(), source.getStackNetworkId())) {
+ return false;
+ }
+ Item consumed = item.clone();
+ consumed.setCount(consume.getCount());
+ consumedItems.add(consumed);
+ }
+
+ return consumedSlots.size() == occupiedSlots.size()
+ && recipe.canExecute(player, output.clone(), consumedItems);
+ }
+
+ private static boolean hasStackNetworkIdMismatch(int serverNetId, int clientNetId) {
+ return serverNetId > 0 && clientNetId > 0 && serverNetId != clientNetId;
+ }
+
static void addExpectedConsumeItem(List
- expected, Item item, int count) {
if (item == null || item.isNull() || count <= 0) {
return;
@@ -434,14 +532,13 @@ static boolean validateExpectedConsumePlan(Player player, List
- expected, I
return Recipe.matchItemList(actual, expectedConsumes);
}
- private static boolean hasFollowupOutputAction(ItemStackRequestAction[] actions, int startIndex) {
+ private static CraftResultsDeprecatedAction findCraftResultsAction(ItemStackRequestAction[] actions, int startIndex) {
for (int i = startIndex; i < actions.length; i++) {
- ItemStackRequestAction action = actions[i];
- if (action instanceof CreateAction || action instanceof CraftResultsDeprecatedAction) {
- return true;
+ if (actions[i] instanceof CraftResultsDeprecatedAction craftResults) {
+ return craftResults;
}
}
- return false;
+ return null;
}
/**
@@ -575,7 +672,9 @@ private ActionResponse handleStonecutter(Player player, StonecutterRecipe recipe
private ActionResponse buildCreatedOutputResponse(ItemStackRequestContext context, Item output) {
ItemStackResponseSlot responseSlot = new ItemStackResponseSlot(
- 0, 0, output.getCount(), output.getStackNetId(),
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT,
+ output.getCount(), output.getStackNetId(),
output.hasCustomName() ? output.getCustomName() : "",
output.getDamage(), ""
);
diff --git a/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
index f9a851a29..66323acd8 100644
--- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
+++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java
@@ -2,6 +2,7 @@
import cn.nukkit.Player;
import cn.nukkit.event.inventory.CraftItemEvent;
+import cn.nukkit.inventory.CraftingRecipe;
import cn.nukkit.inventory.MultiRecipe;
import cn.nukkit.inventory.PlayerUIComponent;
import cn.nukkit.inventory.Recipe;
@@ -9,11 +10,7 @@
import cn.nukkit.network.protocol.types.inventory.ContainerSlotType;
import cn.nukkit.network.protocol.types.inventory.FullContainerName;
import cn.nukkit.network.protocol.types.inventory.descriptor.ItemDescriptorWithCount;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.AutoCraftRecipeAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ConsumeAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestAction;
-import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType;
+import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.*;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer;
import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseSlot;
import lombok.extern.log4j.Log4j2;
@@ -51,7 +48,7 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt
ingredients = List.of();
}
Item[] eventItems = ingredients.stream()
- .map(i -> i.getDescriptor().toItem())
+ .map(CraftRecipeAutoProcessor::toEventItem)
.toArray(Item[]::new);
CraftItemEvent craftItemEvent = new CraftItemEvent(player, eventItems, recipe);
@@ -79,7 +76,8 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt
return context.error();
}
- if (!validateConsumePlan(player, ingredients, consumeActions)) {
+ List