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: + *

+ *

+ * 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 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 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 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 consumedItems = collectConsumedItems(player, consumeActions); + if (consumedItems == null || !validateConsumePlan(ingredients, consumedItems)) { return context.error(); } @@ -90,7 +88,8 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt return context.error(); } Item output = resultsAction.getResultItems()[0]; - if (output == null || output.isNull() || !CraftRecipeActionProcessor.validateCraftingRecipe(player, multiRecipe, output, 1)) { + if (output == null || output.isNull() + || !validateAutoCraftingRecipe(player, multiRecipe, output, 1, consumedItems)) { return context.error(); } context.put(CraftResultDeprecatedActionProcessor.MULTI_RESULTS_KEY, List.of(resultsAction.getResultItems())); @@ -102,7 +101,7 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt return null; } int times = Math.max(1, action.getTimesCrafted()); - if (!CraftRecipeActionProcessor.validateCraftingRecipe(player, recipe, recipeResult, times)) { + if (!validateAutoCraftingRecipe(player, recipe, recipeResult, times, consumedItems)) { return context.error(); } Item output = recipeResult.clone(); @@ -111,7 +110,9 @@ public ActionResponse handle(AutoCraftRecipeAction action, Player player, ItemSt 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(), "" ); @@ -141,40 +142,114 @@ private static CraftResultsDeprecatedAction findCraftResultsAction(ItemStackRequ 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); + private static Item toEventItem(ItemDescriptorWithCount ingredient) { + if (ingredient == null || ingredient.getDescriptor() == null) { + return Item.get(Item.AIR); } + Item item = ingredient.getDescriptor().toItem(); + if (item != null && !item.isNull() && ingredient.getCount() > 0) { + item.setCount(ingredient.getCount()); + } + return item; + } + private static List collectConsumedItems(Player player, List consumeActions) { List actual = new ArrayList<>(); for (ConsumeAction consume : consumeActions) { if (consume.getCount() <= 0) { - return false; + return null; } var source = consume.getSource(); + if (source == null) { + return null; + } var inventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId()); if (inventory == null) { - return false; + return null; } int slot = NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot()); Item item = inventory.getItem(slot); if (item.isNull() || item.getCount() < consume.getCount()) { - return false; + return null; + } + if (hasStackNetworkIdMismatch(item.getStackNetId(), source.getStackNetworkId())) { + return null; } Item consumed = item.clone(); consumed.setCount(consume.getCount()); actual.add(consumed); } + return actual; + } + + private static boolean validateConsumePlan(List ingredients, List consumedItems) { + List actual = cloneItems(consumedItems); + for (ItemDescriptorWithCount ingredient : ingredients) { + if (ingredient == null || ingredient.getDescriptor() == null || ingredient.getCount() <= 0) { + continue; + } + int remaining = ingredient.getCount(); + for (Item actualItem : new ArrayList<>(actual)) { + if (!matchesDescriptor(ingredient, actualItem)) { + continue; + } + int amount = Math.min(actualItem.getCount(), remaining); + actualItem.setCount(actualItem.getCount() - amount); + remaining -= amount; + if (actualItem.getCount() == 0) { + actual.remove(actualItem); + } + if (remaining == 0) { + break; + } + } + if (remaining > 0) { + return false; + } + } + return actual.isEmpty(); + } + + private static boolean matchesDescriptor(ItemDescriptorWithCount ingredient, Item item) { + if (item == null || item.isNull()) { + return false; + } + if (ingredient.getDescriptor().match(item)) { + return true; + } + Item expected = ingredient.getDescriptor().toItem(); + return expected != null + && !expected.isNull() + && expected.equals(item, expected.hasMeta(), expected.hasCompoundTag()); + } + + private static boolean validateAutoCraftingRecipe(Player player, Recipe recipe, Item output, int multiplier, List consumedItems) { + List inputs = cloneItems(consumedItems); + 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 = CraftRecipeActionProcessor.scaleItems(craftingRecipe.getExtraResults(), Math.max(1, multiplier)); + Recipe matched = player.getServer().getCraftingManager().matchRecipe(inputs, primaryOutput, extraOutputs); + return matched == recipe; + } + + private static List cloneItems(List items) { + List cloned = new ArrayList<>(items.size()); + for (Item item : items) { + if (item != null && !item.isNull()) { + cloned.add(item.clone()); + } + } + return cloned; + } - return Recipe.matchItemList(actual, expected); + 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/CraftRecipeOptionalProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java index b6f6c3f11..a95d03371 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java @@ -124,7 +124,9 @@ public ActionResponse handle(CraftRecipeOptionalAction action, Player player, It 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/CraftResultDeprecatedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java index f4e748dae..28f989ce7 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java @@ -42,7 +42,9 @@ public ActionResponse handle(CraftResultsDeprecatedAction action, Player player, output.autoAssignStackNetworkId(); player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false); ItemStackResponseSlot slot = 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/CreateActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java index da11715d2..ea51a8e2f 100644 --- a/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java @@ -73,7 +73,9 @@ public ActionResponse handle(CreateAction action, Player player, ItemStackReques 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(), "" ); diff --git a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java index 2c7400a6b..e50b1506c 100644 --- a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java @@ -25,10 +25,7 @@ public ItemStackRequestActionType getType() { @Override public ActionResponse handle(DestroyAction action, Player player, ItemStackRequestContext context) { - Boolean suppress = context.get(NO_RESPONSE_DESTROY_KEY); - if (suppress != null && suppress) { - return null; - } + boolean suppressResponse = Boolean.TRUE.equals(context.get(NO_RESPONSE_DESTROY_KEY)); ItemStackRequestSlotData src = action.getSource(); Inventory inventory = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId()); @@ -80,6 +77,9 @@ public ActionResponse handle(DestroyAction action, Player player, ItemStackReque } ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src); + if (suppressResponse) { + return null; + } 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 index 8a520ed22..40fc89e95 100644 --- a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java @@ -80,7 +80,8 @@ public ActionResponse handle(DropAction action, Player player, ItemStackRequestC } } - player.dropItem(dropItem); + Item committedDrop = event.getItem().clone(); + context.onCommit(() -> player.dropItem(committedDrop)); ItemStackResponseContainer container = TransferItemActionProcessor.buildContainer(inventory, slot, src); return context.success(List.of(container)); diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java index 925e376d1..73dd51246 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java @@ -2,7 +2,10 @@ import cn.nukkit.Player; import cn.nukkit.event.inventory.ItemStackRequestActionEvent; -import cn.nukkit.inventory.*; +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.item.ItemBundle; import cn.nukkit.network.protocol.ItemStackResponsePacket; @@ -233,22 +236,30 @@ private static void rollbackSnapshots(Map> snapsho private static void restoreInventory(Inventory inventory, Map snapshot) { Inventory canonical = canonicalizeInventory(inventory); - if (!(canonical instanceof BaseInventory baseInventory)) { + if (canonical == null) { return; } - for (int slot : new ArrayList<>(baseInventory.slots.keySet())) { + LinkedHashSet currentSlots = new LinkedHashSet<>(canonical.getContents().keySet()); + for (int slot = 0; slot < canonical.getSize(); slot++) { + currentSlots.add(slot); + } + + for (int slot : currentSlots) { if (!snapshot.containsKey(slot)) { - baseInventory.clear(slot, false); + Item current = canonical.getItem(slot); + if (current != null && !current.isNull()) { + canonical.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); + canonical.setItem(entry.getKey(), item.clone(), false); } else { - baseInventory.clear(entry.getKey(), false); + canonical.clear(entry.getKey(), false); } } } diff --git a/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java index 826a533d7..eb95f7ab8 100644 --- a/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/MineBlockActionProcessor.java @@ -45,7 +45,7 @@ public ActionResponse handle(MineBlockAction action, Player player, ItemStackReq packet.inventoryId = ContainerIds.INVENTORY; packet.slot = heldItemIndex; packet.item = itemInHand; - packet.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, null); + packet.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR, ContainerIds.INVENTORY); player.dataPacket(packet); } } @@ -61,7 +61,7 @@ public ActionResponse handle(MineBlockAction action, Player player, ItemStackReq return context.success(List.of(new ItemStackResponseContainer( ContainerSlotType.HOTBAR_AND_INVENTORY, List.of(responseSlot), - new FullContainerName(ContainerSlotType.HOTBAR_AND_INVENTORY, null) + new FullContainerName(ContainerSlotType.HOTBAR_AND_INVENTORY, ContainerIds.INVENTORY) ))); } } diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java index e1d36bcaf..218a71add 100644 --- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -63,10 +63,21 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon } Item destItem = dstInv.getUnclonedItem(dstSlot); - if (!destItem.isNull() && !destItem.equals(sourceItem, true, true)) { + if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) { return context.error(); } - if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) { + + boolean fullTransfer = sourceItem.getCount() == count; + boolean srcIsCreatedOutput = isCreatedOutput(srcInv, srcSlot); + boolean creativeCreatedOutputTransfer = player.isCreative() + && srcIsCreatedOutput + && Boolean.TRUE.equals(context.get(CraftCreativeActionProcessor.CRAFT_CREATIVE_KEY)); + if (creativeCreatedOutputTransfer) { + return transferCreativeCreatedOutput(action, player, context, srcInv, srcSlot, dstInv, dstSlot, + sourceItem, destItem, fullTransfer); + } + + if (!destItem.isNull() && !destItem.equals(sourceItem, true, true)) { return context.error(); } @@ -85,9 +96,6 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon 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) @@ -170,12 +178,79 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon List containers = new ArrayList<>(); containers.add(buildContainer(srcInv, srcSlot, src)); - if (src.getContainer() != dst.getContainer() || src.getSlot() != dst.getSlot()) { + if (!sameNetworkSlot(src, dst)) { containers.add(buildContainer(dstInv, dstSlot, dst)); } return context.success(containers); } + private ActionResponse transferCreativeCreatedOutput(T action, Player player, ItemStackRequestContext context, + Inventory srcInv, int srcSlot, + Inventory dstInv, int dstSlot, + Item sourceItem, Item destItem, + boolean fullTransfer) { + ItemStackRequestSlotData dst = action.getDestination(); + int count = action.getCount(); + if (srcInv == dstInv && srcSlot == dstSlot) { + return context.error(); + } + if (!isSlotCompatible(dstInv, dstSlot, sourceItem)) { + return context.error(); + } + + boolean sendSource = isEquipmentSlot(action.getSource().getContainer()); + boolean sendDest = isEquipmentSlot(dst.getContainer()); + + Item newDest = sourceItem.clone(); + newDest.setCount(count); + newDest.autoAssignStackNetworkId(); + + Item newSrc; + 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(); + } + if (!fireClickEvent(player, srcInv, srcSlot, sourceItem, newSrc)) { + return context.error(); + } + if (!fireClickEvent(player, dstInv, dstSlot, destItem, newDest)) { + return context.error(); + } + + List transactionActions = new ArrayList<>(); + transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, newSrc)); + transactionActions.add(new SlotChangeAction(dstInv, dstSlot, destItem, newDest)); + InventoryTransaction transaction = new EventOnlyInventoryTransaction(player, transactionActions, context); + if (!transaction.execute()) { + return context.error(); + } + + Item originalDestItem = destItem.clone(); + 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(); + } + + return context.success(List.of(buildContainer(dstInv, dstSlot, dst))); + } + private static boolean isEquipmentSlot(ContainerSlotType type) { return type == ContainerSlotType.OFFHAND || type == ContainerSlotType.ARMOR; } @@ -239,6 +314,12 @@ static boolean fireTransferEvent(Player actor, Inventory sourceInventory, int so return !event.isCancelled(); } + static boolean sameNetworkSlot(ItemStackRequestSlotData first, ItemStackRequestSlotData second) { + return first.getContainer() == second.getContainer() + && first.getSlot() == second.getSlot() + && Objects.equals(first.getDynamicId(), second.getDynamicId()); + } + /** * InventoryTransaction subclass used solely to emit * {@link InventoryTransactionEvent} for server-authoritative item stack @@ -260,10 +341,7 @@ public EventOnlyInventoryTransaction(Player source, List action 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()); - } + this.addAction(action); } } @@ -276,6 +354,10 @@ protected boolean callExecuteEvent() { @Override public boolean execute() { + if (this.invalid || this.actions.isEmpty()) { + return false; + } + // Snapshot affected slots before firing the event so we can tell // whether a plugin actually mutated the inventory when cancelling. Map> preStates = new HashMap<>(); diff --git a/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java index f2e333d73..7a39fe939 100644 --- a/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java +++ b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java @@ -92,7 +92,10 @@ private static PlayerEnchantOptionsPacket.EnchantOptionData createOption(Random private static List filterApplicable(Item input, int power) { List result = new ArrayList<>(); for (Enchantment enchantment : Enchantment.getEnchantments()) { - if (enchantment == null || !enchantment.canEnchant(input)) { + if (enchantment == null + || enchantment.isTreasure() + || enchantment.isCurse() + || !enchantment.canEnchant(input)) { continue; } for (int lvl = enchantment.getMaxLevel(); lvl > 0; lvl--) { diff --git a/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ComplexAliasDescriptor.java b/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ComplexAliasDescriptor.java index 15f812f55..abe30f17a 100644 --- a/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ComplexAliasDescriptor.java +++ b/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ComplexAliasDescriptor.java @@ -1,5 +1,7 @@ package cn.nukkit.network.protocol.types.inventory.descriptor; +import cn.nukkit.inventory.ItemTag; +import cn.nukkit.item.Item; import lombok.Value; @Value @@ -10,4 +12,11 @@ public class ComplexAliasDescriptor implements ItemDescriptor { public ItemDescriptorType getType() { return ItemDescriptorType.COMPLEX_ALIAS; } + + @Override + public boolean match(Item item) { + return item != null + && !item.isNull() + && ItemTag.getItemSet(name).contains(item.getNamespaceId()); + } } diff --git a/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ItemTagDescriptor.java b/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ItemTagDescriptor.java index 6dc3d7c27..34237e588 100644 --- a/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ItemTagDescriptor.java +++ b/src/main/java/cn/nukkit/network/protocol/types/inventory/descriptor/ItemTagDescriptor.java @@ -1,5 +1,7 @@ package cn.nukkit.network.protocol.types.inventory.descriptor; +import cn.nukkit.inventory.ItemTag; +import cn.nukkit.item.Item; import lombok.Value; @Value @@ -10,4 +12,11 @@ public class ItemTagDescriptor implements ItemDescriptor { public ItemDescriptorType getType() { return ItemDescriptorType.ITEM_TAG; } + + @Override + public boolean match(Item item) { + return item != null + && !item.isNull() + && ItemTag.getItemSet(itemTag).contains(item.getNamespaceId()); + } } diff --git a/src/test/java/cn/nukkit/PlayerInventoryTransactionTest.java b/src/test/java/cn/nukkit/PlayerInventoryTransactionTest.java new file mode 100644 index 000000000..4a6e85c18 --- /dev/null +++ b/src/test/java/cn/nukkit/PlayerInventoryTransactionTest.java @@ -0,0 +1,71 @@ +package cn.nukkit; + +import cn.nukkit.inventory.PlayerInventory; +import cn.nukkit.inventory.transaction.action.DropItemAction; +import cn.nukkit.inventory.transaction.action.InventoryAction; +import cn.nukkit.inventory.transaction.action.SlotChangeAction; +import cn.nukkit.item.Item; +import cn.nukkit.network.protocol.InventoryTransactionPacket; +import cn.nukkit.network.protocol.types.ContainerIds; +import cn.nukkit.network.protocol.types.NetworkInventoryAction; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PlayerInventoryTransactionTest { + + @BeforeAll + static void init() { + MockServer.init(); + } + + @Test + void serverAuthoritativeLegacyDropAllowsOnlyWorldDropPlusInventoryDecrease() { + Player player = Mockito.mock(Player.class); + PlayerInventory inventory = new PlayerInventory(player); + + Item oldItem = Item.get(Item.STONE, 0, 64); + Item newItem = Item.get(Item.STONE, 0, 63); + Item droppedItem = Item.get(Item.STONE, 0, 1); + + InventoryTransactionPacket packet = new InventoryTransactionPacket(); + packet.transactionType = InventoryTransactionPacket.TYPE_NORMAL; + packet.actions = new NetworkInventoryAction[]{ + worldDrop(droppedItem), + inventoryChange(oldItem, newItem) + }; + + List actions = List.of( + new DropItemAction(Item.get(Item.AIR), droppedItem), + new SlotChangeAction(inventory, 0, oldItem, newItem) + ); + assertTrue(Player.isServerAuthoritativeLegacyDropTransaction(packet, actions)); + + packet.actions[1].newItem = Item.get(Item.STONE, 0, 62); + assertFalse(Player.isServerAuthoritativeLegacyDropTransaction(packet, actions)); + } + + private static NetworkInventoryAction worldDrop(Item droppedItem) { + NetworkInventoryAction action = new NetworkInventoryAction(); + action.sourceType = NetworkInventoryAction.SOURCE_WORLD; + action.inventorySlot = InventoryTransactionPacket.ACTION_MAGIC_SLOT_DROP_ITEM; + action.oldItem = Item.get(Item.AIR); + action.newItem = droppedItem; + return action; + } + + private static NetworkInventoryAction inventoryChange(Item oldItem, Item newItem) { + NetworkInventoryAction action = new NetworkInventoryAction(); + action.sourceType = NetworkInventoryAction.SOURCE_CONTAINER; + action.windowId = ContainerIds.INVENTORY; + action.inventorySlot = 0; + action.oldItem = oldItem; + action.newItem = newItem; + return action; + } +} diff --git a/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java new file mode 100644 index 000000000..f2a68c054 --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java @@ -0,0 +1,167 @@ +package cn.nukkit.inventory; + +import cn.nukkit.MockServer; +import cn.nukkit.Player; +import cn.nukkit.blockentity.BlockEntityChest; +import cn.nukkit.blockentity.BlockEntityFurnace; +import cn.nukkit.item.Item; +import cn.nukkit.network.protocol.DataPacket; +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 org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.*; + +class InventoryServerAuthoritativeSyncTest { + + @BeforeAll + static void init() { + MockServer.init(); + } + + @BeforeEach + void resetServer() { + MockServer.reset(); + } + + @Test + void baseInventorySyncUsesConcreteContainerName() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + BlockEntityChest holder = Mockito.mock(BlockEntityChest.class); + ChestInventory chest = new ChestInventory(holder); + Mockito.when(player.getWindowId(chest)).thenReturn(7); + + chest.sendSlot(3, player); + InventorySlotPacket slotPacket = capturePacket(player, InventorySlotPacket.class); + assertEquals(ContainerSlotType.LEVEL_ENTITY, slotPacket.containerNameData.getContainer()); + assertEquals(7, slotPacket.containerNameData.getDynamicId()); + + Mockito.reset(player); + player.protocol = ProtocolInfo.v1_21_30; + Mockito.when(player.getWindowId(chest)).thenReturn(7); + chest.sendContents(player); + InventoryContentPacket contentPacket = capturePacket(player, InventoryContentPacket.class); + assertEquals(ContainerSlotType.LEVEL_ENTITY, contentPacket.containerNameData.getContainer()); + assertEquals(7, contentPacket.containerNameData.getDynamicId()); + } + + @Test + void furnaceSlotSyncUsesSlotSpecificContainerName() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + BlockEntityFurnace holder = Mockito.mock(BlockEntityFurnace.class); + FurnaceInventory furnace = new FurnaceInventory(holder); + Mockito.when(player.getWindowId(furnace)).thenReturn(8); + + furnace.sendSlot(0, player); + InventorySlotPacket ingredientPacket = capturePacket(player, InventorySlotPacket.class); + assertEquals(ContainerSlotType.FURNACE_INGREDIENT, ingredientPacket.containerNameData.getContainer()); + assertEquals(8, ingredientPacket.containerNameData.getDynamicId()); + + Mockito.reset(player); + player.protocol = ProtocolInfo.v1_21_30; + Mockito.when(player.getWindowId(furnace)).thenReturn(8); + furnace.sendSlot(2, player); + InventorySlotPacket resultPacket = capturePacket(player, InventorySlotPacket.class); + assertEquals(ContainerSlotType.FURNACE_RESULT, resultPacket.containerNameData.getContainer()); + assertEquals(8, resultPacket.containerNameData.getDynamicId()); + } + + @Test + void doubleChestUnclonedAccessAndClearProxyToRealSides() { + BlockEntityChest leftHolder = Mockito.mock(BlockEntityChest.class); + BlockEntityChest rightHolder = Mockito.mock(BlockEntityChest.class); + ChestInventory left = new ChestInventory(leftHolder); + ChestInventory right = new ChestInventory(rightHolder); + Mockito.when(leftHolder.getRealInventory()).thenReturn(left); + Mockito.when(rightHolder.getRealInventory()).thenReturn(right); + + assertTrue(left.setItem(0, Item.get(Item.STONE, 0, 4), false)); + assertTrue(right.setItem(0, Item.get(Item.DIRT, 0, 6), false)); + + DoubleChestInventory doubleChest = new DoubleChestInventory(leftHolder, rightHolder); + + assertSame(left.getUnclonedItem(0), doubleChest.getUnclonedItem(0)); + assertSame(right.getUnclonedItem(0), doubleChest.getUnclonedItem(left.getSize())); + assertEquals(Item.DIRT, doubleChest.getItem(left.getSize()).getId()); + + assertTrue(doubleChest.clear(left.getSize(), false)); + assertTrue(right.getItem(0).isNull()); + } + + @Test + void doubleChestSlotSyncCarriesWindowDynamicId() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + BlockEntityChest leftHolder = Mockito.mock(BlockEntityChest.class); + BlockEntityChest rightHolder = Mockito.mock(BlockEntityChest.class); + ChestInventory left = new ChestInventory(leftHolder); + ChestInventory right = new ChestInventory(rightHolder); + Mockito.when(leftHolder.getRealInventory()).thenReturn(left); + Mockito.when(rightHolder.getRealInventory()).thenReturn(right); + DoubleChestInventory doubleChest = new DoubleChestInventory(leftHolder, rightHolder); + Mockito.when(player.getWindowId(doubleChest)).thenReturn(9); + + doubleChest.sendSlot(right, 0, player); + + InventorySlotPacket packet = capturePacket(player, InventorySlotPacket.class); + assertEquals(27, packet.slot); + assertEquals(ContainerSlotType.LEVEL_ENTITY, packet.containerNameData.getContainer()); + assertEquals(9, packet.containerNameData.getDynamicId()); + } + + @Test + void playerArmorSlotSyncCarriesSpecialArmorDynamicId() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + PlayerInventory inventory = new PlayerInventory(player); + + inventory.sendArmorSlot(inventory.getSize(), player); + + InventorySlotPacket packet = capturePacket(player, InventorySlotPacket.class); + assertEquals(0, packet.slot); + assertEquals(ContainerSlotType.ARMOR, packet.containerNameData.getContainer()); + assertEquals(InventoryContentPacket.SPECIAL_ARMOR, packet.containerNameData.getDynamicId()); + } + + @Test + void playerInventorySlotSyncCarriesViewerWindowDynamicId() { + Player holder = Mockito.mock(Player.class); + Player viewer = Mockito.mock(Player.class); + viewer.protocol = ProtocolInfo.v1_21_30; + PlayerInventory inventory = new PlayerInventory(holder); + Mockito.when(viewer.getWindowId(inventory)).thenReturn(11); + + inventory.sendSlot(10, viewer); + + InventorySlotPacket packet = capturePacket(viewer, InventorySlotPacket.class); + assertEquals(ContainerSlotType.INVENTORY, packet.containerNameData.getContainer()); + assertEquals(11, packet.containerNameData.getDynamicId()); + } + + @Test + void offhandSyncCarriesStaticDynamicId() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + PlayerOffhandInventory inventory = new PlayerOffhandInventory(player); + + inventory.sendContents(player); + + InventoryContentPacket packet = capturePacket(player, InventoryContentPacket.class); + assertEquals(ContainerSlotType.OFFHAND, packet.containerNameData.getContainer()); + assertEquals(0, packet.containerNameData.getDynamicId()); + } + + private static T capturePacket(Player player, Class type) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataPacket.class); + Mockito.verify(player).dataPacket(captor.capture()); + return assertInstanceOf(type, captor.getValue()); + } +} diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java new file mode 100644 index 000000000..90f374738 --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -0,0 +1,566 @@ +package cn.nukkit.inventory.request; + +import cn.nukkit.GameVersion; +import cn.nukkit.MockServer; +import cn.nukkit.Player; +import cn.nukkit.blockentity.BlockEntityChest; +import cn.nukkit.entity.passive.EntityVillager; +import cn.nukkit.event.inventory.InventoryEvent; +import cn.nukkit.event.inventory.ItemStackRequestActionEvent; +import cn.nukkit.inventory.*; +import cn.nukkit.inventory.special.FireworkRecipe; +import cn.nukkit.inventory.transaction.action.InventoryAction; +import cn.nukkit.inventory.transaction.action.SlotChangeAction; +import cn.nukkit.item.Item; +import cn.nukkit.item.ItemBundle; +import cn.nukkit.item.enchantment.Enchantment; +import cn.nukkit.level.Position; +import cn.nukkit.nbt.tag.CompoundTag; +import cn.nukkit.nbt.tag.ListTag; +import cn.nukkit.nbt.tag.Tag; +import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket; +import cn.nukkit.network.protocol.ProtocolInfo; +import cn.nukkit.network.protocol.types.inventory.ContainerSlotType; +import cn.nukkit.network.protocol.types.inventory.descriptor.ComplexAliasDescriptor; +import cn.nukkit.network.protocol.types.inventory.descriptor.DefaultDescriptor; +import cn.nukkit.network.protocol.types.inventory.descriptor.ItemDescriptorWithCount; +import cn.nukkit.network.protocol.types.inventory.descriptor.ItemTagDescriptor; +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.utils.TradeRecipeBuildUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ItemStackRequestProcessorTest { + + @BeforeAll + static void init() { + MockServer.init(); + Item.initCreativeItems(); + } + + @BeforeEach + void resetServer() { + MockServer.reset(); + PlayerEnchantOptionsPacket.RECIPE_MAP.clear(); + TradeRecipeBuildUtils.RECIPE_MAP.clear(); + } + + @Test + void creativeCraftWithZeroRequestedCountCreatesFullStack() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0, "creative catalog should contain stackable items"); + + Item expected = creativeItems.get(creativeIndex); + ItemStackRequestContext context = context(); + ActionResponse response = new CraftCreativeActionProcessor() + .handle(new CraftCreativeAction(creativeIndex + 1, 0), player, context); + + assertNull(response); + assertEquals(Boolean.TRUE, context.get(CraftCreativeActionProcessor.CRAFT_CREATIVE_KEY)); + Item created = ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT); + assertEquals(expected.getId(), created.getId()); + assertEquals(expected.getMaxStackSize(), created.getCount()); + assertTrue(created.getStackNetId() > 0); + } + + @Test + void creativeCreatedOutputTakeCanOverwriteDifferentDestinationItem() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item source = Item.get(Item.DIAMOND, 0, 64); + source.autoAssignStackNetworkId(); + assertTrue(ui.setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, source, false)); + source = ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT); + + Item existing = Item.get(Item.DIRT, 0, 5); + existing.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, existing, false)); + existing = inventory.getItem(0); + + ItemStackRequestContext context = context(); + context.put(CraftCreativeActionProcessor.CRAFT_CREATIVE_KEY, true); + TakeAction action = new TakeAction( + 64, + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, existing.getStackNetId(), null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(1, response.containers().size()); + assertEquals(ContainerSlotType.HOTBAR, response.containers().get(0).getContainer()); + Item dest = inventory.getItem(0); + assertEquals(Item.DIAMOND, dest.getId()); + assertEquals(64, dest.getCount()); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); + } + + @Test + void suppressedDestroyStillMutatesInventory() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + + Item item = Item.get(Item.STONE, 0, 5); + item.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, item, false)); + item = inventory.getItem(0); + + ItemStackRequestContext context = context(); + context.put(DestroyActionProcessor.NO_RESPONSE_DESTROY_KEY, true); + DestroyAction action = new DestroyAction( + 2, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, item.getStackNetId(), null) + ); + + ActionResponse response = new DestroyActionProcessor().handle(action, player, context); + + assertNull(response); + assertEquals(3, inventory.getItem(0).getCount()); + } + + @Test + void dropActionOnlyDropsItemOnCommit() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + + Item item = Item.get(Item.STONE, 0, 5); + item.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, item, false)); + item = inventory.getItem(0); + + ItemStackRequestContext context = context(); + DropAction action = new DropAction( + 2, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, item.getStackNetId(), null), + false + ); + + ActionResponse response = new DropActionProcessor().handle(action, player, context); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(3, inventory.getItem(0).getCount()); + verify(player, never()).dropItem(any(Item.class)); + + assertTrue(context.commit()); + ArgumentCaptor dropped = ArgumentCaptor.forClass(Item.class); + verify(player).dropItem(dropped.capture()); + assertEquals(Item.STONE, dropped.getValue().getId()); + assertEquals(2, dropped.getValue().getCount()); + } + + @Test + void mineBlockResponseCarriesInventoryDynamicId() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + + Item item = Item.get(Item.STONE, 0, 1); + item.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, item, false)); + item = inventory.getItem(0); + + ActionResponse response = new MineBlockActionProcessor() + .handle(new MineBlockAction(0, 0, item.getStackNetId()), player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(1, response.containers().size()); + assertEquals(ContainerSlotType.HOTBAR_AND_INVENTORY, response.containers().get(0).getContainerName().getContainer()); + assertEquals(0, response.containers().get(0).getContainerName().getDynamicId()); + } + + @Test + void multiRecipeRequiresMatchingConsumePlan() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item paper = Item.get(Item.PAPER, 0, 4); + paper.autoAssignStackNetworkId(); + Item powder = Item.get(Item.GUNPOWDER, 0, 4); + powder.autoAssignStackNetworkId(); + assertTrue(ui.getCraftingGrid().setItem(0, paper, false)); + assertTrue(ui.getCraftingGrid().setItem(1, powder, false)); + paper = ui.getCraftingGrid().getItem(0); + powder = ui.getCraftingGrid().getItem(1); + + MultiRecipe recipe = new FireworkRecipe(); + Item output = Item.get(Item.FIREWORKS, 0, 3); + + ItemStackRequestContext missingConsumes = context( + new CraftRecipeAction(recipe.getNetworkId(), 1), + new CraftResultsDeprecatedAction(new Item[]{output}, 1) + ); + missingConsumes.setCurrentActionIndex(0); + assertFalse(CraftRecipeActionProcessor.validateMultiRecipeConsumePlan(player, recipe, output, missingConsumes)); + + ItemStackRequestContext withConsumes = context( + new CraftRecipeAction(recipe.getNetworkId(), 1), + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.CRAFTING_INPUT, 28, paper.getStackNetId(), null)), + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.CRAFTING_INPUT, 29, powder.getStackNetId(), null)), + new CraftResultsDeprecatedAction(new Item[]{output}, 1) + ); + withConsumes.setCurrentActionIndex(0); + assertTrue(CraftRecipeActionProcessor.validateMultiRecipeConsumePlan(player, recipe, output, withConsumes)); + } + + @Test + void autoCraftUsesConsumedItemsWhenCraftingGridIsEmpty() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + CraftingManager craftingManager = Mockito.mock(CraftingManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getServer().getCraftingManager()).thenReturn(craftingManager); + + ShapelessRecipe recipe = new ShapelessRecipe("test:auto_stone_to_diamond", 10, + Item.get(Item.DIAMOND, 0, 1), + List.of(Item.get(Item.STONE, 0, 1))); + Mockito.when(craftingManager.getRecipeByNetworkId(recipe.getNetworkId())).thenReturn(recipe); + Mockito.when(craftingManager.matchRecipe(anyList(), any(Item.class), anyList())).thenAnswer(invocation -> { + List inputs = cloneItems(invocation.getArgument(0)); + Item output = invocation.getArgument(1); + List expected = new ArrayList<>(List.of(Item.get(Item.STONE, 0, 1))); + return output.getId() == Item.DIAMOND + && output.getCount() == 1 + && Recipe.matchItemList(inputs, expected) ? recipe : null; + }); + + Item stone = Item.get(Item.STONE, 0, 1); + stone.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, stone, false)); + stone = inventory.getItem(0); + + AutoCraftRecipeAction action = new AutoCraftRecipeAction( + recipe.getNetworkId(), + 1, + 1, + List.of(new ItemDescriptorWithCount(new DefaultDescriptor(Item.STONE, 0), 1)) + ); + ItemStackRequestContext context = context( + action, + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, stone.getStackNetId(), null)) + ); + context.setCurrentActionIndex(0); + + ActionResponse response = new CraftRecipeAutoProcessor().handle(action, player, context); + + assertNotNull(response); + assertTrue(response.success()); + Item output = ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT); + assertEquals(Item.DIAMOND, output.getId()); + assertEquals(1, output.getCount()); + assertTrue(ui.getCraftingGrid().getItem(0).isNull(), "auto craft must not require prefilled crafting grid"); + } + + @Test + void beaconPaymentRequiresDestroyFromPaymentSlot() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + BeaconInventory beacon = new BeaconInventory(ui, new Position()); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(beacon)); + Mockito.when(player.getWindowById(Player.BEACON_WINDOW_ID)).thenReturn(beacon); + + Item payment = Item.get(Item.EMERALD, 0, 1); + payment.autoAssignStackNetworkId(); + assertTrue(beacon.setItem(0, payment, false)); + payment = beacon.getItem(0); + + ItemStackRequestContext missingDestroy = context(new BeaconPaymentAction(1, 0)); + missingDestroy.setCurrentActionIndex(0); + assertFalse(BeaconPaymentActionProcessor.hasValidPaymentDestroyAction(player, beacon, missingDestroy)); + + ItemStackRequestContext withDestroy = context( + new BeaconPaymentAction(1, 0), + new DestroyAction(1, new ItemStackRequestSlotData(ContainerSlotType.BEACON_PAYMENT, 27, payment.getStackNetId(), null)) + ); + withDestroy.setCurrentActionIndex(0); + assertTrue(BeaconPaymentActionProcessor.hasValidPaymentDestroyAction(player, beacon, withDestroy)); + } + + @Test + void rollbackClearsNewDoubleChestSlots() throws Exception { + BlockEntityChest leftHolder = Mockito.mock(BlockEntityChest.class); + BlockEntityChest rightHolder = Mockito.mock(BlockEntityChest.class); + ChestInventory left = new ChestInventory(leftHolder); + ChestInventory right = new ChestInventory(rightHolder); + Mockito.when(leftHolder.getRealInventory()).thenReturn(left); + Mockito.when(rightHolder.getRealInventory()).thenReturn(right); + DoubleChestInventory doubleChest = new DoubleChestInventory(leftHolder, rightHolder); + + assertTrue(doubleChest.setItem(left.getSize(), Item.get(Item.DIAMOND, 0, 1), false)); + Method restore = ItemStackRequestHandler.class.getDeclaredMethod("restoreInventory", cn.nukkit.inventory.Inventory.class, Map.class); + restore.setAccessible(true); + restore.invoke(null, doubleChest, Map.of()); + + assertTrue(right.getItem(0).isNull(), "rollback should clear slots backed by the real chest side"); + } + + @Test + void eventOnlyTransactionRejectsBindingCurseArmorRemoval() { + Player player = mockPlayer(); + Mockito.when(player.isCreative()).thenReturn(false); + PlayerInventory inventory = new PlayerInventory(player); + + Item helmet = Item.get(Item.DIAMOND_HELMET, 0, 1); + helmet.addEnchantment(Enchantment.getEnchantment(Enchantment.ID_BINDING_CURSE)); + assertTrue(inventory.setItem(36, helmet, false)); + helmet = inventory.getItem(36); + + List actions = List.of( + new SlotChangeAction(inventory, 36, helmet, Item.get(Item.AIR)) + ); + var transaction = new TransferItemActionProcessor.EventOnlyInventoryTransaction(player, actions, context()); + + assertFalse(transaction.execute(), "SAI compatibility transaction must keep the legacy binding curse guard"); + } + + @Test + void dynamicContainerTransferRespondsForDifferentDynamicIdsWithSameSlot() { + Player player = mockPlayer(); + PlayerInventory inventory = Mockito.mock(PlayerInventory.class); + ItemBundle sourceBundle = new ItemBundle(); + ItemBundle destinationBundle = new ItemBundle(); + + Item sourceItem = Item.get(Item.STONE, 0, 2); + sourceItem.autoAssignStackNetworkId(); + assertTrue(sourceBundle.getInventory().setItem(0, sourceItem, false)); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(inventory.getContents()).thenReturn(Map.of(0, sourceBundle, 1, destinationBundle)); + + sourceItem = sourceBundle.getInventory().getItem(0); + PlaceAction action = new PlaceAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.DYNAMIC_CONTAINER, 0, sourceItem.getStackNetId(), sourceBundle.getBundleId()), + new ItemStackRequestSlotData(ContainerSlotType.DYNAMIC_CONTAINER, 0, 0, destinationBundle.getBundleId()) + ); + + ActionResponse response = new PlaceActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(2, response.containers().size(), "source and destination bundles need separate responses"); + assertEquals(sourceBundle.getBundleId(), response.containers().get(0).getContainerName().getDynamicId()); + assertEquals(destinationBundle.getBundleId(), response.containers().get(1).getContainerName().getDynamicId()); + } + + @Test + void itemStackRequestActionEventIsNotInventoryEvent() { + assertFalse(InventoryEvent.class.isAssignableFrom(ItemStackRequestActionEvent.class)); + } + + @Test + void tagDescriptorsMatchRegisteredItemTags() { + Item planks = Mockito.mock(Item.class); + Mockito.when(planks.isNull()).thenReturn(false); + Mockito.when(planks.getNamespaceId()).thenReturn("minecraft:planks"); + + assertTrue(new ItemTagDescriptor("minecraft:planks").match(planks)); + assertTrue(new ComplexAliasDescriptor("minecraft:planks").match(planks)); + } + + @Test + void enchantRecipeRequiresCurrentWindowRecipeId() throws Exception { + PlayerEnchantOptionsPacket.RECIPE_MAP.clear(); + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + EnchantInventory current = new EnchantInventory(ui, new Position()); + EnchantInventory other = new EnchantInventory(ui, new Position()); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(current)); + + Item sword = Item.get(Item.DIAMOND_SWORD, 0, 1); + sword.autoAssignStackNetworkId(); + assertTrue(current.setItem(0, sword, false)); + + int recipeId = PlayerEnchantOptionsPacket.assignRecipeId(enchantOption(1, 0)); + markPublishedOption(other, recipeId); + + ItemStackRequestContext context = context(new CraftRecipeAction(recipeId, 1)); + context.setCurrentActionIndex(0); + + ActionResponse response = new CraftRecipeActionProcessor() + .handle(new CraftRecipeAction(recipeId, 1), player, context); + + assertNotNull(response); + assertFalse(response.success()); + } + + @Test + void enchantRecipeRequiresDisplayedMinimumLevel() throws Exception { + PlayerEnchantOptionsPacket.RECIPE_MAP.clear(); + Player player = mockPlayer(); + Mockito.when(player.isCreative()).thenReturn(false); + Mockito.when(player.getExperienceLevel()).thenReturn(5); + Mockito.when(player.getExperience()).thenReturn(0); + PlayerUIInventory ui = new PlayerUIInventory(player); + EnchantInventory enchant = new EnchantInventory(ui, new Position()); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(enchant)); + Mockito.when(player.getWindowById(Player.ENCHANT_WINDOW_ID)).thenReturn(enchant); + + Item sword = Item.get(Item.DIAMOND_SWORD, 0, 1); + sword.autoAssignStackNetworkId(); + assertTrue(enchant.setItem(0, sword, false)); + sword = enchant.getItem(0); + Item lapis = Item.get(Item.DYE, 4, 3); + lapis.autoAssignStackNetworkId(); + assertTrue(enchant.setItem(1, lapis, false)); + lapis = enchant.getItem(1); + + int recipeId = PlayerEnchantOptionsPacket.assignRecipeId(enchantOption(30, 0)); + markPublishedOption(enchant, recipeId); + ItemStackRequestContext context = context( + new CraftRecipeAction(recipeId, 1), + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.ENCHANTING_INPUT, 14, sword.getStackNetId(), null)), + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.ENCHANTING_MATERIAL, 15, lapis.getStackNetId(), null)) + ); + context.setCurrentActionIndex(0); + + ActionResponse response = new CraftRecipeActionProcessor() + .handle(new CraftRecipeAction(recipeId, 1), player, context); + + assertNotNull(response); + assertFalse(response.success()); + } + + @Test + void tradeRecipeRequiresCurrentVillagerRecipeId() throws Exception { + TradeRecipeBuildUtils.RECIPE_MAP.clear(); + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + EntityVillager villager = Mockito.mock(EntityVillager.class); + TradeInventory tradeInventory = new TradeInventory(villager); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(tradeInventory)); + + CompoundTag currentRecipe = tradeRecipe(Item.get(Item.COAL, 0, 1), Item.get(Item.APPLE, 0, 1)); + int currentRecipeId = TradeRecipeBuildUtils.assignRecipeId(currentRecipe); + currentRecipe.putInt("netId", currentRecipeId); + ListTag recipes = new ListTag<>("Recipes"); + recipes.add(currentRecipe); + Mockito.when(villager.getRecipes()).thenReturn(recipes); + markAssignedTradeRecipe(tradeInventory, currentRecipeId); + + CompoundTag foreignRecipe = tradeRecipe(Item.get(Item.EMERALD, 0, 1), Item.get(Item.DIAMOND, 0, 1)); + int foreignRecipeId = TradeRecipeBuildUtils.assignRecipeId(foreignRecipe); + foreignRecipe.putInt("netId", foreignRecipeId); + + Item emerald = Item.get(Item.EMERALD, 0, 1); + emerald.autoAssignStackNetworkId(); + assertTrue(tradeInventory.setItem(0, emerald, false)); + emerald = tradeInventory.getItem(0); + ItemStackRequestContext context = context( + new CraftRecipeAction(foreignRecipeId, 1), + new ConsumeAction(1, new ItemStackRequestSlotData(ContainerSlotType.TRADE2_INGREDIENT_1, 0, emerald.getStackNetId(), null)) + ); + context.setCurrentActionIndex(0); + + ActionResponse response = new CraftRecipeActionProcessor() + .handle(new CraftRecipeAction(foreignRecipeId, 1), player, context); + + assertNotNull(response); + assertFalse(response.success()); + } + + private static Player mockPlayer() { + Player player = Mockito.mock(Player.class); + player.protocol = ProtocolInfo.v1_21_30; + Mockito.when(player.getServer()).thenReturn(MockServer.get()); + Mockito.when(player.getName()).thenReturn("test"); + Mockito.when(player.isCreative()).thenReturn(true); + return player; + } + + private static ItemStackRequestContext context() { + return context(new ItemStackRequestAction[0]); + } + + private static ItemStackRequestContext context(ItemStackRequestAction... actions) { + return new ItemStackRequestContext(new ItemStackRequest( + 1, + actions, + new String[0] + )); + } + + private static List cloneItems(List items) { + List cloned = new ArrayList<>(items.size()); + for (Item item : items) { + cloned.add(item.clone()); + } + return cloned; + } + + private static PlayerEnchantOptionsPacket.EnchantOptionData enchantOption(int minLevel, int primarySlot) { + return new PlayerEnchantOptionsPacket.EnchantOptionData( + minLevel, + primarySlot, + List.of(new PlayerEnchantOptionsPacket.EnchantData(Enchantment.ID_DAMAGE_ALL, 1)), + List.of(), + List.of(), + "test", + 0 + ); + } + + @SuppressWarnings("unchecked") + private static void markPublishedOption(EnchantInventory inventory, int recipeId) throws Exception { + Field field = EnchantInventory.class.getDeclaredField("publishedOptionIds"); + field.setAccessible(true); + ((Set) field.get(inventory)).add(recipeId); + } + + private static CompoundTag tradeRecipe(Item buy, Item sell) { + return new TradeInventoryRecipe(sell, buy).toNBT(); + } + + @SuppressWarnings("unchecked") + private static void markAssignedTradeRecipe(TradeInventory inventory, int recipeId) throws Exception { + Field field = TradeInventory.class.getDeclaredField("assignedRecipeIds"); + field.setAccessible(true); + ((Set) field.get(inventory)).add(recipeId); + } +} diff --git a/src/test/java/cn/nukkit/item/enchantment/EnchantmentHelperTest.java b/src/test/java/cn/nukkit/item/enchantment/EnchantmentHelperTest.java new file mode 100644 index 000000000..43f335c36 --- /dev/null +++ b/src/test/java/cn/nukkit/item/enchantment/EnchantmentHelperTest.java @@ -0,0 +1,35 @@ +package cn.nukkit.item.enchantment; + +import cn.nukkit.MockServer; +import cn.nukkit.item.Item; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class EnchantmentHelperTest { + + @BeforeAll + static void init() { + MockServer.init(); + } + + @Test + @SuppressWarnings("unchecked") + void enchantingTableCandidatesExcludeTreasureAndCurses() throws Exception { + Method filterApplicable = EnchantmentHelper.class.getDeclaredMethod("filterApplicable", Item.class, int.class); + filterApplicable.setAccessible(true); + + List candidates = (List) filterApplicable.invoke( + null, + Item.get(Item.DIAMOND_PICKAXE), + 30 + ); + + assertFalse(candidates.isEmpty(), "test item should have normal enchantment candidates"); + assertFalse(candidates.stream().anyMatch(enchantment -> enchantment.isTreasure() || enchantment.isCurse())); + } +} From 634685c123f1c4634b1b9b70efd3036eb6d26f0c Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Fri, 1 May 2026 08:58:15 +0800 Subject: [PATCH 08/29] fix: add missing CrafterInventory mapping in NetworkMapping.getSlotType() --- src/main/java/cn/nukkit/inventory/request/NetworkMapping.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java index 142658857..772c75744 100644 --- a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java +++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java @@ -249,6 +249,9 @@ public static ContainerSlotType getSlotType(Inventory inventory, int internalSlo if (inventory instanceof BundleInventory) { return ContainerSlotType.DYNAMIC_CONTAINER; } + if (inventory instanceof CrafterInventory) { + return ContainerSlotType.CRAFTER_BLOCK_CONTAINER; + } if (inventory instanceof HorseInventory) { return internalSlot <= HorseInventory.SLOT_ARMOR ? ContainerSlotType.HORSE_EQUIP From 0f210fd2b4f74c981586c7009b98f8b088cd8141 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 16 May 2026 20:26:54 +0800 Subject: [PATCH 09/29] fix --- .../event/player/PlayerTransferItemEvent.java | 36 ++++++++++- .../request/CraftLoomActionProcessor.java | 62 +++++++++++++++++++ .../request/DestroyActionProcessor.java | 9 ++- .../request/DropActionProcessor.java | 6 ++ .../request/SwapActionProcessor.java | 11 ++++ .../request/TransferItemActionProcessor.java | 12 +++- .../java/cn/nukkit/utils/BannerPattern.java | 2 +- 7 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java b/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java index b4c762464..f755c3271 100644 --- a/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java +++ b/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java @@ -5,6 +5,7 @@ import cn.nukkit.event.HandlerList; import cn.nukkit.inventory.Inventory; import cn.nukkit.item.Item; +import org.jetbrains.annotations.Nullable; /** * Called when a player transfers an item between inventories via SAI. @@ -17,8 +18,21 @@ public static HandlerList getHandlers() { return handlers; } + /** + * Distinguishes the SAI action category that triggered the event. DROP has + * no destination — {@link #getDestinationInventory()} returns {@code null} + * and {@link #getDestinationSlot()} returns {@code -1}. + */ + public enum Type { + TRANSFER, + SWAP, + DROP + } + + private final Type type; private final Inventory sourceInventory; private final int sourceSlot; + @Nullable private final Inventory destinationInventory; private final int destinationSlot; private final Item sourceItem; @@ -26,9 +40,18 @@ public static HandlerList getHandlers() { private final int count; public PlayerTransferItemEvent(Player player, Inventory sourceInventory, int sourceSlot, - Inventory destinationInventory, int destinationSlot, + @Nullable Inventory destinationInventory, int destinationSlot, + Item sourceItem, Item destinationItem, int count) { + this(player, Type.TRANSFER, sourceInventory, sourceSlot, + destinationInventory, destinationSlot, sourceItem, destinationItem, count); + } + + public PlayerTransferItemEvent(Player player, Type type, + Inventory sourceInventory, int sourceSlot, + @Nullable Inventory destinationInventory, int destinationSlot, Item sourceItem, Item destinationItem, int count) { this.player = player; + this.type = type; this.sourceInventory = sourceInventory; this.sourceSlot = sourceSlot; this.destinationInventory = destinationInventory; @@ -38,6 +61,10 @@ public PlayerTransferItemEvent(Player player, Inventory sourceInventory, int sou this.count = count; } + public Type getType() { + return type; + } + public Inventory getSourceInventory() { return sourceInventory; } @@ -46,10 +73,17 @@ public int getSourceSlot() { return sourceSlot; } + /** + * @return the destination inventory, or {@code null} when the event {@link #getType() type} is {@link Type#DROP}. + */ + @Nullable public Inventory getDestinationInventory() { return destinationInventory; } + /** + * @return the destination slot, or {@code -1} when the event {@link #getType() type} is {@link Type#DROP}. + */ public int getDestinationSlot() { return destinationSlot; } diff --git a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java index ef412a92b..c23ef8b92 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java @@ -37,6 +37,19 @@ public class CraftLoomActionProcessor implements ItemStackRequestActionProcessor public static final String LOOM_PATTERN_KEY = "loomPatternId"; public static final String LOOM_TIMES_KEY = "loomTimesCrafted"; + /** + * Vanilla limit on stacked patterns per banner. The 7th visual layer is the + * banner's base colour, so {@code Patterns} list holds at most 6 entries + * before further additions are rejected. + */ + private static final int MAX_BANNER_PATTERNS = 6; + + /** + * Illager/ominous banner — {@code Type=1} on the banner NBT. Vanilla refuses + * to apply any further pattern to it so loom output is the original banner. + */ + private static final int OMINOUS_BANNER_TYPE = 1; + @Override public ItemStackRequestActionType getType() { return ItemStackRequestActionType.CRAFT_LOOM; @@ -59,6 +72,13 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq return context.error(); } + // Vanilla: ominous banners (Type=1) reject all loom operations. + if (bannerItem.hasCompoundTag() + && bannerItem.getNamedTag().contains("Type") + && bannerItem.getNamedTag().getInt("Type") == OMINOUS_BANNER_TYPE) { + return context.error(); + } + BannerPattern.Type patternType = null; String patternId = action.getPatternId(); if (patternId != null && !patternId.isBlank()) { @@ -68,6 +88,24 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq } } + // Vanilla: applying a new pattern requires the banner has < 6 patterns. + // Pure dye operations (no patternType) only repaint the base and are + // unaffected by the limit. + if (patternType != null && bannerItem.getPatternsSize() >= MAX_BANNER_PATTERNS) { + return context.error(); + } + + // Vanilla: "special" patterns (creeper/skull/flower/mojang/flow/guster) + // require a matching banner-pattern item in the material slot. The + // pattern item itself is NOT consumed (acts like a tool). + Item materialItem = loomInventory.getPattern(); + if (patternType != null && requiresPatternItem(patternType)) { + if (materialItem == null || materialItem.isNull() + || !isMatchingPatternItem(patternType, materialItem)) { + return context.error(); + } + } + DyeColor dyeColor = DyeColor.BLACK; if (dye instanceof ItemDye itemDye) { dyeColor = itemDye.getDyeColor(); @@ -111,4 +149,28 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null) ))); } + + private static boolean requiresPatternItem(BannerPattern.Type type) { + return switch (type) { + case PATTERN_CREEPER, PATTERN_SKULL, PATTERN_FLOWER, PATTERN_MOJANG, + PATTERN_FLOW, PATTERN_GUSTER, + PATTERN_BRICK, PATTERN_CURLY_BORDER -> true; + default -> false; + }; + } + + private static boolean isMatchingPatternItem(BannerPattern.Type type, Item material) { + return switch (type) { + case PATTERN_FLOW -> Item.FLOW_BANNER_PATTERN.equals(material.getNamespaceId()); + case PATTERN_GUSTER -> Item.GUSTER_BANNER_PATTERN.equals(material.getNamespaceId()); + // Legacy banner pattern item (id 434) uses meta to distinguish + // creeper/skull/flower/mojang/bricks/curly_border variants; client + // UI gates this by greying out incompatible patterns, so we accept + // any meta here. + case PATTERN_CREEPER, PATTERN_SKULL, PATTERN_FLOWER, PATTERN_MOJANG, + PATTERN_BRICK, PATTERN_CURLY_BORDER -> + material.getId() == Item.BANNER_PATTERN; + default -> true; + }; + } } diff --git a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java index e50b1506c..ab10c121a 100644 --- a/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java @@ -6,6 +6,7 @@ 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.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; @@ -32,7 +33,13 @@ public ActionResponse handle(DestroyAction action, Player player, ItemStackReque if (inventory == null) { return context.error(); } - if (!player.isCreative() && !(inventory instanceof BeaconInventory)) { + // suppressResponse 表示当前 destroy 紧随 CraftResultsDeprecated —— 这是 + // 协议要求的合成尾声清理,必须严格限定在 CREATED_OUTPUT 槽,否则恶意 + // 客户端可借此绕过生存模式权限销毁背包物品。 + // BeaconInventory 始终豁免(信标 payment 槽的标准消耗路径)。 + boolean isCreatedOutputCleanup = suppressResponse + && src.getContainer() == ContainerSlotType.CREATED_OUTPUT; + if (!isCreatedOutputCleanup && !player.isCreative() && !(inventory instanceof BeaconInventory)) { 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 40fc89e95..925168cf0 100644 --- a/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java @@ -2,6 +2,7 @@ import cn.nukkit.Player; import cn.nukkit.event.player.PlayerDropItemEvent; +import cn.nukkit.event.player.PlayerTransferItemEvent; import cn.nukkit.inventory.Inventory; import cn.nukkit.inventory.transaction.action.InventoryAction; import cn.nukkit.inventory.transaction.action.SlotChangeAction; @@ -46,6 +47,11 @@ public ActionResponse handle(DropAction action, Player player, ItemStackRequestC Item dropItem = item.clone(); dropItem.setCount(count); + if (!TransferItemActionProcessor.fireTransferEvent(player, PlayerTransferItemEvent.Type.DROP, + inventory, slot, null, -1, item, null, count)) { + return context.error(); + } + PlayerDropItemEvent event = new PlayerDropItemEvent(player, dropItem); player.getServer().getPluginManager().callEvent(event); if (event.isCancelled()) { diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java index 3f1a85959..315fd8766 100644 --- a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java @@ -1,6 +1,7 @@ package cn.nukkit.inventory.request; import cn.nukkit.Player; +import cn.nukkit.event.player.PlayerTransferItemEvent; import cn.nukkit.inventory.Inventory; import cn.nukkit.inventory.transaction.action.InventoryAction; import cn.nukkit.inventory.transaction.action.SlotChangeAction; @@ -51,6 +52,16 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC return context.error(); } + // Swap is structurally a bi-directional transfer; emit one event with + // both sides populated so plugins can veto or observe it consistently + // with TAKE/PLACE and DROP. count uses the source stack size — there + // is no partial swap on the wire. + int count = sourceItem.isNull() ? destItem.getCount() : sourceItem.getCount(); + if (!TransferItemActionProcessor.fireTransferEvent(player, PlayerTransferItemEvent.Type.SWAP, + srcInv, srcSlot, dstInv, dstSlot, sourceItem, destItem, count)) { + 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 218a71add..b34aee356 100644 --- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -300,14 +300,24 @@ static boolean fireClickEvent(Player actor, Inventory inventory, int slot, Item static boolean fireTransferEvent(Player actor, Inventory sourceInventory, int sourceSlot, Inventory destinationInventory, int destinationSlot, Item sourceItem, Item destinationItem, int count) { + return fireTransferEvent(actor, PlayerTransferItemEvent.Type.TRANSFER, + sourceInventory, sourceSlot, destinationInventory, destinationSlot, + sourceItem, destinationItem, count); + } + + static boolean fireTransferEvent(Player actor, PlayerTransferItemEvent.Type type, + Inventory sourceInventory, int sourceSlot, + Inventory destinationInventory, int destinationSlot, + Item sourceItem, Item destinationItem, int count) { PlayerTransferItemEvent event = new PlayerTransferItemEvent( actor, + type, sourceInventory, sourceSlot, destinationInventory, destinationSlot, sourceItem.clone(), - destinationItem.clone(), + destinationItem == null ? Item.get(Item.AIR) : destinationItem.clone(), count ); Server.getInstance().getPluginManager().callEvent(event); diff --git a/src/main/java/cn/nukkit/utils/BannerPattern.java b/src/main/java/cn/nukkit/utils/BannerPattern.java index 62ce194e8..6167e7f79 100644 --- a/src/main/java/cn/nukkit/utils/BannerPattern.java +++ b/src/main/java/cn/nukkit/utils/BannerPattern.java @@ -88,7 +88,7 @@ public enum Type { PATTERN_SKULL("sku"), PATTERN_FLOWER("flo"), PATTERN_MOJANG("moj"), - PATTERN_FLOW("flo"), + PATTERN_FLOW("flw"), PATTERN_GUSTER("gus"); private final static Map BY_NAME = new HashMap<>(); From e893b6e3ec63e4e9e1379906c5bb9034c9008eab Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 30 May 2026 14:09:22 +0800 Subject: [PATCH 10/29] feat(inventory): implement bundle interactions, offhand item restriction, and fix action types for 1.21.40+ --- .../cn/nukkit/inventory/BaseInventory.java | 31 ++ .../cn/nukkit/inventory/BundleInventory.java | 51 ++- .../cn/nukkit/inventory/PlayerInventory.java | 4 + .../inventory/PlayerOffhandInventory.java | 9 +- .../request/ItemStackRequestContext.java | 19 +- .../request/ItemStackRequestHandler.java | 33 +- .../request/TransferItemActionProcessor.java | 110 ++++- src/main/java/cn/nukkit/item/Item.java | 11 + src/main/java/cn/nukkit/item/ItemBundle.java | 71 +++- .../network/process/DataPacketManager.java | 2 +- .../network/protocol/StartGamePacket.java | 2 +- .../action/ItemStackRequestActionType.java | 3 + .../nukkit/inventory/BundleInventoryTest.java | 162 +++++++- .../ItemStackRequestProcessorTest.java | 386 ++++++++++++++++++ .../inventory/request/NetworkMappingTest.java | 73 ++++ .../process/DataPacketManagerTest.java | 20 + .../ItemStackRequestActionTypeTest.java | 2 + 17 files changed, 944 insertions(+), 45 deletions(-) create mode 100644 src/test/java/cn/nukkit/network/process/DataPacketManagerTest.java diff --git a/src/main/java/cn/nukkit/inventory/BaseInventory.java b/src/main/java/cn/nukkit/inventory/BaseInventory.java index 74bb60861..5c9876bab 100644 --- a/src/main/java/cn/nukkit/inventory/BaseInventory.java +++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java @@ -10,6 +10,7 @@ import cn.nukkit.event.inventory.InventoryOpenEvent; import cn.nukkit.item.Item; import cn.nukkit.item.ItemBlock; +import cn.nukkit.item.ItemBundle; import cn.nukkit.network.protocol.InventoryContentPacket; import cn.nukkit.network.protocol.InventorySlotPacket; import cn.nukkit.network.protocol.ProtocolInfo; @@ -210,6 +211,10 @@ public boolean setItem(int index, Item item, boolean send) { item.autoAssignStackNetworkId(); } + if (item instanceof ItemBundle bundle) { + ensureUniqueBundleId(index, bundle); + } + Item old = this.getItem(index); this.slots.put(index, item.clone()); this.onSlotChange(index, old, send); @@ -466,6 +471,32 @@ public InventoryHolder getHolder() { return holder; } + protected void ensureUniqueBundleId(int targetSlot, ItemBundle bundle) { + HashSet existingBundleIds = new HashSet<>(); + for (var entry : this.slots.entrySet()) { + if (entry.getKey() != targetSlot) { + collectBundleIds(entry.getValue(), existingBundleIds, new HashSet<>()); + } + } + while (existingBundleIds.contains(bundle.getBundleId())) { + bundle.assignNewBundleId(); + } + } + + private void collectBundleIds(Item item, Set bundleIds, Set visitedBundleIds) { + if (!(item instanceof ItemBundle bundle)) { + return; + } + int currentId = bundle.getBundleId(); + if (!visitedBundleIds.add(currentId)) { + return; + } + bundleIds.add(currentId); + for (Item nested : bundle.getInventory().getContents().values()) { + collectBundleIds(nested, bundleIds, visitedBundleIds); + } + } + @Override public void setMaxStackSize(int maxStackSize) { this.maxStackSize = maxStackSize; diff --git a/src/main/java/cn/nukkit/inventory/BundleInventory.java b/src/main/java/cn/nukkit/inventory/BundleInventory.java index e0fb6c169..5c2629a27 100644 --- a/src/main/java/cn/nukkit/inventory/BundleInventory.java +++ b/src/main/java/cn/nukkit/inventory/BundleInventory.java @@ -1,6 +1,7 @@ package cn.nukkit.inventory; import cn.nukkit.Player; +import cn.nukkit.block.BlockID; import cn.nukkit.item.Item; import cn.nukkit.item.ItemBundle; import cn.nukkit.nbt.NBTIO; @@ -33,6 +34,13 @@ public BundleInventory(ItemBundle holder) { @Override public boolean setItem(int index, Item item, boolean send) { + if (!canStore(item)) { + return false; + } + if (wouldCreateBundleCycle(item)) { + return false; + } + int newWeight = getWeight() - getWeight(this.getItemFast(index)) + getWeight(item); if (newWeight > MAX_FILL) { return false; @@ -66,7 +74,7 @@ public void sendContents(Player... players) { pk.storageItem = getHolder().clone(); for (Player player : players) { - if (!player.spawned || player.protocol < ProtocolInfo.v1_21_20) { + if (!player.spawned || player.protocol < ProtocolInfo.v1_21_40) { continue; } pk.inventoryId = DYNAMIC_REGISTRY_WINDOW_ID; @@ -84,7 +92,7 @@ public void sendSlot(int index, Player... players) { pk.storageItem = getHolder().clone(); for (Player player : players) { - if (!player.spawned || player.protocol < ProtocolInfo.v1_21_20) { + if (!player.spawned || player.protocol < ProtocolInfo.v1_21_40) { continue; } pk.inventoryId = DYNAMIC_REGISTRY_WINDOW_ID; @@ -115,12 +123,22 @@ private void loadFromHolder(ItemBundle holder) { } Item item = NBTIO.getItemHelper(itemTag); - if (!item.isNull()) { - this.slots.put(slot, item); + if (!item.isNull() && canStore(item) && !wouldCreateBundleCycle(item)) { + int newWeight = getWeight() + getWeight(item); + if (newWeight <= MAX_FILL) { + this.slots.put(slot, item); + } } } } + private boolean canStore(Item item) { + if (item == null || item.isNull()) { + return true; + } + return item.getId() != BlockID.SHULKER_BOX && item.getId() != BlockID.UNDYED_SHULKER_BOX; + } + private int getWeight(Set visitedBundleIds) { int weight = 0; for (Item item : this.slots.values()) { @@ -148,4 +166,29 @@ private int getWeight(Item item, Set visitedBundleIds) { return Math.max(1, MAX_FILL / Math.max(1, item.getMaxStackSize())) * item.getCount(); } + + private boolean wouldCreateBundleCycle(Item item) { + if (!(item instanceof ItemBundle bundle)) { + return false; + } + return containsBundleId(bundle, getHolder().getBundleId(), new HashSet<>()); + } + + private boolean containsBundleId(ItemBundle bundle, int targetBundleId, Set visitedBundleIds) { + int bundleId = bundle.getBundleId(); + if (!visitedBundleIds.add(bundleId)) { + return false; + } + if (bundle.matchesBundleIdentity(targetBundleId)) { + return true; + } + + for (Item nested : bundle.getInventory().getContents().values()) { + if (nested instanceof ItemBundle nestedBundle + && containsBundleId(nestedBundle, targetBundleId, visitedBundleIds)) { + return true; + } + } + return false; + } } diff --git a/src/main/java/cn/nukkit/inventory/PlayerInventory.java b/src/main/java/cn/nukkit/inventory/PlayerInventory.java index a0830b3b8..ca736bf2e 100644 --- a/src/main/java/cn/nukkit/inventory/PlayerInventory.java +++ b/src/main/java/cn/nukkit/inventory/PlayerInventory.java @@ -301,6 +301,10 @@ public boolean setItem(int index, Item item, boolean send) { item = ev.getNewItem(); } + if (item instanceof cn.nukkit.item.ItemBundle bundle) { + ensureUniqueBundleId(index, bundle); + } + 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/PlayerOffhandInventory.java b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java index 8d982219c..5ba4a6583 100644 --- a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java +++ b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java @@ -22,8 +22,6 @@ public class PlayerOffhandInventory extends BaseInventory { /** * Items that can be put to offhand inventory on Bedrock Edition */ - //private static final IntSet OFFHAND_ITEMS = new IntOpenHashSet(Arrays.asList(ItemID.SHIELD, ItemID.ARROW, ItemID.TOTEM, ItemID.MAP, ItemID.FIREWORKS, ItemID.NAUTILUS_SHELL, ItemID.SPARKLER)); - public PlayerOffhandInventory(EntityHumanType holder) { super(holder, InventoryType.OFFHAND); } @@ -101,8 +99,7 @@ public EntityHuman getHolder() { @Override public boolean allowedToAdd(Item item) { - //return OFFHAND_ITEMS.contains(item.getId()); - return true; + return item == null || item.isNull() || item.canBePutInOffhandSlot(); } @Override @@ -137,6 +134,10 @@ public boolean setItem(int index, Item item, boolean send) { item = ev2.getNewItem(); } + if (item instanceof cn.nukkit.item.ItemBundle bundle) { + ensureUniqueBundleId(index, bundle); + } + this.slots.put(index, item.clone()); this.onSlotChange(index, oldItem, send); return true; diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java index 33e11629b..179463d75 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java @@ -1,6 +1,7 @@ package cn.nukkit.inventory.request; import cn.nukkit.inventory.Inventory; +import cn.nukkit.item.Item; import cn.nukkit.network.protocol.types.inventory.itemstack.request.ItemStackRequest; import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer; import lombok.Getter; @@ -23,7 +24,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<>(); + private final Map> pluginModifiedSlots = new LinkedHashMap<>(); public ItemStackRequestContext(ItemStackRequest itemStackRequest) { this.itemStackRequest = itemStackRequest; @@ -61,14 +62,20 @@ public boolean commit() { } } - public void addPluginModifiedInventory(Inventory inventory) { - if (inventory != null) { - this.pluginModifiedInventories.add(inventory); + public void addPluginModifiedSlots(Inventory inventory, Map slots) { + Inventory canonical = ItemStackRequestHandler.canonicalizeInventory(inventory); + if (canonical != null && slots != null && !slots.isEmpty()) { + Map modifiedSlots = this.pluginModifiedSlots + .computeIfAbsent(canonical, ignored -> new LinkedHashMap<>()); + for (var entry : slots.entrySet()) { + Item item = entry.getValue(); + modifiedSlots.put(entry.getKey(), item == null ? Item.get(Item.AIR) : item.clone()); + } } } - public Set getPluginModifiedInventories() { - return this.pluginModifiedInventories; + public Map> getPluginModifiedSlots() { + return this.pluginModifiedSlots; } public 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 73dd51246..705b314fb 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java @@ -131,7 +131,7 @@ public static void handleRequests(Player player, List requests } if (error) { - rollbackSnapshots(snapshots, context.getPluginModifiedInventories()); + rollbackSnapshots(snapshots, context.getPluginModifiedSlots()); resyncActor(player, snapshots.keySet()); } @@ -197,7 +197,7 @@ private static boolean writesCreatedOutput(ItemStackRequestAction action) { || action instanceof CraftResultsDeprecatedAction; } - private static Inventory canonicalizeInventory(Inventory inventory) { + static Inventory canonicalizeInventory(Inventory inventory) { if (inventory instanceof PlayerUIComponent component && component.getHolder() instanceof Player player) { return player.getUIInventory(); } @@ -224,13 +224,11 @@ private static Map copyContents(Inventory inventory) { return snapshot; } - private static void rollbackSnapshots(Map> snapshots, Set pluginModifiedInventories) { + private static void rollbackSnapshots(Map> snapshots, + Map> pluginModifiedSlots) { for (var entry : snapshots.entrySet()) { - Inventory canonical = canonicalizeInventory(entry.getKey()); - if (canonical != null && pluginModifiedInventories.contains(canonical)) { - continue; - } restoreInventory(entry.getKey(), entry.getValue()); + replayPluginModifiedSlots(entry.getKey(), pluginModifiedSlots); } } @@ -264,6 +262,27 @@ private static void restoreInventory(Inventory inventory, Map sna } } + private static void replayPluginModifiedSlots(Inventory inventory, Map> pluginModifiedSlots) { + Inventory canonical = canonicalizeInventory(inventory); + if (canonical == null || pluginModifiedSlots == null) { + return; + } + + Map modifiedSlots = pluginModifiedSlots.get(canonical); + if (modifiedSlots == null || modifiedSlots.isEmpty()) { + return; + } + + for (var entry : modifiedSlots.entrySet()) { + Item item = entry.getValue(); + if (item == null || item.isNull() || item.getCount() <= 0) { + canonical.clear(entry.getKey(), false); + } else { + canonical.setItem(entry.getKey(), item.clone(), false); + } + } + } + private static void resyncActor(Player actor, Collection inventories) { actor.getCursorInventory().sendContents(actor); actor.sendAllInventories(); diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java index b34aee356..c47c0396a 100644 --- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -5,14 +5,12 @@ 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.*; 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.level.Sound; 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; @@ -50,6 +48,10 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon return context.error(); } + if (sameInventorySlot(srcInv, srcSlot, dstInv, dstSlot)) { + return context.error(); + } + if (count <= 0) { return context.error(); } @@ -155,6 +157,7 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon Item originalSourceItem = sourceItem.clone(); if (!dstInv.setItem(dstSlot, newDest, sendDest)) { + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT_FAIL); return context.error(); } @@ -176,6 +179,9 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon srcInv.setItem(srcSlot, originalSourceItem, sendSource); } + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT); + playBundleSound(player, srcInv, Sound.BUNDLE_REMOVE_ONE); + List containers = new ArrayList<>(); containers.add(buildContainer(srcInv, srcSlot, src)); if (!sameNetworkSlot(src, dst)) { @@ -233,6 +239,7 @@ private ActionResponse transferCreativeCreatedOutput(T action, Player player, It Item originalDestItem = destItem.clone(); if (!dstInv.setItem(dstSlot, newDest, sendDest)) { + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT_FAIL); return context.error(); } @@ -248,9 +255,19 @@ private ActionResponse transferCreativeCreatedOutput(T action, Player player, It return context.error(); } + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT); + playBundleSound(player, srcInv, Sound.BUNDLE_REMOVE_ONE); + return context.success(List.of(buildContainer(dstInv, dstSlot, dst))); } + private static void playBundleSound(Player player, Inventory inventory, Sound sound) { + if (!(inventory instanceof BundleInventory) || player.getLevel() == null) { + return; + } + player.getLevel().addSound(player, sound); + } + private static boolean isEquipmentSlot(ContainerSlotType type) { return type == ContainerSlotType.OFFHAND || type == ContainerSlotType.ARMOR; } @@ -261,6 +278,9 @@ private static boolean isEquipmentSlot(ContainerSlotType type) { * inventories accept any item. */ static boolean isSlotCompatible(Inventory inventory, int slot, Item item) { + if (inventory instanceof PlayerOffhandInventory) { + return item == null || item.isNull() || item.canBePutInOffhandSlot(); + } if (inventory instanceof PlayerInventory playerInv) { int size = playerInv.getSize(); if (slot == size) { @@ -330,6 +350,29 @@ static boolean sameNetworkSlot(ItemStackRequestSlotData first, ItemStackRequestS && Objects.equals(first.getDynamicId(), second.getDynamicId()); } + private static boolean sameInventorySlot(Inventory first, int firstSlot, Inventory second, int secondSlot) { + Inventory firstCanonical = ItemStackRequestHandler.canonicalizeInventory(first); + Inventory secondCanonical = ItemStackRequestHandler.canonicalizeInventory(second); + int firstCanonicalSlot = canonicalSlot(first, firstSlot); + int secondCanonicalSlot = canonicalSlot(second, secondSlot); + return firstCanonical != null + && firstCanonical == secondCanonical + && firstCanonicalSlot == secondCanonicalSlot; + } + + private static int canonicalSlot(Inventory inventory, int slot) { + if (inventory instanceof PlayerCursorInventory) { + return 0; + } + if (inventory instanceof BigCraftingGrid) { + return slot + 32; + } + if (inventory instanceof CraftingGrid) { + return slot + 28; + } + return slot; + } + /** * InventoryTransaction subclass used solely to emit * {@link InventoryTransactionEvent} for server-authoritative item stack @@ -368,28 +411,27 @@ public boolean execute() { return false; } - // Snapshot affected slots before firing the event so we can tell - // whether a plugin actually mutated the inventory when cancelling. + // Snapshot complete affected inventories before firing the event so + // cancellation handlers can intentionally modify any slot without + // the outer ItemStackRequest rollback undoing those plugin changes. 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()); + Inventory canonical = ItemStackRequestHandler.canonicalizeInventory(inv); + if (canonical != null && !preStates.containsKey(canonical)) { + preStates.put(canonical, copyContents(canonical)); + } } } if (!callExecuteEvent()) { if (context != null) { - for (InventoryAction action : this.actions) { - if (action instanceof SlotChangeAction slotChange) { - 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); - } + for (Map.Entry> entry : preStates.entrySet()) { + Inventory inv = entry.getKey(); + Map modifiedSlots = changedSlots(entry.getValue(), copyContents(inv)); + if (!modifiedSlots.isEmpty()) { + context.addPluginModifiedSlots(inv, modifiedSlots); } } } @@ -398,6 +440,40 @@ public boolean execute() { this.hasExecuted = true; return true; } + + 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 Map changedSlots(Map before, Map after) { + LinkedHashMap changed = new LinkedHashMap<>(); + LinkedHashSet slots = new LinkedHashSet<>(before.keySet()); + slots.addAll(after.keySet()); + for (int slot : slots) { + Item previous = before.get(slot); + Item current = after.get(slot); + if (!itemsEqual(previous, current)) { + changed.put(slot, current == null ? Item.get(Item.AIR) : current.clone()); + } + } + return changed; + } + + private static boolean itemsEqual(Item first, Item second) { + boolean firstEmpty = first == null || first.isNull() || first.getCount() <= 0; + boolean secondEmpty = second == null || second.isNull() || second.getCount() <= 0; + if (firstEmpty || secondEmpty) { + return firstEmpty == secondEmpty; + } + return first.equalsExact(second); + } } static ItemStackResponseContainer buildContainer(Inventory inv, int internalSlot, ItemStackRequestSlotData slotData) { diff --git a/src/main/java/cn/nukkit/item/Item.java b/src/main/java/cn/nukkit/item/Item.java index b8b26b92c..9bf5d65a7 100644 --- a/src/main/java/cn/nukkit/item/Item.java +++ b/src/main/java/cn/nukkit/item/Item.java @@ -1660,6 +1660,17 @@ public boolean isShield() { return false; } + public boolean canBePutInOffhandSlot() { + return this.isShield() + || this.id == ARROW + || this.id == TOTEM + || this.id == MAP + || this.id == EMPTY_MAP + || this.id == FIREWORKS + || this.id == NAUTILUS_SHELL + || this.id == SPARKLER; + } + public boolean isHelmet() { return false; } diff --git a/src/main/java/cn/nukkit/item/ItemBundle.java b/src/main/java/cn/nukkit/item/ItemBundle.java index 035cad52f..21a18fdaa 100644 --- a/src/main/java/cn/nukkit/item/ItemBundle.java +++ b/src/main/java/cn/nukkit/item/ItemBundle.java @@ -1,8 +1,11 @@ package cn.nukkit.item; import cn.nukkit.GameVersion; +import cn.nukkit.Player; import cn.nukkit.inventory.BundleInventory; import cn.nukkit.inventory.InventoryHolder; +import cn.nukkit.level.Sound; +import cn.nukkit.math.Vector3; import cn.nukkit.nbt.NBTIO; import cn.nukkit.nbt.tag.CompoundTag; import cn.nukkit.nbt.tag.ListTag; @@ -20,6 +23,8 @@ public class ItemBundle extends StringItemBase implements InventoryHolder { private static final AtomicInteger NEXT_BUNDLE_ID = new AtomicInteger(1); + private int bundleId; + private int sourceBundleId; private BundleInventory inventory; public ItemBundle() { @@ -44,6 +49,17 @@ public int getBundleId() { return ensureBundleTag().getInt(TAG_BUNDLE_ID); } + public boolean matchesBundleIdentity(int bundleId) { + if (bundleId <= 0) { + return false; + } + if (this.bundleId == bundleId || this.sourceBundleId == bundleId) { + return true; + } + CompoundTag tag = this.hasCompoundTag() ? this.getNamedTag() : null; + return tag != null && tag.contains(TAG_BUNDLE_ID) && tag.getInt(TAG_BUNDLE_ID) == bundleId; + } + @Override public BundleInventory getInventory() { if (this.inventory == null) { @@ -70,11 +86,40 @@ public void saveNBT() { 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)); + int tagBundleId = tag.getInt(TAG_BUNDLE_ID); + NEXT_BUNDLE_ID.updateAndGet(current -> Math.max(current, tagBundleId + 1)); + if (this.bundleId <= 0) { + this.sourceBundleId = tagBundleId; + this.bundleId = NEXT_BUNDLE_ID.getAndIncrement(); + } } return this; } + @Override + public boolean onClickAir(Player player, Vector3 directionVector) { + Map.Entry entry = this.getInventory().getContents().entrySet().stream() + .filter(e -> e.getValue() != null && !e.getValue().isNull()) + .min(Map.Entry.comparingByKey()) + .orElse(null); + if (entry == null) { + return false; + } + + Item item = entry.getValue().clone(); + if (!this.getInventory().clear(entry.getKey(), false)) { + return false; + } + if (!player.dropItem(item)) { + this.getInventory().setItem(entry.getKey(), entry.getValue(), false); + return false; + } + if (player.getLevel() != null) { + player.getLevel().addSound(player, Sound.BUNDLE_DROP_CONTENTS); + } + return true; + } + @Override public ItemBundle clone() { ItemBundle cloned = (ItemBundle) super.clone(); @@ -82,12 +127,32 @@ public ItemBundle clone() { return cloned; } + public void assignNewBundleId() { + CompoundTag tag = this.hasCompoundTag() ? this.getNamedTag() : new CompoundTag(); + int oldBundleId = this.bundleId > 0 ? this.bundleId + : tag.contains(TAG_BUNDLE_ID) ? tag.getInt(TAG_BUNDLE_ID) : this.sourceBundleId; + this.sourceBundleId = oldBundleId; + this.bundleId = NEXT_BUNDLE_ID.getAndIncrement(); + tag.putInt(TAG_BUNDLE_ID, this.bundleId); + if (!tag.containsList(TAG_STORAGE_ITEM_COMPONENT_CONTENT)) { + tag.putList(new ListTag<>(TAG_STORAGE_ITEM_COMPONENT_CONTENT)); + } + this.setNamedTag(tag); + } + 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()); + if (this.bundleId <= 0) { + if (this.sourceBundleId <= 0 && tag.contains(TAG_BUNDLE_ID)) { + this.sourceBundleId = tag.getInt(TAG_BUNDLE_ID); + } + this.bundleId = NEXT_BUNDLE_ID.getAndIncrement(); + tag.putInt(TAG_BUNDLE_ID, this.bundleId); + dirty = true; + } else if (!tag.contains(TAG_BUNDLE_ID) || tag.getInt(TAG_BUNDLE_ID) != this.bundleId) { + tag.putInt(TAG_BUNDLE_ID, this.bundleId); dirty = true; } else { NEXT_BUNDLE_ID.updateAndGet(current -> Math.max(current, tag.getInt(TAG_BUNDLE_ID) + 1)); diff --git a/src/main/java/cn/nukkit/network/process/DataPacketManager.java b/src/main/java/cn/nukkit/network/process/DataPacketManager.java index 1a57f15b9..27ce02f9b 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_0, + ProtocolInfo.v1_16_100, ItemStackRequestProcessor.INSTANCE ); diff --git a/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java b/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java index 190bb08d0..755dedfb2 100644 --- a/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java +++ b/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java @@ -401,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(this.isInventoryServerAuthoritative); + this.putBoolean(protocol >= ProtocolInfo.v1_16_100 && 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/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionType.java b/src/main/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionType.java index 573079003..605fe2693 100644 --- a/src/main/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionType.java +++ b/src/main/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionType.java @@ -51,6 +51,9 @@ public static ItemStackRequestActionType fromId(int id) { public static ItemStackRequestActionType fromId(int id, GameVersion gameVersion) { int protocol = gameVersion.getProtocol(); + if (protocol >= ProtocolInfo.v1_21_40) { + return fromId(id); + } if (protocol >= ProtocolInfo.v1_21_20) { return switch (id) { case 7, 8 -> null; diff --git a/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java index 4623b183d..56bd2fff9 100644 --- a/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java +++ b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java @@ -1,13 +1,28 @@ package cn.nukkit.inventory; +import cn.nukkit.GameVersion; import cn.nukkit.MockServer; -import cn.nukkit.item.Item; -import cn.nukkit.item.ItemBundle; +import cn.nukkit.Player; +import cn.nukkit.item.*; +import cn.nukkit.level.Level; +import cn.nukkit.level.Sound; +import cn.nukkit.math.Vector3; import cn.nukkit.nbt.tag.CompoundTag; import cn.nukkit.nbt.tag.ListTag; +import cn.nukkit.network.protocol.DataPacket; +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 org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -50,4 +65,147 @@ void rejectsItemsThatWouldOverfillTheBundle() { assertFalse(inventory.setItem(0, Item.get(Item.DIRT, 0, 65), false)); assertTrue(inventory.isEmpty()); } + + @Test + void rejectsShulkerBoxesLikeOtherContainerInventories() { + ItemBundle bundle = new ItemBundle(); + BundleInventory inventory = bundle.getInventory(); + + assertFalse(inventory.setItem(0, Item.get(Item.SHULKER_BOX, 0, 1), false)); + assertFalse(inventory.setItem(0, Item.get(Item.UNDYED_SHULKER_BOX, 0, 1), false)); + assertTrue(inventory.isEmpty()); + } + + @Test + void nestedBundleWeightIncludesInnerContentsAndBaseCost() { + ItemBundle outer = new ItemBundle(); + ItemBundle inner = new ItemBundle(); + assertTrue(inner.getInventory().setItem(0, Item.get(Item.DIRT, 0, 16), false)); + + BundleInventory outerInventory = outer.getInventory(); + assertTrue(outerInventory.setItem(0, inner, false)); + + assertEquals(20, outerInventory.getWeight()); + assertTrue(outerInventory.setItem(1, Item.get(Item.STONE, 0, 44), false)); + assertFalse(outerInventory.setItem(2, Item.get(Item.DIRT, 0, 1), false)); + } + + @Test + void rejectsBundleCycles() { + ItemBundle outer = new ItemBundle(); + ItemBundle inner = new ItemBundle(); + + assertFalse(outer.getInventory().setItem(0, outer, false)); + assertTrue(outer.getInventory().setItem(0, inner, false)); + assertFalse(inner.getInventory().setItem(0, outer, false)); + } + + @Test + void persistedNamedTagRestoresStoredContentsOnFreshBundleInstance() { + ItemBundle source = new ItemBundle(); + assertTrue(source.getInventory().setItem(5, Item.get(Item.APPLE, 0, 7), false)); + + ItemBundle restored = new ItemBundle(); + restored.setNamedTag(source.getNamedTag().copy()); + + assertNotEquals(source.getBundleId(), restored.getBundleId()); + assertEquals(Item.APPLE, restored.getInventory().getItem(5).getId()); + assertEquals(7, restored.getInventory().getItem(5).getCount()); + } + + @Test + void clickAirDropsStoredItemAndUpdatesNbt() { + Player player = Mockito.mock(Player.class); + Level level = Mockito.mock(Level.class); + Mockito.when(player.dropItem(Mockito.any(Item.class))).thenReturn(true); + Mockito.when(player.getLevel()).thenReturn(level); + + ItemBundle bundle = new ItemBundle(); + BundleInventory inventory = bundle.getInventory(); + assertTrue(inventory.setItem(3, Item.get(Item.DIRT, 0, 5), false)); + + assertTrue(bundle.onClickAir(player, new Vector3(0, 0, 1))); + + ArgumentCaptor dropped = ArgumentCaptor.forClass(Item.class); + Mockito.verify(player).dropItem(dropped.capture()); + assertEquals(Item.DIRT, dropped.getValue().getId()); + assertEquals(5, dropped.getValue().getCount()); + assertTrue(inventory.getItem(3).isNull()); + assertEquals(0, bundle.getNamedTag() + .getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class) + .size()); + Mockito.verify(level).addSound(Mockito.eq(player), Mockito.eq(Sound.BUNDLE_DROP_CONTENTS)); + } + + @Test + void sendsDynamicContainerPacketsOnlyToBundleCapableProtocols() { + Player oldPlayer = Mockito.mock(Player.class); + oldPlayer.spawned = true; + oldPlayer.protocol = ProtocolInfo.v1_21_20; + Player newPlayer = Mockito.mock(Player.class); + newPlayer.spawned = true; + newPlayer.protocol = ProtocolInfo.v1_21_40; + + ItemBundle bundle = new ItemBundle(); + assertTrue(bundle.getInventory().setItem(0, Item.get(Item.STONE, 0, 1), false)); + + bundle.getInventory().sendContents(oldPlayer, newPlayer); + Mockito.verify(oldPlayer, Mockito.never()).dataPacket(Mockito.any(DataPacket.class)); + InventoryContentPacket content = capturePacket(newPlayer, InventoryContentPacket.class); + assertEquals(BundleInventory.DYNAMIC_REGISTRY_WINDOW_ID, content.inventoryId); + assertEquals(ContainerSlotType.DYNAMIC_CONTAINER, content.containerNameData.getContainer()); + assertEquals(bundle.getBundleId(), content.containerNameData.getDynamicId()); + assertEquals(Item.BUNDLE, content.storageItem.getNamespaceId()); + + Mockito.reset(newPlayer); + newPlayer.spawned = true; + newPlayer.protocol = ProtocolInfo.v1_21_40; + bundle.getInventory().sendSlot(0, oldPlayer, newPlayer); + Mockito.verify(oldPlayer, Mockito.never()).dataPacket(Mockito.any(DataPacket.class)); + InventorySlotPacket slot = capturePacket(newPlayer, InventorySlotPacket.class); + assertEquals(BundleInventory.DYNAMIC_REGISTRY_WINDOW_ID, slot.inventoryId); + assertEquals(ContainerSlotType.DYNAMIC_CONTAINER, slot.containerNameData.getContainer()); + assertEquals(bundle.getBundleId(), slot.containerNameData.getDynamicId()); + assertEquals(Item.BUNDLE, slot.storageItem.getNamespaceId()); + } + + @ParameterizedTest + @MethodSource("coloredBundleVariants") + void coloredBundleVariantsShareBundleInventoryBehavior(ItemBundle bundle) { + assertEquals(1, bundle.getMaxStackSize()); + assertTrue(bundle.isSupportedOn(GameVersion.V1_21_40)); + assertSame(bundle, bundle.getInventory().getHolder()); + + assertTrue(bundle.getInventory().setItem(0, Item.get(Item.STONE, 0, 1), false)); + assertEquals(1, bundle.getNamedTag() + .getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class) + .size()); + } + + static Stream coloredBundleVariants() { + return Stream.of( + new ItemBundleWhite(), + new ItemBundleLightGray(), + new ItemBundleGray(), + new ItemBundleBlack(), + new ItemBundleBrown(), + new ItemBundleRed(), + new ItemBundleOrange(), + new ItemBundleYellow(), + new ItemBundleLime(), + new ItemBundleGreen(), + new ItemBundleCyan(), + new ItemBundleLightBlue(), + new ItemBundleBlue(), + new ItemBundlePurple(), + new ItemBundleMagenta(), + new ItemBundlePink() + ); + } + + private static T capturePacket(Player player, Class type) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataPacket.class); + Mockito.verify(player).dataPacket(captor.capture()); + return assertInstanceOf(type, captor.getValue()); + } } diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 90f374738..86eaba95e 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -5,7 +5,9 @@ import cn.nukkit.Player; import cn.nukkit.blockentity.BlockEntityChest; import cn.nukkit.entity.passive.EntityVillager; +import cn.nukkit.event.Event; import cn.nukkit.event.inventory.InventoryEvent; +import cn.nukkit.event.inventory.InventoryTransactionEvent; import cn.nukkit.event.inventory.ItemStackRequestActionEvent; import cn.nukkit.inventory.*; import cn.nukkit.inventory.special.FireworkRecipe; @@ -14,10 +16,14 @@ import cn.nukkit.item.Item; import cn.nukkit.item.ItemBundle; import cn.nukkit.item.enchantment.Enchantment; +import cn.nukkit.level.Level; import cn.nukkit.level.Position; +import cn.nukkit.level.Sound; import cn.nukkit.nbt.tag.CompoundTag; import cn.nukkit.nbt.tag.ListTag; import cn.nukkit.nbt.tag.Tag; +import cn.nukkit.network.protocol.DataPacket; +import cn.nukkit.network.protocol.ItemStackResponsePacket; import cn.nukkit.network.protocol.PlayerEnchantOptionsPacket; import cn.nukkit.network.protocol.ProtocolInfo; import cn.nukkit.network.protocol.types.inventory.ContainerSlotType; @@ -28,6 +34,8 @@ 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.ItemStackResponseStatus; +import cn.nukkit.plugin.PluginManager; import cn.nukkit.utils.TradeRecipeBuildUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -127,6 +135,73 @@ void creativeCreatedOutputTakeCanOverwriteDifferentDestinationItem() { assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); } + @Test + void offhandRejectsItemsThatBedrockCannotEquip() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item stone = Item.get(Item.STONE, 0, 1); + stone.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, stone, false)); + stone = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, stone.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertFalse(response.success()); + assertEquals(Item.STONE, inventory.getItem(0).getId()); + assertTrue(offhand.getItem(0).isNull()); + } + + @Test + void offhandAcceptsShield() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item shield = Item.get(Item.SHIELD, 0, 1); + shield.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, shield, false)); + shield = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, shield.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertTrue(inventory.getItem(0).isNull()); + assertEquals(Item.SHIELD, offhand.getItem(0).getId()); + } + + @Test + void offhandInventoryRejectsNonBedrockOffhandItemsDirectly() { + Player player = mockPlayer(); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + + assertFalse(offhand.setItem(0, Item.get(Item.STONE, 0, 1), false)); + assertTrue(offhand.getItem(0).isNull()); + assertTrue(offhand.setItem(0, Item.get(Item.SHIELD, 0, 1), false)); + assertEquals(Item.SHIELD, offhand.getItem(0).getId()); + } + @Test void suppressedDestroyStillMutatesInventory() { Player player = mockPlayer(); @@ -356,6 +431,200 @@ void eventOnlyTransactionRejectsBindingCurseArmorRemoval() { assertFalse(transaction.execute(), "SAI compatibility transaction must keep the legacy binding curse guard"); } + @Test + void cancelledTransactionKeepsPluginCountChange() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(player.getServer().getPluginManager()).thenReturn(pluginManager); + Mockito.doAnswer(invocation -> { + Event event = invocation.getArgument(0); + if (event instanceof InventoryTransactionEvent transactionEvent) { + inventory.setItem(0, Item.get(Item.STONE, 0, 3), false); + transactionEvent.setCancelled(true); + } + return null; + }).when(pluginManager).callEvent(any(Event.class)); + + Item source = Item.get(Item.STONE, 0, 5); + source.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, source, false)); + source = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 1, 0, null) + ); + ItemStackRequest request = new ItemStackRequest(7, new ItemStackRequestAction[]{action}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(Item.STONE, inventory.getItem(0).getId()); + assertEquals(3, inventory.getItem(0).getCount(), "plugin count-only change must survive SAI error rollback"); + assertTrue(inventory.getItem(1).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.ERROR, response.entries.get(0).getResult()); + } + + @Test + void transferToSameSlotIsRejectedWithoutMutatingInventory() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + + Item source = Item.get(Item.STONE, 0, 5); + source.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, source, false)); + source = inventory.getItem(0); + + TakeAction action = new TakeAction( + 5, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null) + ); + ItemStackRequest request = new ItemStackRequest(8, new ItemStackRequestAction[]{action}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(Item.STONE, inventory.getItem(0).getId()); + assertEquals(5, inventory.getItem(0).getCount()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.ERROR, response.entries.get(0).getResult()); + } + + @Test + void cancelledTransactionKeepsPluginChangesOutsideTransactionSlots() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(player.getServer().getPluginManager()).thenReturn(pluginManager); + Mockito.doAnswer(invocation -> { + Event event = invocation.getArgument(0); + if (event instanceof InventoryTransactionEvent transactionEvent) { + inventory.setItem(2, Item.get(Item.DIAMOND, 0, 4), false); + transactionEvent.setCancelled(true); + } + return null; + }).when(pluginManager).callEvent(any(Event.class)); + + Item source = Item.get(Item.STONE, 0, 5); + source.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, source, false)); + source = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 1, 0, null) + ); + ItemStackRequest request = new ItemStackRequest(9, new ItemStackRequestAction[]{action}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(Item.STONE, inventory.getItem(0).getId()); + assertEquals(5, inventory.getItem(0).getCount()); + assertTrue(inventory.getItem(1).isNull()); + assertEquals(Item.DIAMOND, inventory.getItem(2).getId()); + assertEquals(4, inventory.getItem(2).getCount(), "plugin changes in other slots must survive SAI error rollback"); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.ERROR, response.entries.get(0).getResult()); + } + + @Test + void cancelledLaterActionRollsBackEarlierActionButKeepsPluginSlotChanges() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(player.getServer().getPluginManager()).thenReturn(pluginManager); + Mockito.doAnswer(invocation -> { + Event event = invocation.getArgument(0); + if (event instanceof InventoryTransactionEvent transactionEvent + && !inventory.getItem(1).isNull()) { + inventory.setItem(2, Item.get(Item.DIAMOND, 0, 4), false); + transactionEvent.setCancelled(true); + } + return null; + }).when(pluginManager).callEvent(any(Event.class)); + + Item source = Item.get(Item.STONE, 0, 5); + source.autoAssignStackNetworkId(); + Item secondSource = Item.get(Item.DIRT, 0, 3); + secondSource.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, source, false)); + assertTrue(inventory.setItem(3, secondSource, false)); + source = inventory.getItem(0); + secondSource = inventory.getItem(3); + + TakeAction firstAction = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 1, 0, null) + ); + TakeAction secondAction = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 3, secondSource.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 4, 0, null) + ); + ItemStackRequest request = new ItemStackRequest(10, new ItemStackRequestAction[]{firstAction, secondAction}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(Item.STONE, inventory.getItem(0).getId()); + assertEquals(5, inventory.getItem(0).getCount()); + assertTrue(inventory.getItem(1).isNull(), "earlier successful actions must roll back on request error"); + assertEquals(Item.DIAMOND, inventory.getItem(2).getId()); + assertEquals(4, inventory.getItem(2).getCount(), "plugin slot changes must survive rollback"); + assertEquals(Item.DIRT, inventory.getItem(3).getId()); + assertEquals(3, inventory.getItem(3).getCount()); + assertTrue(inventory.getItem(4).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.ERROR, response.entries.get(0).getResult()); + } + @Test void dynamicContainerTransferRespondsForDifferentDynamicIdsWithSameSlot() { Player player = mockPlayer(); @@ -386,6 +655,111 @@ void dynamicContainerTransferRespondsForDifferentDynamicIdsWithSameSlot() { assertEquals(destinationBundle.getBundleId(), response.containers().get(1).getContainerName().getDynamicId()); } + @Test + void placeInItemContainerStoresItemInBundleAndPersistsNbt() { + Player player = mockPlayer(); + Level level = Mockito.mock(Level.class); + PlayerInventory inventory = new PlayerInventory(player); + ItemBundle bundle = new ItemBundle(); + Mockito.when(player.getLevel()).thenReturn(level); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(inventory); + + Item dirt = Item.get(Item.DIRT, 0, 32); + dirt.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, dirt, false)); + assertTrue(inventory.setItem(1, bundle, false)); + ItemBundle storedBundle = (ItemBundle) inventory.getUnclonedItem(1); + int bundleId = storedBundle.getBundleId(); + dirt = inventory.getItem(0); + + PlaceInItemContainerAction action = new PlaceInItemContainerAction( + 16, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, dirt.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.DYNAMIC_CONTAINER, 0, 0, bundleId) + ); + + ActionResponse response = new PlaceInItemContainerActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(16, inventory.getItem(0).getCount()); + assertEquals(Item.DIRT, storedBundle.getInventory().getItem(0).getId()); + assertEquals(16, storedBundle.getInventory().getItem(0).getCount()); + assertEquals(1, storedBundle.getNamedTag() + .getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class) + .size()); + Mockito.verify(level).addSound(player, Sound.BUNDLE_INSERT); + } + + @Test + void placeInItemContainerRejectsPuttingBundleInsideItself() { + Player player = mockPlayer(); + Level level = Mockito.mock(Level.class); + PlayerInventory inventory = new PlayerInventory(player); + ItemBundle bundle = new ItemBundle(); + Mockito.when(player.getLevel()).thenReturn(level); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(inventory); + + assertTrue(inventory.setItem(0, bundle, false)); + ItemBundle storedBundle = (ItemBundle) inventory.getUnclonedItem(0); + int bundleId = storedBundle.getBundleId(); + int stackNetworkId = inventory.getItem(0).getStackNetId(); + + PlaceInItemContainerAction action = new PlaceInItemContainerAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, stackNetworkId, null), + new ItemStackRequestSlotData(ContainerSlotType.DYNAMIC_CONTAINER, 0, 0, bundleId) + ); + + ActionResponse response = new PlaceInItemContainerActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertFalse(response.success()); + assertSame(storedBundle, inventory.getUnclonedItem(0)); + assertTrue(storedBundle.getInventory().isEmpty()); + Mockito.verify(level).addSound(player, Sound.BUNDLE_INSERT_FAIL); + } + + @Test + void takeFromItemContainerMovesItemOutOfBundleAndPersistsNbt() { + Player player = mockPlayer(); + Level level = Mockito.mock(Level.class); + PlayerInventory inventory = new PlayerInventory(player); + ItemBundle bundle = new ItemBundle(); + Mockito.when(player.getLevel()).thenReturn(level); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(inventory); + + assertTrue(inventory.setItem(1, bundle, false)); + ItemBundle storedBundle = (ItemBundle) inventory.getUnclonedItem(1); + int bundleId = storedBundle.getBundleId(); + Item dirt = Item.get(Item.DIRT, 0, 10); + dirt.autoAssignStackNetworkId(); + assertTrue(storedBundle.getInventory().setItem(0, dirt, false)); + dirt = storedBundle.getInventory().getItem(0); + + TakeFromItemContainerAction action = new TakeFromItemContainerAction( + 6, + new ItemStackRequestSlotData(ContainerSlotType.DYNAMIC_CONTAINER, 0, dirt.getStackNetId(), bundleId), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, 0, null) + ); + + ActionResponse response = new TakeFromItemContainerActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(Item.DIRT, inventory.getItem(0).getId()); + assertEquals(6, inventory.getItem(0).getCount()); + assertEquals(4, storedBundle.getInventory().getItem(0).getCount()); + ListTag storedItems = storedBundle.getNamedTag() + .getList(ItemBundle.TAG_STORAGE_ITEM_COMPONENT_CONTENT, CompoundTag.class); + assertEquals(1, storedItems.size()); + assertEquals(4, storedItems.get(0).getByte("Count")); + Mockito.verify(level).addSound(player, Sound.BUNDLE_REMOVE_ONE); + } + @Test void itemStackRequestActionEventIsNotInventoryEvent() { assertFalse(InventoryEvent.class.isAssignableFrom(ItemStackRequestActionEvent.class)); @@ -526,6 +900,18 @@ private static ItemStackRequestContext context(ItemStackRequestAction... actions )); } + private static T capturePacket(Player player, Class type) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataPacket.class); + Mockito.verify(player, atLeastOnce()).dataPacket(captor.capture()); + for (DataPacket packet : captor.getAllValues()) { + if (type.isInstance(packet)) { + return type.cast(packet); + } + } + fail("Expected packet " + type.getSimpleName()); + return null; + } + private static List cloneItems(List items) { List cloned = new ArrayList<>(items.size()); for (Item item : items) { diff --git a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java index 17e2df3f2..69d8e687a 100644 --- a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java +++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java @@ -3,6 +3,7 @@ import cn.nukkit.MockServer; import cn.nukkit.Player; import cn.nukkit.inventory.*; +import cn.nukkit.item.Item; import cn.nukkit.item.ItemBundle; import cn.nukkit.network.protocol.types.inventory.ContainerSlotType; import org.junit.jupiter.api.BeforeAll; @@ -92,4 +93,76 @@ void dynamicContainerCanResolveNestedBundleFromAccessibleInventories() { assertEquals(innerBundle.getBundleId(), bundleInventory.getHolder().getBundleId()); assertEquals(innerBundle.getInventory().getContents(), bundleInventory.getContents()); } + + @Test + void clonedBundlesReceiveDistinctDynamicContainerIdsInSameInventory() { + Player player = Mockito.mock(Player.class); + PlayerOffhandInventory offhand = Mockito.mock(PlayerOffhandInventory.class); + PlayerCursorInventory cursor = Mockito.mock(PlayerCursorInventory.class); + CraftingGrid craftingGrid = Mockito.mock(CraftingGrid.class); + + Player holder = Mockito.mock(Player.class); + PlayerInventory realInventory = new PlayerInventory(holder); + + ItemBundle firstBundle = new ItemBundle(); + firstBundle.getInventory().setItem(0, Item.get(Item.STONE, 0, 1), false); + ItemBundle secondBundle = firstBundle.clone(); + assertTrue(realInventory.setItem(0, firstBundle, false)); + assertTrue(realInventory.setItem(1, secondBundle, false)); + firstBundle = (ItemBundle) realInventory.getUnclonedItem(0); + secondBundle = (ItemBundle) realInventory.getUnclonedItem(1); + + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(realInventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getCursorInventory()).thenReturn(cursor); + Mockito.when(player.getCraftingGrid()).thenReturn(craftingGrid); + Mockito.when(offhand.getContents()).thenReturn(Map.of()); + Mockito.when(cursor.getContents()).thenReturn(Map.of()); + Mockito.when(craftingGrid.getContents()).thenReturn(Map.of()); + + assertNotEquals(firstBundle.getBundleId(), secondBundle.getBundleId()); + + Inventory resolved = NetworkMapping.getInventory(player, ContainerSlotType.DYNAMIC_CONTAINER, secondBundle.getBundleId()); + BundleInventory bundleInventory = assertInstanceOf(BundleInventory.class, resolved); + + assertSame(secondBundle.getInventory(), bundleInventory); + } + + @Test + void clonedBundleIdDoesNotCollideWithNestedBundleInSameAccessibleInventory() { + Player player = Mockito.mock(Player.class); + PlayerOffhandInventory offhand = Mockito.mock(PlayerOffhandInventory.class); + PlayerCursorInventory cursor = Mockito.mock(PlayerCursorInventory.class); + CraftingGrid craftingGrid = Mockito.mock(CraftingGrid.class); + + Player holder = Mockito.mock(Player.class); + PlayerInventory realInventory = new PlayerInventory(holder); + + ItemBundle nestedBundle = new ItemBundle(); + ItemBundle outerBundle = new ItemBundle(); + assertTrue(outerBundle.getInventory().setItem(0, nestedBundle, false)); + ItemBundle looseBundle = nestedBundle.clone(); + assertTrue(realInventory.setItem(0, outerBundle, false)); + assertTrue(realInventory.setItem(1, looseBundle, false)); + outerBundle = (ItemBundle) realInventory.getUnclonedItem(0); + looseBundle = (ItemBundle) realInventory.getUnclonedItem(1); + nestedBundle = (ItemBundle) outerBundle.getInventory().getUnclonedItem(0); + + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getInventory()).thenReturn(realInventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getCursorInventory()).thenReturn(cursor); + Mockito.when(player.getCraftingGrid()).thenReturn(craftingGrid); + Mockito.when(offhand.getContents()).thenReturn(Map.of()); + Mockito.when(cursor.getContents()).thenReturn(Map.of()); + Mockito.when(craftingGrid.getContents()).thenReturn(Map.of()); + + assertNotEquals(nestedBundle.getBundleId(), looseBundle.getBundleId()); + + Inventory resolved = NetworkMapping.getInventory(player, ContainerSlotType.DYNAMIC_CONTAINER, looseBundle.getBundleId()); + BundleInventory bundleInventory = assertInstanceOf(BundleInventory.class, resolved); + + assertSame(looseBundle.getInventory(), bundleInventory); + } } diff --git a/src/test/java/cn/nukkit/network/process/DataPacketManagerTest.java b/src/test/java/cn/nukkit/network/process/DataPacketManagerTest.java new file mode 100644 index 000000000..9c2950e50 --- /dev/null +++ b/src/test/java/cn/nukkit/network/process/DataPacketManagerTest.java @@ -0,0 +1,20 @@ +package cn.nukkit.network.process; + +import cn.nukkit.network.protocol.ItemStackRequestPacket; +import cn.nukkit.network.protocol.ProtocolInfo; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataPacketManagerTest { + + @Test + void itemStackRequestProcessorStartsAtV116100() { + DataPacketManager.registerDefaultProcessors(); + + assertFalse(DataPacketManager.canProcess(ProtocolInfo.v1_16_0, ItemStackRequestPacket.class)); + assertFalse(DataPacketManager.canProcess(ProtocolInfo.v1_16_100_52, ItemStackRequestPacket.class)); + assertTrue(DataPacketManager.canProcess(ProtocolInfo.v1_16_100, ItemStackRequestPacket.class)); + } +} diff --git a/src/test/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionTypeTest.java b/src/test/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionTypeTest.java index d0645e98b..dd568da3e 100644 --- a/src/test/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionTypeTest.java +++ b/src/test/java/cn/nukkit/network/protocol/types/inventory/itemstack/request/action/ItemStackRequestActionTypeTest.java @@ -14,5 +14,7 @@ void itemContainerActionIdsRemainDistinctBeforeV712() { assertSame(ItemStackRequestActionType.TAKE_FROM_ITEM_CONTAINER, ItemStackRequestActionType.fromId(8, GameVersion.V1_20_50)); assertNull(ItemStackRequestActionType.fromId(7, GameVersion.V1_21_20)); assertNull(ItemStackRequestActionType.fromId(8, GameVersion.V1_21_20)); + assertSame(ItemStackRequestActionType.PLACE_IN_ITEM_CONTAINER, ItemStackRequestActionType.fromId(7, GameVersion.V1_21_40)); + assertSame(ItemStackRequestActionType.TAKE_FROM_ITEM_CONTAINER, ItemStackRequestActionType.fromId(8, GameVersion.V1_21_40)); } } From 05615180203759ea5c567cceeca41ba3e2025457 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sun, 31 May 2026 17:30:02 +0800 Subject: [PATCH 11/29] fix: CraftResultsDeprecated item instance decoding --- .../java/cn/nukkit/utils/BinaryStream.java | 17 +++- .../decode/MiscDecodeRegressionTest.java | 80 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/main/java/cn/nukkit/utils/BinaryStream.java b/src/main/java/cn/nukkit/utils/BinaryStream.java index d5a180d25..12ea5d25a 100644 --- a/src/main/java/cn/nukkit/utils/BinaryStream.java +++ b/src/main/java/cn/nukkit/utils/BinaryStream.java @@ -712,6 +712,10 @@ public Item getSlot(GameVersion gameVersion) { } private Item getSlotNew(GameVersion gameVersion) { + return this.getSlotNew(gameVersion, false); + } + + private Item getSlotNew(GameVersion gameVersion, boolean instanceItem) { int protocolId = gameVersion.getProtocol(); int runtimeId = this.getVarInt(); if (runtimeId == 0) { @@ -745,7 +749,7 @@ private Item getSlotNew(GameVersion gameVersion) { } int stackNetId = 0; - if (this.getBoolean()) { // hasStackNetId + if (!instanceItem && this.getBoolean()) { // hasStackNetId stackNetId = this.getVarInt(); } @@ -840,7 +844,7 @@ private Item getSlotNew(GameVersion gameVersion) { if (compoundTag.contains(MV_ORIGIN_NBT)) { item.setNamedTag(compoundTag.getCompound(MV_ORIGIN_NBT)); } - if (stackNetId != 0) { + if (!instanceItem && stackNetId != 0) { item.setStackNetId(stackNetId); } return item; @@ -887,7 +891,7 @@ private Item getSlotNew(GameVersion gameVersion) { item.setNamedTag(namedTag); } - if (stackNetId != 0) { + if (!instanceItem && stackNetId != 0) { item.setStackNetId(stackNetId); } return item; @@ -2097,7 +2101,12 @@ protected ItemStackRequestAction readRequestActionData(GameVersion gameVersion, yield new AutoCraftRecipeAction(recipeId, numberOfRequestedCrafts, timesCrafted, ingredients); } case CRAFT_RESULTS_DEPRECATED -> new CraftResultsDeprecatedAction( - getArray(Item.class, (s) -> s.getSlot(gameVersion)), + getArray(Item.class, (s) -> { + if (gameVersion.getProtocol() >= ProtocolInfo.v1_16_220) { + return this.getSlotNew(gameVersion, true); + } + return this.getSlot(gameVersion); + }), getByte() & 0xFF ); case MINE_BLOCK -> new MineBlockAction(getVarInt(), getVarInt(), getVarInt()); diff --git a/src/test/java/cn/nukkit/network/protocol/regression/decode/MiscDecodeRegressionTest.java b/src/test/java/cn/nukkit/network/protocol/regression/decode/MiscDecodeRegressionTest.java index a35725ace..c6c1cfdb6 100644 --- a/src/test/java/cn/nukkit/network/protocol/regression/decode/MiscDecodeRegressionTest.java +++ b/src/test/java/cn/nukkit/network/protocol/regression/decode/MiscDecodeRegressionTest.java @@ -165,6 +165,16 @@ static Stream versionsAt729() { return Stream.of(Arguments.of(ProtocolInfo.v1_21_30)); } + static Stream versionsForCraftResultsDeprecatedItemInstance() { + return Stream.of( + Arguments.of(ProtocolInfo.v1_16_220), + Arguments.of(ProtocolInfo.v1_17_40), + Arguments.of(ProtocolInfo.v1_18_10), + Arguments.of(ProtocolInfo.v1_21_30), + Arguments.of(ProtocolInfo.CURRENT_PROTOCOL) + ); + } + static Stream versionsFrom818() { return filteredVersions(818); } @@ -2850,6 +2860,46 @@ void itemStackRequestPreV471LegacyActionIds(int protocol) { assertEquals(nk.getCount(), nk.getOffset(), "ItemStackRequestPacket decode should consume the full payload"); } + @ParameterizedTest(name = "ItemStackRequestPacket v{0} craft results use item instance") + @MethodSource("versionsForCraftResultsDeprecatedItemInstance") + void itemStackRequestCraftResultsDeprecatedUsesItemInstance(int protocol) { + var gameVersion = GameVersion.byProtocol(protocol, false); + int runtimeId = cn.nukkit.item.RuntimeItems.getMapping(gameVersion) + .toRuntime(cn.nukkit.item.Item.DIAMOND_SWORD, 0) + .getRuntimeId(); + + ItemStackRequestPacket nk = decodeRawItemStackRequestPacket(protocol, stream -> { + stream.putUnsignedVarInt(1); + stream.putVarInt(91); + stream.putUnsignedVarInt(1); + + stream.putByte((byte) craftResultsDeprecatedActionId(protocol)); + stream.putUnsignedVarInt(1); + writeItemInstance(stream, runtimeId, 1, 0, 0); + stream.putByte((byte) 1); + + if (protocol >= ProtocolInfo.v1_16_200) { + stream.putUnsignedVarInt(0); + } + if (protocol >= ProtocolInfo.v1_19_30) { + stream.putLInt(-1); + } + }); + + assertEquals(1, nk.getRequests().size()); + var request = nk.getRequests().get(0); + assertEquals(91, request.getRequestId()); + assertEquals(1, request.getActions().length); + assertInstanceOf(cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction.class, + request.getActions()[0]); + var action = (cn.nukkit.network.protocol.types.inventory.itemstack.request.action.CraftResultsDeprecatedAction) + request.getActions()[0]; + assertEquals(1, action.getTimesCrafted()); + assertEquals(1, action.getResultItems().length); + assertEquals(cn.nukkit.item.Item.DIAMOND_SWORD, action.getResultItems()[0].getId()); + assertEquals(1, action.getResultItems()[0].getCount()); + } + @ParameterizedTest(name = "ItemStackRequestPacket pre-v554 without text origin v{0}") @MethodSource("versionsFrom407ToV554") void itemStackRequestBeforeTextProcessingOrigin(int protocol) { @@ -3471,6 +3521,36 @@ private void writeStackRequestSlotData(BinaryStream stream, GameVersion gameVers stream.putVarInt(stackNetworkId); } + private static int craftResultsDeprecatedActionId(int protocol) { + if (protocol >= ProtocolInfo.v1_18_10_26) { + return cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType + .CRAFT_RESULTS_DEPRECATED.getId(); + } + if (protocol >= ProtocolInfo.v1_17_40) { + return 17; + } + if (protocol >= ProtocolInfo.v1_16_210) { + return 15; + } + if (protocol >= ProtocolInfo.v1_16_200) { + return 14; + } + return 13; + } + + private static void writeItemInstance(BinaryStream stream, int runtimeId, int count, int damage, int blockRuntimeId) { + BinaryStream userData = new BinaryStream(); + userData.putLShort(0); + userData.putLInt(0); + userData.putLInt(0); + + stream.putVarInt(runtimeId); + stream.putLShort(count); + stream.putUnsignedVarInt(damage); + stream.putVarInt(blockRuntimeId); + stream.putByteArray(userData.getBuffer()); + } + private static T readField(Object instance, String fieldName, Class type) { try { var field = instance.getClass().getDeclaredField(fieldName); From 3e4b885df73a2299105e64f8d16cee8542372f71 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sun, 31 May 2026 19:46:35 +0800 Subject: [PATCH 12/29] fix: restore inventory compatibility for SAI horse armor and offhand --- .../entity/passive/EntityHorseBase.java | 48 +++++--- .../cn/nukkit/inventory/HorseInventory.java | 33 ++++-- .../inventory/PlayerOffhandInventory.java | 2 +- .../request/SwapActionProcessor.java | 12 +- .../request/TransferItemActionProcessor.java | 48 +------- .../transaction/InventoryTransaction.java | 12 +- .../nukkit/inventory/HorseInventoryTest.java | 110 ++++++++++++++++++ .../ItemStackRequestProcessorTest.java | 52 ++++++++- 8 files changed, 228 insertions(+), 89 deletions(-) create mode 100644 src/test/java/cn/nukkit/inventory/HorseInventoryTest.java diff --git a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java index 550066004..f765e7cde 100644 --- a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java +++ b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java @@ -38,7 +38,6 @@ public class EntityHorseBase extends EntityWalkingAnimal implements EntityRideab private boolean saddled; private HorseInventory horseInventory; - private Item horseArmor = Item.AIR_ITEM; public EntityHorseBase(FullChunk chunk, CompoundTag nbt) { super(chunk, nbt); @@ -84,8 +83,9 @@ public void saveNBT() { if (this.horseInventory != null) { this.namedTag.putList(TAG_CHEST_ITEMS, this.horseInventory.saveToNBT()); } - if (this.hasHorseArmor()) { - this.namedTag.putCompound(NBT_KEY_ARMOR_ITEM, NBTIO.putItemHelper(this.horseArmor)); + Item armor = this.getHorseArmor(); + if (!armor.isNull()) { + this.namedTag.putCompound(NBT_KEY_ARMOR_ITEM, NBTIO.putItemHelper(armor)); } else { this.namedTag.remove(NBT_KEY_ARMOR_ITEM); } @@ -154,7 +154,7 @@ public boolean onInteract(Player player, Item item, Vector3 clickedPos) { Item armor = item.clone(); armor.setCount(1); this.setHorseArmor(armor); - if (!player.isCreative()) { + if (this.hasHorseArmor() && !player.isCreative()) { player.getInventory().decreaseCount(player.getInventory().getHeldItemIndex()); } } else if (this.passengers.isEmpty() && !this.isBaby() && !player.isSneaking() && (!this.canBeSaddled() || this.isSaddled())) { @@ -194,11 +194,15 @@ public void setSaddled(boolean saddled) { } public boolean hasHorseArmor() { - return !this.horseArmor.isNull(); + return !this.getHorseArmor().isNull(); } public Item getHorseArmor() { - return this.horseArmor; + if (this.horseInventory == null) { + return Item.AIR_ITEM; + } + Item armor = this.horseInventory.getItem(HorseInventory.SLOT_ARMOR); + return armor == null ? Item.AIR_ITEM : armor; } public void setHorseArmor(Item armor) { @@ -206,25 +210,34 @@ public void setHorseArmor(Item armor) { } private void setHorseArmor(Item armor, boolean send) { + if (this.horseInventory == null) { + return; + } + + Item target = Item.get(Item.AIR); if (this.canWearHorseArmor() && armor != null && armor.isHorseArmor()) { - this.horseArmor = armor.clone(); - this.horseArmor.setCount(1); - if (send) { - this.level.addSound(this, Sound.MOB_HORSE_ARMOR); - } - } else { - this.horseArmor = Item.AIR_ITEM; + target = armor.clone(); + target.setCount(1); + } + + if (!this.horseInventory.applyArmorWithoutVisual(target)) { + return; } if (send) { + Item current = this.getHorseArmor(); + if (!current.isNull() && this.level != null) { + this.level.addSound(this, Sound.MOB_HORSE_ARMOR); + } this.sendHorseArmor(this.getViewers().values().toArray(Player.EMPTY_ARRAY)); } } @Override public boolean attack(EntityDamageEvent source) { - if (this.hasHorseArmor() && source.canBeReducedByArmor()) { - float reduction = source.getFinalDamage() * this.horseArmor.getArmorPoints() * 0.04f; + Item armor = this.getHorseArmor(); + if (!armor.isNull() && source.canBeReducedByArmor()) { + float reduction = source.getFinalDamage() * armor.getArmorPoints() * 0.04f; source.setDamage(-reduction, EntityDamageEvent.DamageModifier.ARMOR); } return super.attack(source); @@ -321,7 +334,10 @@ private void sendHorseArmor(Player... players) { return; } - Item armor = this.hasHorseArmor() ? this.horseArmor : Item.AIR_ITEM; + Item armor = this.getHorseArmor(); + if (armor.isNull()) { + armor = Item.AIR_ITEM; + } MobArmorEquipmentPacket packet = new MobArmorEquipmentPacket(); packet.eid = this.getId(); packet.slots = new Item[]{Item.AIR_ITEM, armor, Item.AIR_ITEM, Item.AIR_ITEM}; diff --git a/src/main/java/cn/nukkit/inventory/HorseInventory.java b/src/main/java/cn/nukkit/inventory/HorseInventory.java index 92ac4e4db..40303c865 100644 --- a/src/main/java/cn/nukkit/inventory/HorseInventory.java +++ b/src/main/java/cn/nukkit/inventory/HorseInventory.java @@ -28,6 +28,7 @@ public class HorseInventory extends BaseInventory { private int chestSize; private boolean suppressSaddleSync; + private boolean suppressArmorVisual; public HorseInventory(EntityHorseBase holder, int chestSize) { super(holder, InventoryType.HORSE, Map.of(), SLOT_CHEST_BASE + Math.max(0, chestSize), "Horse"); @@ -82,14 +83,10 @@ public boolean isValidForSlot(int slot, Item item) { 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; + EntityHorseBase holder = getHolder(); + return !(holder instanceof EntityLlama) + && holder.canWearHorseArmor() + && item.isHorseArmor(); } return isChestSlot(slot); } @@ -109,7 +106,7 @@ public void onSlotChange(int index, Item before, boolean send) { Item now = this.getItem(index); if (index == SLOT_SADDLE) { syncSaddle(!now.isNull()); - } else if (index == SLOT_ARMOR) { + } else if (index == SLOT_ARMOR && !suppressArmorVisual) { broadcastArmorVisual(now); } } @@ -137,6 +134,7 @@ private void broadcastArmorVisual(Item armor) { pk.eid = holder.getId(); Item air = Item.get(Item.AIR); pk.slots = new Item[]{air, body, air, air}; + pk.body = body; Collection viewers = holder.getViewers().values(); for (Player viewer : viewers) { @@ -159,6 +157,16 @@ public void applySaddleWithoutSync(Item saddleItem) { } } + public boolean applyArmorWithoutVisual(Item armorItem) { + boolean previous = suppressArmorVisual; + suppressArmorVisual = true; + try { + return this.setItem(SLOT_ARMOR, armorItem == null ? Item.get(Item.AIR) : armorItem, false); + } finally { + suppressArmorVisual = previous; + } + } + public ListTag saveToNBT() { ListTag list = new ListTag<>(); for (int slot = 0; slot < this.getSize(); slot++) { @@ -175,8 +183,10 @@ public void loadFromNBT(ListTag list) { if (list == null) { return; } - boolean previous = suppressSaddleSync; + boolean previousSaddle = suppressSaddleSync; + boolean previousArmor = suppressArmorVisual; suppressSaddleSync = true; + suppressArmorVisual = true; try { for (CompoundTag tag : list.getAll()) { int slot = tag.contains("Slot") ? (tag.getByte("Slot") & 0xFF) : -1; @@ -193,7 +203,8 @@ public void loadFromNBT(ListTag list) { super.setItem(slot, item, false); } } finally { - suppressSaddleSync = previous; + suppressSaddleSync = previousSaddle; + suppressArmorVisual = previousArmor; } } } diff --git a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java index 5ba4a6583..c17b20778 100644 --- a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java +++ b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java @@ -99,7 +99,7 @@ public EntityHuman getHolder() { @Override public boolean allowedToAdd(Item item) { - return item == null || item.isNull() || item.canBePutInOffhandSlot(); + return true; } @Override diff --git a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java index 315fd8766..63325ee71 100644 --- a/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java @@ -62,17 +62,7 @@ public ActionResponse handle(SwapAction action, Player player, ItemStackRequestC 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(); - } - - // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent, - // 但 SAI 路径默认不触发。 + // 向后兼容:复用 legacy InventoryTransaction 的事件语义。 List transactionActions = new ArrayList<>(); transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, destItem)); transactionActions.add(new SlotChangeAction(dstInv, dstSlot, destItem, sourceItem)); diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java index c47c0396a..b1285e93b 100644 --- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -2,8 +2,6 @@ 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.*; import cn.nukkit.inventory.transaction.InventoryTransaction; @@ -128,18 +126,8 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon 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. - if (!fireClickEvent(player, srcInv, srcSlot, sourceItem, newSrc)) { - return context.error(); - } - if (!fireClickEvent(player, dstInv, dstSlot, destItem, newDest)) { - return context.error(); - } - - // 向后兼容:旧路径中每次库存交互都会触发 InventoryTransactionEvent, - // 但 SAI 路径默认不触发。为保持与旧插件的兼容性,在此处补发事件。 + // 向后兼容:复用 legacy InventoryTransaction 的事件语义,按相同顺序 + // 触发 InventoryTransactionEvent,并在适用时最多触发一次 InventoryClickEvent。 List transactionActions = new ArrayList<>(); transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, newSrc)); if (srcInv != dstInv || srcSlot != dstSlot) { @@ -222,12 +210,6 @@ private ActionResponse transferCreativeCreatedOutput(T action, Player player, It if (!fireTransferEvent(player, srcInv, srcSlot, dstInv, dstSlot, sourceItem, destItem, count)) { return context.error(); } - if (!fireClickEvent(player, srcInv, srcSlot, sourceItem, newSrc)) { - return context.error(); - } - if (!fireClickEvent(player, dstInv, dstSlot, destItem, newDest)) { - return context.error(); - } List transactionActions = new ArrayList<>(); transactionActions.add(new SlotChangeAction(srcInv, srcSlot, sourceItem, newSrc)); @@ -300,23 +282,6 @@ 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 - * 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 boolean fireTransferEvent(Player actor, Inventory sourceInventory, int sourceSlot, Inventory destinationInventory, int destinationSlot, Item sourceItem, Item destinationItem, int count) { @@ -375,11 +340,10 @@ private static int canonicalSlot(Inventory inventory, int slot) { /** * InventoryTransaction subclass used solely to emit - * {@link InventoryTransactionEvent} for server-authoritative item stack - * requests that involve block-entity containers (e.g. chests). It does + * legacy inventory events for server-authoritative item stack requests. 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. + * events and returns whether a plugin cancelled them. */ static class EventOnlyInventoryTransaction extends InventoryTransaction { private final ItemStackRequestContext context; @@ -400,9 +364,7 @@ protected void init(Player source, List actions) { @Override protected boolean callExecuteEvent() { - InventoryTransactionEvent ev = new InventoryTransactionEvent(this); - this.source.getServer().getPluginManager().callEvent(ev); - return !ev.isCancelled(); + return InventoryTransaction.callExecuteEvents(this); } @Override diff --git a/src/main/java/cn/nukkit/inventory/transaction/InventoryTransaction.java b/src/main/java/cn/nukkit/inventory/transaction/InventoryTransaction.java index 1f134f2b7..8aac50118 100644 --- a/src/main/java/cn/nukkit/inventory/transaction/InventoryTransaction.java +++ b/src/main/java/cn/nukkit/inventory/transaction/InventoryTransaction.java @@ -228,14 +228,18 @@ public boolean canExecute() { } protected boolean callExecuteEvent() { - InventoryTransactionEvent ev = new InventoryTransactionEvent(this); - this.source.getServer().getPluginManager().callEvent(ev); + return callExecuteEvents(this); + } + + public static boolean callExecuteEvents(InventoryTransaction transaction) { + InventoryTransactionEvent ev = new InventoryTransactionEvent(transaction); + transaction.source.getServer().getPluginManager().callEvent(ev); SlotChangeAction from = null; SlotChangeAction to = null; Player who = null; - for (InventoryAction action : this.actions) { + for (InventoryAction action : transaction.actions) { if (!(action instanceof SlotChangeAction)) { continue; } @@ -258,7 +262,7 @@ protected boolean callExecuteEvent() { } InventoryClickEvent ev2 = new InventoryClickEvent(who, from.getInventory(), from.getSlot(), from.getSourceItem(), from.getTargetItem()); - this.source.getServer().getPluginManager().callEvent(ev2); + transaction.source.getServer().getPluginManager().callEvent(ev2); if (ev2.isCancelled()) { return false; diff --git a/src/test/java/cn/nukkit/inventory/HorseInventoryTest.java b/src/test/java/cn/nukkit/inventory/HorseInventoryTest.java new file mode 100644 index 000000000..beccf709a --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/HorseInventoryTest.java @@ -0,0 +1,110 @@ +package cn.nukkit.inventory; + +import cn.nukkit.MockServer; +import cn.nukkit.Player; +import cn.nukkit.entity.passive.EntityHorseBase; +import cn.nukkit.item.Item; +import cn.nukkit.item.ItemNamespaceId; +import cn.nukkit.network.protocol.DataPacket; +import cn.nukkit.network.protocol.MobArmorEquipmentPacket; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; + +class HorseInventoryTest { + + @BeforeAll + static void init() { + MockServer.init(); + } + + @BeforeEach + void resetServer() { + MockServer.reset(); + } + + @Test + void armorSlotAcceptsStringBackedHorseArmor() { + EntityHorseBase horse = Mockito.mock(EntityHorseBase.class); + Mockito.when(horse.canWearHorseArmor()).thenReturn(true); + Mockito.when(horse.getViewers()).thenReturn(Map.of()); + HorseInventory inventory = new HorseInventory(horse, 0); + + Item copper = Item.fromString(ItemNamespaceId.COPPER_HORSE_ARMOR); + Item netherite = Item.fromString(ItemNamespaceId.NETHERITE_HORSE_ARMOR); + + assertTrue(copper.isHorseArmor()); + assertTrue(netherite.isHorseArmor()); + assertTrue(inventory.setItem(HorseInventory.SLOT_ARMOR, copper, false)); + assertEquals(ItemNamespaceId.COPPER_HORSE_ARMOR, inventory.getItem(HorseInventory.SLOT_ARMOR).getNamespaceId()); + assertTrue(inventory.setItem(HorseInventory.SLOT_ARMOR, netherite, false)); + assertEquals(ItemNamespaceId.NETHERITE_HORSE_ARMOR, inventory.getItem(HorseInventory.SLOT_ARMOR).getNamespaceId()); + } + + @Test + void horseArmorAccessorsReadInventorySlot() throws Exception { + EntityHorseBase horse = Mockito.mock(EntityHorseBase.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(true).when(horse).canWearHorseArmor(); + Mockito.doReturn(Map.of()).when(horse).getViewers(); + HorseInventory inventory = new HorseInventory(horse, 0); + setHorseInventory(horse, inventory); + + Item diamond = Item.get(Item.DIAMOND_HORSE_ARMOR, 0, 1); + horse.setHorseArmor(diamond); + + assertTrue(horse.hasHorseArmor()); + assertEquals(Item.DIAMOND_HORSE_ARMOR, inventory.getItem(HorseInventory.SLOT_ARMOR).getId()); + assertEquals(Item.DIAMOND_HORSE_ARMOR, horse.getHorseArmor().getId()); + + Item copper = Item.fromString(ItemNamespaceId.COPPER_HORSE_ARMOR); + assertTrue(inventory.setItem(HorseInventory.SLOT_ARMOR, copper, false)); + + assertTrue(horse.hasHorseArmor()); + assertEquals(ItemNamespaceId.COPPER_HORSE_ARMOR, horse.getHorseArmor().getNamespaceId()); + } + + @Test + void armorSlotBroadcastIncludesBodyField() { + Player viewer = Mockito.mock(Player.class); + EntityHorseBase horse = Mockito.mock(EntityHorseBase.class); + Mockito.when(horse.canWearHorseArmor()).thenReturn(true); + Mockito.when(horse.getId()).thenReturn(77L); + Mockito.when(horse.getViewers()).thenReturn(Map.of(1, viewer)); + HorseInventory inventory = new HorseInventory(horse, 0); + + Item armor = Item.get(Item.DIAMOND_HORSE_ARMOR, 0, 1); + assertTrue(inventory.setItem(HorseInventory.SLOT_ARMOR, armor, false)); + + MobArmorEquipmentPacket packet = capturePacket(viewer, MobArmorEquipmentPacket.class); + assertEquals(77L, packet.eid); + assertEquals(Item.DIAMOND_HORSE_ARMOR, packet.slots[1].getId()); + assertEquals(Item.DIAMOND_HORSE_ARMOR, packet.body.getId()); + } + + private static void setHorseInventory(EntityHorseBase horse, HorseInventory inventory) throws Exception { + Field field = EntityHorseBase.class.getDeclaredField("horseInventory"); + field.setAccessible(true); + field.set(horse, inventory); + } + + private static T capturePacket(Player player, Class type) { + ArgumentCaptor captor = ArgumentCaptor.forClass(DataPacket.class); + verify(player, atLeastOnce()).dataPacket(captor.capture()); + for (DataPacket packet : captor.getAllValues()) { + if (type.isInstance(packet)) { + return type.cast(packet); + } + } + fail("Expected packet " + type.getSimpleName()); + return null; + } +} diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 86eaba95e..6e55ed3ae 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -6,6 +6,7 @@ import cn.nukkit.blockentity.BlockEntityChest; import cn.nukkit.entity.passive.EntityVillager; import cn.nukkit.event.Event; +import cn.nukkit.event.inventory.InventoryClickEvent; import cn.nukkit.event.inventory.InventoryEvent; import cn.nukkit.event.inventory.InventoryTransactionEvent; import cn.nukkit.event.inventory.ItemStackRequestActionEvent; @@ -192,16 +193,61 @@ void offhandAcceptsShield() { } @Test - void offhandInventoryRejectsNonBedrockOffhandItemsDirectly() { + void offhandInventoryAllowsDirectApiForCompatibility() { Player player = mockPlayer(); PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); - assertFalse(offhand.setItem(0, Item.get(Item.STONE, 0, 1), false)); - assertTrue(offhand.getItem(0).isNull()); + assertTrue(offhand.setItem(0, Item.get(Item.STONE, 0, 1), false)); + assertEquals(Item.STONE, offhand.getItem(0).getId()); assertTrue(offhand.setItem(0, Item.get(Item.SHIELD, 0, 1), false)); assertEquals(Item.SHIELD, offhand.getItem(0).getId()); } + @Test + void transferFiresLegacyTransactionThenSingleClickEvent() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + + List> legacyEvents = new ArrayList<>(); + Mockito.doAnswer(invocation -> { + Event event = invocation.getArgument(0); + if (event instanceof InventoryTransactionEvent || event instanceof InventoryClickEvent) { + legacyEvents.add(event.getClass()); + } + return null; + }).when(pluginManager).callEvent(any(Event.class)); + + Item source = Item.get(Item.STONE, 0, 5); + source.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, source, false)); + source = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, source.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.INVENTORY, 1, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertEquals(List.of(InventoryTransactionEvent.class, InventoryClickEvent.class), legacyEvents); + } + @Test void suppressedDestroyStillMutatesInventory() { Player player = mockPlayer(); From d5f938409bf94b2dda2814375e23dfaeb93e9488 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 13 Jun 2026 18:37:22 +0800 Subject: [PATCH 13/29] test: align multi-recipe test output with strict firework canExecute validation --- .../inventory/request/ItemStackRequestProcessorTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 6e55ed3ae..673eaad4c 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -16,6 +16,7 @@ import cn.nukkit.inventory.transaction.action.SlotChangeAction; import cn.nukkit.item.Item; import cn.nukkit.item.ItemBundle; +import cn.nukkit.item.ItemFirework; import cn.nukkit.item.enchantment.Enchantment; import cn.nukkit.level.Level; import cn.nukkit.level.Position; @@ -344,6 +345,8 @@ void multiRecipeRequiresMatchingConsumePlan() { MultiRecipe recipe = new FireworkRecipe(); Item output = Item.get(Item.FIREWORKS, 0, 3); + // 1 份火药 -> flight 1;canExecute 要求客户端 output 与服务端按材料计算的结果精确匹配(含 Flight NBT) + ((ItemFirework) output).setFlight(1); ItemStackRequestContext missingConsumes = context( new CraftRecipeAction(recipe.getNetworkId(), 1), From effbef6d9fc9ce5d1f1e3ea6eea32ab2db1eb27f Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 13 Jun 2026 19:48:43 +0800 Subject: [PATCH 14/29] fix: validate topWindow type for typed container slots in NetworkMapping --- .../inventory/request/NetworkMapping.java | 32 +++++-- .../inventory/request/NetworkMappingTest.java | 93 ++++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java index 772c75744..4dffdff6c 100644 --- a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java +++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java @@ -60,23 +60,43 @@ public static Inventory getInventory(Player player, ContainerSlotType type, @Nul case CARTOGRAPHY_INPUT, CARTOGRAPHY_ADDITIONAL, CARTOGRAPHY_RESULT -> topWindow instanceof CartographyTableInventory ? topWindow : null; case BEACON_PAYMENT -> player.getWindowById(Player.BEACON_WINDOW_ID); + // 教育版化学桌容器在 MOT 中未实现(无对应 Inventory 子类, + // LabTableCombine 处理器为空壳),返回 null 拒绝任何针对这些槽类型的操作。 case COMPOUND_CREATOR_INPUT, COMPOUND_CREATOR_OUTPUT, ELEMENT_CONSTRUCTOR_OUTPUT, MATERIAL_REDUCER_INPUT, MATERIAL_REDUCER_OUTPUT, - LAB_TABLE_INPUT -> topWindow; + LAB_TABLE_INPUT -> null; case TRADE_INGREDIENT_1, TRADE_INGREDIENT_2, TRADE_RESULT, TRADE2_INGREDIENT_1, TRADE2_INGREDIENT_2, TRADE2_RESULT -> - topWindow; + typedContainer(topWindow, TradeInventory.class); 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 -> topWindow; + BLAST_FURNACE_INGREDIENT, SMOKER_INGREDIENT -> + typedContainer(topWindow, FurnaceInventory.class); + case BREWING_INPUT, BREWING_RESULT, BREWING_FUEL -> + typedContainer(topWindow, BrewingInventory.class); + case SHULKER_BOX -> typedContainer(topWindow, ShulkerBoxInventory.class); + case BARREL -> typedContainer(topWindow, BarrelInventory.class); + case CRAFTER_BLOCK_CONTAINER -> typedContainer(topWindow, CrafterInventory.class); + // LEVEL_ENTITY 是 chest/hopper/dispenser/dropper/ender chest 等多种 + // 方块实体容器的共用泛化槽类型,无法精确校验窗口类型,保留返回 topWindow。 + case LEVEL_ENTITY -> topWindow; case DYNAMIC_CONTAINER -> resolveDynamicContainer(player, dynamicId); default -> null; }; } + /** + * Return {@code topWindow} only when it is an instance of the expected inventory + * class. A null return (no window open, or a window of an unrelated type) lets + * callers translate the result into an error response, so a client-reported + * {@link ContainerSlotType} cannot operate on a mismatched inventory. Mirrors the + * inline defensive check used for the cartography-table branch. + */ + @Nullable + private static Inventory typedContainer(@Nullable Inventory topWindow, Class expected) { + return expected.isInstance(topWindow) ? topWindow : null; + } + private static boolean isSlotTypeCompatibleWithFakeUI(FakeBlockUIComponent fakeUI, ContainerSlotType type) { return switch (fakeUI.getFakeBlockType()) { case ANVIL -> type == ContainerSlotType.ANVIL_INPUT diff --git a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java index 69d8e687a..5ddc301b1 100644 --- a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java +++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java @@ -29,14 +29,103 @@ void resetServer() { } @Test - void levelEntityAndCrafterContainerResolveToTopWindow() { + void levelEntityResolvesToTopWindow() { Player player = Mockito.mock(Player.class); Inventory topWindow = Mockito.mock(Inventory.class); Mockito.when(player.getTopWindow()).thenReturn(Optional.of(topWindow)); + // LEVEL_ENTITY is a catch-all (chest/hopper/dispenser/...) and intentionally + // returns the open window without a type check. assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); - assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null)); + } + + @Test + void typedContainerSlotsRejectMismatchedTopWindow() { + Player player = Mockito.mock(Player.class); + // topWindow is a barrel, but the client claims furnace/brewing/shulker/crafter/trade slots. + Inventory barrel = Mockito.mock(BarrelInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(barrel)); + + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_INGREDIENT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_FUEL, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.BLAST_FURNACE_INGREDIENT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.SMOKER_INGREDIENT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.BREWING_INPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.BREWING_FUEL, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.SHULKER_BOX, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.TRADE_INGREDIENT_1, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.TRADE2_RESULT, null)); + } + + @Test + void typedContainerSlotsResolveWhenTopWindowMatches() { + Player player = Mockito.mock(Player.class); + + FurnaceInventory furnace = Mockito.mock(FurnaceInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(furnace)); + assertSame(furnace, NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_INGREDIENT, null)); + assertSame(furnace, NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_FUEL, null)); + assertSame(furnace, NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_RESULT, null)); + + // BlastFurnaceInventory / SmokerInventory extend FurnaceInventory, so a single + // instanceof check covers all three furnace variants. + BlastFurnaceInventory blast = Mockito.mock(BlastFurnaceInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(blast)); + assertSame(blast, NetworkMapping.getInventory(player, ContainerSlotType.BLAST_FURNACE_INGREDIENT, null)); + + SmokerInventory smoker = Mockito.mock(SmokerInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(smoker)); + assertSame(smoker, NetworkMapping.getInventory(player, ContainerSlotType.SMOKER_INGREDIENT, null)); + + BrewingInventory brewing = Mockito.mock(BrewingInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(brewing)); + assertSame(brewing, NetworkMapping.getInventory(player, ContainerSlotType.BREWING_INPUT, null)); + + ShulkerBoxInventory shulker = Mockito.mock(ShulkerBoxInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(shulker)); + assertSame(shulker, NetworkMapping.getInventory(player, ContainerSlotType.SHULKER_BOX, null)); + + BarrelInventory barrel = Mockito.mock(BarrelInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(barrel)); + assertSame(barrel, NetworkMapping.getInventory(player, ContainerSlotType.BARREL, null)); + + CrafterInventory crafter = Mockito.mock(CrafterInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(crafter)); + assertSame(crafter, NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null)); + + TradeInventory trade = Mockito.mock(TradeInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(trade)); + assertSame(trade, NetworkMapping.getInventory(player, ContainerSlotType.TRADE_INGREDIENT_1, null)); + assertSame(trade, NetworkMapping.getInventory(player, ContainerSlotType.TRADE2_RESULT, null)); + } + + @Test + void typedContainerSlotsRejectNullTopWindow() { + Player player = Mockito.mock(Player.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.FURNACE_INGREDIENT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.BARREL, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.SHULKER_BOX, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.CRAFTER_BLOCK_CONTAINER, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.TRADE_INGREDIENT_1, null)); + } + + @Test + void educationEditionSlotsReturnNullRegardlessOfTopWindow() { + Player player = Mockito.mock(Player.class); + Inventory topWindow = Mockito.mock(Inventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(topWindow)); + + // MOT does not implement education-edition chemistry containers. + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.COMPOUND_CREATOR_INPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.COMPOUND_CREATOR_OUTPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.MATERIAL_REDUCER_INPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.MATERIAL_REDUCER_OUTPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.LAB_TABLE_INPUT, null)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.ELEMENT_CONSTRUCTOR_OUTPUT, null)); } @Test From 2a924932d9cee630fe664b93d08e6d9c21b817ee Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 13 Jun 2026 20:22:46 +0800 Subject: [PATCH 15/29] fix(inventory): skip deprecated/unimplemented item stack request actions instead of failing --- .../CraftNonImplementedActionProcessor.java | 4 +++- .../request/ItemStackRequestHandler.java | 5 +++-- .../LabTableCombineActionProcessor.java | 4 +++- .../ItemStackRequestProcessorTest.java | 22 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java index 5860d4667..e93cec648 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java @@ -13,6 +13,8 @@ public ItemStackRequestActionType getType() { @Override public ActionResponse handle(CraftNonImplementedAction action, Player player, ItemStackRequestContext context) { - return context.error(); + // 协议占位/no-op action(老协议下还会承接映射歧义的若干真实 action)。 + // 返回 null 表示静默跳过:不产出响应、不判定为错误,避免中断整条 request, + return null; } } diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java index 705b314fb..1689c1565 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java @@ -89,9 +89,10 @@ public static void handleRequests(Player player, List requests (ItemStackRequestActionProcessor) PROCESSORS.get(action.getType()); if (processor == null) { + // 未注册/未实现的 action 类型静默跳过,继续处理同一请求内的后续 action, + // 单条 action 不应令整条 request 失败。 log.warn("{}: unhandled item stack request action {}", player.getName(), action.getType()); - error = true; - break; + continue; } try { diff --git a/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java index 46e57f6c7..4b7a7b7da 100644 --- a/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java @@ -13,6 +13,8 @@ public ItemStackRequestActionType getType() { @Override public ActionResponse handle(LabTableCombineAction action, Player player, ItemStackRequestContext context) { - return context.error(); + // 实验台(教育版)合成 action,服务端未实现。返回 null 表示静默跳过: + // 不产出响应、不判定为错误,避免中断整条 request, + return null; } } diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 673eaad4c..54bfeab73 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -814,6 +814,28 @@ void itemStackRequestActionEventIsNotInventoryEvent() { assertFalse(InventoryEvent.class.isAssignableFrom(ItemStackRequestActionEvent.class)); } + @Test + void unimplementedActionsAreSkippedInsteadOfFailingRequest() { + Player player = mockPlayer(); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getServer().getPluginManager()).thenReturn(pluginManager); + + // CraftNonImplemented / LabTableCombine 是占位/未实现的 action 类型, + // 必须被静默跳过而非令整条 request 失败。 + // 仅含这类 action 的请求应返回 OK。 + ItemStackRequest request = new ItemStackRequest( + 11, + new ItemStackRequestAction[]{new CraftNonImplementedAction(), new LabTableCombineAction()}, + new String[0] + ); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult(), + "deprecated/unimplemented actions must be skipped, not treated as request errors"); + } + @Test void tagDescriptorsMatchRegisteredItemTags() { Item planks = Mockito.mock(Item.class); From d1cf6890daf7dcec4b9eeee147fbc4e12a87756d Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 13 Jun 2026 21:41:35 +0800 Subject: [PATCH 16/29] fix(inventory): prevent item duping via force-restore rollback and harden commit atomicity --- .../cn/nukkit/inventory/BaseInventory.java | 26 +++++++++++++++- .../inventory/DoubleChestInventory.java | 10 +++++++ .../java/cn/nukkit/inventory/Inventory.java | 23 ++++++++++++++ .../nukkit/inventory/PlayerUIComponent.java | 11 +++++++ .../request/ItemStackRequestContext.java | 25 +++++++++++----- .../request/ItemStackRequestHandler.java | 24 ++++++++++----- .../request/SwapActionProcessor.java | 2 +- .../request/TransferItemActionProcessor.java | 14 ++------- .../InventoryServerAuthoritativeSyncTest.java | 22 ++++++++++++++ .../ItemStackRequestProcessorTest.java | 30 +++++++++++++++++++ 10 files changed, 160 insertions(+), 27 deletions(-) diff --git a/src/main/java/cn/nukkit/inventory/BaseInventory.java b/src/main/java/cn/nukkit/inventory/BaseInventory.java index 76426c025..ebf89cd20 100644 --- a/src/main/java/cn/nukkit/inventory/BaseInventory.java +++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java @@ -202,7 +202,6 @@ public boolean setItem(int index, Item item, boolean send) { if (holder instanceof BlockEntity) { ((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 @@ -221,6 +220,31 @@ public boolean setItem(int index, Item item, boolean send) { return true; } + @Override + @ApiStatus.Internal + public void setItemForce(int index, Item item) { + if (index < 0 || index >= this.size) { + return; + } + Item old = this.getItem(index); + if (item == null || item.isNull() || item.getCount() <= 0) { + this.slots.remove(index); + } else { + if (item.getStackNetId() == 0) { + item.autoAssignStackNetworkId(); + } + if (item instanceof ItemBundle bundle) { + ensureUniqueBundleId(index, bundle); + } + this.slots.put(index, item.clone()); + } + InventoryHolder holder = this.getHolder(); + if (holder instanceof BlockEntity) { + ((BlockEntity) holder).setDirty(); + } + this.onSlotChange(index, old, false); + } + @Override public boolean contains(Item item) { int count = Math.max(1, item.getCount()); diff --git a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java index f998accc4..fc99d36c2 100644 --- a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java +++ b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java @@ -78,6 +78,16 @@ 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.left.getSize(), item, send); } + @Override + @ApiStatus.Internal + public void setItemForce(int index, Item item) { + if (index < this.left.getSize()) { + this.left.setItemForce(index, item); + } else { + this.right.setItemForce(index - this.left.getSize(), item); + } + } + @Override public boolean clear(int index) { return this.clear(index, true); diff --git a/src/main/java/cn/nukkit/inventory/Inventory.java b/src/main/java/cn/nukkit/inventory/Inventory.java index 84ee1e20e..f5e3190ec 100644 --- a/src/main/java/cn/nukkit/inventory/Inventory.java +++ b/src/main/java/cn/nukkit/inventory/Inventory.java @@ -43,6 +43,29 @@ default boolean setItem(int index, Item item) { boolean setItem(int index, Item item, boolean send); + /** + * Unconditionally write an item to the given slot, bypassing all inventory + * change events (EntityInventoryChangeEvent, EntityArmorChangeEvent, etc.). + *

+ * This is intended solely for server-authoritative rollback/restore + * operations where the server's snapshot must be restored regardless of + * plugin event handlers that might veto a normal {@link #setItem} call. + * Using this method in any other context will silently break plugin + * integrations that rely on change events. + *

+ * No network packet is sent (equivalent to {@code send = false}). Callers + * are responsible for ensuring visual synchronisation afterwards, e.g. via + * {@code sendContents}/{@code sendSlot} or the {@code resyncActor} path in + * {@link cn.nukkit.inventory.request.ItemStackRequestHandler}. + * + * @param index the slot index + * @param item the item to write (null or empty item clears the slot) + */ + @ApiStatus.Internal + default void setItemForce(int index, Item item) { + setItem(index, item, false); + } + Item[] addItem(Item... slots); boolean canAddItem(Item item); diff --git a/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java b/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java index 2c7a857d6..db51a3c46 100644 --- a/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java +++ b/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java @@ -69,6 +69,17 @@ public boolean setItem(int index, Item item, boolean send) { return false; } + @Override + @ApiStatus.Internal + public void setItemForce(int index, Item item) { + if (index < 0 || index >= this.size) { + return; + } + Item before = this.playerUI.getItem(index + this.offset); + this.playerUI.setItemForce(index + this.offset, item); + onSlotChange(index, before, false); + } + @Override public boolean clear(int index, boolean send) { Item before = playerUI.getItem(index + this.offset); diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java index 179463d75..df14f7435 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java @@ -6,6 +6,7 @@ import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer; import lombok.Getter; import lombok.Setter; +import lombok.extern.log4j.Log4j2; import java.util.*; @@ -15,6 +16,7 @@ * opaque scratchpad for cross-action state (e.g. the recipe resolved by * CraftRecipeAction that CreateAction / CraftResultsDeprecated will consume). */ +@Log4j2 public class ItemStackRequestContext { @Getter @@ -49,17 +51,26 @@ public void onCommit(Runnable action) { } } + /** + * Execute all registered commit actions. Each action is wrapped in its own + * try-catch so that a single failure does not skip subsequent actions — + * commit side-effects (exp changes, entity spawns, NBT updates) are often + * independent and should not be abandoned just because one of them threw. + * + * @return {@code true} only if every action completed without throwing + */ public boolean commit() { - try { - for (Runnable action : commitActions) { + boolean allOk = true; + for (Runnable action : commitActions) { + try { action.run(); + } catch (Throwable t) { + log.error("Failed to execute item stack request commit action", t); + allOk = false; } - commitActions.clear(); - return true; - } catch (Throwable t) { - commitActions.clear(); - return false; } + commitActions.clear(); + return allOk; } public void addPluginModifiedSlots(Inventory inventory, Map slots) { diff --git a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java index 1689c1565..98b34ec42 100644 --- a/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java @@ -127,8 +127,14 @@ public static void handleRequests(Player player, List requests } } - if (!error && !context.commit()) { - error = true; + if (!error) { + // commit 副作用(经验扣除、实体生成、NBT 更新等)是不可逆的。 + // 如果 commit 部分失败,回滚库存只会制造新的不一致 + // (如经验已扣但物品也恢复),因此 commit 失败时不回滚, + // 仅记录日志并同步当前真实状态给客户端。 + if (!context.commit()) { + log.warn("{}: item stack request {} commit partially failed", player.getName(), request.getRequestId()); + } } if (error) { @@ -244,11 +250,15 @@ private static void restoreInventory(Inventory inventory, Map sna currentSlots.add(slot); } + // Use setItemForce to bypass EntityInventoryChangeEvent / EntityArmorChangeEvent + // — rollback is a server-authoritative restore and must not be vetoed by + // plugin event handlers, otherwise the slot stays in its post-action state + // and the client receives an inconsistent inventory. for (int slot : currentSlots) { if (!snapshot.containsKey(slot)) { Item current = canonical.getItem(slot); if (current != null && !current.isNull()) { - canonical.clear(slot, false); + canonical.setItemForce(slot, Item.get(Item.AIR)); } } } @@ -256,9 +266,9 @@ private static void restoreInventory(Inventory inventory, Map sna for (var entry : snapshot.entrySet()) { Item item = entry.getValue(); if (item != null && !item.isNull() && item.getCount() > 0) { - canonical.setItem(entry.getKey(), item.clone(), false); + canonical.setItemForce(entry.getKey(), item.clone()); } else { - canonical.clear(entry.getKey(), false); + canonical.setItemForce(entry.getKey(), Item.get(Item.AIR)); } } } @@ -277,9 +287,9 @@ private static void replayPluginModifiedSlots(Inventory inventory, Map T capturePacket(Player player, Class type) { ArgumentCaptor captor = ArgumentCaptor.forClass(DataPacket.class); Mockito.verify(player).dataPacket(captor.capture()); diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 54bfeab73..f20df52f2 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -950,6 +950,36 @@ void tradeRecipeRequiresCurrentVillagerRecipeId() throws Exception { assertFalse(response.success()); } + @Test + void commitExecutesAllActionsEvenWhenSomeFail() { + ItemStackRequestContext ctx = context(); + boolean[] executed = new boolean[3]; + ctx.onCommit(() -> executed[0] = true); + ctx.onCommit(() -> { executed[1] = true; throw new RuntimeException("boom"); }); + ctx.onCommit(() -> executed[2] = true); + + boolean result = ctx.commit(); + + assertFalse(result, "commit should return false when any action fails"); + assertTrue(executed[0], "first action should have executed"); + assertTrue(executed[1], "second action should have executed (before throwing)"); + assertTrue(executed[2], "third action should still execute after second threw"); + } + + @Test + void setItemForceWritesDirectlyWithoutEvents() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + + Item diamond = Item.get(Item.DIAMOND, 0, 32); + inventory.setItemForce(0, diamond); + assertEquals(Item.DIAMOND, inventory.getItem(0).getId()); + assertEquals(32, inventory.getItem(0).getCount()); + + inventory.setItemForce(0, Item.get(Item.AIR)); + assertTrue(inventory.getItem(0).isNull()); + } + private static Player mockPlayer() { Player player = Mockito.mock(Player.class); player.protocol = ProtocolInfo.v1_21_30; From e9b1d102bb674df8cc9ba23013f8cbe4cc9c75f8 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 13 Jun 2026 23:39:50 +0800 Subject: [PATCH 17/29] fix(inventory): creative take in SAI mode rolls back cursor due to count mismatch CraftCreativeActionProcessor used the client's numberOfRequestedCrafts to size the CREATED_OUTPUT stack, but the client always requests a full stack (maxStackSize) in the follow-up PLACE/DROP action. When the two numbers diverged (e.g. server count=1 vs client request=64), doTransfer's source count check rejected the request, triggering the d1cf6890d rollback path and clearing the cursor, appearing as the item briefly held then vanishing. Fix: always materialize the creative output at maxStackSize, matching the Allay/PNX reference implementation and the client's always-full-stack expectation. Also skip the source stackNetworkId validation on the creative CREATED_OUTPUT transfer path: the server-allocated id is never echoed back to the client, so the follow-up action's source id is the client's own prediction and would otherwise never match. Added regression tests covering the full handler path (hotbar, cursor, occupied-slot, client-predicted netId, and the numberOfRequestedCrafts mismatch that reproduced the reported symptom). --- .../request/CraftCreativeActionProcessor.java | 11 +- .../request/TransferItemActionProcessor.java | 21 +- .../ItemStackRequestProcessorTest.java | 278 ++++++++++++++++++ 3 files changed, 300 insertions(+), 10 deletions(-) diff --git a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java index 62e5d3f52..ccf4ef094 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java @@ -37,10 +37,13 @@ public ActionResponse handle(CraftCreativeAction action, Player player, ItemStac } item = item.clone(); - int requestedCount = action.getNumberOfRequestedCrafts() <= 0 - ? item.getMaxStackSize() - : action.getNumberOfRequestedCrafts(); - item.setCount(Math.min(item.getMaxStackSize(), requestedCount)); + // 创造背包拿物品时,客户端总是按最大堆叠数请求整堆(maxStackSize),随后的 PLACE/DROP + // action 也会带这个数量。如果这里按 numberOfRequestedCrafts 写入更小的数量 + // (该字段在部分客户端版本下未正确填充或含义不一致),服务端 CREATED_OUTPUT 的 count + // 就会小于客户端 PLACE 的 count,导致 doTransfer 的 "count < need" 校验失败 -> + // 请求被判 error -> 回滚清空光标("光标短暂持有后被清")。 + // 因此对齐 Allay/PNX:创造产物始终使用 maxStackSize。 + item.setCount(item.getMaxStackSize()); item.autoAssignStackNetworkId(); player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, item, false); diff --git a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java index 037f00316..22940050f 100644 --- a/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -58,7 +58,21 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon if (sourceItem.isNull() || sourceItem.getCount() < count) { return context.error(); } - if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) { + + boolean fullTransfer = sourceItem.getCount() == count; + boolean srcIsCreatedOutput = isCreatedOutput(srcInv, srcSlot); + boolean creativeCreatedOutputTransfer = player.isCreative() + && srcIsCreatedOutput + && Boolean.TRUE.equals(context.get(CraftCreativeActionProcessor.CRAFT_CREATIVE_KEY)); + + // Creative item creation writes a server-allocated stackNetworkId into CREATED_OUTPUT + // (CraftCreativeActionProcessor returns no response), so the client's source-slot + // stackNetworkId for the follow-up PLACE/TAKE is its own prediction and will not match + // the server's id. Validating it here would reject every creative take — the items would + // appear then vanish as the request is rolled back. Skip the source-id check on the + // creative CREATED_OUTPUT transfer path and rely on the count/item checks above. + if (!creativeCreatedOutputTransfer + && validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) { return context.error(); } @@ -67,11 +81,6 @@ protected ActionResponse doTransfer(T action, Player player, ItemStackRequestCon return context.error(); } - boolean fullTransfer = sourceItem.getCount() == count; - boolean srcIsCreatedOutput = isCreatedOutput(srcInv, srcSlot); - boolean creativeCreatedOutputTransfer = player.isCreative() - && srcIsCreatedOutput - && Boolean.TRUE.equals(context.get(CraftCreativeActionProcessor.CRAFT_CREATIVE_KEY)); if (creativeCreatedOutputTransfer) { return transferCreativeCreatedOutput(action, player, context, srcInv, srcSlot, dstInv, dstSlot, sourceItem, destItem, fullTransfer); diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index f20df52f2..9aa7a7bdf 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -67,6 +67,227 @@ void resetServer() { TradeRecipeBuildUtils.RECIPE_MAP.clear(); } + @Test + void creativeTakeThroughFullHandlerEndsUpInPlayerInventory() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0, "creative catalog should contain stackable items"); + Item expected = creativeItems.get(creativeIndex); + + // 模拟真实创造拿物品流程: CraftCreative (写 CREATED_OUTPUT) -> Place (移到 hotbar 0) + CraftCreativeAction craft = new CraftCreativeAction(creativeIndex + 1, 0); + PlaceAction place = new PlaceAction( + expected.getMaxStackSize(), + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, 0, null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, 0, null) + ); + ItemStackRequest request = new ItemStackRequest( + 12, new ItemStackRequestAction[]{craft, place}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(expected.getId(), inventory.getItem(0).getId(), "creative item should reach hotbar slot 0"); + assertEquals(expected.getMaxStackSize(), inventory.getItem(0).getCount(), + "creative item should keep its full stack count"); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull(), + "CREATED_OUTPUT should be cleared after the transfer"); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult(), + "creative take request should succeed"); + } + + @Test + void creativeTakeToOccupiedDifferentItemSlotFails() { + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + + // hotbar 0 已被不同物品(泥土)占据 + Item occupied = Item.get(Item.DIRT, 0, 5); + occupied.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, occupied, false)); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0); + Item expected = creativeItems.get(creativeIndex); + + CraftCreativeAction craft = new CraftCreativeAction(creativeIndex + 1, 0); + PlaceAction place = new PlaceAction( + expected.getMaxStackSize(), + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, 0, null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, occupied.getStackNetId(), null) + ); + ItemStackRequest request = new ItemStackRequest( + 13, new ItemStackRequestAction[]{craft, place}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(expected.getId(), inventory.getItem(0).getId(), + "creative item should overwrite the occupied slot (creative uses transferCreativeCreatedOutput)"); + assertEquals(expected.getMaxStackSize(), inventory.getItem(0).getCount()); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult()); + } + + @Test + void creativeTakeWithClientPredictedSourceNetIdStillSucceeds() { + // 真实 Bedrock 客户端在 CraftCreative 后,PlaceAction 的 source(CREATED_OUTPUT) + // 携带的 stackNetworkId 是客户端预测值,与服务端 autoAssignStackNetworkId 分配的不一致。 + // 如果 validateStackNetworkId 因此拒绝,就会 error -> 回滚 -> 物品闪现后消失。 + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0); + Item expected = creativeItems.get(creativeIndex); + + CraftCreativeAction craft = new CraftCreativeAction(creativeIndex + 1, 0); + // 客户端 source stackNetworkId = 客户端预测值(非零,与服务端分配的不同) + PlaceAction place = new PlaceAction( + expected.getMaxStackSize(), + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, 123456, null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, 0, null) + ); + ItemStackRequest request = new ItemStackRequest( + 14, new ItemStackRequestAction[]{craft, place}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(expected.getId(), inventory.getItem(0).getId(), + "creative item should reach hotbar even when client source netId differs from server"); + assertEquals(expected.getMaxStackSize(), inventory.getItem(0).getCount()); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult(), + "creative take with client-predicted source netId should not be rejected"); + } + + @Test + void creativeTakeToCursorSurvivesSnapshotRollback() { + // 复现用户报告的核心症状:开启 SAI 后点击创造背包物品,光标短暂持有后被清空。 + // 根因: CraftCreative 写入 CREATED_OUTPUT 的服务端 stackNetId 不回传客户端, + // 后续 PLACE 到 CURSOR 的源 stackNetId 失配 -> validateStackNetworkId 拒绝 -> + // 回滚把光标清空。目标为 CURSOR 时 dstInv 经 canonicalizeInventory 归并到 UI 库存, + // 也会被纳入快照回滚,故必须用真实光标验证。 + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0); + Item expected = creativeItems.get(creativeIndex); + + CraftCreativeAction craft = new CraftCreativeAction(creativeIndex + 1, 0); + // 目标为 CURSOR(客户端真实点击创造物品后的拾取动作);source stackNetworkId 为客户端预测值 + PlaceAction place = new PlaceAction( + expected.getMaxStackSize(), + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, 123456, null), + new ItemStackRequestSlotData(ContainerSlotType.CURSOR, 0, 0, null) + ); + ItemStackRequest request = new ItemStackRequest( + 15, new ItemStackRequestAction[]{craft, place}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + Item cursor = player.getCursorInventory().getItem(0); + assertEquals(expected.getId(), cursor.getId(), + "creative item should be held by cursor, not cleared by rollback"); + assertEquals(expected.getMaxStackSize(), cursor.getCount()); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult(), + "creative take to cursor should succeed"); + } + @Test void creativeCraftWithZeroRequestedCountCreatesFullStack() { Player player = mockPlayer(); @@ -97,6 +318,63 @@ void creativeCraftWithZeroRequestedCountCreatesFullStack() { assertTrue(created.getStackNetId() > 0); } + @Test + void creativeCreatedOutputUsesMaxStackRegardlessOfRequestedCrafts() { + // 回归测试:真实客户端从创造背包拿可堆叠物品时,CraftCreative 的 numberOfRequestedCrafts + // 可能是任意值(部分客户端传 1),但客户端随后的 PLACE/DROP 请求会带整堆数量(maxStackSize)。 + // 若 CraftCreative 按 numberOfRequestedCrafts 写入更小数量,doTransfer 的 count 校验就会失败 + // -> 请求 error -> 回滚清空光标("光标短暂持有后被清")。 + // 因此 CREATED_OUTPUT 必须始终写入 maxStackSize。 + Player player = mockPlayer(); + PlayerUIInventory ui = new PlayerUIInventory(player); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + PluginManager pluginManager = Mockito.mock(PluginManager.class); + Mockito.when(player.getUIInventory()).thenReturn(ui); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getCursorInventory()).thenReturn(ui.getCursorInventory()); + Mockito.when(player.getCraftingGrid()).thenReturn(ui.getCraftingGrid()); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + Mockito.when(player.getGameVersion()).thenReturn(GameVersion.V1_21_130); + Mockito.when(player.getWindowId(inventory)).thenReturn(0); + Mockito.when(player.getWindowId(ui)).thenReturn(0); + Mockito.when(player.getWindowId(offhand)).thenReturn(0); + Mockito.when(MockServer.get().getPluginManager()).thenReturn(pluginManager); + + List creativeItems = Item.getCreativeItems(GameVersion.V1_21_130); + int creativeIndex = -1; + for (int i = 0; i < creativeItems.size(); i++) { + if (creativeItems.get(i).getMaxStackSize() > 1) { + creativeIndex = i; + break; + } + } + assertTrue(creativeIndex >= 0); + Item expected = creativeItems.get(creativeIndex); + int maxStack = expected.getMaxStackSize(); + + // numberOfRequestedCrafts=1(模拟真实客户端),但 PLACE 请求整堆 maxStack + CraftCreativeAction craft = new CraftCreativeAction(creativeIndex + 1, 1); + PlaceAction place = new PlaceAction( + maxStack, + new ItemStackRequestSlotData(ContainerSlotType.CREATED_OUTPUT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, 0, null), + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, 0, null) + ); + ItemStackRequest request = new ItemStackRequest( + 16, new ItemStackRequestAction[]{craft, place}, new String[0]); + + ItemStackRequestHandler.handleRequests(player, List.of(request)); + + assertEquals(expected.getId(), inventory.getItem(0).getId(), + "creative item should reach hotbar even when numberOfRequestedCrafts is smaller than maxStack"); + assertEquals(maxStack, inventory.getItem(0).getCount()); + assertTrue(ui.getItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT).isNull()); + ItemStackResponsePacket response = capturePacket(player, ItemStackResponsePacket.class); + assertEquals(ItemStackResponseStatus.OK, response.entries.get(0).getResult()); + } + @Test void creativeCreatedOutputTakeCanOverwriteDifferentDestinationItem() { Player player = mockPlayer(); From dd60ddae19edb5ebd1c6f557391a08184d82ec39 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sun, 14 Jun 2026 09:54:48 +0800 Subject: [PATCH 18/29] fix(inventory): null dynamicId for dynamic containers; add cartography UI and block-entity holders --- src/main/java/cn/nukkit/Player.java | 3 +++ .../nukkit/block/BlockCartographyTable.java | 22 +++++++++++++++++++ src/main/java/cn/nukkit/block/BlockChest.java | 2 +- .../java/cn/nukkit/block/BlockDropper.java | 14 +++++++++++- .../cn/nukkit/block/BlockEntityHolder.java | 8 +++---- .../java/cn/nukkit/block/BlockShulkerBox.java | 16 ++++++++++++-- .../cn/nukkit/inventory/BaseInventory.java | 10 +++------ .../inventory/DoubleChestInventory.java | 2 +- .../protocol/ContainerClosePacket.java | 2 +- .../InventoryServerAuthoritativeSyncTest.java | 12 +++++----- 10 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java index 691547bcb..3741ee86a 100644 --- a/src/main/java/cn/nukkit/Player.java +++ b/src/main/java/cn/nukkit/Player.java @@ -164,6 +164,7 @@ public class Player extends EntityHuman implements CommandSender, InventoryHolde */ public static final int LECTERN_WINDOW_ID = 7; public static final int STONECUTTER_WINDOW_ID = 8; + public static final int CARTOGRAPHY_WINDOW_ID = 9; // 后续创建的窗口应该从此数值开始 public static final int MINIMUM_OTHER_WINDOW_ID = Utils.dynamic(10); @@ -7400,8 +7401,10 @@ public void resetCraftingGridType() { this.moveBlockUIContents(Player.ANVIL_WINDOW_ID); // LOOM_WINDOW_ID is the same as ANVIL_WINDOW_ID? this.moveBlockUIContents(Player.ENCHANT_WINDOW_ID); this.moveBlockUIContents(Player.BEACON_WINDOW_ID); + this.moveBlockUIContents(Player.GRINDSTONE_WINDOW_ID); this.moveBlockUIContents(Player.SMITHING_WINDOW_ID); this.moveBlockUIContents(Player.STONECUTTER_WINDOW_ID); + this.moveBlockUIContents(Player.CARTOGRAPHY_WINDOW_ID); this.playerUIInventory.clearAll(); diff --git a/src/main/java/cn/nukkit/block/BlockCartographyTable.java b/src/main/java/cn/nukkit/block/BlockCartographyTable.java index 447373c39..8c8a20321 100644 --- a/src/main/java/cn/nukkit/block/BlockCartographyTable.java +++ b/src/main/java/cn/nukkit/block/BlockCartographyTable.java @@ -1,5 +1,9 @@ package cn.nukkit.block; +import cn.nukkit.Player; +import cn.nukkit.inventory.CartographyTableInventory; +import cn.nukkit.item.Item; +import cn.nukkit.item.ItemBlock; import cn.nukkit.item.ItemTool; import cn.nukkit.utils.BlockColor; @@ -39,4 +43,22 @@ public BlockColor getColor() { public int getBurnChance() { return 5; } + + @Override + public boolean canBeActivated() { + return true; + } + + @Override + public boolean onActivate(Item item, Player player) { + if (player != null) { + player.addWindow(new CartographyTableInventory(player.getUIInventory(), this), Player.CARTOGRAPHY_WINDOW_ID); + } + return true; + } + + @Override + public Item toItem() { + return new ItemBlock(Block.get(this.getId(), 0), 0); + } } diff --git a/src/main/java/cn/nukkit/block/BlockChest.java b/src/main/java/cn/nukkit/block/BlockChest.java index 5f7580d0f..430043aae 100644 --- a/src/main/java/cn/nukkit/block/BlockChest.java +++ b/src/main/java/cn/nukkit/block/BlockChest.java @@ -54,7 +54,7 @@ public int getId() { public Class getBlockEntityClass() { return BlockEntityChest.class; } - + @NotNull @Override public String getBlockEntityType() { diff --git a/src/main/java/cn/nukkit/block/BlockDropper.java b/src/main/java/cn/nukkit/block/BlockDropper.java index b27371d5e..dba705c57 100644 --- a/src/main/java/cn/nukkit/block/BlockDropper.java +++ b/src/main/java/cn/nukkit/block/BlockDropper.java @@ -18,7 +18,7 @@ import java.util.Map; import java.util.concurrent.ThreadLocalRandom; -public class BlockDropper extends BlockSolidMeta implements Faceable { +public class BlockDropper extends BlockSolidMeta implements Faceable, BlockEntityHolder { protected boolean triggered = false; @@ -40,6 +40,18 @@ public String getName() { return "Dropper"; } + @NotNull + @Override + public Class getBlockEntityClass() { + return BlockEntityDropper.class; + } + + @NotNull + @Override + public String getBlockEntityType() { + return BlockEntity.DROPPER; + } + @Override public double getHardness() { return 0.5; diff --git a/src/main/java/cn/nukkit/block/BlockEntityHolder.java b/src/main/java/cn/nukkit/block/BlockEntityHolder.java index 9254e2eef..a54e9d715 100644 --- a/src/main/java/cn/nukkit/block/BlockEntityHolder.java +++ b/src/main/java/cn/nukkit/block/BlockEntityHolder.java @@ -58,12 +58,12 @@ default E createBlockEntity(@Nullable CompoundTag initialData, @Nullable Object. } else { initialData = initialData.copy(); } - BlockEntity created = BlockEntity.createBlockEntity(typeName, chunk, + BlockEntity created = BlockEntity.createBlockEntity(typeName, chunk, initialData .putString("id", typeName) .putInt("x", getFloorX()) .putInt("y", getFloorY()) - .putInt("z", getFloorZ()), + .putInt("z", getFloorZ()), args); Class entityClass = getBlockEntityClass(); @@ -123,7 +123,7 @@ static > E setBlockAndCrea static > E setBlockAndCreateEntity( @NotNull H holder, boolean direct, boolean update, @Nullable CompoundTag initialData, @Nullable Object... args) { - Block block = holder.getBlock(); + Block block = holder.getBlock(); Level level = block.getLevel(); Block layer0 = level.getBlock(block, 0); Block layer1 = level.getBlock(block, 1); @@ -137,7 +137,7 @@ static > E setBlockAndCrea throw e; } } - + return null; } diff --git a/src/main/java/cn/nukkit/block/BlockShulkerBox.java b/src/main/java/cn/nukkit/block/BlockShulkerBox.java index 2ab57f952..52de21e00 100644 --- a/src/main/java/cn/nukkit/block/BlockShulkerBox.java +++ b/src/main/java/cn/nukkit/block/BlockShulkerBox.java @@ -20,7 +20,7 @@ /** * Created by PetteriM1 */ -public class BlockShulkerBox extends BlockTransparentMeta { +public class BlockShulkerBox extends BlockTransparentMeta implements BlockEntityHolder { public BlockShulkerBox() { this(0); @@ -45,6 +45,18 @@ public String getName() { return this.getDyeColor().getName() + " Shulker Box"; } + @NotNull + @Override + public Class getBlockEntityClass() { + return BlockEntityShulkerBox.class; + } + + @NotNull + @Override + public String getBlockEntityType() { + return BlockEntity.SHULKER_BOX; + } + @Override public double getHardness() { return 2.5; @@ -196,4 +208,4 @@ public Item[] getDrops(@Nullable Player player, Item item) { public boolean diffusesSkyLight() { return true; } -} \ No newline at end of file +} diff --git a/src/main/java/cn/nukkit/inventory/BaseInventory.java b/src/main/java/cn/nukkit/inventory/BaseInventory.java index ebf89cd20..2c3a9f5f7 100644 --- a/src/main/java/cn/nukkit/inventory/BaseInventory.java +++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java @@ -601,7 +601,7 @@ public void sendContents(Player... players) { continue; } pk.inventoryId = id; - pk.containerNameData = this.resolveFullContainerName(0, id); + pk.containerNameData = this.resolveFullContainerName(0); player.dataPacket(pk); } } @@ -675,7 +675,7 @@ private void sendSlotTo(int index, Player player) { return; } pk.inventoryId = id; - pk.containerNameData = this.resolveFullContainerName(index, id); + pk.containerNameData = this.resolveFullContainerName(index); player.dataPacket(pk); } else { ContainerSetSlotPacket_v113 pk = new ContainerSetSlotPacket_v113(); @@ -710,7 +710,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); + pk.containerNameData = this.resolveFullContainerName(index); player.dataPacket(pk); } else { player.dataPacket(pk2); @@ -732,10 +732,6 @@ 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 -> { diff --git a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java index fc99d36c2..2d7659e6c 100644 --- a/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java +++ b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java @@ -214,7 +214,7 @@ public void sendSlot(Inventory inv, int index, Player... players) { continue; } pk.inventoryId = id; - pk.containerNameData = new FullContainerName(ContainerSlotType.LEVEL_ENTITY, id); + pk.containerNameData = new FullContainerName(ContainerSlotType.LEVEL_ENTITY, null); player.dataPacket(pk); } } diff --git a/src/main/java/cn/nukkit/network/protocol/ContainerClosePacket.java b/src/main/java/cn/nukkit/network/protocol/ContainerClosePacket.java index 6f411f3d3..3fdbf0547 100644 --- a/src/main/java/cn/nukkit/network/protocol/ContainerClosePacket.java +++ b/src/main/java/cn/nukkit/network/protocol/ContainerClosePacket.java @@ -41,7 +41,7 @@ public void encode() { this.putByte((byte) this.windowId); if (protocol >= ProtocolInfo.v1_16_100) { if (protocol >= ProtocolInfo.v1_21_0) { - this.putByte((byte) this.type.ordinal()); + this.putByte((byte) this.type.getId()); } this.putBoolean(this.wasServerInitiated); } diff --git a/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java index 6c138124b..f9f76f02b 100644 --- a/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java +++ b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java @@ -42,7 +42,9 @@ void baseInventorySyncUsesConcreteContainerName() { chest.sendSlot(3, player); InventorySlotPacket slotPacket = capturePacket(player, InventorySlotPacket.class); assertEquals(ContainerSlotType.LEVEL_ENTITY, slotPacket.containerNameData.getContainer()); - assertEquals(7, slotPacket.containerNameData.getDynamicId()); + // Dynamic containers are never registered server-side, so a non-null dynamicId + // makes the client reject the packet (e.g. hopper/chest GUI closes immediately). + assertNull(slotPacket.containerNameData.getDynamicId()); Mockito.reset(player); player.protocol = ProtocolInfo.v1_21_30; @@ -50,7 +52,7 @@ void baseInventorySyncUsesConcreteContainerName() { chest.sendContents(player); InventoryContentPacket contentPacket = capturePacket(player, InventoryContentPacket.class); assertEquals(ContainerSlotType.LEVEL_ENTITY, contentPacket.containerNameData.getContainer()); - assertEquals(7, contentPacket.containerNameData.getDynamicId()); + assertNull(contentPacket.containerNameData.getDynamicId()); } @Test @@ -64,7 +66,7 @@ void furnaceSlotSyncUsesSlotSpecificContainerName() { furnace.sendSlot(0, player); InventorySlotPacket ingredientPacket = capturePacket(player, InventorySlotPacket.class); assertEquals(ContainerSlotType.FURNACE_INGREDIENT, ingredientPacket.containerNameData.getContainer()); - assertEquals(8, ingredientPacket.containerNameData.getDynamicId()); + assertNull(ingredientPacket.containerNameData.getDynamicId()); Mockito.reset(player); player.protocol = ProtocolInfo.v1_21_30; @@ -72,7 +74,7 @@ void furnaceSlotSyncUsesSlotSpecificContainerName() { furnace.sendSlot(2, player); InventorySlotPacket resultPacket = capturePacket(player, InventorySlotPacket.class); assertEquals(ContainerSlotType.FURNACE_RESULT, resultPacket.containerNameData.getContainer()); - assertEquals(8, resultPacket.containerNameData.getDynamicId()); + assertNull(resultPacket.containerNameData.getDynamicId()); } @Test @@ -115,7 +117,7 @@ void doubleChestSlotSyncCarriesWindowDynamicId() { InventorySlotPacket packet = capturePacket(player, InventorySlotPacket.class); assertEquals(27, packet.slot); assertEquals(ContainerSlotType.LEVEL_ENTITY, packet.containerNameData.getContainer()); - assertEquals(9, packet.containerNameData.getDynamicId()); + assertNull(packet.containerNameData.getDynamicId()); } @Test From 706caadd10aa9960dd478b8b15cab0a7f45fe1df Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sun, 14 Jun 2026 14:06:00 +0800 Subject: [PATCH 19/29] fix(inventory): correct failed window open handling and cartography network type --- src/main/java/cn/nukkit/Player.java | 4 +- .../cn/nukkit/inventory/InventoryType.java | 2 +- .../InventoryServerAuthoritativeSyncTest.java | 114 +++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java index 3741ee86a..2b8525940 100644 --- a/src/main/java/cn/nukkit/Player.java +++ b/src/main/java/cn/nukkit/Player.java @@ -7298,7 +7298,9 @@ public int addWindow(Inventory inventory, Integer forceId, boolean isPermanent, if (this.spawned && !this.inventoryOpen && inventory.open(this)) { return cnt; } else if (!alwaysOpen) { - this.removeWindow(inventory); + if (!this.permanentWindows.contains(cnt)) { + this.windows.remove(inventory); + } return -1; } else { diff --git a/src/main/java/cn/nukkit/inventory/InventoryType.java b/src/main/java/cn/nukkit/inventory/InventoryType.java index 0613d2e0f..0d8c659d8 100644 --- a/src/main/java/cn/nukkit/inventory/InventoryType.java +++ b/src/main/java/cn/nukkit/inventory/InventoryType.java @@ -37,7 +37,7 @@ public enum InventoryType { SMITHING_TABLE(3, "Smithing Table", 33), GRINDSTONE(3, "Grindstone", 26), STONECUTTER(2, "Stonecutter", 29), - CARTOGRAPHY(2, "Cartography Table", 32), + CARTOGRAPHY(2, "Cartography Table", 30), HORSE(17, "Horse", 12), //1 SADDLE, 1 ARMOR, up to 15 CHEST CRAFTER(9, "Crafter", 36); diff --git a/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java index f9f76f02b..b8fd8ecbb 100644 --- a/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java +++ b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java @@ -4,19 +4,24 @@ import cn.nukkit.Player; import cn.nukkit.blockentity.BlockEntityChest; import cn.nukkit.blockentity.BlockEntityFurnace; +import cn.nukkit.entity.item.EntityMinecartHopper; import cn.nukkit.item.Item; import cn.nukkit.level.Position; -import cn.nukkit.network.protocol.DataPacket; -import cn.nukkit.network.protocol.InventoryContentPacket; -import cn.nukkit.network.protocol.InventorySlotPacket; -import cn.nukkit.network.protocol.ProtocolInfo; +import cn.nukkit.network.SourceInterface; +import cn.nukkit.network.protocol.*; import cn.nukkit.network.protocol.types.inventory.ContainerSlotType; +import cn.nukkit.network.protocol.types.inventory.ContainerType; +import cn.nukkit.network.session.NetworkPlayerSession; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class InventoryServerAuthoritativeSyncTest { @@ -162,6 +167,61 @@ void offhandSyncCarriesStaticDynamicId() { assertEquals(0, packet.containerNameData.getDynamicId()); } + @Test + void failedDynamicWindowOpenDoesNotSendClosePacket() { + TestPlayer player = createWindowTestPlayer(); + FailingContainerInventory inventory = new FailingContainerInventory(Mockito.mock(BlockEntityChest.class)); + + int result = player.addWindow(inventory); + + assertEquals(-1, result); + assertEquals(-1, player.getWindowId(inventory)); + assertFalse(player.sentPackets.stream().anyMatch(ContainerClosePacket.class::isInstance)); + } + + @Test + void failedPermanentWindowOpenKeepsWindowMappingWithoutClosePacket() { + TestPlayer player = createWindowTestPlayer(); + player.spawned = false; + FailingContainerInventory inventory = new FailingContainerInventory(Mockito.mock(BlockEntityChest.class)); + + int result = player.addWindow(inventory, 42, true); + + assertEquals(-1, result); + assertEquals(42, player.getWindowId(inventory)); + assertFalse(player.sentPackets.stream().anyMatch(ContainerClosePacket.class::isInstance)); + } + + @Test + void minecartHopperUsesHopperUiTypeWhileCartographyUsesProtocolContainerId() { + assertEquals(InventoryType.HOPPER.getNetworkType(), InventoryType.MINECART_HOPPER.getNetworkType()); + assertEquals(ContainerType.CARTOGRAPHY.getId(), InventoryType.CARTOGRAPHY.getNetworkType()); + } + + @Test + void minecartHopperWindowOpensWithHopperUiTypeAndEntityId() { + TestPlayer player = createWindowTestPlayer(); + EntityMinecartHopper minecart = Mockito.mock(EntityMinecartHopper.class); + Mockito.when(minecart.getId()).thenReturn(1234L); + MinecartHopperInventory inventory = new MinecartHopperInventory(minecart); + + int windowId = player.addWindow(inventory); + + assertTrue(windowId >= Player.MINIMUM_OTHER_WINDOW_ID); + ContainerOpenPacket openPacket = findPacket(player, ContainerOpenPacket.class); + assertNotNull(openPacket); + assertEquals(windowId, openPacket.windowId); + assertEquals(InventoryType.HOPPER.getNetworkType(), openPacket.type); + assertEquals(1234L, openPacket.entityId); + + InventoryContentPacket contentPacket = findPacket(player, InventoryContentPacket.class); + assertNotNull(contentPacket); + assertTrue(player.sentPackets.indexOf(openPacket) < player.sentPackets.indexOf(contentPacket)); + assertEquals(windowId, contentPacket.inventoryId); + assertEquals(ContainerSlotType.LEVEL_ENTITY, contentPacket.containerNameData.getContainer()); + assertNull(contentPacket.containerNameData.getDynamicId()); + } + @Test void playerUIComponentForceWriteUsesBackingUIInventory() { Player player = Mockito.mock(Player.class); @@ -188,4 +248,50 @@ private static T capturePacket(Player player, Class ty Mockito.verify(player).dataPacket(captor.capture()); return assertInstanceOf(type, captor.getValue()); } + + private static T findPacket(TestPlayer player, Class type) { + return player.sentPackets.stream() + .filter(type::isInstance) + .map(type::cast) + .findFirst() + .orElse(null); + } + + private static TestPlayer createWindowTestPlayer() { + SourceInterface sourceInterface = Mockito.mock(SourceInterface.class); + Mockito.when(sourceInterface.getSession(Mockito.any(InetSocketAddress.class))) + .thenReturn(Mockito.mock(NetworkPlayerSession.class)); + + TestPlayer player = new TestPlayer(sourceInterface); + player.protocol = ProtocolInfo.v1_21_30; + player.spawned = true; + return player; + } + + private static final class TestPlayer extends Player { + + private final List sentPackets = new ArrayList<>(); + + private TestPlayer(SourceInterface sourceInterface) { + super(sourceInterface, 1L, new InetSocketAddress("127.0.0.1", 19132)); + } + + @Override + public boolean dataPacket(DataPacket packet) { + this.sentPackets.add(packet); + return true; + } + } + + private static final class FailingContainerInventory extends ContainerInventory { + + private FailingContainerInventory(InventoryHolder holder) { + super(holder, InventoryType.HOPPER); + } + + @Override + public boolean open(Player who) { + return false; + } + } } From a9a2b2161e9d05d7b8b19f327b62f9aed8ca1230 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Mon, 15 Jun 2026 07:32:54 +0800 Subject: [PATCH 20/29] fix(inventory): allow custom items with allowOffHand in off-hand slot canBePutInOffhandSlot() used a hardcoded numeric-ID whitelist that rejected every custom item (id == 255), even when the mod author called CustomItemDefinition.allowOffHand(true). That setting was only written to the client-bound NBT and never read server-side, so placing a custom item in the off-hand was always rejected in SAI mode. Override canBePutInOffhandSlot() in ItemCustom to read the allow_off_hand property from its CustomItemDefinition, so custom items opt-in via allowOffHand(true) now work in the off-hand slot. Root cause: commit e893b6e3e introduced the whitelist without a hook for custom items. --- .../cn/nukkit/item/customitem/ItemCustom.java | 15 ++++ .../ItemStackRequestProcessorTest.java | 78 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java index 805c8689a..11d6bc67f 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java @@ -40,6 +40,21 @@ public String getTextureName() { @Override public abstract CustomItemDefinition getDefinition(); + /** + * 当{@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)}设置为{@code true}时, + * 允许该自定义物品放入副手槽。 + *

+ * Allows this custom item to be put into the off-hand slot when + * {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} is set to {@code true}. + */ + @Override + public boolean canBePutInOffhandSlot() { + return this.getDefinition().getNbt() + .getCompound("components") + .getCompound("item_properties") + .getBoolean("allow_off_hand"); + } + @Override public ItemCustom clone() { return (ItemCustom) super.clone(); diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 9aa7a7bdf..c7c31db57 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -482,6 +482,84 @@ void offhandInventoryAllowsDirectApiForCompatibility() { assertEquals(Item.SHIELD, offhand.getItem(0).getId()); } + @Test + void offhandAcceptsCustomItemThatAllowsOffHand() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item custom = new TestCustomItem("test:offhand_allowed", "Offhand Allowed", true); + custom.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, custom, false)); + custom = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, custom.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertTrue(inventory.getItem(0).isNull()); + assertEquals(custom.getNamespaceId(), offhand.getItem(0).getNamespaceId()); + } + + @Test + void offhandRejectsCustomItemThatDisallowsOffHand() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item custom = new TestCustomItem("test:offhand_disallowed", "Offhand Disallowed", false); + custom.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, custom, false)); + custom = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, custom.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertFalse(response.success()); + assertEquals(custom.getNamespaceId(), inventory.getItem(0).getNamespaceId()); + assertTrue(offhand.getItem(0).isNull()); + } + + /** + * Minimal {@link cn.nukkit.item.customitem.ItemCustom} used by the off-hand tests. + * Its {@link cn.nukkit.item.customitem.CustomItemDefinition} is built with the + * {@code allow_off_hand} property driven by the constructor argument. + */ + private static final class TestCustomItem extends cn.nukkit.item.customitem.ItemCustom { + private final boolean allowOffHand; + + TestCustomItem(String id, String name, boolean allowOffHand) { + super(id, name); + this.allowOffHand = allowOffHand; + } + + @Override + public cn.nukkit.item.customitem.CustomItemDefinition getDefinition() { + return cn.nukkit.item.customitem.CustomItemDefinition + .customBuilder(this, cn.nukkit.network.protocol.types.inventory.creative.CreativeItemCategory.ITEMS) + .allowOffHand(this.allowOffHand) + .build(); + } + } + @Test void transferFiresLegacyTransactionThenSingleClickEvent() { Player player = mockPlayer(); From c8d5a02c4a81bf4d7783a972f0c98a6ea9dce892 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Mon, 15 Jun 2026 10:12:54 +0800 Subject: [PATCH 21/29] fix(customitem): make custom armor and tools fully functional server-side Custom armor and tools were silently broken because their server-side properties came from Item base defaults (0 armor points, 1 attack damage, tier 0, no equipment slot/tool type) instead of the configured CustomItemDefinition. The root cause was a circular dependency: ArmorBuilder/ToolBuilder.build() called item.isHelmet()/isPickaxe() etc. to decide which NBT to write, but ItemCustomArmor/ItemCustomTool never overrode those methods (all returned false), so no NBT was written, and there was nothing to read back. Break the cycle by making it explicit: - ArmorBuilder.slot(ArmorSlot) and ToolBuilder.toolType(ToolType) chain methods let mod authors declare the slot/type; builders write the NBT, and ItemCustomArmor/ItemCustomTool read it back via overrides of isHelmet/isChestplate/isLeggings/isBoots and isPickaxe/isAxe/isShovel/isHoe/isSword/isShears. - New builder chain methods (attackDamage, maxDurability, armorPoints, toughness, tier) feed the server-side getAttackDamage/getArmorPoints/ getToughness/getTier overrides so custom gear uses real values. - Add CustomItem.getDefinitionNbt() default method that reads the registry snapshot (falling back to getDefinition() when unregistered) as the single NBT source for all ItemCustom* overrides. Also fixes a pre-existing bug in SimpleBuilder.tag(): getList(name, cls) returns a fresh empty ListTag (never null) when the key is missing, so tags were silently dropped on first use. Now checks contains() first. Enchantability is now derived from the configured tier in build() (tierToArmorEnchantAbility/tierToToolEnchantAbility) instead of calling item.getEnchantAbility() during construction, which caused infinite recursion once getTier() started reading the definition NBT. Custom armor can now be equipped and provides protection; custom tools deal configured damage, have durability, receive type-appropriate enchantments, and can be enchanted at an enchant table. Root cause: commit e893b6e3e introduced server-authoritative slot validation without a hook for custom items. --- .../cn/nukkit/item/customitem/ArmorSlot.java | 41 ++++ .../cn/nukkit/item/customitem/CustomItem.java | 25 +- .../item/customitem/CustomItemDefinition.java | 229 +++++++++++++++--- .../cn/nukkit/item/customitem/ItemCustom.java | 4 +- .../item/customitem/ItemCustomArmor.java | 63 +++++ .../item/customitem/ItemCustomTool.java | 93 ++++++- .../cn/nukkit/item/customitem/ToolType.java | 48 ++++ .../customitem/CustomItemPropertyTest.java | 198 +++++++++++++++ 8 files changed, 653 insertions(+), 48 deletions(-) create mode 100644 src/main/java/cn/nukkit/item/customitem/ArmorSlot.java create mode 100644 src/main/java/cn/nukkit/item/customitem/ToolType.java create mode 100644 src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java diff --git a/src/main/java/cn/nukkit/item/customitem/ArmorSlot.java b/src/main/java/cn/nukkit/item/customitem/ArmorSlot.java new file mode 100644 index 000000000..ed327170c --- /dev/null +++ b/src/main/java/cn/nukkit/item/customitem/ArmorSlot.java @@ -0,0 +1,41 @@ +package cn.nukkit.item.customitem; + +import org.jetbrains.annotations.NotNull; + +/** + * 自定义盔甲的装备槽位。 + *

+ * The equipment slot of custom armor. + */ +public enum ArmorSlot { + HEAD("slot.armor.head", "armor_head"), + CHEST("slot.armor.chest", "armor_torso"), + LEGS("slot.armor.legs", "armor_legs"), + FEET("slot.armor.feet", "armor_feet"); + + private final String wearableSlot; + private final String enchantableSlot; + + ArmorSlot(@NotNull String wearableSlot, @NotNull String enchantableSlot) { + this.wearableSlot = wearableSlot; + this.enchantableSlot = enchantableSlot; + } + + /** + * 客户端 {@code minecraft:wearable.slot} 值。 + *

+ * The {@code minecraft:wearable.slot} value sent to the client. + */ + public @NotNull String getWearableSlot() { + return wearableSlot; + } + + /** + * 客户端 {@code item_properties.enchantable_slot} 值。 + *

+ * The {@code item_properties.enchantable_slot} value sent to the client. + */ + public @NotNull String getEnchantableSlot() { + return enchantableSlot; + } +} diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItem.java b/src/main/java/cn/nukkit/item/customitem/CustomItem.java index 9f74df85a..f1384b79c 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItem.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItem.java @@ -2,11 +2,12 @@ import cn.nukkit.item.Item; import cn.nukkit.item.StringItem; +import cn.nukkit.nbt.tag.CompoundTag; /** * 继承这个类实现自定义物品,重写{@link Item}中的方法控制方块属性 *

- * Inherit this class to implement a custom item, override the methods in the {@link Item} to control the feature of the item. + * Inherit this class to implement a custom item, override the methods in {@link Item} to control the feature of the item. * * @author lt_name */ @@ -26,4 +27,26 @@ public interface CustomItem extends StringItem { * This method sets the definition of custom item */ CustomItemDefinition getDefinition(); + + /** + * 从注册表读取当前物品定义的 NBT。注册表保存的是注册时的快照, + * 比 {@link #getDefinition()}(每次调用都重建)更可靠、更高效。 + * 若物品未注册(如测试或注册前访问),回退到 {@link #getDefinition()}。 + *

+ * Reads this item definition's NBT from the registry. The registry holds the + * snapshot taken at registration time, which is more reliable and efficient + * than {@link #getDefinition()} (rebuilt on every call). Falls back to + * {@link #getDefinition()} when the item is not registered (e.g. in tests). + * + * @return 定义 NBT + */ + default CompoundTag getDefinitionNbt() { + var definitions = Item.getCustomItemDefinition(); + var definition = definitions.get(this.getNamespaceId()); + if (definition == null) { + // 未注册时回退到实时定义,保证测试和边界场景可用 + definition = this.getDefinition(); + } + return definition.getNbt(); + } } diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index e2fc90824..e393dcd77 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -3,6 +3,8 @@ import cn.nukkit.GameVersion; import cn.nukkit.inventory.ItemTag; import cn.nukkit.item.Item; +import cn.nukkit.item.ItemArmor; +import cn.nukkit.item.ItemTool; import cn.nukkit.item.RuntimeItems; import cn.nukkit.item.customitem.data.DigProperty; import cn.nukkit.item.customitem.data.ItemCreativeGroup; @@ -402,8 +404,10 @@ public SimpleBuilder renderOffsets(@NotNull RenderOffsets renderOffsets) { */ public SimpleBuilder tag(String... tags) { Arrays.stream(tags).forEach(Identifier::assertValid); - var list = this.nbt.getCompound("components").getList("item_tags", StringTag.class); - if (list == null) { + ListTag list; + if (this.nbt.getCompound("components").contains("item_tags")) { + list = this.nbt.getCompound("components").getList("item_tags", StringTag.class); + } else { list = new ListTag<>("item_tags"); this.nbt.getCompound("components").putList(list); } @@ -513,6 +517,10 @@ public static class ToolBuilder extends SimpleBuilder { private final CompoundTag diggerRoot = new CompoundTag("minecraft:digger") .putBoolean("use_efficiency", true) .putList(new ListTag<>("destroy_speeds")); + private @Nullable ToolType toolType = null; + private @Nullable Integer attackDamage = null; + private @Nullable Integer maxDurability = null; + private @Nullable Integer tier = null; public static Map> toolBlocks = new HashMap<>(); @@ -553,10 +561,6 @@ public static class ToolBuilder extends SimpleBuilder { private ToolBuilder(ItemCustomTool item, CreativeItemCategory creativeCategory) { super(item, creativeCategory); this.item = item; - this.nbt.getCompound("components") - .getCompound("item_properties") - .putInt("enchantable_value", item.getEnchantAbility()); - this.nbt.getCompound("components") .getCompound("item_properties") .putFloat("mining_speed", 1f) @@ -583,6 +587,54 @@ public ToolBuilder addRepairItems(@NotNull List repairItems, int repairAmo return this; } + /** + * 指定工具类型。决定可挖掘方块、{@code enchantable_slot}、{@code item_tags}, + * 并使服务端的 {@code isPickaxe()/isAxe()/...} 返回 {@code true}。 + *

+ * Specifies the tool type. Determines mineable blocks, {@code enchantable_slot}, + * {@code item_tags}, and makes server-side {@code isPickaxe()/isAxe()/...} return {@code true}. + */ + public ToolBuilder toolType(@NotNull ToolType toolType) { + this.toolType = toolType; + return this; + } + + /** + * 设置攻击伤害。服务端的 {@link Item#getAttackDamage()} 会读取此值。 + * 未设置时,使用物品实例的 {@code getAttackDamage()}(通常为基类默认 1)。 + *

+ * Sets the attack damage. Server-side {@link Item#getAttackDamage()} reads this value. + * When unset, the item instance's {@code getAttackDamage()} is used. + */ + public ToolBuilder attackDamage(int attackDamage) { + this.attackDamage = attackDamage; + return this; + } + + /** + * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 + * 未设置时,使用物品实例的 {@code getMaxDurability()}。 + *

+ * Sets the max durability. Server-side {@link Item#getMaxDurability()} reads this value. + * When unset, the item instance's {@code getMaxDurability()} is used. + */ + public ToolBuilder maxDurability(int maxDurability) { + this.maxDurability = maxDurability; + return this; + } + + /** + * 设置工具层级(tier)。影响 {@code getEnchantAbility()} 及默认挖掘速度。 + * 未设置时默认为 0(无附魔能力)。 + *

+ * Sets the tool tier. Affects {@code getEnchantAbility()} and the default mining speed. + * Defaults to 0 (no enchantability) when unset. + */ + public ToolBuilder tier(int tier) { + this.tier = tier; + return this; + } + /** * 控制采集类工具的挖掘速度 * @@ -593,7 +645,9 @@ public ToolBuilder speed(int speed) { log.warn("speed has an invalid value!"); return this; } - if (item.isPickaxe() || item.isShovel() || item.isHoe() || item.isAxe() || item.isShears()) { + if (this.toolType != null) { + this.speed = speed; + } else if (item.isPickaxe() || item.isShovel() || item.isHoe() || item.isAxe() || item.isShears()) { this.speed = speed; } return this; @@ -706,14 +760,19 @@ public ToolBuilder addExtraBlockTags(@NotNull List blockTags) { @Override public CustomItemDefinition build() { - //附加耐久 攻击伤害信息 + //附加耐久 攻击伤害 tier 信息 + int resolvedDurability = this.maxDurability != null ? this.maxDurability : item.getMaxDurability(); + int resolvedDamage = this.attackDamage != null ? this.attackDamage : item.getAttackDamage(); + int resolvedTier = this.tier != null ? this.tier : item.getTier(); this.nbt.getCompound("components") - .putCompound("minecraft:durability", new CompoundTag().putInt("max_durability", item.getMaxDurability())) + .putCompound("minecraft:durability", new CompoundTag().putInt("max_durability", resolvedDurability)) .getCompound("item_properties") - .putInt("damage", item.getAttackDamage()); + .putInt("damage", resolvedDamage) + .putInt("tier", resolvedTier) + .putInt("enchantable_value", tierToToolEnchantAbility(resolvedTier)); if (speed == null) { - speed = switch (item.getTier()) { + speed = switch (resolvedTier) { case 6 -> 7; case 5 -> 6; case 4 -> 5; @@ -723,8 +782,17 @@ public CustomItemDefinition build() { default -> 1; }; } + //确定工具类型:仅使用显式设置的 toolType。避免调用 item.isPickaxe() 等实例方法, + //因为这些方法现在从本 NBT 读取,构造期调用会造成无限递归。 + //模组作者应通过 toolType(ToolType) 显式指定工具类型。 Identifier type = null; - if (item.isPickaxe()) { + boolean isPickaxe = this.toolType == ToolType.PICKAXE; + boolean isAxe = this.toolType == ToolType.AXE; + boolean isShovel = this.toolType == ToolType.SHOVEL; + boolean isHoe = this.toolType == ToolType.HOE; + boolean isSword = this.toolType == ToolType.SWORD; + boolean isShears = this.toolType == ToolType.SHEARS; + if (isPickaxe) { //添加可挖掘方块Tags this.blockTags.addAll(List.of("'stone'", "'metal'", "'diamond_pick_diggable'", "'mob_spawner'", "'rail'", "'slab_block'", "'stair_block'", "'smooth stone slab'", "'sandstone slab'", "'cobblestone slab'", "'brick slab'", "'stone bricks slab'", "'quartz slab'", "'nether brick slab'", "'glazed terracotta'", "coral")); //添加可挖掘方块 @@ -734,31 +802,34 @@ public CustomItemDefinition build() { .putString("enchantable_slot", "pickaxe"); this.tag("minecraft:is_pickaxe"); //this.isWeapon(); - } else if (item.isAxe()) { + } else if (isAxe) { this.blockTags.addAll(List.of("'wood'", "'pumpkin'", "'plant'")); type = ItemTag.IS_AXE; this.nbt.getCompound("components").getCompound("item_properties") .putString("enchantable_slot", "axe"); this.tag("minecraft:is_axe"); //this.isWeapon(); - } else if (item.isShovel()) { + } else if (isShovel) { this.blockTags.addAll(List.of("'sand'", "'dirt'", "'gravel'", "'grass'", "'snow'")); type = ItemTag.IS_SHOVEL; this.nbt.getCompound("components").getCompound("item_properties") .putString("enchantable_slot", "shovel"); this.tag("minecraft:is_shovel"); //this.isWeapon(); - } else if (item.isHoe()) { + } else if (isHoe) { this.nbt.getCompound("components").getCompound("item_properties") .putString("enchantable_slot", "hoe"); type = ItemTag.IS_HOE; this.tag("minecraft:is_hoe"); //this.isWeapon(); - } else if (item.isSword()) { + } else if (isSword) { this.nbt.getCompound("components").getCompound("item_properties") .putString("enchantable_slot", "sword"); type = ItemTag.IS_SWORD; //this.isWeapon(); + } else if (isShears) { + type = null; + this.tag("minecraft:is_shears"); } else { if (this.nbt.getCompound("components").contains("item_tags")) { var list = this.nbt.getCompound("components").getList("item_tags", StringTag.class).getAll(); @@ -805,17 +876,37 @@ public CustomItemDefinition build() { } return calculateID(); } + + /** + * tier → 工具附魔能力映射,复刻 {@link cn.nukkit.item.ItemTool#getEnchantAbility()}。 + *

+ * tier → tool enchantability mapping, mirroring {@link cn.nukkit.item.ItemTool#getEnchantAbility()}. + */ + private static int tierToToolEnchantAbility(int tier) { + return switch (tier) { + case ItemTool.TIER_STONE -> 5; + case ItemTool.TIER_WOODEN -> 15; + case ItemTool.TIER_DIAMOND -> 10; + case ItemTool.TIER_GOLD -> 22; + case ItemTool.TIER_IRON -> 14; + case ItemTool.TIER_NETHERITE -> 10; + default -> 0; + }; + } } public static class ArmorBuilder extends SimpleBuilder { private final ItemCustomArmor item; + private @Nullable ArmorSlot slot = null; + private @Nullable Integer armorPoints = null; + private @Nullable Integer toughness = null; + private @Nullable Integer tier = null; private ArmorBuilder(ItemCustomArmor item, CreativeItemCategory creativeCategory) { super(item, creativeCategory); this.item = item; this.nbt.getCompound("components") .getCompound("item_properties") - .putInt("enchantable_value", item.getEnchantAbility()) .putBoolean("can_destroy_in_creative", true); } @@ -839,40 +930,100 @@ public ArmorBuilder addRepairItems(@NotNull List repairItems, int repairAm return this; } + /** + * 指定盔甲装备槽位。决定 {@code wearable.slot}、{@code enchantable_slot}, + * 并使服务端的 {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} 返回 {@code true}。 + * 未设置时回退到基于 item 实例方法的判定。 + *

+ * Specifies the armor equipment slot. Determines {@code wearable.slot}, {@code enchantable_slot}, + * and makes server-side {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} return {@code true}. + * Falls back to item instance methods when unset. + */ + public ArmorBuilder slot(@NotNull ArmorSlot slot) { + this.slot = slot; + return this; + } + + /** + * 设置护甲值。服务端的 {@link Item#getArmorPoints()} 会读取此值。 + * 未设置时,使用物品实例的 {@code getArmorPoints()}。 + *

+ * Sets the armor points. Server-side {@link Item#getArmorPoints()} reads this value. + * When unset, the item instance's {@code getArmorPoints()} is used. + */ + public ArmorBuilder armorPoints(int armorPoints) { + this.armorPoints = armorPoints; + return this; + } + + /** + * 设置盔甲韧性(toughness)。服务端的 {@link Item#getToughness()} 会读取此值。 + * 未设置时默认为 0。 + *

+ * Sets the armor toughness. Server-side {@link Item#getToughness()} reads this value. + * Defaults to 0 when unset. + */ + public ArmorBuilder toughness(int toughness) { + this.toughness = toughness; + return this; + } + + /** + * 设置盔甲层级(tier)。影响 {@code getEnchantAbility()}。 + * 未设置时默认为 0(无附魔能力)。 + *

+ * Sets the armor tier. Affects {@code getEnchantAbility()}. + * Defaults to 0 (no enchantability) when unset. + */ + public ArmorBuilder tier(int tier) { + this.tier = tier; + return this; + } + @Override public CustomItemDefinition build() { + int resolvedProtection = this.armorPoints != null ? this.armorPoints : item.getArmorPoints(); + int resolvedToughness = this.toughness != null ? this.toughness : item.getToughness(); + int resolvedTier = this.tier != null ? this.tier : item.getTier(); this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag() .putInt("max_durability", item.getMaxDurability())) .putCompound("minecraft:wearable", new CompoundTag() - .putInt("protection", item.getArmorPoints())); - if (item.isHelmet()) { - this.nbt.getCompound("components").getCompound("item_properties") - .putString("enchantable_slot", "armor_head"); - this.nbt.getCompound("components") - .getCompound("minecraft:wearable") - .putString("slot", "slot.armor.head"); - } else if (item.isChestplate()) { - this.nbt.getCompound("components").getCompound("item_properties") - .putString("enchantable_slot", "armor_torso"); - this.nbt.getCompound("components") - .getCompound("minecraft:wearable") - .putString("slot", "slot.armor.chest"); - } else if (item.isLeggings()) { - this.nbt.getCompound("components").getCompound("item_properties") - .putString("enchantable_slot", "armor_legs"); - this.nbt.getCompound("components") - .getCompound("minecraft:wearable") - .putString("slot", "slot.armor.legs"); - } else if (item.isBoots()) { + .putInt("protection", resolvedProtection) + .putInt("toughness", resolvedToughness)) + .getCompound("item_properties") + .putInt("tier", resolvedTier) + .putInt("enchantable_value", tierToArmorEnchantAbility(resolvedTier)); + //确定槽位:仅使用显式设置的 slot。避免调用 item.isHelmet() 等实例方法, + //因为这些方法现在从本 NBT 读取,构造期调用会造成无限递归。 + //模组作者应通过 slot(ArmorSlot) 显式指定装备槽位。 + ArmorSlot resolvedSlot = this.slot; + if (resolvedSlot != null) { this.nbt.getCompound("components").getCompound("item_properties") - .putString("enchantable_slot", "armor_feet"); + .putString("enchantable_slot", resolvedSlot.getEnchantableSlot()); this.nbt.getCompound("components") .getCompound("minecraft:wearable") - .putString("slot", "slot.armor.feet"); + .putString("slot", resolvedSlot.getWearableSlot()); } return calculateID(); } + + /** + * tier → 盔甲附魔能力映射,复刻 {@link cn.nukkit.item.ItemArmor#getEnchantAbility()}。 + *

+ * tier → armor enchantability mapping, mirroring {@link cn.nukkit.item.ItemArmor#getEnchantAbility()}. + */ + private static int tierToArmorEnchantAbility(int tier) { + return switch (tier) { + case ItemArmor.TIER_CHAIN, ItemArmor.TIER_COPPER -> 12; + case ItemArmor.TIER_LEATHER -> 15; + case ItemArmor.TIER_DIAMOND -> 10; + case ItemArmor.TIER_GOLD -> 25; + case ItemArmor.TIER_IRON -> 9; + case ItemArmor.TIER_NETHERITE -> 10; + default -> 0; + }; + } } public static class EdibleBuilder extends SimpleBuilder { diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java index 11d6bc67f..5ea7ab548 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java @@ -10,7 +10,7 @@ /** * 继承这个类实现自定义物品,重写{@link Item}中的方法控制方块属性 *

- * Inherit this class to implement a custom item, override the methods in the {@link Item} to control the feature of the item. + * Inherit this class to implement a custom item, override the methods in {@link Item} to control the feature of the item. * * @author lt_name */ @@ -49,7 +49,7 @@ public String getTextureName() { */ @Override public boolean canBePutInOffhandSlot() { - return this.getDefinition().getNbt() + return this.getDefinitionNbt() .getCompound("components") .getCompound("item_properties") .getBoolean("allow_off_hand"); diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java index dcb8a9e40..c381154c7 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java @@ -4,6 +4,7 @@ import cn.nukkit.item.ItemArmor; import cn.nukkit.item.ItemID; import cn.nukkit.item.StringItem; +import cn.nukkit.nbt.tag.CompoundTag; import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -46,4 +47,66 @@ public String getNamespaceId(GameVersion protocolId) { public final int getId() { return CustomItem.super.getId(); } + + /** + * 读取 {@code minecraft:wearable.slot} 判定装备槽位。 + *

+ * Reads {@code minecraft:wearable.slot} to determine the equipment slot. + */ + private boolean wearableSlotEquals(@NotNull String expected) { + String slot = this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:wearable") + .getString("slot"); + return expected.equals(slot); + } + + @Override + public boolean isHelmet() { + return wearableSlotEquals("slot.armor.head"); + } + + @Override + public boolean isChestplate() { + return wearableSlotEquals("slot.armor.chest"); + } + + @Override + public boolean isLeggings() { + return wearableSlotEquals("slot.armor.legs"); + } + + @Override + public boolean isBoots() { + return wearableSlotEquals("slot.armor.feet"); + } + + @Override + public int getArmorPoints() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:wearable") + .getInt("protection"); + } + + @Override + public int getToughness() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:wearable") + .getInt("toughness"); + } + + @Override + public int getTier() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("tier"); + } + + @Override + public ItemCustomArmor clone() { + return (ItemCustomArmor) super.clone(); + } } diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java index fea5584a9..b558ac480 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java @@ -2,7 +2,9 @@ import cn.nukkit.item.*; import cn.nukkit.nbt.tag.CompoundTag; -import cn.nukkit.network.protocol.ProtocolInfo; +import cn.nukkit.nbt.tag.ListTag; +import cn.nukkit.nbt.tag.StringTag; +import cn.nukkit.nbt.tag.Tag; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -23,22 +25,101 @@ public ItemCustomTool(@NotNull String id, @Nullable String name, @NotNull String this.textureName = textureName; } + @Override + public String getTextureName() { + return textureName; + } + + /** + * 判断物品是否含有指定的 item_tag(写入 {@code components.item_tags} 中的标签)。 + *

+ * Checks whether the item has the given item_tag (written into {@code components.item_tags}). + */ + private boolean hasItemTag(@NotNull String expected) { + CompoundTag components = this.getDefinitionNbt().getCompound("components"); + if (!components.contains("item_tags")) { + return false; + } + ListTag list = components.getList("item_tags"); + for (Tag tag : list.getAll()) { + if (tag instanceof StringTag stringTag && expected.equals(stringTag.parseValue())) { + return true; + } + } + return false; + } + @Override public int getMaxDurability() { - return ItemTool.DURABILITY_WOODEN; + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:durability") + .getInt("max_durability"); } @Override - public String getTextureName() { - return textureName; + public int getTier() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("tier"); + } + + @Override + public int getAttackDamage() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("damage"); + } + + @Override + public boolean isPickaxe() { + return hasItemTag("minecraft:is_pickaxe"); + } + + @Override + public boolean isAxe() { + return hasItemTag("minecraft:is_axe"); + } + + @Override + public boolean isShovel() { + return hasItemTag("minecraft:is_shovel"); + } + + @Override + public boolean isHoe() { + return hasItemTag("minecraft:is_hoe"); + } + + @Override + public boolean isShears() { + return hasItemTag("minecraft:is_shears"); + } + + @Override + public boolean isSword() { + //剑无 item_tag,通过 enchantable_slot 判定 + //Swords have no item tag, so determine via enchantable_slot + String slot = this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getString("enchantable_slot"); + return "sword".equals(slot); } @Nullable public final Integer getSpeed() { - var nbt = Item.getCustomItemDefinition().get(this.getNamespaceId()).getNbt(ProtocolInfo.CURRENT_PROTOCOL); - if (nbt == null || !nbt.getCompound("components").contains("minecraft:digger")) return null; + var nbt = this.getDefinitionNbt(); + if (!nbt.getCompound("components").contains("minecraft:digger")) return null; return nbt.getCompound("components") .getCompound("minecraft:digger") .getList("destroy_speeds", CompoundTag.class).get(0).getInt("speed"); } + + @Override + public ItemCustomTool clone() { + return (ItemCustomTool) super.clone(); + } } diff --git a/src/main/java/cn/nukkit/item/customitem/ToolType.java b/src/main/java/cn/nukkit/item/customitem/ToolType.java new file mode 100644 index 000000000..7c065e959 --- /dev/null +++ b/src/main/java/cn/nukkit/item/customitem/ToolType.java @@ -0,0 +1,48 @@ +package cn.nukkit.item.customitem; + +import org.jetbrains.annotations.Nullable; + +/** + * 自定义工具的类型。 + *

+ * The type of custom tool. + */ +public enum ToolType { + PICKAXE("minecraft:is_pickaxe", "pickaxe"), + AXE("minecraft:is_axe", "axe"), + SHOVEL("minecraft:is_shovel", "shovel"), + HOE("minecraft:is_hoe", "hoe"), + SWORD(null, "sword"), + SHEARS("minecraft:is_shears", null); + + @Nullable + private final String itemTag; + @Nullable + private final String enchantableSlot; + + ToolType(@Nullable String itemTag, @Nullable String enchantableSlot) { + this.itemTag = itemTag; + this.enchantableSlot = enchantableSlot; + } + + /** + * 写入 {@code item_tags} 的标签(用于服务端判定 {@code isPickaxe()} 等及客户端工具类型)。 + * {@code null} 表示该工具类型无 item_tag(如剑)。 + *

+ * The tag written into {@code item_tags}. {@code null} means the tool type + * has no item tag (e.g. sword). + */ + public @Nullable String getItemTag() { + return itemTag; + } + + /** + * 客户端 {@code item_properties.enchantable_slot} 值。{@code null} 表示无附魔槽位。 + *

+ * The {@code item_properties.enchantable_slot} value sent to the client. + * {@code null} means no enchantable slot. + */ + public @Nullable String getEnchantableSlot() { + return enchantableSlot; + } +} diff --git a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java new file mode 100644 index 000000000..8d4774943 --- /dev/null +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -0,0 +1,198 @@ +package cn.nukkit.item.customitem; + +import cn.nukkit.MockServer; +import cn.nukkit.item.Item; +import cn.nukkit.item.ItemArmor; +import cn.nukkit.item.ItemTool; +import cn.nukkit.item.enchantment.EnchantmentType; +import cn.nukkit.nbt.tag.CompoundTag; +import cn.nukkit.network.protocol.types.inventory.creative.CreativeItemCategory; +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.*; + +/** + * 回归测试:自定义盔甲和工具的服务端属性必须从 {@link CustomItemDefinition} 的 NBT 正确读取, + * 而不是返回 {@link Item} 基类的错误默认值。 + *

+ * Regression tests: custom armor and tool server-side properties must be read from the + * {@link CustomItemDefinition} NBT instead of the wrong {@link Item} base defaults. + */ +class CustomItemPropertyTest { + + @BeforeAll + static void init() { + MockServer.init(); + } + + private CustomArmor helmet; + private CustomTool pickaxe; + private CustomTool sword; + + @BeforeEach + void setUp() { + helmet = new CustomArmor("test:helmet", "Test Helmet"); + pickaxe = new CustomTool("test:pickaxe", "Test Pickaxe"); + sword = new CustomTool("test:sword", "Test Sword"); + } + + // ===== 自定义盔甲 ===== + + @Test + void customHelmetIsHelmetAndEquippable() { + assertTrue(helmet.isHelmet()); + assertFalse(helmet.isChestplate()); + assertFalse(helmet.isLeggings()); + assertFalse(helmet.isBoots()); + assertTrue(helmet.isArmor()); + assertTrue(helmet.canBePutInHelmetSlot()); + } + + @Test + void customHelmetHasConfiguredArmorPoints() { + assertEquals(5, helmet.getArmorPoints()); + } + + @Test + void customHelmetHasConfiguredToughness() { + assertEquals(2, helmet.getToughness()); + } + + @Test + void customHelmetHasConfiguredTier() { + assertEquals(ItemArmor.TIER_IRON, helmet.getTier()); + } + + @Test + void customHelmetEnchantAbilityNonZero() { + // ItemArmor.getEnchantAbility() 分派于 getTier(),tier=IRON(3) -> 9 + assertEquals(9, helmet.getEnchantAbility()); + } + + @Test + void customArmorBuilderWritesWearableSlotToNbt() { + CompoundTag nbt = helmet.getDefinition().getNbt(); + assertEquals("slot.armor.head", nbt.getCompound("components").getCompound("minecraft:wearable").getString("slot")); + assertEquals("armor_head", nbt.getCompound("components").getCompound("item_properties").getString("enchantable_slot")); + } + + @Test + void enchantArmorHeadAcceptsCustomHelmet() { + assertTrue(EnchantmentType.ARMOR_HEAD.canEnchantItem(helmet)); + } + + // ===== 自定义工具 ===== + + @Test + void customPickaxeIsPickaxe() { + assertTrue(pickaxe.isPickaxe()); + assertFalse(pickaxe.isAxe()); + assertFalse(pickaxe.isSword()); + } + + @Test + void customPickaxeHasConfiguredAttackDamage() { + assertEquals(7, pickaxe.getAttackDamage()); + } + + @Test + void customPickaxeHasConfiguredMaxDurability() { + assertEquals(1561, pickaxe.getMaxDurability()); + } + + @Test + void customPickaxeHasConfiguredTier() { + assertEquals(ItemTool.TIER_IRON, pickaxe.getTier()); + } + + @Test + void customPickaxeEnchantAbilityNonZero() { + // ItemTool.getEnchantAbility() 分派于 getTier(),TIER_IRON(5) -> 14 + assertEquals(14, pickaxe.getEnchantAbility()); + } + + @Test + void customSwordIsSword() { + assertTrue(sword.isSword()); + assertFalse(sword.isPickaxe()); + } + + @Test + void enchantDiggerAcceptsCustomPickaxe() { + assertTrue(EnchantmentType.DIGGER.canEnchantItem(pickaxe)); + } + + @Test + void enchantSwordAcceptsCustomSword() { + assertTrue(EnchantmentType.SWORD.canEnchantItem(sword)); + } + + @Test + void customToolBuilderWritesAttackDamageToNbt() { + CompoundTag nbt = pickaxe.getDefinition().getNbt(); + assertEquals(7, nbt.getCompound("components").getCompound("item_properties").getInt("damage")); + } + + @Test + void customToolBuilderWritesDurabilityToNbt() { + CompoundTag nbt = pickaxe.getDefinition().getNbt(); + assertEquals(1561, nbt.getCompound("components").getCompound("minecraft:durability").getInt("max_durability")); + } + + @Test + void customToolBuilderWritesPickaxeTag() { + CompoundTag nbt = pickaxe.getDefinition().getNbt(); + assertTrue(nbt.getCompound("components").contains("item_tags")); + var tags = nbt.getCompound("components").getList("item_tags").getAll(); + boolean found = false; + for (var tag : tags) { + if ("minecraft:is_pickaxe".equals(tag.parseValue())) { + found = true; + break; + } + } + assertTrue(found, "minecraft:is_pickaxe tag should be written"); + } + + // ===== 测试用自定义物品 ===== + + private static final class CustomArmor extends ItemCustomArmor { + CustomArmor(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .armorBuilder(this, CreativeItemCategory.EQUIPMENT) + .slot(ArmorSlot.HEAD) + .armorPoints(5) + .toughness(2) + .tier(ItemArmor.TIER_IRON) + .build(); + } + } + + private static final class CustomTool extends ItemCustomTool { + CustomTool(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + var builder = CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .attackDamage(7) + .maxDurability(1561) + .tier(ItemTool.TIER_IRON); + if (getNamespaceId().contains("pickaxe")) { + builder.toolType(ToolType.PICKAXE); + } else if (getNamespaceId().contains("sword")) { + builder.toolType(ToolType.SWORD); + } + return builder.build(); + } + } +} From 15d50ab70f8961898d0118e9f4a0ed700057610b Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Mon, 15 Jun 2026 10:37:33 +0800 Subject: [PATCH 22/29] fix(customitem): prevent StackOverflow recursion and fix armor durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (c8d5a02c4) introduced server-side overrides on ItemCustomTool/ItemCustomArmor that read from getDefinitionNbt(), but the builder fallbacks still called item.getMaxDurability()/getAttackDamage()/ getTier()/getArmorPoints()/getToughness(). Since the item is unregistered during build(), those overrides fall back to getDefinition() -> build() -> infinite recursion -> StackOverflowError, whenever a mod author omitted any of the chain setters. Fixes: - ToolBuilder.build(): use safe defaults (DURABILITY_WOODEN, 1, 0) instead of item instance methods when chain setters are unset. - ArmorBuilder.build(): same treatment (0 for protection/toughness/tier/ durability), plus a new maxDurability(int) chain method (was missing, causing max_durability to be written as -1). - ItemCustomArmor: add getMaxDurability() override reading minecraft:durability.max_durability (was missing, returned -1 from base, excluding custom armor from anvil repair). - ToolBuilder.speed(int): no longer calls item.isPickaxe() etc. when toolType is unset — that path also recursed. Now unconditionally accepts the speed value. Regression tests added: minimal-config custom armor and tool (only slot/toolType set) verify build() completes without StackOverflowError and unset properties return safe defaults. --- .../item/customitem/CustomItemDefinition.java | 42 +++++++--- .../item/customitem/ItemCustomArmor.java | 8 ++ .../customitem/CustomItemPropertyTest.java | 82 +++++++++++++++++++ 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index e393dcd77..8a1a74e6c 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -645,11 +645,9 @@ public ToolBuilder speed(int speed) { log.warn("speed has an invalid value!"); return this; } - if (this.toolType != null) { - this.speed = speed; - } else if (item.isPickaxe() || item.isShovel() || item.isHoe() || item.isAxe() || item.isShears()) { - this.speed = speed; - } + //不调用 item.isPickaxe() 等实例方法,因为它们会通过 getDefinitionNbt() 递归。 + //直接接受 speed 值;若 toolType 未设置,build() 也不会应用工具类型方块挖掘速度。 + this.speed = speed; return this; } @@ -760,10 +758,12 @@ public ToolBuilder addExtraBlockTags(@NotNull List blockTags) { @Override public CustomItemDefinition build() { - //附加耐久 攻击伤害 tier 信息 - int resolvedDurability = this.maxDurability != null ? this.maxDurability : item.getMaxDurability(); - int resolvedDamage = this.attackDamage != null ? this.attackDamage : item.getAttackDamage(); - int resolvedTier = this.tier != null ? this.tier : item.getTier(); + //附加耐久 攻击伤害 tier 信息。 + //注意:不能用 item.getXxx() 作为 fallback,因为 ItemCustomTool 的覆写会调用 getDefinitionNbt() + //→ getDefinition() → build(),造成无限递归。未设置时使用基类默认值。 + int resolvedDurability = this.maxDurability != null ? this.maxDurability : ItemTool.DURABILITY_WOODEN; + int resolvedDamage = this.attackDamage != null ? this.attackDamage : 1; + int resolvedTier = this.tier != null ? this.tier : 0; this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag().putInt("max_durability", resolvedDurability)) .getCompound("item_properties") @@ -901,6 +901,7 @@ public static class ArmorBuilder extends SimpleBuilder { private @Nullable Integer armorPoints = null; private @Nullable Integer toughness = null; private @Nullable Integer tier = null; + private @Nullable Integer maxDurability = null; private ArmorBuilder(ItemCustomArmor item, CreativeItemCategory creativeCategory) { super(item, creativeCategory); @@ -980,14 +981,29 @@ public ArmorBuilder tier(int tier) { return this; } + /** + * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 + * 未设置时默认为 0。 + *

+ * Sets the max durability. Server-side {@link Item#getMaxDurability()} reads this value. + * Defaults to 0 when unset. + */ + public ArmorBuilder maxDurability(int maxDurability) { + this.maxDurability = maxDurability; + return this; + } + @Override public CustomItemDefinition build() { - int resolvedProtection = this.armorPoints != null ? this.armorPoints : item.getArmorPoints(); - int resolvedToughness = this.toughness != null ? this.toughness : item.getToughness(); - int resolvedTier = this.tier != null ? this.tier : item.getTier(); + //注意:不能用 item.getXxx() 作为 fallback,因为 ItemCustomArmor 的覆写会调用 getDefinitionNbt() + //→ getDefinition() → build(),造成无限递归。未设置时使用默认值。 + int resolvedProtection = this.armorPoints != null ? this.armorPoints : 0; + int resolvedToughness = this.toughness != null ? this.toughness : 0; + int resolvedTier = this.tier != null ? this.tier : 0; + int resolvedDurability = this.maxDurability != null ? this.maxDurability : 0; this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag() - .putInt("max_durability", item.getMaxDurability())) + .putInt("max_durability", resolvedDurability)) .putCompound("minecraft:wearable", new CompoundTag() .putInt("protection", resolvedProtection) .putInt("toughness", resolvedToughness)) diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java index c381154c7..b075d715a 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java @@ -105,6 +105,14 @@ public int getTier() { .getInt("tier"); } + @Override + public int getMaxDurability() { + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:durability") + .getInt("max_durability"); + } + @Override public ItemCustomArmor clone() { return (ItemCustomArmor) super.clone(); diff --git a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java index 8d4774943..6510aa08c 100644 --- a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -156,6 +156,49 @@ void customToolBuilderWritesPickaxeTag() { assertTrue(found, "minecraft:is_pickaxe tag should be written"); } + @Test + void customArmorHasConfiguredMaxDurability() { + assertEquals(165, helmet.getMaxDurability()); + } + + // ===== 最小配置(防递归)测试 ===== + + @Test + void minimalArmorDoesNotRecurseAndReturnsDefaults() { + MinimalArmor chest = new MinimalArmor("test:min_armor", "Min Armor"); + //getDefinition() 内部调 build(),若递归会 StackOverflowError + assertDoesNotThrow(chest::getDefinition); + //未设置的属性返回安全默认值 + assertTrue(chest.isChestplate()); + assertEquals(0, chest.getArmorPoints()); + assertEquals(0, chest.getToughness()); + assertEquals(0, chest.getTier()); + assertEquals(0, chest.getMaxDurability()); + } + + @Test + void minimalToolDoesNotRecurseAndReturnsDefaults() { + MinimalTool axe = new MinimalTool("test:min_tool", "Min Tool"); + //getDefinition() 内部调 build(),若递归会 StackOverflowError + assertDoesNotThrow(axe::getDefinition); + //未设置的属性返回安全默认值 + assertTrue(axe.isAxe()); + //attackDamage 未设 → 默认 1(Item 基类默认) + assertEquals(1, axe.getAttackDamage()); + //tier 未设 → 默认 0 + assertEquals(0, axe.getTier()); + //maxDurability 未设 → 默认 WOODEN(60) + assertEquals(ItemTool.DURABILITY_WOODEN, axe.getMaxDurability()); + } + + @Test + void speedWithoutToolTypeDoesNotRecurse() { + //speed() 不再调 item.isPickaxe() 等,不会递归 + MinimalTool axe = new MinimalTool("test:min_tool2", "Min Tool 2"); + assertDoesNotThrow(axe::getDefinition); + assertTrue(axe.isAxe()); + } + // ===== 测试用自定义物品 ===== private static final class CustomArmor extends ItemCustomArmor { @@ -171,6 +214,7 @@ public CustomItemDefinition getDefinition() { .armorPoints(5) .toughness(2) .tier(ItemArmor.TIER_IRON) + .maxDurability(165) .build(); } } @@ -195,4 +239,42 @@ public CustomItemDefinition getDefinition() { return builder.build(); } } + + /** + * 最小配置的自定义盔甲:只设 slot,不设 armorPoints/toughness/tier/maxDurability。 + * 用于验证 build() 不会递归,且未设置属性返回安全默认值。 + */ + private static final class MinimalArmor extends ItemCustomArmor { + MinimalArmor(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .armorBuilder(this, CreativeItemCategory.EQUIPMENT) + .slot(ArmorSlot.CHEST) + .build(); + } + } + + /** + * 最小配置的自定义工具:只设 toolType,不设 attackDamage/maxDurability/tier,且调 speed() 不设 toolType 路径。 + * 用于验证 build() 不会递归(StackOverflow),且未设置属性返回安全默认值。 + */ + private static final class MinimalTool extends ItemCustomTool { + MinimalTool(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + //只设 toolType + speed,不设 attackDamage/maxDurability/tier + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .toolType(ToolType.AXE) + .speed(6) + .build(); + } + } } From 34017c368549c00920959fb7d6343867ca0cb8ea Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Mon, 15 Jun 2026 11:01:13 +0800 Subject: [PATCH 23/29] docs(customitem): fix misleading builder javadocs about fallback behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder chain-method javadocs claimed that unset properties fall back to item instance methods (e.g. getAttackDamage/getArmorPoints), but the build() methods actually use hardcoded safe defaults to avoid the getDefinitionNbt() recursion. Correct the docs to describe the real defaults. Critically, slot(ArmorSlot) no longer claims a fallback exists — when unset, no slot is written and the custom armor is unequippable. This is now explicitly documented as a warning, since a mod author relying on the non-existent fallback would ship broken armor. --- .../item/customitem/CustomItemDefinition.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index 8a1a74e6c..1fd7af00d 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -601,10 +601,10 @@ public ToolBuilder toolType(@NotNull ToolType toolType) { /** * 设置攻击伤害。服务端的 {@link Item#getAttackDamage()} 会读取此值。 - * 未设置时,使用物品实例的 {@code getAttackDamage()}(通常为基类默认 1)。 + * 未设置时默认为 1。 *

* Sets the attack damage. Server-side {@link Item#getAttackDamage()} reads this value. - * When unset, the item instance's {@code getAttackDamage()} is used. + * Defaults to 1 when unset. */ public ToolBuilder attackDamage(int attackDamage) { this.attackDamage = attackDamage; @@ -613,10 +613,10 @@ public ToolBuilder attackDamage(int attackDamage) { /** * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 - * 未设置时,使用物品实例的 {@code getMaxDurability()}。 + * 未设置时默认为 {@link ItemTool#DURABILITY_WOODEN}。 *

* Sets the max durability. Server-side {@link Item#getMaxDurability()} reads this value. - * When unset, the item instance's {@code getMaxDurability()} is used. + * Defaults to {@link ItemTool#DURABILITY_WOODEN} when unset. */ public ToolBuilder maxDurability(int maxDurability) { this.maxDurability = maxDurability; @@ -934,11 +934,13 @@ public ArmorBuilder addRepairItems(@NotNull List repairItems, int repairAm /** * 指定盔甲装备槽位。决定 {@code wearable.slot}、{@code enchantable_slot}, * 并使服务端的 {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} 返回 {@code true}。 - * 未设置时回退到基于 item 实例方法的判定。 + *

+ * 重要:未设置时不会写入槽位,自定义盔甲将无法装备。 *

* Specifies the armor equipment slot. Determines {@code wearable.slot}, {@code enchantable_slot}, * and makes server-side {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} return {@code true}. - * Falls back to item instance methods when unset. + *

+ * Important: when unset, no slot is written and the custom armor cannot be equipped. */ public ArmorBuilder slot(@NotNull ArmorSlot slot) { this.slot = slot; @@ -947,10 +949,10 @@ public ArmorBuilder slot(@NotNull ArmorSlot slot) { /** * 设置护甲值。服务端的 {@link Item#getArmorPoints()} 会读取此值。 - * 未设置时,使用物品实例的 {@code getArmorPoints()}。 + * 未设置时默认为 0。 *

* Sets the armor points. Server-side {@link Item#getArmorPoints()} reads this value. - * When unset, the item instance's {@code getArmorPoints()} is used. + * Defaults to 0 when unset. */ public ArmorBuilder armorPoints(int armorPoints) { this.armorPoints = armorPoints; From fb6c3fff438a172779b062cd3f3c2ac3479aec94 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Tue, 16 Jun 2026 11:24:42 +0800 Subject: [PATCH 24/29] fix(customitem): write digger for tagless tools and default armor durability Two related fixes to CustomItemDefinition tool/armor builders: 1. digger component write-back (CustomItemDefinition): Previously minecraft:digger was written back immediately inside the toolType branch, so SWORD/HOE (no blockTags) and tools that only call addExtraBlock never got digger written back. This made server-side getSpeed() return null (mining speed lost) and the client never received destroy speeds. Now destroy_speeds entries are collected first and digger is written back only when non-empty, so tagless tools and isolated addExtraBlock tools still emit digger. (putCompound stores by reference, so entries appended to the list before write-back take effect.) 2. Armor default durability: maxDurability defaulted to 0 when unset, which destroyed the armor on the first hit in EntityHumanType#damageArmor. Default it to DURABILITY_DEFAULT (56, the lowest vanilla value - leather helmet) as a safe lower bound. Items that should be unbreakable must set a large durability or use Item#setUnbreakable(). Added regression tests covering both fixes: - sword/hoe/addExtraBlock write digger with correct destroy_speeds - shears with no blocks writes no digger - armor unset durability resolves to DURABILITY_DEFAULT --- .../item/customitem/CustomItemDefinition.java | 27 ++-- .../customitem/CustomItemPropertyTest.java | 125 +++++++++++++++++- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index 1fd7af00d..eea0e13e8 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -866,14 +866,20 @@ public CustomItemDefinition build() { ) .putInt("speed", speed); this.diggerRoot.getList("destroy_speeds", CompoundTag.class).add(cmp); - this.nbt.getCompound("components") - .putCompound("minecraft:digger", this.diggerRoot); } - //添加可挖掘的方块 + //toolType 导出方块 + addExtraBlock 方块 for (var k : this.blocks) { this.diggerRoot.getList("destroy_speeds", CompoundTag.class).add(k); } + + //有 destroy_speeds 条目才写回 digger:避免无 blockTags(SWORD/HOE/孤立 addExtraBlock) + //时 digger 缺失导致 getSpeed() 返回 null。putCompound 按引用存储,前面追加的条目一并生效。 + if (!this.diggerRoot.getList("destroy_speeds", CompoundTag.class).isEmpty()) { + this.nbt.getCompound("components") + .putCompound("minecraft:digger", this.diggerRoot); + } + return calculateID(); } @@ -896,6 +902,14 @@ private static int tierToToolEnchantAbility(int tier) { } public static class ArmorBuilder extends SimpleBuilder { + /** + * 自定义盔甲未显式调用 {@link #maxDurability(int)} 时的默认耐久。 + * 取原版最低值(皮革头盔 = 56)作安全下限,避免 {@code max_durability=0} 在 + * {@link cn.nukkit.entity.EntityHumanType#damageArmor} 中首次受击即摧毁护甲。 + * 需不可损坏的盔甲应显式设置较大耐久或用 {@link cn.nukkit.item.Item#setUnbreakable()}。 + */ + public static final int DURABILITY_DEFAULT = 56; + private final ItemCustomArmor item; private @Nullable ArmorSlot slot = null; private @Nullable Integer armorPoints = null; @@ -985,10 +999,7 @@ public ArmorBuilder tier(int tier) { /** * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 - * 未设置时默认为 0。 - *

- * Sets the max durability. Server-side {@link Item#getMaxDurability()} reads this value. - * Defaults to 0 when unset. + * 未设置时默认为 {@link #DURABILITY_DEFAULT}(正数安全值),避免护甲受击时被摧毁。 */ public ArmorBuilder maxDurability(int maxDurability) { this.maxDurability = maxDurability; @@ -1002,7 +1013,7 @@ public CustomItemDefinition build() { int resolvedProtection = this.armorPoints != null ? this.armorPoints : 0; int resolvedToughness = this.toughness != null ? this.toughness : 0; int resolvedTier = this.tier != null ? this.tier : 0; - int resolvedDurability = this.maxDurability != null ? this.maxDurability : 0; + int resolvedDurability = this.maxDurability != null ? this.maxDurability : DURABILITY_DEFAULT; this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag() .putInt("max_durability", resolvedDurability)) diff --git a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java index 6510aa08c..d33826e96 100644 --- a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -173,7 +173,8 @@ void minimalArmorDoesNotRecurseAndReturnsDefaults() { assertEquals(0, chest.getArmorPoints()); assertEquals(0, chest.getToughness()); assertEquals(0, chest.getTier()); - assertEquals(0, chest.getMaxDurability()); + //maxDurability 未设 → 默认 DURABILITY_DEFAULT(56),避免首次受击即摧毁护甲。 + assertEquals(CustomItemDefinition.ArmorBuilder.DURABILITY_DEFAULT, chest.getMaxDurability()); } @Test @@ -199,6 +200,86 @@ void speedWithoutToolTypeDoesNotRecurse() { assertTrue(axe.isAxe()); } + // ===== digger 组件写回回归测试 ===== + // 回归:toolType(SWORD/HOE) 无 blockTags,或仅调 addExtraBlock,build() 仍应写回 + // minecraft:digger,否则服务端 getSpeed() 返回 null、客户端收不到挖掘速度。 + + @Test + void customSwordWritesDiggerComponent() { + CompoundTag nbt = sword.getDefinition().getNbt(); + assertTrue(nbt.getCompound("components").contains("minecraft:digger"), + "SWORD should have minecraft:digger even without blockTags"); + var speeds = nbt.getCompound("components") + .getCompound("minecraft:digger") + .getList("destroy_speeds", CompoundTag.class).getAll(); + assertFalse(speeds.isEmpty(), "destroy_speeds must not be empty for sword"); + + //SWORD 的 toolBlocks 应含 web 和 bamboo + boolean hasWeb = false, hasBamboo = false; + for (var entry : speeds) { + String name = entry.getCompound("block").getString("name"); + if ("minecraft:web".equals(name)) hasWeb = true; + if ("minecraft:bamboo".equals(name)) hasBamboo = true; + } + assertTrue(hasWeb, "sword digger should include minecraft:web"); + assertTrue(hasBamboo, "sword digger should include minecraft:bamboo"); + } + + @Test + void customSwordGetSpeedNotNull() { + //CustomTool tier=IRON(5) → 默认 speed=6 + Integer s = sword.getSpeed(); + assertNotNull(s, "getSpeed() must not be null when digger is written"); + assertEquals(6, s); + } + + @Test + void customHoeWritesDiggerComponent() { + CustomHoe hoe = new CustomHoe("test:hoe", "Test Hoe"); + CompoundTag nbt = hoe.getDefinition().getNbt(); + assertTrue(nbt.getCompound("components").contains("minecraft:digger"), + "HOE should have minecraft:digger even without blockTags"); + var speeds = nbt.getCompound("components") + .getCompound("minecraft:digger") + .getList("destroy_speeds", CompoundTag.class).getAll(); + assertFalse(speeds.isEmpty(), "destroy_speeds must not be empty for hoe"); + + //HOE 的 toolBlocks 应含 leaves + boolean hasLeaves = false; + for (var entry : speeds) { + if ("minecraft:leaves".equals(entry.getCompound("block").getString("name"))) { + hasLeaves = true; + break; + } + } + assertTrue(hasLeaves, "hoe digger should include minecraft:leaves"); + assertNotNull(hoe.getSpeed(), "hoe getSpeed() must not be null"); + } + + @Test + void addExtraBlockOnlyWritesDiggerWithoutToolType() { + //不设 toolType、仅调 addExtraBlock:此前 digger 不被写回。 + ExtraBlockTool tool = new ExtraBlockTool("test:extra_block", "Extra Block Tool"); + CompoundTag nbt = tool.getDefinition().getNbt(); + assertTrue(nbt.getCompound("components").contains("minecraft:digger"), + "addExtraBlock without addExtraBlockTags should still write minecraft:digger"); + var speeds = nbt.getCompound("components") + .getCompound("minecraft:digger") + .getList("destroy_speeds", CompoundTag.class).getAll(); + assertEquals(1, speeds.size()); + assertEquals("minecraft:stone", speeds.get(0).getCompound("block").getString("name")); + assertEquals(5, speeds.get(0).getInt("speed")); + } + + @Test + void shearsWithoutBlocksWritesNoDigger() { + //SHEARS 无 type 方块也无 blockTags:digger 不应被写入。 + ShearsTool shears = new ShearsTool("test:shears", "Test Shears"); + CompoundTag nbt = shears.getDefinition().getNbt(); + assertFalse(nbt.getCompound("components").contains("minecraft:digger"), + "shears with no blocks should not write minecraft:digger"); + } + // ===== 测试用自定义物品 ===== private static final class CustomArmor extends ItemCustomArmor { @@ -277,4 +358,46 @@ public CustomItemDefinition getDefinition() { .build(); } } + + private static final class CustomHoe extends ItemCustomTool { + CustomHoe(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .toolType(ToolType.HOE) + .build(); + } + } + + private static final class ExtraBlockTool extends ItemCustomTool { + ExtraBlockTool(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .addExtraBlock("minecraft:stone", 5) + .build(); + } + } + + private static final class ShearsTool extends ItemCustomTool { + ShearsTool(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .toolType(ToolType.SHEARS) + .build(); + } + } } From 7d0504292e63a7b1c6d1c04fe7e59b9da92349d5 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Tue, 16 Jun 2026 13:04:20 +0800 Subject: [PATCH 25/29] fix(customitem): resolve dig speed per block from destroy_speeds getSpeed() returned destroy_speeds[0] regardless of the mined block, so every block used one speed. Replace it with getSpeedFor(Block) which looks up the matching entry per blockId, and extend correctTool0 so digger-listed blocks count as correct tools. Fixes the sword/cobweb regression (15 not 6) and makes addExtraBlock per-block speeds apply. - ItemCustomTool: add getSpeedFor(Block/int) with lazy blockId cache; deprecate getSpeed() - Block: correctTool0 extension + toolBreakTimeBonus0 uses getSpeedFor; drop redundant customToolBreakTimeBonus/customToolType - CustomItemDefinition: align speed switch with the tier table (gold/diamond/netherite were wrong); sword web=15; stop mutating the shared static toolBlocks DigProperty (pollution across builds) --- src/main/java/cn/nukkit/block/Block.java | 35 ++--- .../item/customitem/CustomItemDefinition.java | 26 ++-- .../item/customitem/ItemCustomTool.java | 67 ++++++++- .../customitem/CustomItemPropertyTest.java | 135 ++++++++++++++++-- 4 files changed, 215 insertions(+), 48 deletions(-) diff --git a/src/main/java/cn/nukkit/block/Block.java b/src/main/java/cn/nukkit/block/Block.java index 828527b8a..431c8aa37 100644 --- a/src/main/java/cn/nukkit/block/Block.java +++ b/src/main/java/cn/nukkit/block/Block.java @@ -765,27 +765,13 @@ public Item[] getDrops(@Nullable Player player, Item item) { return this.getDrops(item); } - private double customToolBreakTimeBonus(int toolType, @Nullable Integer speed) { - if (speed != null) return speed; - else if (toolType == ItemTool.TYPE_SWORD) { - if (this instanceof BlockCobweb) { - return 15.0; - } else if (this instanceof BlockBamboo) { - return 30.0; - } else return 1.0; - } else if (toolType == ItemTool.TYPE_SHEARS) { - if (this instanceof BlockWool || this instanceof BlockLeaves) { - return 5.0; - } else if (this instanceof BlockCobweb) { - return 15.0; - } else return 1.0; - } else if (toolType == ItemTool.TYPE_NONE) return 1.0; - return 0; - } - private double toolBreakTimeBonus0(Item item) { - if (item instanceof ItemCustomTool itemCustomTool && itemCustomTool.getSpeed() != null) { - return customToolBreakTimeBonus(customToolType(item), itemCustomTool.getSpeed()); + if (item instanceof ItemCustomTool itemCustomTool) { + //按当前方块查 destroy_speeds;未命中则回退原版逻辑(tier 查表 + sword/shears 特殊值) + Integer speed = itemCustomTool.getSpeedFor(getId()); + if (speed != null) { + return speed; + } } return toolBreakTimeBonus0(toolType0(item, getId()), item.getTier(), this.getId()); } @@ -830,10 +816,6 @@ private static double speedRateByHasteLore0(int hasteLoreLevel) { return 1.0 + (0.2 * hasteLoreLevel); } - private int customToolType(Item item) { - return toolType0(item, this.getId()); - } - private static int toolType0(Item item, int blockId) { if (item.isHoe()) { switch (blockId) { @@ -857,6 +839,11 @@ private static int toolType0(Item item, int blockId) { } private static boolean correctTool0(int blockToolType, Item item, int blockId) { + //自定义工具:digger 含此方块即视为正确工具(让 addExtraBlock 能挖非自身类型的方块) + if (item instanceof ItemCustomTool customTool && customTool.getSpeedFor(blockId) != null) { + return true; + } + boolean isLeaves = blockId == LEAVES || blockId == LEAVES2 || blockId == AZALEA_LEAVES || blockId == AZALEA_LEAVES_FLOWERED || blockId == MANGROVE_LEAVES || blockId == CHERRY_LEAVES || blockId == PALE_OAK_LEAVES; diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index eea0e13e8..10f3c512d 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -552,9 +552,9 @@ public static class ToolBuilder extends SimpleBuilder { } toolBlocks.put(ItemTag.IS_HOE, hoeBlocks); - for (var name : List.of("minecraft:web", "minecraft:bamboo")) { - swordBlocks.put(name, new DigProperty()); - } + //web 须显式写入原版 cobweb 速度 15,否则被 tier 默认值覆盖致回归;bamboo 瞬间破坏,不经 toolBreakTimeBonus0。 + swordBlocks.put("minecraft:web", new DigProperty(new CompoundTag(), 15)); + swordBlocks.put("minecraft:bamboo", new DigProperty()); toolBlocks.put(ItemTag.IS_SWORD, swordBlocks); } @@ -772,14 +772,15 @@ public CustomItemDefinition build() { .putInt("enchantable_value", tierToToolEnchantAbility(resolvedTier)); if (speed == null) { + //对齐 Block.toolBreakTimeBonus0 的 tier→speed 查表(WOODEN=1,GOLD=2,STONE=3,COPPER=4,IRON=5,DIAMOND=6,NETHERITE=7);COPPER 等无对应原版工具,回退 1。 speed = switch (resolvedTier) { - case 6 -> 7; - case 5 -> 6; - case 4 -> 5; - case 3 -> 4; - case 2 -> 3; - case 1 -> 2; - default -> 1; + case 1 -> 2; // TIER_WOODEN + case 2 -> 12; // TIER_GOLD + case 3 -> 4; // TIER_STONE + case 5 -> 6; // TIER_IRON + case 6 -> 8; // TIER_DIAMOND + case 7 -> 9; // TIER_NETHERITE + default -> 1; // TIER_COPPER(4) 等 }; } //确定工具类型:仅使用显式设置的 toolType。避免调用 item.isPickaxe() 等实例方法, @@ -845,14 +846,15 @@ public CustomItemDefinition build() { if (type != null) { toolBlocks.get(type).forEach( (k, v) -> { - if (v.getSpeed() == null) v.setSpeed(speed); + //不修改共享 DigProperty(static toolBlocks),否则首次 build 会污染后续不同 tier 的 build。 + int blockSpeed = v.getSpeed() != null ? v.getSpeed() : speed; blocks.add(new CompoundTag() .putCompound("block", new CompoundTag() .putString("name", k) .putCompound("states", v.getStates()) .putString("tags", "") ) - .putInt("speed", v.getSpeed())); + .putInt("speed", blockSpeed)); } ); } diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java index b558ac480..d2cf84c40 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java @@ -1,5 +1,6 @@ package cn.nukkit.item.customitem; +import cn.nukkit.block.Block; import cn.nukkit.item.*; import cn.nukkit.nbt.tag.CompoundTag; import cn.nukkit.nbt.tag.ListTag; @@ -8,6 +9,9 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.HashMap; +import java.util.Map; + /** * @author lt_name */ @@ -15,6 +19,9 @@ public abstract class ItemCustomTool extends StringItemToolBase implements ItemD private final String textureName; + /** destroy_speeds 按 blockId 解析的速度缓存,懒加载,仅含具名条目(tags 条目不参与)。clone 浅拷贝共享,只读。 */ + private transient Map blockSpeedCache; + public ItemCustomTool(@NotNull String id, @Nullable String name) { super(id, StringItem.notEmpty(name)); this.textureName = name; @@ -109,13 +116,69 @@ public boolean isSword() { return "sword".equals(slot); } + /** + * 返回 destroy_speeds 首项速度。固定取第一项、不区分方块,已过时,保留仅为 API 兼容。 + * + * @deprecated 改用 {@link #getSpeedFor(Block)}。 + */ + @Deprecated @Nullable public final Integer getSpeed() { var nbt = this.getDefinitionNbt(); if (!nbt.getCompound("components").contains("minecraft:digger")) return null; - return nbt.getCompound("components") + var speeds = nbt.getCompound("components") .getCompound("minecraft:digger") - .getList("destroy_speeds", CompoundTag.class).get(0).getInt("speed"); + .getList("destroy_speeds", CompoundTag.class); + if (speeds.size() == 0) return null; + return speeds.get(0).getInt("speed"); + } + + /** + * 返回此工具挖掘指定方块的速度(取自 destroy_speeds 匹配条目),未指定返回 {@code null} 由调用方回退 tier 查表。 + * 按当前方块查找,正确实现逐方块语义:{@code addExtraBlock(name, speed)} 只对该方块生效。 + * 匹配按 blockId(name 经 {@link Item#fromString(String)} 解析);tags 条目不参与,由 correctTool 覆盖。 + */ + @Nullable + public final Integer getSpeedFor(@NotNull Block block) { + return this.getSpeedFor(block.getId()); + } + + /** @see #getSpeedFor(Block) */ + @Nullable + public final Integer getSpeedFor(int blockId) { + return this.getBlockSpeedCache().get(blockId); + } + + private Map getBlockSpeedCache() { + if (this.blockSpeedCache == null) { + this.blockSpeedCache = this.buildBlockSpeedCache(); + } + return this.blockSpeedCache; + } + + private Map buildBlockSpeedCache() { + CompoundTag components = this.getDefinitionNbt().getCompound("components"); + if (!components.contains("minecraft:digger")) { + return Map.of(); + } + ListTag speeds = components.getCompound("minecraft:digger") + .getList("destroy_speeds", CompoundTag.class); + if (speeds.size() == 0) { + return Map.of(); + } + Map cache = new HashMap<>(); + for (CompoundTag entry : speeds.getAll()) { + CompoundTag blockTag = entry.getCompound("block"); + String name = blockTag.getString("name"); + if (name.isEmpty()) { + continue; //tags 条目(q.any_tag(...)),由 correctTool + tier 查表处理 + } + Block block = Item.fromString(name).getBlock(); + if (block != null && block.getId() != Block.AIR) { + cache.put(block.getId(), entry.getInt("speed")); + } + } + return cache; } @Override diff --git a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java index d33826e96..53e27ba65 100644 --- a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -1,6 +1,7 @@ package cn.nukkit.item.customitem; import cn.nukkit.MockServer; +import cn.nukkit.block.Block; import cn.nukkit.item.Item; import cn.nukkit.item.ItemArmor; import cn.nukkit.item.ItemTool; @@ -200,9 +201,45 @@ void speedWithoutToolTypeDoesNotRecurse() { assertTrue(axe.isAxe()); } - // ===== digger 组件写回回归测试 ===== - // 回归:toolType(SWORD/HOE) 无 blockTags,或仅调 addExtraBlock,build() 仍应写回 - // minecraft:digger,否则服务端 getSpeed() 返回 null、客户端收不到挖掘速度。 + // ===== digger 写回 + 逐方块速度回归测试(方案 B:getSpeedFor 按 blockId 查 destroy_speeds)===== + + @Test + void customPickaxeBreakTimeMatchesVanilla() { + //自定义铁镐挖石头须与原版一致(base=1.5*1.5=2.25, bonus=6 → 0.375) + Block stone = Block.get(Block.STONE); + double customTime = stone.calculateBreakTimeNotInAir(pickaxe, null); + double vanillaTime = stone.calculateBreakTimeNotInAir(Item.get(Item.IRON_PICKAXE), null); + assertEquals(vanillaTime, customTime, 0.001, "custom iron pickaxe must match vanilla"); + assertEquals(0.375, customTime, 0.001); + } + + @Test + void customHoeLeavesBreakTimeMatchesVanilla() { + //原版锄头挖树叶本就慢(BlockLeaves.getToolType=HOE,correctTool0 line850 要求 ==SHEARS 故 false)。自定义锄头虽因 correctTool 扩展 correctTool=true,但 bonus 仍取 tier=1,保持一致。 + CustomHoe hoe = new CustomHoe("test:hoe_leaves", "Test Hoe Leaves"); + Block leaves = Block.get(Block.LEAVES); + double customTime = leaves.calculateBreakTimeNotInAir(hoe, null); + double vanillaTime = leaves.calculateBreakTimeNotInAir(Item.get(Item.IRON_HOE), null); + assertEquals(vanillaTime, customTime, 0.001, "custom hoe leaves must match vanilla hoe"); + } + + @Test + void customSwordCobwebBreakTimeMatchesVanilla() { + //BlockCobweb.getToolType=SWORD,correctTool0 line854 true → bonus=15(base=6 → 0.4) + Block cobweb = Block.get(Block.COBWEB); + double customTime = cobweb.calculateBreakTimeNotInAir(sword, null); + double vanillaTime = cobweb.calculateBreakTimeNotInAir(Item.get(Item.IRON_SWORD), null); + assertEquals(vanillaTime, customTime, 0.001, "custom sword cobweb must match vanilla sword"); + assertEquals(0.4, customTime, 0.001); + } + + @Test + void clonedToolSpeedCacheConsistent() { + Integer original = pickaxe.getSpeedFor(Block.get(Block.STONE)); + ItemCustomTool cloned = pickaxe.clone(); + assertEquals(original, cloned.getSpeedFor(Block.get(Block.STONE))); + assertNotNull(cloned.getSpeedFor(Block.get(Block.STONE))); + } @Test void customSwordWritesDiggerComponent() { @@ -226,11 +263,67 @@ void customSwordWritesDiggerComponent() { } @Test - void customSwordGetSpeedNotNull() { - //CustomTool tier=IRON(5) → 默认 speed=6 - Integer s = sword.getSpeed(); - assertNotNull(s, "getSpeed() must not be null when digger is written"); - assertEquals(6, s); + void customSwordCobwebUsesVanillaSpeed() { + //cobweb 回归修复:剑挖蜘蛛网须用原版 15,而非 tier 默认值(此前 getSpeed()[0] 误判为 6) + Block cobweb = Block.get(Block.COBWEB); + assertEquals(15, sword.getSpeedFor(cobweb), + "sword dig speed for cobweb must be the vanilla value 15"); + } + + @Test + void customSwordNonListedBlockReturnsNull() { + //石头不在 sword digger 列表 → null(Block 回退 tier 查表) + assertNull(sword.getSpeedFor(Block.get(Block.STONE)), + "sword getSpeedFor must be null for non-digger blocks"); + } + + @Test + void customPickaxeToolBlockSpeedMatchesTier() { + //PICKAXE toolBlocks 方块 speed = tier 查表值(IRON=6) + Integer speed = pickaxe.getSpeedFor(Block.get(Block.STONE)); + assertNotNull(speed); + assertEquals(6, speed, "iron pickaxe dig speed for stone must be 6"); + } + + @Test + void addExtraBlockSpeedHonoredPerBlock() { + //方案 B 核心:addExtraBlock 逐方块自定义速度,而非取 destroy_speeds[0] + ExtraBlockTool tool = new ExtraBlockTool("test:extra_block", "Extra Block Tool"); + Integer speed = tool.getSpeedFor(Block.get(Block.STONE)); + assertNotNull(speed); + assertEquals(5, speed, "addExtraBlock speed must apply to the specific block"); + } + + @Test + void addExtraBlockEnablesCorrectToolBreakSpeed() { + //correctTool 扩展:ExtraBlockTool 非 toolType 但 digger 含 stone → correctTool=true → bonus=5 + //(base=1.5*5=7.5 / 5 = 1.5;无扩展则 7.5) + ExtraBlockTool tool = new ExtraBlockTool("test:extra_block", "Extra Block Tool"); + Block stone = Block.get(Block.STONE); + double breakTime = stone.calculateBreakTimeNotInAir(tool, null); + assertEquals(1.5, breakTime, 0.01, + "digger-listed block must use digger speed (correctTool extension)"); + } + + @Test + void goldTierPickaxeSpeedMatchesVanilla() { + //tier 修正:GOLD 须为 12(此前误算 3) + var gold = new CustomTierTool("test:gold_pickaxe", "Gold Pickaxe", ItemTool.TIER_GOLD, ToolType.PICKAXE); + assertEquals(12, gold.getSpeedFor(Block.get(Block.STONE))); + } + + @Test + void diamondTierPickaxeSpeedMatchesVanilla() { + //tier 修正:DIAMOND 须为 8(此前误算 7) + var diamond = new CustomTierTool("test:diamond_pickaxe", "Diamond Pickaxe", ItemTool.TIER_DIAMOND, ToolType.PICKAXE); + assertEquals(8, diamond.getSpeedFor(Block.get(Block.STONE))); + } + + @Test + void netheriteTierPickaxeSpeedMatchesVanilla() { + //tier 修正:NETHERITE 须为 9(此前误算 1) + var netherite = new CustomTierTool("test:netherite_pickaxe", "Netherite Pickaxe", ItemTool.TIER_NETHERITE, ToolType.PICKAXE); + assertEquals(9, netherite.getSpeedFor(Block.get(Block.STONE))); } @Test @@ -253,12 +346,13 @@ void customHoeWritesDiggerComponent() { } } assertTrue(hasLeaves, "hoe digger should include minecraft:leaves"); - assertNotNull(hoe.getSpeed(), "hoe getSpeed() must not be null"); + //hoe leaves 走 tier 默认值(tier=0 → 1);原版锄头挖树叶本就慢,保持一致 + assertEquals(1, hoe.getSpeedFor(Block.get(Block.LEAVES))); } @Test void addExtraBlockOnlyWritesDiggerWithoutToolType() { - //不设 toolType、仅调 addExtraBlock:此前 digger 不被写回。 + //不设 toolType 仅调 addExtraBlock:digger 仍应写回 ExtraBlockTool tool = new ExtraBlockTool("test:extra_block", "Extra Block Tool"); CompoundTag nbt = tool.getDefinition().getNbt(); assertTrue(nbt.getCompound("components").contains("minecraft:digger"), @@ -400,4 +494,25 @@ public CustomItemDefinition getDefinition() { .build(); } } + + /** 可配置 tier + toolType 的自定义工具,验证不同 tier 的 toolBlocks speed 是否与原版 tier 查表一致。 */ + private static final class CustomTierTool extends ItemCustomTool { + private final int tier; + private final ToolType toolType; + + CustomTierTool(String id, String name, int tier, ToolType toolType) { + super(id, name); + this.tier = tier; + this.toolType = toolType; + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .toolType(toolType) + .tier(tier) + .build(); + } + } } From 7728e657f3938a5538401f90de5a489c023f1738 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Tue, 16 Jun 2026 14:22:58 +0800 Subject: [PATCH 26/29] fix(customitem): allow legacy items in off-hand slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canBePutInOffhandSlot() read allow_off_hand from components.item_properties, but LegacyItemBuilder produces a flat NBT (no components/item_properties path). CompoundTag.getCompound returns a fresh empty tag for missing keys, so getBoolean("allow_off_hand") was always false — silently blocking every legacy custom item from the off-hand slot, including legitimate ones (e.g. custom shields defined in a behavior pack). Guard the NBT read with isComponentBased(). Legacy items return true because the server holds no allow_off_hand information; the policy lives in the client behavior pack, so the server defers to the client. This drops server-side defense-in-depth for legacy items, but the server fundamentally cannot decide correctly, and the previous false was a definite false negative. Component-based items keep their exact behavior. - CustomItem: extract resolveDefinition() returning CustomItemDefinition; getDefinitionNbt() delegates to it (no behavior change) - ItemCustom: branch on isComponentBased() in canBePutInOffhandSlot() - ItemStackRequestProcessorTest: add offhandAcceptsLegacyCustomItem using a LegacyItemBuilder-based TestLegacyCustomItem Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cn/nukkit/item/customitem/CustomItem.java | 26 ++++++---- .../cn/nukkit/item/customitem/ItemCustom.java | 20 ++++++-- .../ItemStackRequestProcessorTest.java | 48 +++++++++++++++++++ 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItem.java b/src/main/java/cn/nukkit/item/customitem/CustomItem.java index f1384b79c..a9f95ab91 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItem.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItem.java @@ -29,24 +29,32 @@ public interface CustomItem extends StringItem { CustomItemDefinition getDefinition(); /** - * 从注册表读取当前物品定义的 NBT。注册表保存的是注册时的快照, + * 从注册表读取当前物品的定义。注册表保存的是注册时的快照, * 比 {@link #getDefinition()}(每次调用都重建)更可靠、更高效。 * 若物品未注册(如测试或注册前访问),回退到 {@link #getDefinition()}。 *

- * Reads this item definition's NBT from the registry. The registry holds the + * Reads this item's definition from the registry. The registry holds the * snapshot taken at registration time, which is more reliable and efficient * than {@link #getDefinition()} (rebuilt on every call). Falls back to * {@link #getDefinition()} when the item is not registered (e.g. in tests). * + * @return 物品定义 + */ + default CustomItemDefinition resolveDefinition() { + var definition = Item.getCustomItemDefinition().get(this.getNamespaceId()); + return definition != null ? definition : this.getDefinition(); + } + + /** + * 从注册表读取当前物品定义的 NBT。等价于 + * {@code resolveDefinition().getNbt()}。 + *

+ * Reads this item definition's NBT from the registry. Equivalent to + * {@code resolveDefinition().getNbt()}. + * * @return 定义 NBT */ default CompoundTag getDefinitionNbt() { - var definitions = Item.getCustomItemDefinition(); - var definition = definitions.get(this.getNamespaceId()); - if (definition == null) { - // 未注册时回退到实时定义,保证测试和边界场景可用 - definition = this.getDefinition(); - } - return definition.getNbt(); + return this.resolveDefinition().getNbt(); } } diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java index 5ea7ab548..5c72c86e7 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java @@ -41,15 +41,25 @@ public String getTextureName() { public abstract CustomItemDefinition getDefinition(); /** - * 当{@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)}设置为{@code true}时, - * 允许该自定义物品放入副手槽。 + * Component-based 模式下,仅当 {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} + * 设置为 {@code true} 时允许放入副手槽。 *

- * Allows this custom item to be put into the off-hand slot when - * {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} is set to {@code true}. + * Legacy 模式的物品定义由客户端 behavior pack 提供,服务端不持有 {@code allow_off_hand} + * 信息,因此信任客户端裁决(返回 {@code true}),避免服务端误拒 behavior pack 已放行的物品。 + *

+ * For component-based items, the off-hand slot is allowed only when + * {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} is {@code true}. + * Legacy-mode items are defined by the client behavior pack, so the server has no + * {@code allow_off_hand} information and defers to the client (returns {@code true}). */ @Override public boolean canBePutInOffhandSlot() { - return this.getDefinitionNbt() + var def = this.resolveDefinition(); + if (!def.isComponentBased()) { + // Legacy 模式:物品定义在 behavior pack,服务端无 allow_off_hand 信息,信任客户端 + return true; + } + return def.getNbt() .getCompound("components") .getCompound("item_properties") .getBoolean("allow_off_hand"); diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index c7c31db57..7ca86c641 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -538,6 +538,35 @@ void offhandRejectsCustomItemThatDisallowsOffHand() { assertTrue(offhand.getItem(0).isNull()); } + @Test + void offhandAcceptsLegacyCustomItem() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item custom = new TestLegacyCustomItem("test:offhand_legacy", "Offhand Legacy"); + custom.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, custom, false)); + custom = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, custom.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + // Legacy 模式物品定义在 behavior pack,服务端无 allow_off_hand 信息,应信任客户端放行 + assertNotNull(response); + assertTrue(response.success()); + assertTrue(inventory.getItem(0).isNull()); + assertEquals(custom.getNamespaceId(), offhand.getItem(0).getNamespaceId()); + } + /** * Minimal {@link cn.nukkit.item.customitem.ItemCustom} used by the off-hand tests. * Its {@link cn.nukkit.item.customitem.CustomItemDefinition} is built with the @@ -560,6 +589,25 @@ public cn.nukkit.item.customitem.CustomItemDefinition getDefinition() { } } + /** + * Minimal legacy-mode {@link cn.nukkit.item.customitem.ItemCustom}. Its definition + * is built with {@link cn.nukkit.item.customitem.CustomItemDefinition.LegacyItemBuilder}, + * so it has no {@code components.item_properties}; its off-hand eligibility must defer + * to the client behavior pack (server returns {@code true}). + */ + private static final class TestLegacyCustomItem extends cn.nukkit.item.customitem.ItemCustom { + TestLegacyCustomItem(String id, String name) { + super(id, name); + } + + @Override + public cn.nukkit.item.customitem.CustomItemDefinition getDefinition() { + return cn.nukkit.item.customitem.CustomItemDefinition + .legacyBuilder(this) + .build(); + } + } + @Test void transferFiresLegacyTransactionThenSingleClickEvent() { Player player = mockPlayer(); From 10002064de13674d135440eaacc4a69c5d128610 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 20 Jun 2026 21:18:14 +0800 Subject: [PATCH 27/29] fix(inventory): resolve review issues in ItemStack SAI system - getTopWindow(): track most recently opened non-permanent window explicitly so the result is deterministic regardless of HashBiMap iteration order - CraftLoom/Grindstone: validate the consume plan before firing the craft event, so a rejected request never surfaces to plugin handlers as success - Trade/EnchantInventory.onClose: capture the addItem() remainder and drop it instead of silently deleting the unplaced portion when the pack is partial - BundleInventory.setItemForce: keep shulker-box and bundle-cycle invariants intact on the force/rollback path - Item.getCustomItemDefinition(String): add a direct lookup that avoids cloning the registry on hot paths (custom armor/tool property reads) --- src/main/java/cn/nukkit/Player.java | 24 +++++++++++++++++-- .../cn/nukkit/inventory/BundleInventory.java | 12 ++++++++++ .../cn/nukkit/inventory/EnchantInventory.java | 16 +++++++++---- .../cn/nukkit/inventory/TradeInventory.java | 15 ++++++++---- .../CraftGrindstoneActionProcessor.java | 16 +++++++------ .../request/CraftLoomActionProcessor.java | 17 +++++++------ src/main/java/cn/nukkit/item/Item.java | 5 ++++ .../cn/nukkit/item/RuntimeItemMapping.java | 2 +- .../cn/nukkit/item/customitem/CustomItem.java | 2 +- 9 files changed, 82 insertions(+), 27 deletions(-) diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java index 7fc112e3a..f3529fdb5 100644 --- a/src/main/java/cn/nukkit/Player.java +++ b/src/main/java/cn/nukkit/Player.java @@ -227,6 +227,9 @@ public class Player extends EntityHuman implements CommandSender, InventoryHolde protected final BiMap windows = HashBiMap.create(); protected final BiMap windowIndex = windows.inverse(); protected final Set permanentWindows = new IntOpenHashSet(); + // Most recently opened non-permanent window; getTopWindow() is deterministic + // regardless of the undefined iteration order of the {@link HashBiMap} windows. + protected Inventory topWindow; private boolean inventoryOpen; protected int closingWindowId = Integer.MIN_VALUE; @@ -6214,6 +6217,7 @@ public void close(TextContainer message, String reason, boolean notify) { String.valueOf(this.getPort()), this.getServer().getLanguage().translateString(reason))); this.windows.clear(); + this.topWindow = null; this.hasSpawned.clear(); this.spawnPosition = null; @@ -7339,6 +7343,8 @@ public int addWindow(Inventory inventory, Integer forceId, boolean isPermanent, if (isPermanent) { this.permanentWindows.add(cnt); + } else { + this.topWindow = inventory; } if (this.spawned && !this.inventoryOpen && inventory.open(this)) { @@ -7346,6 +7352,9 @@ public int addWindow(Inventory inventory, Integer forceId, boolean isPermanent, } else if (!alwaysOpen) { if (!this.permanentWindows.contains(cnt)) { this.windows.remove(inventory); + if (this.topWindow == inventory) { + this.topWindow = null; + } } return -1; @@ -7357,12 +7366,20 @@ public int addWindow(Inventory inventory, Integer forceId, boolean isPermanent, } public Optional getTopWindow() { + if (this.topWindow != null && this.windows.containsKey(this.topWindow) + && !this.permanentWindows.contains(this.windows.get(this.topWindow))) { + return Optional.of(this.topWindow); + } + // Re-scan if the tracked reference is stale (e.g. removed outside removeWindow). + Inventory fallback = null; for (Entry entry : this.windows.entrySet()) { if (!this.permanentWindows.contains(entry.getValue())) { - return Optional.of(entry.getKey()); + fallback = entry.getKey(); + break; } } - return Optional.empty(); + this.topWindow = fallback; + return Optional.ofNullable(fallback); } public void removeWindow(Inventory inventory) { @@ -7375,6 +7392,9 @@ protected void removeWindow(Inventory inventory, boolean isResponse) { // Requiring isResponse here causes issues with inventory events and an item duplication glitch if (/*isResponse &&*/ !this.permanentWindows.contains(this.getWindowId(inventory))) { this.windows.remove(inventory); + if (inventory == this.topWindow) { + this.topWindow = null; + } } } diff --git a/src/main/java/cn/nukkit/inventory/BundleInventory.java b/src/main/java/cn/nukkit/inventory/BundleInventory.java index 5c2629a27..48adb1811 100644 --- a/src/main/java/cn/nukkit/inventory/BundleInventory.java +++ b/src/main/java/cn/nukkit/inventory/BundleInventory.java @@ -53,6 +53,18 @@ public boolean setItem(int index, Item item, boolean send) { return changed; } + // Force-set still rejects shulker boxes and bundle cycles to keep the + // setItem invariants intact on the rollback path. Weight is not re-checked: + // a rollback must restore the exact prior slot. + @Override + public void setItemForce(int index, Item item) { + if (!canStore(item) || wouldCreateBundleCycle(item)) { + return; + } + super.setItemForce(index, item); + getHolder().saveNBT(); + } + @Override public boolean clear(int index, boolean send) { boolean changed = super.clear(index, send); diff --git a/src/main/java/cn/nukkit/inventory/EnchantInventory.java b/src/main/java/cn/nukkit/inventory/EnchantInventory.java index 62c49641f..b8f77b642 100644 --- a/src/main/java/cn/nukkit/inventory/EnchantInventory.java +++ b/src/main/java/cn/nukkit/inventory/EnchantInventory.java @@ -39,11 +39,19 @@ public void onOpen(Player who) { @Override public void onClose(Player who) { super.onClose(who); - if (this.getViewers().isEmpty()) { - for (int i = 0; i < 2; ++i) { - who.getInventory().addItem(this.getItem(i)); - this.clear(i); + // Return input slots, dropping the unplaced remainder so it isn't lost. + 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); } releasePublishedOptions(); who.craftingType = Player.CRAFTING_SMALL; diff --git a/src/main/java/cn/nukkit/inventory/TradeInventory.java b/src/main/java/cn/nukkit/inventory/TradeInventory.java index cefa3f3ce..04879d44e 100644 --- a/src/main/java/cn/nukkit/inventory/TradeInventory.java +++ b/src/main/java/cn/nukkit/inventory/TradeInventory.java @@ -69,12 +69,17 @@ public void onOpen(Player who) { @Override public void onClose(Player who) { + // Return input slots, dropping the unplaced remainder so it isn't lost. for (int i = 0; i <= 1; i++) { - Item item = getItem(i); - if (who.getInventory().canAddItem(item)) { - who.getInventory().addItem(item); - } else { - who.dropItem(item); + Item item = this.getItem(i); + if (item.isNull()) { + continue; + } + Item[] drops = who.getInventory().addItem(item); + for (Item drop : drops) { + if (!who.dropItem(drop)) { + who.getLevel().dropItem(who, drop); + } } this.clear(i); } diff --git a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java index abfb844ee..7204ac5af 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java @@ -42,6 +42,15 @@ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemSt return context.error(); } + // Validate the consume plan before firing the event so a rejected + // request never surfaces to plugin handlers as a success. + 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 experience = grindstone.calculateExperience(); GrindItemEvent event = new GrindItemEvent( grindstone, @@ -56,13 +65,6 @@ public ActionResponse handle(CraftGrindstoneAction action, Player player, ItemSt return context.error(); } - 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(() -> { diff --git a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java index c23ef8b92..822342d4e 100644 --- a/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java +++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java @@ -112,6 +112,16 @@ public ActionResponse handle(CraftLoomAction action, Player player, ItemStackReq } int times = Math.max(1, action.getTimesCrafted()); + + // Validate the consume plan before firing the event so a rejected + // request never surfaces to plugin handlers as a success. + List expectedConsumes = new ArrayList<>(2); + CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, banner, times); + CraftRecipeActionProcessor.addExpectedConsumeItem(expectedConsumes, dye, times); + if (!CraftRecipeActionProcessor.validateExpectedConsumePlan(player, expectedConsumes, context)) { + return context.error(); + } + ItemBanner result = (ItemBanner) bannerItem.clone(); result.setCount(times); if (patternType != null) { @@ -126,13 +136,6 @@ 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/item/Item.java b/src/main/java/cn/nukkit/item/Item.java index c02fe8f0b..1b53df6e3 100644 --- a/src/main/java/cn/nukkit/item/Item.java +++ b/src/main/java/cn/nukkit/item/Item.java @@ -1038,6 +1038,11 @@ public static HashMap getCustomItemDefinition() { return new HashMap<>(CUSTOM_ITEM_DEFINITIONS); } + /** Direct lookup without cloning; for hot paths instead of {@link #getCustomItemDefinition()}{@code .get(id)}. */ + public static CustomItemDefinition getCustomItemDefinition(String namespaceId) { + return CUSTOM_ITEM_DEFINITIONS.get(namespaceId); + } + public static Item get(int id) { return get(id, 0); } diff --git a/src/main/java/cn/nukkit/item/RuntimeItemMapping.java b/src/main/java/cn/nukkit/item/RuntimeItemMapping.java index 1823d75dc..e0f5e5c38 100644 --- a/src/main/java/cn/nukkit/item/RuntimeItemMapping.java +++ b/src/main/java/cn/nukkit/item/RuntimeItemMapping.java @@ -300,7 +300,7 @@ public void generatePalette() { if (Server.getInstance().enableExperimentMode && protocolId >= ProtocolInfo.v1_16_100) { paletteBuffer.putString(entry.getIdentifier()); paletteBuffer.putLShort(entry.getRuntimeId()); - var def = Item.getCustomItemDefinition().get(entry.getIdentifier()); + var def = Item.getCustomItemDefinition(entry.getIdentifier()); paletteBuffer.putBoolean(def != null && def.isComponentBased()); } } else { diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItem.java b/src/main/java/cn/nukkit/item/customitem/CustomItem.java index a9f95ab91..5d84a3703 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItem.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItem.java @@ -41,7 +41,7 @@ public interface CustomItem extends StringItem { * @return 物品定义 */ default CustomItemDefinition resolveDefinition() { - var definition = Item.getCustomItemDefinition().get(this.getNamespaceId()); + var definition = Item.getCustomItemDefinition(this.getNamespaceId()); return definition != null ? definition : this.getDefinition(); } From c70c3820e0e0d183f5a303eab39cb55054809935 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Sat, 20 Jun 2026 22:17:16 +0800 Subject: [PATCH 28/29] fix(customitem): allow custom item subclasses into off-hand slot canBePutInOffhandSlot() only existed on ItemCustom, but ItemCustomTool, ItemCustomArmor, ItemCustomEdible, ItemCustomProjectile and ItemCustomBookEnchanted do not extend it (they extend XxxBase implements CustomItem). They fell through to Item.canBePutInOffhandSlot(), which only admits vanilla shields/arrows/etc., so the SAI path rejected every custom item of these subclasses from the off-hand slot. A CustomItem interface default cannot override Item's method due to Java's "class wins" rule, so centralize the logic in CustomItem.isAllowedInOffHand(CustomItem) (static) and have each ItemCustom* subclass override canBePutInOffhandSlot() to delegate. - CustomItem: add static isAllowedInOffHand(CustomItem) - ItemCustom: delegate instead of duplicating the rule - ItemCustomTool/Armor/Edible/Projectile/BookEnchanted: override and delegate - ItemStackRequestProcessorTest: cover ItemCustomTool off-hand path (offhandAcceptsCustomToolThatAllowsOffHand / offhandRejectsCustomToolThatDisallowsOffHand) --- .../cn/nukkit/item/customitem/CustomItem.java | 23 +++++ .../cn/nukkit/item/customitem/ItemCustom.java | 22 +---- .../item/customitem/ItemCustomArmor.java | 5 ++ .../customitem/ItemCustomBookEnchanted.java | 5 ++ .../item/customitem/ItemCustomEdible.java | 5 ++ .../item/customitem/ItemCustomProjectile.java | 5 ++ .../item/customitem/ItemCustomTool.java | 5 ++ .../ItemStackRequestProcessorTest.java | 86 +++++++++++++++++++ 8 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItem.java b/src/main/java/cn/nukkit/item/customitem/CustomItem.java index 5d84a3703..2be5e312b 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItem.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItem.java @@ -57,4 +57,27 @@ default CustomItemDefinition resolveDefinition() { default CompoundTag getDefinitionNbt() { return this.resolveDefinition().getNbt(); } + + /** + * 判断自定义物品是否允许放入副手槽。Component-based 模式遵循 {@code allow_off_hand}; + * legacy 模式交由客户端 behavior pack 裁决(返回 {@code true})。 + *

+ * 因 Java "类优先"规则,无法作为接口 default 覆盖 {@link cn.nukkit.item.Item#canBePutInOffhandSlot()}, + * 各 {@code ItemCustom*} 子类需自行 {@code @Override} 并委托本方法。 + *

+ * Whether a custom item may enter the off-hand slot. Component-based honors {@code allow_off_hand}; + * legacy defers to the client behavior pack (returns {@code true}). Cannot be a {@code default} + * override of {@code Item.canBePutInOffhandSlot()} due to Java's "class wins" rule — each + * {@code ItemCustom*} subclass must {@code @Override} and delegate here. + */ + static boolean isAllowedInOffHand(CustomItem item) { + var def = item.resolveDefinition(); + if (!def.isComponentBased()) { + return true; + } + return def.getNbt() + .getCompound("components") + .getCompound("item_properties") + .getBoolean("allow_off_hand"); + } } diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java index 5c72c86e7..91a767d26 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustom.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustom.java @@ -40,29 +40,9 @@ public String getTextureName() { @Override public abstract CustomItemDefinition getDefinition(); - /** - * Component-based 模式下,仅当 {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} - * 设置为 {@code true} 时允许放入副手槽。 - *

- * Legacy 模式的物品定义由客户端 behavior pack 提供,服务端不持有 {@code allow_off_hand} - * 信息,因此信任客户端裁决(返回 {@code true}),避免服务端误拒 behavior pack 已放行的物品。 - *

- * For component-based items, the off-hand slot is allowed only when - * {@link CustomItemDefinition.SimpleBuilder#allowOffHand(boolean)} is {@code true}. - * Legacy-mode items are defined by the client behavior pack, so the server has no - * {@code allow_off_hand} information and defers to the client (returns {@code true}). - */ @Override public boolean canBePutInOffhandSlot() { - var def = this.resolveDefinition(); - if (!def.isComponentBased()) { - // Legacy 模式:物品定义在 behavior pack,服务端无 allow_off_hand 信息,信任客户端 - return true; - } - return def.getNbt() - .getCompound("components") - .getCompound("item_properties") - .getBoolean("allow_off_hand"); + return CustomItem.isAllowedInOffHand(this); } @Override diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java index b075d715a..e3738ef4e 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java @@ -33,6 +33,11 @@ public String getTextureName() { return textureName; } + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + @Override public String getNamespaceId() { return id; diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomBookEnchanted.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomBookEnchanted.java index 8c1815625..d5520241d 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomBookEnchanted.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomBookEnchanted.java @@ -19,6 +19,11 @@ public String getTextureName() { return "book_enchanted"; } + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + @Override public String getNamespaceId() { return id; diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomEdible.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomEdible.java index e5dd7c56d..772644e26 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomEdible.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomEdible.java @@ -46,6 +46,11 @@ public String getTextureName() { return textureName; } + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + @Override public String getNamespaceId() { return id; diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomProjectile.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomProjectile.java index f3ccd0a8c..149f6bdd3 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomProjectile.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomProjectile.java @@ -25,4 +25,9 @@ public ItemCustomProjectile(@NotNull String id, @Nullable String name, @NotNull public String getTextureName() { return textureName; } + + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } } \ No newline at end of file diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java index d2cf84c40..3062d344d 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java @@ -37,6 +37,11 @@ public String getTextureName() { return textureName; } + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + /** * 判断物品是否含有指定的 item_tag(写入 {@code components.item_tags} 中的标签)。 *

diff --git a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java index 7ca86c641..a3ad1684a 100644 --- a/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -567,6 +567,69 @@ void offhandAcceptsLegacyCustomItem() { assertEquals(custom.getNamespaceId(), offhand.getItem(0).getNamespaceId()); } + /** + * {@code ItemCustomTool} does not extend {@code ItemCustom}; verify its off-hand admission + * is not blocked by the base {@code Item} implementation. + */ + @Test + void offhandAcceptsCustomToolThatAllowsOffHand() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item custom = new TestCustomTool("test:offhand_tool_allowed", "Offhand Tool", true); + custom.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, custom, false)); + custom = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, custom.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertTrue(response.success()); + assertTrue(inventory.getItem(0).isNull()); + assertEquals(custom.getNamespaceId(), offhand.getItem(0).getNamespaceId()); + } + + /** + * Counterpart: a custom tool without {@code allowOffHand} must still be rejected by the off-hand slot. + */ + @Test + void offhandRejectsCustomToolThatDisallowsOffHand() { + Player player = mockPlayer(); + PlayerInventory inventory = new PlayerInventory(player); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + Mockito.when(player.getInventory()).thenReturn(inventory); + Mockito.when(player.getOffhandInventory()).thenReturn(offhand); + Mockito.when(player.getTopWindow()).thenReturn(Optional.empty()); + + Item custom = new TestCustomTool("test:offhand_tool_disallowed", "Offhand Tool No", false); + custom.autoAssignStackNetworkId(); + assertTrue(inventory.setItem(0, custom, false)); + custom = inventory.getItem(0); + + TakeAction action = new TakeAction( + 1, + new ItemStackRequestSlotData(ContainerSlotType.HOTBAR, 0, custom.getStackNetId(), null), + new ItemStackRequestSlotData(ContainerSlotType.OFFHAND, 0, 0, null) + ); + + ActionResponse response = new TakeActionProcessor().handle(action, player, context()); + + assertNotNull(response); + assertFalse(response.success()); + assertEquals(custom.getNamespaceId(), inventory.getItem(0).getNamespaceId()); + assertTrue(offhand.getItem(0).isNull()); + } + /** * Minimal {@link cn.nukkit.item.customitem.ItemCustom} used by the off-hand tests. * Its {@link cn.nukkit.item.customitem.CustomItemDefinition} is built with the @@ -608,6 +671,29 @@ public cn.nukkit.item.customitem.CustomItemDefinition getDefinition() { } } + /** + * Minimal {@link cn.nukkit.item.customitem.ItemCustomTool} driven by {@code allowOffHand}, + * covering the subclass branch that does not extend {@code ItemCustom}. + */ + private static final class TestCustomTool extends cn.nukkit.item.customitem.ItemCustomTool { + private final boolean allowOffHand; + + TestCustomTool(String id, String name, boolean allowOffHand) { + super(id, name); + this.allowOffHand = allowOffHand; + } + + @Override + public cn.nukkit.item.customitem.CustomItemDefinition getDefinition() { + var builder = cn.nukkit.item.customitem.CustomItemDefinition + .toolBuilder(this, cn.nukkit.network.protocol.types.inventory.creative.CreativeItemCategory.EQUIPMENT) + .attackDamage(5) + .maxDurability(100) + .tier(cn.nukkit.item.ItemTool.TIER_IRON); + return builder.allowOffHand(this.allowOffHand).build(); + } + } + @Test void transferFiresLegacyTransactionThenSingleClickEvent() { Player player = mockPlayer(); From b58cc4a1c2aaea06114a1c782bc29e9ccf2ea776 Mon Sep 17 00:00:00 2001 From: LT_Name <572413378@qq.com> Date: Wed, 24 Jun 2026 21:22:43 +0800 Subject: [PATCH 29/29] fix: restore legacy override contract for custom items and harden LEVEL_ENTITY slot validation --- .../inventory/request/NetworkMapping.java | 7 +- .../item/customitem/CustomItemDefinition.java | 117 ++++++++++++---- .../item/customitem/ItemCustomArmor.java | 26 +++- .../item/customitem/ItemCustomTool.java | 33 ++++- .../inventory/request/NetworkMappingTest.java | 45 ++++++- .../customitem/CustomItemPropertyTest.java | 127 ++++++++++++++++++ 6 files changed, 321 insertions(+), 34 deletions(-) diff --git a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java index 4dffdff6c..0a4bf2d90 100644 --- a/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java +++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java @@ -77,9 +77,10 @@ public static Inventory getInventory(Player player, ContainerSlotType type, @Nul case SHULKER_BOX -> typedContainer(topWindow, ShulkerBoxInventory.class); case BARREL -> typedContainer(topWindow, BarrelInventory.class); case CRAFTER_BLOCK_CONTAINER -> typedContainer(topWindow, CrafterInventory.class); - // LEVEL_ENTITY 是 chest/hopper/dispenser/dropper/ender chest 等多种 - // 方块实体容器的共用泛化槽类型,无法精确校验窗口类型,保留返回 topWindow。 - case LEVEL_ENTITY -> topWindow; + // LEVEL_ENTITY 是多种方块实体容器的共用泛化槽类型,无法精确校验窗口类型。 + // 但须拒绝 PlayerUIComponent(铁砧/切石机/附魔台等 FakeBlockUI 及合成格/光标): + // 它们按偏移读写槽位且关闭时只回收部分槽,身份映射会被恶意 SAI 请求利用造成物品丢失。 + case LEVEL_ENTITY -> topWindow instanceof PlayerUIComponent ? null : topWindow; case DYNAMIC_CONTAINER -> resolveDynamicContainer(player, dynamicId); default -> null; }; diff --git a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index 10f3c512d..22f2a860a 100644 --- a/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java +++ b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java @@ -59,6 +59,35 @@ public enum ItemRegistrationMode { private static final ConcurrentHashMap INTERNAL_ALLOCATION_ID_MAP = new ConcurrentHashMap<>(); private static final AtomicInteger nextRuntimeId = new AtomicInteger(10000); + /** + * 当前线程正在构建中的物品标识符集合,用于打破 build() 递归:ItemCustom* 的覆写从 + * getDefinitionNbt() 读属性,而 build() 早于注册会经 resolveDefinition() → getDefinition() + * 重入 build();构建期间覆写检测到自身在此集合即回退 super,既断开递归又保留旧契约。 + *

+ * Thread-local set of identifiers currently being built, used to break build() recursion: + * ItemCustom* overrides read from getDefinitionNbt(), but build() runs before registration and + * resolveDefinition() → getDefinition() re-enters it; while building, overrides fall back to super, + * breaking the cycle while preserving the legacy contract. + */ + private static final ThreadLocal> BUILDING = ThreadLocal.withInitial(HashSet::new); + + /** + * 当前线程是否正在构建给定标识符的自定义物品定义。 + *

+ * Whether the current thread is currently building the definition for the given identifier. + */ + public static boolean isBuilding(@NotNull String identifier) { + return BUILDING.get().contains(identifier); + } + + private static void beginBuild(@NotNull String identifier) { + BUILDING.get().add(identifier); + } + + private static void endBuild(@NotNull String identifier) { + BUILDING.get().remove(identifier); + } + private final String identifier; private final CompoundTag nbt; //649 private final CompoundTag nbt465; @@ -444,7 +473,13 @@ public CustomItemDefinition customBuild(Consumer nbt) { } public CustomItemDefinition build() { - return calculateID(); + //构建期间标记:使 ItemCustom* 覆写回退 super 以避免 NBT 递归(见 BUILDING)。 + beginBuild(this.identifier); + try { + return calculateID(); + } finally { + endBuild(this.identifier); + } } protected CustomItemDefinition calculateID() { @@ -758,12 +793,24 @@ public ToolBuilder addExtraBlockTags(@NotNull List blockTags) { @Override public CustomItemDefinition build() { - //附加耐久 攻击伤害 tier 信息。 - //注意:不能用 item.getXxx() 作为 fallback,因为 ItemCustomTool 的覆写会调用 getDefinitionNbt() - //→ getDefinition() → build(),造成无限递归。未设置时使用基类默认值。 - int resolvedDurability = this.maxDurability != null ? this.maxDurability : ItemTool.DURABILITY_WOODEN; - int resolvedDamage = this.attackDamage != null ? this.attackDamage : 1; - int resolvedTier = this.tier != null ? this.tier : 0; + //标记正在构建:使 ItemCustomTool 覆写(getAttackDamage/getTier/...)回退 super, + //避免 getDefinitionNbt() → getDefinition() → build() 递归(见 BUILDING)。 + beginBuild(this.identifier); + try { + return doBuild(); + } finally { + endBuild(this.identifier); + } + } + + private CustomItemDefinition doBuild() { + //fallback 走 item.getXxx():构建期间覆写已回退 super(见 BUILDING),可安全调用并继承插件覆写值。 + int resolvedDamage = this.attackDamage != null ? this.attackDamage : item.getAttackDamage(); + int resolvedTier = this.tier != null ? this.tier : item.getTier(); + //耐久 fallback:super 链回基类返回 -1 时用 DURABILITY_WOODEN 作下限,避免负耐久。 + int itemDurability = item.getMaxDurability(); + int resolvedDurability = this.maxDurability != null ? this.maxDurability + : (itemDurability > 0 ? itemDurability : ItemTool.DURABILITY_WOODEN); this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag().putInt("max_durability", resolvedDurability)) .getCompound("item_properties") @@ -783,16 +830,14 @@ public CustomItemDefinition build() { default -> 1; // TIER_COPPER(4) 等 }; } - //确定工具类型:仅使用显式设置的 toolType。避免调用 item.isPickaxe() 等实例方法, - //因为这些方法现在从本 NBT 读取,构造期调用会造成无限递归。 - //模组作者应通过 toolType(ToolType) 显式指定工具类型。 + //工具类型:优先显式 toolType,否则回退 item.isPickaxe() 等(构建期已回退 super,见 BUILDING)。 Identifier type = null; - boolean isPickaxe = this.toolType == ToolType.PICKAXE; - boolean isAxe = this.toolType == ToolType.AXE; - boolean isShovel = this.toolType == ToolType.SHOVEL; - boolean isHoe = this.toolType == ToolType.HOE; - boolean isSword = this.toolType == ToolType.SWORD; - boolean isShears = this.toolType == ToolType.SHEARS; + boolean isPickaxe = this.toolType == ToolType.PICKAXE || (this.toolType == null && item.isPickaxe()); + boolean isAxe = this.toolType == ToolType.AXE || (this.toolType == null && item.isAxe() && !isPickaxe); + boolean isShovel = this.toolType == ToolType.SHOVEL || (this.toolType == null && item.isShovel() && !isPickaxe && !isAxe); + boolean isHoe = this.toolType == ToolType.HOE || (this.toolType == null && item.isHoe() && !isPickaxe && !isAxe && !isShovel); + boolean isSword = this.toolType == ToolType.SWORD || (this.toolType == null && item.isSword() && !isPickaxe && !isAxe && !isShovel && !isHoe); + boolean isShears = this.toolType == ToolType.SHEARS || (this.toolType == null && item.isShears() && !isPickaxe && !isAxe && !isShovel && !isHoe && !isSword); if (isPickaxe) { //添加可挖掘方块Tags this.blockTags.addAll(List.of("'stone'", "'metal'", "'diamond_pick_diggable'", "'mob_spawner'", "'rail'", "'slab_block'", "'stair_block'", "'smooth stone slab'", "'sandstone slab'", "'cobblestone slab'", "'brick slab'", "'stone bricks slab'", "'quartz slab'", "'nether brick slab'", "'glazed terracotta'", "coral")); @@ -1010,12 +1055,25 @@ public ArmorBuilder maxDurability(int maxDurability) { @Override public CustomItemDefinition build() { - //注意:不能用 item.getXxx() 作为 fallback,因为 ItemCustomArmor 的覆写会调用 getDefinitionNbt() - //→ getDefinition() → build(),造成无限递归。未设置时使用默认值。 - int resolvedProtection = this.armorPoints != null ? this.armorPoints : 0; - int resolvedToughness = this.toughness != null ? this.toughness : 0; - int resolvedTier = this.tier != null ? this.tier : 0; - int resolvedDurability = this.maxDurability != null ? this.maxDurability : DURABILITY_DEFAULT; + //标记正在构建:使 ItemCustomArmor 覆写(getArmorPoints/getTier/...)回退 super, + //避免 getDefinitionNbt() → getDefinition() → build() 递归(见 BUILDING)。 + beginBuild(this.identifier); + try { + return doBuild(); + } finally { + endBuild(this.identifier); + } + } + + private CustomItemDefinition doBuild() { + //fallback 走 item.getXxx():构建期间覆写已回退 super(见 BUILDING),可安全调用并继承插件覆写值。 + int resolvedProtection = this.armorPoints != null ? this.armorPoints : item.getArmorPoints(); + int resolvedToughness = this.toughness != null ? this.toughness : item.getToughness(); + int resolvedTier = this.tier != null ? this.tier : item.getTier(); + //耐久 fallback:super 链回基类返回 -1 时用 DURABILITY_DEFAULT 作下限,避免护甲被秒毁。 + int itemDurability = item.getMaxDurability(); + int resolvedDurability = this.maxDurability != null ? this.maxDurability + : (itemDurability > 0 ? itemDurability : DURABILITY_DEFAULT); this.nbt.getCompound("components") .putCompound("minecraft:durability", new CompoundTag() .putInt("max_durability", resolvedDurability)) @@ -1025,10 +1083,19 @@ public CustomItemDefinition build() { .getCompound("item_properties") .putInt("tier", resolvedTier) .putInt("enchantable_value", tierToArmorEnchantAbility(resolvedTier)); - //确定槽位:仅使用显式设置的 slot。避免调用 item.isHelmet() 等实例方法, - //因为这些方法现在从本 NBT 读取,构造期调用会造成无限递归。 - //模组作者应通过 slot(ArmorSlot) 显式指定装备槽位。 + //槽位:优先显式 slot,否则回退 item.isHelmet()/isChestplate()/...(构建期已回退 super,见 BUILDING)。 ArmorSlot resolvedSlot = this.slot; + if (resolvedSlot == null) { + if (item.isHelmet()) { + resolvedSlot = ArmorSlot.HEAD; + } else if (item.isChestplate()) { + resolvedSlot = ArmorSlot.CHEST; + } else if (item.isLeggings()) { + resolvedSlot = ArmorSlot.LEGS; + } else if (item.isBoots()) { + resolvedSlot = ArmorSlot.FEET; + } + } if (resolvedSlot != null) { this.nbt.getCompound("components").getCompound("item_properties") .putString("enchantable_slot", resolvedSlot.getEnchantableSlot()); diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java index e3738ef4e..07e2844b7 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java @@ -4,7 +4,6 @@ import cn.nukkit.item.ItemArmor; import cn.nukkit.item.ItemID; import cn.nukkit.item.StringItem; -import cn.nukkit.nbt.tag.CompoundTag; import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -68,26 +67,42 @@ private boolean wearableSlotEquals(@NotNull String expected) { @Override public boolean isHelmet() { + //构建期间回退 super 以避免 getDefinitionNbt() → getDefinition() → build() 递归(见 CustomItemDefinition.BUILDING)。 + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isHelmet(); + } return wearableSlotEquals("slot.armor.head"); } @Override public boolean isChestplate() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isChestplate(); + } return wearableSlotEquals("slot.armor.chest"); } @Override public boolean isLeggings() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isLeggings(); + } return wearableSlotEquals("slot.armor.legs"); } @Override public boolean isBoots() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isBoots(); + } return wearableSlotEquals("slot.armor.feet"); } @Override public int getArmorPoints() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getArmorPoints(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("minecraft:wearable") @@ -96,6 +111,9 @@ public int getArmorPoints() { @Override public int getToughness() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getToughness(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("minecraft:wearable") @@ -104,6 +122,9 @@ public int getToughness() { @Override public int getTier() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getTier(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("item_properties") @@ -112,6 +133,9 @@ public int getTier() { @Override public int getMaxDurability() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getMaxDurability(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("minecraft:durability") diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java index 3062d344d..583d94a2a 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java @@ -1,7 +1,10 @@ package cn.nukkit.item.customitem; import cn.nukkit.block.Block; -import cn.nukkit.item.*; +import cn.nukkit.item.Item; +import cn.nukkit.item.ItemDurable; +import cn.nukkit.item.StringItem; +import cn.nukkit.item.StringItemToolBase; import cn.nukkit.nbt.tag.CompoundTag; import cn.nukkit.nbt.tag.ListTag; import cn.nukkit.nbt.tag.StringTag; @@ -63,6 +66,10 @@ private boolean hasItemTag(@NotNull String expected) { @Override public int getMaxDurability() { + //构建期间回退 super 以避免 getDefinitionNbt() → getDefinition() → build() 递归(见 CustomItemDefinition.BUILDING)。 + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getMaxDurability(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("minecraft:durability") @@ -71,6 +78,9 @@ public int getMaxDurability() { @Override public int getTier() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getTier(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("item_properties") @@ -79,6 +89,9 @@ public int getTier() { @Override public int getAttackDamage() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getAttackDamage(); + } return this.getDefinitionNbt() .getCompound("components") .getCompound("item_properties") @@ -87,31 +100,49 @@ public int getAttackDamage() { @Override public boolean isPickaxe() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isPickaxe(); + } return hasItemTag("minecraft:is_pickaxe"); } @Override public boolean isAxe() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isAxe(); + } return hasItemTag("minecraft:is_axe"); } @Override public boolean isShovel() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isShovel(); + } return hasItemTag("minecraft:is_shovel"); } @Override public boolean isHoe() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isHoe(); + } return hasItemTag("minecraft:is_hoe"); } @Override public boolean isShears() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isShears(); + } return hasItemTag("minecraft:is_shears"); } @Override public boolean isSword() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.isSword(); + } //剑无 item_tag,通过 enchantable_slot 判定 //Swords have no item tag, so determine via enchantable_slot String slot = this.getDefinitionNbt() diff --git a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java index 5ddc301b1..4888611fe 100644 --- a/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java +++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java @@ -29,17 +29,54 @@ void resetServer() { } @Test - void levelEntityResolvesToTopWindow() { + void levelEntityResolvesToRealTopWindow() { Player player = Mockito.mock(Player.class); + // A real level/entity container (chest/hopper/...) is not a PlayerUIComponent, + // so LEVEL_ENTITY returns the open window without a stricter type check. Inventory topWindow = Mockito.mock(Inventory.class); Mockito.when(player.getTopWindow()).thenReturn(Optional.of(topWindow)); - - // LEVEL_ENTITY is a catch-all (chest/hopper/dispenser/...) and intentionally - // returns the open window without a type check. assertSame(topWindow, NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); } + @Test + void levelEntityRejectsFakeBlockUiWindows() { + Player player = Mockito.mock(Player.class); + + // FakeBlockUIComponent windows (anvil/stonecutter/enchant/beacon/...) live on + // PlayerUIInventory at an offset and only return a subset of slots on close. + // LEVEL_ENTITY must not identity-map to them, or a malicious SAI request could + // touch UI slots never returned on close (item loss). Each has its own typed branch. + AnvilInventory anvil = Mockito.mock(AnvilInventory.class); + Mockito.when(anvil.getFakeBlockType()).thenReturn(InventoryType.ANVIL); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(anvil)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); + + StonecutterInventory stonecutter = Mockito.mock(StonecutterInventory.class); + Mockito.when(stonecutter.getFakeBlockType()).thenReturn(InventoryType.STONECUTTER); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(stonecutter)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); + + EnchantInventory enchant = Mockito.mock(EnchantInventory.class); + Mockito.when(enchant.getFakeBlockType()).thenReturn(InventoryType.ENCHANT_TABLE); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(enchant)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); + + BeaconInventory beacon = Mockito.mock(BeaconInventory.class); + Mockito.when(beacon.getFakeBlockType()).thenReturn(InventoryType.BEACON); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(beacon)); + assertNull(NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); + } + + @Test + void levelEntityRejectsRealContainerInventory() { + // ContainerInventory subclasses (chest/hopper/...) are real containers and must still resolve through LEVEL_ENTITY. + Player player = Mockito.mock(Player.class); + BarrelInventory barrel = Mockito.mock(BarrelInventory.class); + Mockito.when(player.getTopWindow()).thenReturn(Optional.of(barrel)); + assertSame(barrel, NetworkMapping.getInventory(player, ContainerSlotType.LEVEL_ENTITY, null)); + } + @Test void typedContainerSlotsRejectMismatchedTopWindow() { Player player = Mockito.mock(Player.class); diff --git a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java index 53e27ba65..cdfee8ee0 100644 --- a/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -374,6 +374,60 @@ void shearsWithoutBlocksWritesNoDigger() { "shears with no blocks should not write minecraft:digger"); } + // ===== 旧式覆写契约回归测试 ===== + //旧插件不调用 builder setter,仅覆写 Item 方法(isHelmet/getArmorPoints/...、isPickaxe/getTier/...)声明属性; + //build() 须写入这些覆写值且不因 getDefinitionNbt() 递归抛 StackOverflowError。 + + @Test + void legacyOverrideArmorWritesOverrideValuesAndIsEquippable() { + LegacyOverrideArmor chest = new LegacyOverrideArmor("test:legacy_armor", "Legacy Armor"); + //build() 不得递归 + assertDoesNotThrow(chest::getDefinition); + CompoundTag nbt = chest.getDefinition().getNbt(); + assertEquals(7, nbt.getCompound("components").getCompound("minecraft:wearable").getInt("protection")); + assertEquals(3, nbt.getCompound("components").getCompound("minecraft:wearable").getInt("toughness")); + assertEquals(ItemArmor.TIER_DIAMOND, nbt.getCompound("components").getCompound("item_properties").getInt("tier")); + assertEquals(363, nbt.getCompound("components").getCompound("minecraft:durability").getInt("max_durability")); + //slot 由 isChestplate() 覆写推断 + assertEquals("slot.armor.chest", nbt.getCompound("components").getCompound("minecraft:wearable").getString("slot")); + assertEquals("armor_torso", nbt.getCompound("components").getCompound("item_properties").getString("enchantable_slot")); + //服务端读回 + assertTrue(chest.isChestplate()); + assertFalse(chest.isHelmet()); + assertEquals(7, chest.getArmorPoints()); + assertEquals(3, chest.getToughness()); + assertEquals(ItemArmor.TIER_DIAMOND, chest.getTier()); + assertEquals(363, chest.getMaxDurability()); + //附魔能力分派于 tier,DIAMOND(6) -> 10 + assertEquals(10, chest.getEnchantAbility()); + } + + @Test + void legacyOverrideToolWritesOverrideValuesAndToolType() { + LegacyOverrideTool pick = new LegacyOverrideTool("test:legacy_tool", "Legacy Tool"); + //build() 不得递归 + assertDoesNotThrow(pick::getDefinition); + CompoundTag nbt = pick.getDefinition().getNbt(); + assertEquals(9, nbt.getCompound("components").getCompound("item_properties").getInt("damage")); + assertEquals(ItemTool.TIER_DIAMOND, nbt.getCompound("components").getCompound("item_properties").getInt("tier")); + assertEquals(1234, nbt.getCompound("components").getCompound("minecraft:durability").getInt("max_durability")); + //isPickaxe() 覆写 → toolType 回退 → 写入 item_tag / enchantable_slot / 可挖掘方块 + assertTrue(nbt.getCompound("components").contains("item_tags")); + boolean found = false; + for (var tag : nbt.getCompound("components").getList("item_tags").getAll()) { + if ("minecraft:is_pickaxe".equals(tag.parseValue())) { found = true; break; } + } + assertTrue(found, "isPickaxe() override should cause minecraft:is_pickaxe tag to be written"); + assertEquals("pickaxe", nbt.getCompound("components").getCompound("item_properties").getString("enchantable_slot")); + assertTrue(nbt.getCompound("components").contains("minecraft:digger"), + "isPickaxe() override should cause digger blocks to be written"); + //服务端读回 + assertTrue(pick.isPickaxe()); + assertEquals(9, pick.getAttackDamage()); + assertEquals(ItemTool.TIER_DIAMOND, pick.getTier()); + assertEquals(1234, pick.getMaxDurability()); + } + // ===== 测试用自定义物品 ===== private static final class CustomArmor extends ItemCustomArmor { @@ -515,4 +569,77 @@ public CustomItemDefinition getDefinition() { .build(); } } + + /** 旧式覆写契约盔甲:不调用 builder setter,仅覆写 Item/ItemArmor 方法,build() 须从中读取写入 NBT。 */ + private static final class LegacyOverrideArmor extends ItemCustomArmor { + LegacyOverrideArmor(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .armorBuilder(this, CreativeItemCategory.EQUIPMENT) + .build(); + } + + @Override + public boolean isChestplate() { + return true; + } + + @Override + public int getArmorPoints() { + return 7; + } + + @Override + public int getToughness() { + return 3; + } + + @Override + public int getTier() { + return ItemArmor.TIER_DIAMOND; + } + + @Override + public int getMaxDurability() { + return 363; + } + } + + /** 旧式覆写契约工具:不设 toolType/attackDamage/...,仅覆写 Item 方法,isPickaxe() 须触发工具类型回退。 */ + private static final class LegacyOverrideTool extends ItemCustomTool { + LegacyOverrideTool(String id, String name) { + super(id, name); + } + + @Override + public CustomItemDefinition getDefinition() { + return CustomItemDefinition + .toolBuilder(this, CreativeItemCategory.EQUIPMENT) + .build(); + } + + @Override + public boolean isPickaxe() { + return true; + } + + @Override + public int getAttackDamage() { + return 9; + } + + @Override + public int getTier() { + return ItemTool.TIER_DIAMOND; + } + + @Override + public int getMaxDurability() { + return 1234; + } + } }