diff --git a/src/main/java/cn/nukkit/Player.java b/src/main/java/cn/nukkit/Player.java index 3f2b64c40..3dc220c05 100644 --- a/src/main/java/cn/nukkit/Player.java +++ b/src/main/java/cn/nukkit/Player.java @@ -41,8 +41,11 @@ 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.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; @@ -74,6 +77,9 @@ import cn.nukkit.network.protocol.netease.pyrpc.PyRpcSubPacket; 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.session.NetworkPlayerSession; import cn.nukkit.network.session.NetworkPlayerSession.ImmediatePacketMode; import cn.nukkit.network.session.login.SessionLoginPhase; @@ -161,6 +167,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); @@ -220,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; @@ -385,6 +395,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; @@ -3208,6 +3220,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); @@ -3770,12 +3783,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(); @@ -4151,6 +4165,7 @@ public void onCompletion(Server server) { } this.forceMovement = null; } + break; case ProtocolInfo.PLAYER_ACTION_PACKET: PlayerActionPacket playerActionPacket = (PlayerActionPacket) packet; @@ -4702,771 +4717,875 @@ 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.spawned || !this.isAlive()) { + 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; + } + + 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); + } + } + + 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); + 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); + } - List actions = new ArrayList<>(); - for (NetworkInventoryAction networkInventoryAction : transactionPacket.actions) { - InventoryAction a = networkInventoryAction.createInventoryAction(this); + 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 (a == null) { - this.getServer().getLogger().debug("Unmatched inventory action from " + this.username + ": " + networkInventoryAction); - this.needSendInventory = true; - break packetswitch; - } + if (this.shouldRejectLegacyInventoryUiTransaction(transactionPacket)) { + this.server.getLogger().debug(this.username + ": dropping legacy InventoryTransaction UI transaction while SAI is enabled"); + this.needSendInventory = true; + return; + } - 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); + } } - - 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 (this.loomTransaction.canExecute()) { + if (this.loomTransaction.execute()) { + level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_LOOM_USE); } + } + this.loomTransaction = null; + 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 (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 (StonecutterTransaction.isIn(actions)) { + if (this.stonecutterTransaction == null) { + this.stonecutterTransaction = new StonecutterTransaction(this, actions); + } else { + for (InventoryAction action : actions) { + this.stonecutterTransaction.addAction(action); } - - if (this.craftingTransaction == null) { - this.craftingTransaction = new CraftingTransaction(this, actions); - } else { - for (InventoryAction action : actions) { - this.craftingTransaction.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) { + this.setNeedSendInventory(true); + this.stonecutterTransaction = null; + } + return; + } - 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 == 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; + } + 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); + level.addLevelSoundEvent(this, LevelSoundEventPacket.SOUND_BLOCK_GRINDSTONE_USE); } - } - 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 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; + break; + } - try { - useItemData = (UseItemData) transactionPacket.transactionData; - blockVector = useItemData.blockPos; - face = useItemData.face; - } catch (Exception ignored) { - break packetswitch; + 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; - if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) { - inventory.equipItem(useItemData.hotbarSlot); - } + try { + useItemData = (UseItemData) transactionPacket.transactionData; + blockVector = useItemData.blockPos; + face = useItemData.face; + } catch (Exception ignored) { + break; + } - 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; - } + if (inventory.getHeldItemIndex() != useItemData.hotbarSlot) { + inventory.equipItem(useItemData.hotbarSlot); + } - this.setUsingItem(false); + 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; - 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; - } + lastRightClickPos = blockVector; + lastRightClickTime = System.currentTimeMillis(); - 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 (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"); } - 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); - } - } - } - break packetswitch; - case InventoryTransactionPacket.USE_ITEM_ACTION_BREAK_BLOCK: - if (!this.spawned || !this.isAlive()) { - break packetswitch; + 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; + case InventoryTransactionPacket.USE_ITEM_ACTION_BREAK_BLOCK: + if (!this.spawned || !this.isAlive()) { + break; + } - this.resetCraftingGridType(); - - 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; + } - item = this.inventory.getItemInHand(); + PlayerInteractEvent interactEvent = new PlayerInteractEvent(this, item, directionVector, face, Action.RIGHT_CLICK_AIR); + this.server.getPluginManager().callEvent(interactEvent); - if (item instanceof ItemCrossbow) { - if (!item.onClickAir(this, directionVector)) { - return; // Shoot + if (interactEvent.isCancelled()) { + this.needSendHeldItem = true; + break; + } + + 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); + float maceFallDistance = item.isMace() ? (float) (this.highestPosition - this.y) : 0f; + 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); - float maceFallDistance = item.isMace() ? (float) (this.highestPosition - this.y) : 0f; - 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; + } + + for (Enchantment enchantment : item.getEnchantments()) { + enchantment.doPostAttack(this, target); + } - 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(); + if (item instanceof ItemMace mace) { + mace.onPostAttack(this, target, maceFallDistance, itemDamage); + } + + 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); } + this.setUsingItem(false); + } else { + this.inventory.sendContents(this); + } + return; + case InventoryTransactionPacket.RELEASE_ITEM_ACTION_CONSUME: + if (this.protocol >= 388) { break; } - for (Enchantment enchantment : item.getEnchantments()) { - enchantment.doPostAttack(this, target); - } + 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; + } - if (item instanceof ItemMace mace) { - mace.onPostAttack(this, target, maceFallDistance, itemDamage); - } + Potion potion = Potion.getPotion(itemInHand.getDamage()); + if (this.gamemode == SURVIVAL || this.gamemode == ADVENTURE) { + this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex()); + this.inventory.addItem(new ItemGlassBottle()); + } + this.level.getVibrationManager().callVibrationEvent(new VibrationEvent(this, this.add(0, this.getEyeHeight()), VibrationType.DRINK)); + if (potion != null) { + potion.applyPotion(this); + } + } else { + this.server.getPluginManager().callEvent(consumeEvent); + if (consumeEvent.isCancelled()) { + this.inventory.sendContents(this); + 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"); - } + Food food = Food.getByRelative(itemInHand); + if (food != null && food.eatenBy(this)) { + this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex()); + this.level.getVibrationManager().callVibrationEvent(new VibrationEvent(this, this.add(0, this.getEyeHeight()), VibrationType.EAT)); } } return; default: + this.getServer().getLogger().debug(username + ": unknown release item action type: " + releaseItemData.actionType); break; } + } finally { + this.setUsingItem(false); + } + break; + default: + this.inventory.sendContents(this); + break; + } + } finally { + if (resyncInventoryAfterLegacyTransaction) { + this.needSendInventory = true; + } + } + } - 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 { - this.inventory.sendContents(this); - } - 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()); + private boolean shouldRejectLegacyInventoryUiTransaction(InventoryTransactionPacket packet) { + return this.isInventoryServerAuthoritative() + && (packet.transactionType == InventoryTransactionPacket.TYPE_NORMAL + || packet.isCraftingPart + || packet.isEnchantingPart + || packet.isRepairItemPart + || packet.isTradeItemPart); + } - if (this.gamemode == SURVIVAL || this.gamemode == ADVENTURE) { - this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex()); - this.inventory.addItem(new ItemGlassBottle()); - } + 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; + } - this.level.getVibrationManager().callVibrationEvent(new VibrationEvent(this, this.add(0, this.getEyeHeight()), VibrationType.DRINK)); + 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; + } - if (potion != null) { - potion.applyPotion(this); - } - } else { // Food - this.server.getPluginManager().callEvent(consumeEvent); - if (consumeEvent.isCancelled()) { - this.inventory.sendContents(this); - break; - } + 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; + } - Food food = Food.getByRelative(itemInHand); - if (food != null && food.eatenBy(this)) { - this.getInventory().decreaseCount(this.getInventory().getHeldItemIndex()); - this.level.getVibrationManager().callVibrationEvent(new VibrationEvent(this, this.add(0, this.getEyeHeight()), VibrationType.EAT)); - } - } - return; - default: - this.getServer().getLogger().debug(username + ": unknown release item action type: " + releaseItemData.actionType); - break; - } - } finally { - this.setUsingItem(false); - } - break; - default: - this.inventory.sendContents(this); - break; - } - break; - default: - break; + 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) { @@ -6124,6 +6243,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; @@ -6424,6 +6544,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); } } @@ -6435,6 +6556,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); } } @@ -7244,12 +7369,19 @@ 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)) { return cnt; } else if (!alwaysOpen) { - this.removeWindow(inventory); + if (!this.permanentWindows.contains(cnt)) { + this.windows.remove(inventory); + if (this.topWindow == inventory) { + this.topWindow = null; + } + } return -1; } else { @@ -7260,12 +7392,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) { @@ -7278,6 +7418,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; + } } } @@ -7352,8 +7495,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(); @@ -8247,6 +8392,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()*/; } @@ -8388,6 +8541,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, ContainerIds.INVENTORY); this.dataPacket(pk); } diff --git a/src/main/java/cn/nukkit/Server.java b/src/main/java/cn/nukkit/Server.java index 4e2b0e2b1..248001d5d 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 */ @@ -3358,6 +3364,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; @@ -3577,6 +3584,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/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/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 b53cf0e26..7b9fa4766 100644 --- a/src/main/java/cn/nukkit/block/BlockDropper.java +++ b/src/main/java/cn/nukkit/block/BlockDropper.java @@ -20,7 +20,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; @@ -42,6 +42,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/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 c0a210f6a..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,7 +115,16 @@ 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 + 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 5a5ddcaa5..f765e7cde 100644 --- a/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java +++ b/src/main/java/cn/nukkit/entity/passive/EntityHorseBase.java @@ -7,6 +7,8 @@ import cn.nukkit.entity.EntityRideable; import cn.nukkit.entity.data.Vector3fEntityData; import cn.nukkit.event.entity.EntityDamageEvent; +import cn.nukkit.inventory.HorseInventory; +import cn.nukkit.inventory.InventoryHolder; import cn.nukkit.item.Item; import cn.nukkit.level.Sound; import cn.nukkit.level.format.FullChunk; @@ -28,12 +30,14 @@ /** * @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 static final String NBT_KEY_ARMOR_ITEM = "ArmorItem"; private boolean saddled; - private Item horseArmor = Item.AIR_ITEM; + private HorseInventory horseInventory; public EntityHorseBase(FullChunk chunk, CompoundTag nbt) { super(chunk, nbt); @@ -53,8 +57,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)); + } } if (this.namedTag.containsCompound(NBT_KEY_ARMOR_ITEM)) { @@ -66,13 +80,36 @@ protected void initEntity() { public void saveNBT() { super.saveNBT(); this.namedTag.putBoolean("Saddle", this.isSaddled()); - if (this.hasHorseArmor()) { - this.namedTag.putCompound(NBT_KEY_ARMOR_ITEM, NBTIO.putItemHelper(this.horseArmor)); + if (this.horseInventory != null) { + this.namedTag.putList(TAG_CHEST_ITEMS, this.horseInventory.saveToNBT()); + } + 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); } } + @Override + 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. + */ + protected int getChestSize() { + return 0; + } + @Override public boolean mountEntity(Entity entity, byte mode) { Objects.requireNonNull(entity, "The target of the mounting entity can't be null"); @@ -117,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())) { @@ -145,15 +182,27 @@ 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); + } + } } } public boolean hasHorseArmor() { - return this.horseArmor != null && !this.horseArmor.isNull(); + return !this.getHorseArmor().isNull(); } public Item getHorseArmor() { - return this.horseArmor == null ? Item.AIR_ITEM : 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) { @@ -161,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); @@ -276,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/event/inventory/ItemStackRequestActionEvent.java b/src/main/java/cn/nukkit/event/inventory/ItemStackRequestActionEvent.java new file mode 100644 index 000000000..69dd95cdb --- /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.Event; +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 Event 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) { + 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..f755c3271 --- /dev/null +++ b/src/main/java/cn/nukkit/event/player/PlayerTransferItemEvent.java @@ -0,0 +1,102 @@ +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; +import org.jetbrains.annotations.Nullable; + +/** + * 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; + } + + /** + * 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; + private final Item destinationItem; + private final int count; + + public PlayerTransferItemEvent(Player player, Inventory sourceInventory, int sourceSlot, + @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; + this.destinationSlot = destinationSlot; + this.sourceItem = sourceItem; + this.destinationItem = destinationItem; + this.count = count; + } + + public Type getType() { + return type; + } + + public Inventory getSourceInventory() { + return sourceInventory; + } + + 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; + } + + 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 8df945944..97bd9ede1 100644 --- a/src/main/java/cn/nukkit/inventory/BaseInventory.java +++ b/src/main/java/cn/nukkit/inventory/BaseInventory.java @@ -10,13 +10,17 @@ 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; +import cn.nukkit.network.protocol.types.inventory.ContainerSlotType; +import cn.nukkit.network.protocol.types.inventory.FullContainerName; import cn.nukkit.network.protocol.v113.ContainerSetContentPacket_v113; import cn.nukkit.network.protocol.v113.ContainerSetSlotPacket_v113; import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntList; +import org.jetbrains.annotations.ApiStatus; import java.util.*; @@ -106,12 +110,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 @@ -173,6 +202,17 @@ 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 + // "empty slot" in ItemStackResponse packets and causes cursor/inventory desync. + if (!item.isNull() && item.getStackNetId() == 0) { + item.autoAssignStackNetworkId(); + } + + if (item instanceof ItemBundle bundle) { + ensureUniqueBundleId(index, bundle); + } Item old = this.getItem(index); this.slots.put(index, item.clone()); @@ -180,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()); @@ -430,6 +495,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; @@ -530,6 +621,7 @@ public void sendContents(Player... players) { continue; } pk.inventoryId = id; + pk.containerNameData = this.resolveFullContainerName(0); player.dataPacket(pk); } } @@ -603,6 +695,7 @@ private void sendSlotTo(int index, Player player) { return; } pk.inventoryId = id; + pk.containerNameData = this.resolveFullContainerName(index); player.dataPacket(pk); } else { ContainerSetSlotPacket_v113 pk = new ContainerSetSlotPacket_v113(); @@ -637,6 +730,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); player.dataPacket(pk); } else { player.dataPacket(pk2); @@ -653,4 +747,59 @@ public void sendSlot(int index, Collection players) { public InventoryType getType() { return type; } + + protected FullContainerName resolveFullContainerName(int index) { + return new FullContainerName(resolveContainerSlotType(index), null); + } + + 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/BundleInventory.java b/src/main/java/cn/nukkit/inventory/BundleInventory.java new file mode 100644 index 000000000..48adb1811 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/BundleInventory.java @@ -0,0 +1,206 @@ +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; +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) { + 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; + } + + boolean changed = super.setItem(index, item, send); + if (changed) { + getHolder().saveNBT(); + } + 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); + 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_40) { + 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_40) { + 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() && 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()) { + 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(); + } + + 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/CartographyTableInventory.java b/src/main/java/cn/nukkit/inventory/CartographyTableInventory.java new file mode 100644 index 000000000..cea07f9d8 --- /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, CARTOGRAPHY_INPUT_UI_SLOT, 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 45e11b1e8..ed08d2b78 100644 --- a/src/main/java/cn/nukkit/inventory/CraftingManager.java +++ b/src/main/java/cn/nukkit/inventory/CraftingManager.java @@ -95,6 +95,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; @@ -248,7 +256,7 @@ public CraftingManager() { ingredients.add(ingredientItem); } - this.registerSmithingRecipe(new SmithingRecipe(recipeId, 0, ingredients, item)); + this.registerSmithingRecipe(new SmithingTransformRecipe(recipeId, 0, ingredients, item)); } this.rebuildPacket(); @@ -1328,6 +1336,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 @@ -1374,6 +1383,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 @@ -1395,11 +1405,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 @@ -1486,6 +1498,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/DoubleChestInventory.java b/src/main/java/cn/nukkit/inventory/DoubleChestInventory.java index e48f104f9..2d7659e6c 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,40 @@ 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 + @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 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 +214,7 @@ public void sendSlot(Inventory inv, int index, Player... players) { continue; } pk.inventoryId = id; + pk.containerNameData = new FullContainerName(ContainerSlotType.LEVEL_ENTITY, null); player.dataPacket(pk); } } diff --git a/src/main/java/cn/nukkit/inventory/EnchantInventory.java b/src/main/java/cn/nukkit/inventory/EnchantInventory.java index ac1207b5b..b8f77b642 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); } @@ -26,16 +39,97 @@ 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; 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 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 e3ae56c03..47f5dfe4b 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; @@ -94,9 +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, id); player.dataPacket(inventorySlotPacket); } else { MobArmorEquipmentPacket mobArmorEquipmentPacket = new MobArmorEquipmentPacket(); @@ -118,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/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/HorseInventory.java b/src/main/java/cn/nukkit/inventory/HorseInventory.java new file mode 100644 index 000000000..40303c865 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/HorseInventory.java @@ -0,0 +1,210 @@ +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 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"); + this.chestSize = Math.max(0, chestSize); + } + + @Override + public EntityHorseBase getHolder() { + return (EntityHorseBase) this.holder; + } + + 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; + } + + 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) { + EntityHorseBase holder = getHolder(); + return !(holder instanceof EntityLlama) + && holder.canWearHorseArmor() + && item.isHorseArmor(); + } + 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 && !suppressArmorVisual) { + 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}; + pk.body = body; + + 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 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++) { + 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 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; + 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 = previousSaddle; + suppressArmorVisual = previousArmor; + } + } +} diff --git a/src/main/java/cn/nukkit/inventory/Inventory.java b/src/main/java/cn/nukkit/inventory/Inventory.java index c966b192c..f5e3190ec 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); } @@ -37,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/InventoryType.java b/src/main/java/cn/nukkit/inventory/InventoryType.java index 344117f76..c64c6f17e 100644 --- a/src/main/java/cn/nukkit/inventory/InventoryType.java +++ b/src/main/java/cn/nukkit/inventory/InventoryType.java @@ -37,6 +37,8 @@ public enum InventoryType { SMITHING_TABLE(3, "Smithing Table", 33), GRINDSTONE(3, "Grindstone", 26), STONECUTTER(2, "Stonecutter", 29), + CARTOGRAPHY(2, "Cartography Table", 30), + HORSE(17, "Horse", 12), //1 SADDLE, 1 ARMOR, up to 15 CHEST CRAFTER(9, "Crafter", 36), COMMAND_BLOCK(0, "Command Block", 16); diff --git a/src/main/java/cn/nukkit/inventory/PlayerInventory.java b/src/main/java/cn/nukkit/inventory/PlayerInventory.java index 4f766c8fa..e12a02d2b 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.ContainerSetContentPacket_v113; import cn.nukkit.network.protocol.v113.ContainerSetSlotPacket_v113; @@ -299,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); @@ -381,8 +387,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 { ContainerSetContentPacket_v113 pk2 = new ContainerSetContentPacket_v113(); @@ -446,9 +454,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 { ContainerSetSlotPacket_v113 pk3 = new ContainerSetSlotPacket_v113(); @@ -523,6 +533,7 @@ public void sendContents(Player[] players) { continue; } pk.inventoryId = id; + pk.containerNameData = new FullContainerName(ContainerSlotType.HOTBAR_AND_INVENTORY, id); player.dataPacket(pk.clone()); } } @@ -557,6 +568,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); @@ -570,6 +582,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()); @@ -580,6 +593,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 289c79d03..c17b20778 100644 --- a/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java +++ b/src/main/java/cn/nukkit/inventory/PlayerOffhandInventory.java @@ -13,14 +13,15 @@ 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 { + private static final int OFFHAND_CONTAINER_DYNAMIC_ID = 0; /** * 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); } @@ -56,6 +57,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); @@ -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, OFFHAND_CONTAINER_DYNAMIC_ID); player.dataPacket(pk2); } else { player.dataPacket(pk); @@ -96,7 +99,6 @@ public EntityHuman getHolder() { @Override public boolean allowedToAdd(Item item) { - //return OFFHAND_ITEMS.contains(item.getId()); return true; } @@ -132,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/PlayerUIComponent.java b/src/main/java/cn/nukkit/inventory/PlayerUIComponent.java index cc88b104d..db51a3c46 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); @@ -62,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/PlayerUIInventory.java b/src/main/java/cn/nukkit/inventory/PlayerUIInventory.java index 293a45435..c1a0ceca8 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,10 @@ 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). + pk.containerNameData = new FullContainerName(resolveUISlotType(index), null); + for (Player p : target) { if (p == this.getHolder()) { pk.inventoryId = ContainerIds.UI; @@ -93,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()) { @@ -112,13 +119,41 @@ 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 } } + 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/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..04879d44e 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,31 +61,79 @@ public void onOpen(Player who) { pk.newTradingUi = true; pk.usingEconomyTrade = true; - + who.dataPacket(pk); - + this.sendContents(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); } - + 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(); + } + + 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/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..0495c3a53 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/BeaconPaymentActionProcessor.java @@ -0,0 +1,93 @@ +package cn.nukkit.inventory.request; + +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 { + + @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(); + } + 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(); + if (!BlockEntityBeacon.isAllowedEffect(primary)) { + return context.error(); + } + if (!BlockEntityBeacon.isAllowedEffect(secondary)) { + return context.error(); + } + + Position holder = beaconInventory.getHolder(); + if (holder == null || !(holder.level.getBlockEntity(holder) instanceof BlockEntityBeacon beacon)) { + return context.error(); + } + if (beacon.getPowerLevel() < 1) { + return context.error(); + } + context.onCommit(() -> { + beacon.setPrimaryPower(primary); + beacon.setSecondaryPower(secondary); + }); + 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/ConsumeActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java new file mode 100644 index 000000000..ca42d0f38 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/ConsumeActionProcessor.java @@ -0,0 +1,74 @@ +package cn.nukkit.inventory.request; + +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 { + + @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.getUnclonedItem(slot); + if (item.isNull() || item.getCount() < count) { + return context.error(); + } + if (validateStackNetworkId(item.getStackNetId(), src.getStackNetworkId())) { + 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 { + if (!inventory.setItem(slot, targetItem, false)) { + return context.error(); + } + } + + 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..ccf4ef094 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftCreativeActionProcessor.java @@ -0,0 +1,53 @@ +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.itemstack.request.action.CraftCreativeAction; +import cn.nukkit.network.protocol.types.inventory.itemstack.request.action.ItemStackRequestActionType; + +/** + * 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(); + // 创造背包拿物品时,客户端总是按最大堆叠数请求整堆(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); + context.put(CRAFT_CREATIVE_KEY, true); + return 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..7204ac5af --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftGrindstoneActionProcessor.java @@ -0,0 +1,93 @@ +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.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; +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.ArrayList; +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(); + } + + // 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, + grindstone.getEquipment(), + result, + grindstone.getIngredient(), + experience, + player + ); + player.getServer().getPluginManager().callEvent(event); + if (event.isCancelled()) { + 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, experienceDropped); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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..822342d4e --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftLoomActionProcessor.java @@ -0,0 +1,179 @@ +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.ArrayList; +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"; + + /** + * 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; + } + + @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(); + } + + // 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()) { + patternType = BannerPattern.Type.getByName(patternId); + if (patternType == null) { + return context.error(); + } + } + + // 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(); + } + + 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) { + 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( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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 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/CraftNonImplementedActionProcessor.java b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java new file mode 100644 index 000000000..e93cec648 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftNonImplementedActionProcessor.java @@ -0,0 +1,20 @@ +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) { + // 协议占位/no-op action(老协议下还会承接映射歧义的若干真实 action)。 + // 返回 null 表示静默跳过:不产出响应、不判定为错误,避免中断整条 request, + return null; + } +} 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..97d8af14d --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeActionProcessor.java @@ -0,0 +1,687 @@ +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.CraftingDataPacket; +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.*; +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.*; + +/** + * Resolves the recipe referenced by a CraftRecipeAction and dispatches to one of + * three branches depending on the network id range: + *

    + *
  • {@code >= ENCH_RECIPEID}: enchantment option from + * {@link PlayerEnchantOptionsPacket#RECIPE_MAP}
  • + *
  • {@code >= TRADE_RECIPEID}: villager trade from + * {@link TradeRecipeBuildUtils#RECIPE_MAP}
  • + *
  • otherwise: regular crafting recipe in + * {@link cn.nukkit.inventory.CraftingManager}
  • + *
+ *

+ * Adapted from PowerNukkitX (PowerNukkitX) + */ +@Log4j2 +public class CraftRecipeActionProcessor implements ItemStackRequestActionProcessor { + + public static final String RECIPE_NET_ID_KEY = "recipeNetId"; + public static final String ENCH_RECIPE_KEY = "enchRecipe"; + + @Override + public ItemStackRequestActionType getType() { + return ItemStackRequestActionType.CRAFT_RECIPE; + } + + @Override + public ActionResponse handle(CraftRecipeAction action, Player player, ItemStackRequestContext context) { + int recipeNetId = action.getRecipeNetworkId(); + context.put(RECIPE_NET_ID_KEY, recipeNetId); + + // TRADE_RECIPEID (0x20000000) > ENCH_RECIPEID (0x10000000); dispatch in + // descending order so a trade id does not get mistaken for an enchant id. + if (recipeNetId >= TradeRecipeBuildUtils.TRADE_RECIPEID) { + return handleTrade(action, player, context); + } + if (recipeNetId >= PlayerEnchantOptionsPacket.ENCH_RECIPEID) { + return handleEnchant(action, player, context); + } + if (recipeNetId == CraftingDataPacket.SMITHING_ARMOR_TRIM_NETWORK_ID) { + return handleSmithingTrim(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); + + 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(); + } + + // 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()); + 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(); + player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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) { + 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(); + } + 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) { + 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 || player.getExperienceLevel() < option.getMinLevel()) { + 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(); + } + 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(); + 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, 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() - finalCost)); + } + player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, finalOutput, false); + context.onCommit(() -> enchantInventory.releasePublishedOption(action.getRecipeNetworkId())); + context.put(ENCH_RECIPE_KEY, true); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + finalOutput.getCount(), finalOutput.getStackNetId(), + finalOutput.hasCustomName() ? finalOutput.getCustomName() : "", + finalOutput.getDamage(), "" + ); + return context.success(List.of(new ItemStackResponseContainer( + ContainerSlotType.CREATED_OUTPUT, + List.of(responseSlot), + new FullContainerName(ContainerSlotType.CREATED_OUTPUT, null) + ))); + } + + 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) { + 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; + if (uses + times > maxUses) { + return context.error(); + } + + Item buyA = tradeInventory.getUnclonedItem(0); + Item buyB = tradeInventory.getUnclonedItem(1); + boolean hasBuyA = recipe.contains("buyA"); + boolean hasBuyB = recipe.contains("buyB"); + + if (hasBuyA && checkTrade(recipe.getCompound("buyA"), buyA, times)) { + return context.error(); + } + if (hasBuyB && checkTrade(recipe.getCompound("buyB"), buyB, times)) { + 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(); + } + output.setCount(output.getCount() * times); + output.autoAssignStackNetworkId(); + player.getUIInventory().setItem(PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, output, false); + + int rewardExp = recipe.contains("rewardExp") ? recipe.getInt("rewardExp") : 0; + EntityVillager villager = tradeInventory.getHolder(); + 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) { + villager.namedTag.putBoolean("traded", true); + if (traderExp > 0) { + villager.addExperience(traderExp * times); + } + } + }); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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 multiplier) { + if (actual == null || actual.isNull()) { + return true; + } + int required = Math.max(expected.getByte("Count") * Math.max(1, multiplier), 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; + } + + 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 + * {@code input} parameter of {@link CraftItemEvent} so plugin listeners can + * 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) { + CraftingGrid grid = getActiveCraftingGrid(player); + int size = grid.getSize(); + List items = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + Item item = grid.getUnclonedItem(i); + if (item != null && !item.isNull()) { + items.add(item.clone()); + } + } + 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) { + 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 validateCraftingConsumePlan(Player player, CraftingRecipe recipe, int times, ItemStackRequestContext context) { + List expected = new ArrayList<>(); + for (Item ingredient : recipe.getIngredientsAggregate()) { + addExpectedConsumeItem(expected, ingredient, ingredient == null ? 0 : ingredient.getCount() * times); + } + + 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; + } + 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; + } + expectedConsumes.add(item.clone()); + } + if (expectedConsumes.isEmpty()) { + return true; + } + + 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, expectedConsumes); + } + + 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; + } + + /** + * 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 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(); + } + 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 (!validateSmithingConsumePlan(player, context, + smithingInventory.getEquipment(), smithingInventory.getIngredient(), smithingInventory.getTemplate())) { + 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); + } + + 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. + * 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 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()) { + 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( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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..66323acd8 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeAutoProcessor.java @@ -0,0 +1,255 @@ +package cn.nukkit.inventory.request; + +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; +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.*; +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(CraftRecipeAutoProcessor::toEventItem) + .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(); + } + + List consumedItems = collectConsumedItems(player, consumeActions); + if (consumedItems == null || !validateConsumePlan(ingredients, consumedItems)) { + 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() + || !validateAutoCraftingRecipe(player, multiRecipe, output, 1, consumedItems)) { + 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 (!validateAutoCraftingRecipe(player, recipe, recipeResult, times, consumedItems)) { + return context.error(); + } + 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( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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; + } + + 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 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 null; + } + var source = consume.getSource(); + if (source == null) { + return null; + } + var inventory = NetworkMapping.getInventory(player, source.getContainer(), source.getDynamicId()); + if (inventory == null) { + return null; + } + int slot = NetworkMapping.toInternalSlot(source.getContainer(), source.getSlot()); + Item item = inventory.getItem(slot); + if (item.isNull() || item.getCount() < consume.getCount()) { + 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; + } + + 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 new file mode 100644 index 000000000..a95d03371 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftRecipeOptionalProcessor.java @@ -0,0 +1,451 @@ +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; + List expectedConsumes = new ArrayList<>(2); + 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; + 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 + // 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(); + } + } + 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()) { + 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); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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, int materialCost) {} + + /** + * 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(); + int materialCost = 0; + + 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); + } + 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 + 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, materialCost); + } + + if (costHelper == extraCost && costHelper > 0 && levelCost >= 40) { + levelCost = 39; + } + if (levelCost >= 40 && !player.isCreative()) { + return new AnvilResult(Item.get(Item.AIR), levelCost, materialCost); + } + + 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, materialCost); + } + + 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..28f989ce7 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CraftResultDeprecatedActionProcessor.java @@ -0,0 +1,61 @@ +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 { + + public static final String MULTI_RESULTS_KEY = "multiResults"; + + @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(); + 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( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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..ea51a8e2f --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/CreateActionProcessor.java @@ -0,0 +1,88 @@ +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; + 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); + + ItemStackResponseSlot responseSlot = new ItemStackResponseSlot( + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + PlayerUIComponent.CREATED_ITEM_OUTPUT_UI_SLOT, + 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..ab10c121a --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/DestroyActionProcessor.java @@ -0,0 +1,92 @@ +package cn.nukkit.inventory.request; + +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.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; +import cn.nukkit.network.protocol.types.inventory.itemstack.response.ItemStackResponseContainer; + +import java.util.ArrayList; +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 suppressResponse = Boolean.TRUE.equals(context.get(NO_RESPONSE_DESTROY_KEY)); + + ItemStackRequestSlotData src = action.getSource(); + Inventory inventory = NetworkMapping.getInventory(player, src.getContainer(), src.getDynamicId()); + if (inventory == null) { + return context.error(); + } + // 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(); + } + + int slot = NetworkMapping.toInternalSlot(src.getContainer(), src.getSlot()); + int count = action.getCount(); + if (count <= 0) { + return context.error(); + } + + Item item = inventory.getUnclonedItem(slot); + if (item.isNull() || item.getCount() < count) { + return context.error(); + } + if (validateStackNetworkId(item.getStackNetId(), src.getStackNetworkId())) { + 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 { + if (!inventory.setItem(slot, targetItem, false)) { + return context.error(); + } + } + + 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 new file mode 100644 index 000000000..925168cf0 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/DropActionProcessor.java @@ -0,0 +1,95 @@ +package cn.nukkit.inventory.request; + +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; +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 { + + @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.getUnclonedItem(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); + + 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()) { + 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(); + } + } else { + Item remaining = item.clone(); + remaining.setCount(item.getCount() - count); + if (!inventory.setItem(slot, remaining, false)) { + return context.error(); + } + } + + 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/InventoryObserverSync.java b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java new file mode 100644 index 000000000..55e83ec51 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/InventoryObserverSync.java @@ -0,0 +1,83 @@ +package cn.nukkit.inventory.request; + +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; + +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()) { + 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); + } + } + } + + /** + * 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..df14f7435 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestContext.java @@ -0,0 +1,103 @@ +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; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; + +import java.util.*; + +/** + * 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). + */ +@Log4j2 +public class ItemStackRequestContext { + + @Getter + private final ItemStackRequest itemStackRequest; + @Getter + @Setter + private int currentActionIndex; + private final Map extraData = new HashMap<>(); + private final List commitActions = new ArrayList<>(); + private final Map> pluginModifiedSlots = new LinkedHashMap<>(); + + 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 void onCommit(Runnable action) { + if (action != null) { + commitActions.add(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() { + 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 allOk; + } + + 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 Map> getPluginModifiedSlots() { + return this.pluginModifiedSlots; + } + + 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..98b34ec42 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/ItemStackRequestHandler.java @@ -0,0 +1,402 @@ +package cn.nukkit.inventory.request; + +import cn.nukkit.Player; +import cn.nukkit.event.inventory.ItemStackRequestActionEvent; +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; +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 TakeFromItemContainerActionProcessor()); + register(new PlaceInItemContainerActionProcessor()); + 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<>(); + LinkedHashMap> snapshots = new LinkedHashMap<>(); + boolean error = false; + + 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)); + captureSnapshots(snapshots, affectedInventories); + + ItemStackRequestActionProcessor processor = + (ItemStackRequestActionProcessor) PROCESSORS.get(action.getType()); + + if (processor == null) { + // 未注册/未实现的 action 类型静默跳过,继续处理同一请求内的后续 action, + // 单条 action 不应令整条 request 失败。 + log.warn("{}: unhandled item stack request action {}", player.getName(), action.getType()); + continue; + } + + 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; + } + 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; + } + } + + if (!error) { + // commit 副作用(经验扣除、实体生成、NBT 更新等)是不可逆的。 + // 如果 commit 部分失败,回滚库存只会制造新的不一致 + // (如经验已扣但物品也恢复),因此 commit 失败时不回滚, + // 仅记录日志并同步当前真实状态给客户端。 + if (!context.commit()) { + log.warn("{}: item stack request {} commit partially failed", player.getName(), request.getRequestId()); + } + } + + if (error) { + rollbackSnapshots(snapshots, context.getPluginModifiedSlots()); + resyncActor(player, snapshots.keySet()); + } + + 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()); + } 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); + } + + return affected; + } + + private static void addAffectedInventory(Set affected, Player player, ItemStackRequestSlotData slotData) { + Inventory inventory = NetworkMapping.getInventory(player, slotData.getContainer(), slotData.getDynamicId()); + 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; + } + + 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, + Map> pluginModifiedSlots) { + for (var entry : snapshots.entrySet()) { + restoreInventory(entry.getKey(), entry.getValue()); + replayPluginModifiedSlots(entry.getKey(), pluginModifiedSlots); + } + } + + private static void restoreInventory(Inventory inventory, Map snapshot) { + Inventory canonical = canonicalizeInventory(inventory); + if (canonical == null) { + return; + } + + LinkedHashSet currentSlots = new LinkedHashSet<>(canonical.getContents().keySet()); + for (int slot = 0; slot < canonical.getSize(); slot++) { + 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.setItemForce(slot, Item.get(Item.AIR)); + } + } + } + + for (var entry : snapshot.entrySet()) { + Item item = entry.getValue(); + if (item != null && !item.isNull() && item.getCount() > 0) { + canonical.setItemForce(entry.getKey(), item.clone()); + } else { + canonical.setItemForce(entry.getKey(), Item.get(Item.AIR)); + } + } + } + + 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.setItemForce(entry.getKey(), Item.get(Item.AIR)); + } else { + canonical.setItemForce(entry.getKey(), item.clone()); + } + } + } + + 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); + } + } + + 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()) { + // 用 (slot<<32)|hotbarSlot 作为组合 key,避免 Objects.hash 碰撞导致 + // 同一容器不同 slot 的响应被相互覆盖(这会让客户端丢失更新并回滚对应 slot)。 + long key = ((long) slot.getSlot() << 32) | (slot.getHotbarSlot() & 0xFFFFFFFFL); + items.put(key, 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..4b7a7b7da --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/LabTableCombineActionProcessor.java @@ -0,0 +1,20 @@ +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) { + // 实验台(教育版)合成 action,服务端未实现。返回 null 表示静默跳过: + // 不产出响应、不判定为错误,避免中断整条 request, + return null; + } +} 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..eb95f7ab8 --- /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, ContainerIds.INVENTORY); + 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, ContainerIds.INVENTORY) + ))); + } +} 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..0a4bf2d90 --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/NetworkMapping.java @@ -0,0 +1,438 @@ +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) { + 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(); + case CREATED_OUTPUT -> ui; // slot 50 of PlayerUIInventory hosts the created output + case CRAFTING_INPUT, CRAFTING_OUTPUT -> { + // 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(); + 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 -> + 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 -> null; + case TRADE_INGREDIENT_1, TRADE_INGREDIENT_2, TRADE_RESULT, + TRADE2_INGREDIENT_1, TRADE2_INGREDIENT_2, TRADE2_RESULT -> + typedContainer(topWindow, TradeInventory.class); + case FURNACE_FUEL, FURNACE_INGREDIENT, FURNACE_RESULT, + 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 是多种方块实体容器的共用泛化槽类型,无法精确校验窗口类型。 + // 但须拒绝 PlayerUIComponent(铁砧/切石机/附魔台等 FakeBlockUI 及合成格/光标): + // 它们按偏移读写槽位且关闭时只回收部分槽,身份映射会被恶意 SAI 请求利用造成物品丢失。 + case LEVEL_ENTITY -> topWindow instanceof PlayerUIComponent ? null : 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 + || 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. + *

+ * 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; + 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 -> 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. + */ + 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 switch (internalSlot) { + case 0 -> ContainerSlotType.CURSOR; + case 50 -> ContainerSlotType.CREATED_OUTPUT; + default -> ContainerSlotType.CRAFTING_INPUT; + }; + } + 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 CrafterInventory) { + return ContainerSlotType.CRAFTER_BLOCK_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) { + // 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; + } + + 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/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 new file mode 100644 index 000000000..e6e238bbc --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/SwapActionProcessor.java @@ -0,0 +1,89 @@ +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; +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 { + + @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.getUnclonedItem(srcSlot); + Item destItem = dstInv.getUnclonedItem(dstSlot); + + if (validateStackNetworkId(sourceItem.getStackNetId(), src.getStackNetworkId())) { + return context.error(); + } + if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) { + return context.error(); + } + + if (!TransferItemActionProcessor.isSlotCompatible(srcInv, srcSlot, destItem)) { + return context.error(); + } + if (!TransferItemActionProcessor.isSlotCompatible(dstInv, dstSlot, sourceItem)) { + 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(); + } + + // 向后兼容:复用 legacy InventoryTransaction 的事件语义。 + 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(); + + if (!srcInv.setItem(srcSlot, destItem.clone(), false)) { + return context.error(); + } + if (!dstInv.setItem(dstSlot, sourceItem.clone(), false)) { + srcInv.setItemForce(srcSlot, originalSource); + return context.error(); + } + + 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/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 new file mode 100644 index 000000000..22940050f --- /dev/null +++ b/src/main/java/cn/nukkit/inventory/request/TransferItemActionProcessor.java @@ -0,0 +1,462 @@ +package cn.nukkit.inventory.request; + +import cn.nukkit.Player; +import cn.nukkit.Server; +import cn.nukkit.event.player.PlayerTransferItemEvent; +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; +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.*; + +/** + * 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(); + + if (srcInv == null || dstInv == null) { + return context.error(); + } + + if (sameInventorySlot(srcInv, srcSlot, dstInv, dstSlot)) { + return context.error(); + } + + if (count <= 0) { + return context.error(); + } + + Item sourceItem = srcInv.getUnclonedItem(srcSlot); + if (sourceItem.isNull() || sourceItem.getCount() < count) { + 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)); + + // 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(); + } + + Item destItem = dstInv.getUnclonedItem(dstSlot); + if (validateStackNetworkId(destItem.getStackNetId(), dst.getStackNetworkId())) { + return context.error(); + } + + 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(); + } + + int destCount = destItem.isNull() ? 0 : destItem.getCount(); + if (destCount + count > sourceItem.getMaxStackSize()) { + return context.error(); + } + + if (!isSlotCompatible(dstInv, dstSlot, sourceItem)) { + 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()); + + // 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); + if (!fullTransfer || srcIsCreatedOutput) { + newDest.autoAssignStackNetworkId(); + } + } else { + newDest = destItem.clone(); + newDest.setCount(destCount + count); + } + + 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(); + } + + // 向后兼容:复用 legacy InventoryTransaction 的事件语义,按相同顺序 + // 触发 InventoryTransactionEvent,并在适用时最多触发一次 InventoryClickEvent。 + 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 恢复到原状态避免出现悬挂写入。 + Item originalDestItem = destItem.clone(); + Item originalSourceItem = sourceItem.clone(); + + if (!dstInv.setItem(dstSlot, newDest, sendDest)) { + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT_FAIL); + return context.error(); + } + + boolean srcOk = fullTransfer + ? srcInv.clear(srcSlot, sendSource) + : srcInv.setItem(srcSlot, newSrc, sendSource); + if (!srcOk) { + dstInv.setItemForce(dstSlot, originalDestItem); + return context.error(); + } + + // 防御:如果 src 的底层 setItem 不知为何把原物品写没了(如插件篡改为 AIR), + // 也要把 src 恢复,避免客户端回滚看到不一致状态。 + if (srcInv.getItem(srcSlot).isNull() && !fullTransfer) { + srcInv.setItemForce(srcSlot, originalSourceItem); + } + + 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)) { + 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(); + } + + 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)) { + playBundleSound(player, dstInv, Sound.BUNDLE_INSERT_FAIL); + return context.error(); + } + + boolean srcOk = fullTransfer + ? srcInv.clear(srcSlot, sendSource) + : srcInv.setItem(srcSlot, newSrc, sendSource); + if (!srcOk) { + dstInv.setItemForce(dstSlot, originalDestItem); + 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; + } + + /** + * 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. + */ + 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) { + 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; + } + + 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 == null ? Item.get(Item.AIR) : destinationItem.clone(), + count + ); + Server.getInstance().getPluginManager().callEvent(event); + 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()); + } + + 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 + * 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 + * events and returns whether a plugin cancelled them. + */ + 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.addAction(action); + } + } + + @Override + protected boolean callExecuteEvent() { + return InventoryTransaction.callExecuteEvents(this); + } + + @Override + public boolean execute() { + if (this.invalid || this.actions.isEmpty()) { + return false; + } + + // 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(); + Inventory canonical = ItemStackRequestHandler.canonicalizeInventory(inv); + if (canonical != null && !preStates.containsKey(canonical)) { + preStates.put(canonical, copyContents(canonical)); + } + } + } + + if (!callExecuteEvent()) { + if (context != null) { + for (Map.Entry> entry : preStates.entrySet()) { + Inventory inv = entry.getKey(); + Map modifiedSlots = changedSlots(entry.getValue(), copyContents(inv)); + if (!modifiedSlots.isEmpty()) { + context.addPluginModifiedSlots(inv, modifiedSlots); + } + } + } + return false; + } + 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) { + Item current = inv.getUnclonedItem(internalSlot); + int networkSlot = slotData.getSlot(); + // hotbarSlot 与 slot 保持一致,与 Allay/PNX 对齐。部分客户端对"非 HOTBAR 容器 + // 填 hotbarSlot=0"会误判需要刷新热栏槽 0 造成视觉错位。 + ItemStackResponseSlot slot = new ItemStackResponseSlot( + networkSlot, + networkSlot, + 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/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/main/java/cn/nukkit/item/Item.java b/src/main/java/cn/nukkit/item/Item.java index c12170595..1b53df6e3 100644 --- a/src/main/java/cn/nukkit/item/Item.java +++ b/src/main/java/cn/nukkit/item/Item.java @@ -84,7 +84,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 @@ -1039,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); } @@ -1719,6 +1723,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; } @@ -2002,7 +2017,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 @@ -2011,7 +2026,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/item/ItemBundle.java b/src/main/java/cn/nukkit/item/ItemBundle.java index 5c3e4638e..21a18fdaa 100644 --- a/src/main/java/cn/nukkit/item/ItemBundle.java +++ b/src/main/java/cn/nukkit/item/ItemBundle.java @@ -1,11 +1,31 @@ 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; 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 int bundleId; + private int sourceBundleId; + private BundleInventory inventory; public ItemBundle() { super(BUNDLE, "Bundle"); @@ -24,4 +44,126 @@ public boolean isSupportedOn(GameVersion protocolId) { public int getMaxStackSize() { return 1; } + + 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) { + 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 Item setNamedTag(CompoundTag tag) { + super.setNamedTag(tag); + if (tag != null && tag.contains(TAG_BUNDLE_ID)) { + 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(); + cloned.inventory = null; + 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 (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)); + } + 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/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/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..2be5e312b 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,57 @@ public interface CustomItem extends StringItem { * This method sets the definition of custom item */ CustomItemDefinition getDefinition(); + + /** + * 从注册表读取当前物品的定义。注册表保存的是注册时的快照, + * 比 {@link #getDefinition()}(每次调用都重建)更可靠、更高效。 + * 若物品未注册(如测试或注册前访问),回退到 {@link #getDefinition()}。 + *

+ * 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(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() { + 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/CustomItemDefinition.java b/src/main/java/cn/nukkit/item/customitem/CustomItemDefinition.java index e2fc90824..22f2a860a 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; @@ -57,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; @@ -402,8 +433,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); } @@ -440,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() { @@ -513,6 +552,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<>(); @@ -544,19 +587,15 @@ 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); } 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 +622,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()} 会读取此值。 + * 未设置时默认为 1。 + *

+ * Sets the attack damage. Server-side {@link Item#getAttackDamage()} reads this value. + * Defaults to 1 when unset. + */ + public ToolBuilder attackDamage(int attackDamage) { + this.attackDamage = attackDamage; + return this; + } + + /** + * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 + * 未设置时默认为 {@link ItemTool#DURABILITY_WOODEN}。 + *

+ * Sets the max durability. Server-side {@link Item#getMaxDurability()} reads this value. + * Defaults to {@link ItemTool#DURABILITY_WOODEN} when unset. + */ + 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,9 +680,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()) { - this.speed = speed; - } + //不调用 item.isPickaxe() 等实例方法,因为它们会通过 getDefinitionNbt() 递归。 + //直接接受 speed 值;若 toolType 未设置,build() 也不会应用工具类型方块挖掘速度。 + this.speed = speed; return this; } @@ -706,25 +793,52 @@ public ToolBuilder addExtraBlockTags(@NotNull List blockTags) { @Override public CustomItemDefinition build() { - //附加耐久 攻击伤害信息 + //标记正在构建:使 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", 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()) { - case 6 -> 7; - case 5 -> 6; - case 4 -> 5; - case 3 -> 4; - case 2 -> 3; - case 1 -> 2; - default -> 1; + //对齐 Block.toolBreakTimeBonus0 的 tier→speed 查表(WOODEN=1,GOLD=2,STONE=3,COPPER=4,IRON=5,DIAMOND=6,NETHERITE=7);COPPER 等无对应原版工具,回退 1。 + speed = switch (resolvedTier) { + 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() 等(构建期已回退 super,见 BUILDING)。 Identifier type = null; - if (item.isPickaxe()) { + 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")); //添加可挖掘方块 @@ -734,31 +848,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(); @@ -774,14 +891,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)); } ); } @@ -795,27 +913,62 @@ 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(); } + + /** + * 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 { + /** + * 自定义盔甲未显式调用 {@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; + private @Nullable Integer toughness = null; + private @Nullable Integer tier = null; + private @Nullable Integer maxDurability = 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 +992,136 @@ public ArmorBuilder addRepairItems(@NotNull List repairItems, int repairAm return this; } + /** + * 指定盔甲装备槽位。决定 {@code wearable.slot}、{@code enchantable_slot}, + * 并使服务端的 {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} 返回 {@code true}。 + *

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

+ * Specifies the armor equipment slot. Determines {@code wearable.slot}, {@code enchantable_slot}, + * and makes server-side {@code isHelmet()/isChestplate()/isLeggings()/isBoots()} return {@code true}. + *

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

+ * Sets the armor points. Server-side {@link Item#getArmorPoints()} reads this value. + * Defaults to 0 when unset. + */ + 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; + } + + /** + * 设置最大耐久。服务端的 {@link Item#getMaxDurability()} 会读取此值。 + * 未设置时默认为 {@link #DURABILITY_DEFAULT}(正数安全值),避免护甲受击时被摧毁。 + */ + public ArmorBuilder maxDurability(int maxDurability) { + this.maxDurability = maxDurability; + return this; + } + @Override public CustomItemDefinition build() { + //标记正在构建:使 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", item.getMaxDurability())) + .putInt("max_durability", resolvedDurability)) .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()/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", "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 805c8689a..91a767d26 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 */ @@ -40,6 +40,11 @@ public String getTextureName() { @Override public abstract CustomItemDefinition getDefinition(); + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + @Override public ItemCustom clone() { return (ItemCustom) super.clone(); diff --git a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java index dcb8a9e40..07e2844b7 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomArmor.java @@ -32,6 +32,11 @@ public String getTextureName() { return textureName; } + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + @Override public String getNamespaceId() { return id; @@ -46,4 +51,99 @@ 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() { + //构建期间回退 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") + .getInt("protection"); + } + + @Override + public int getToughness() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getToughness(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:wearable") + .getInt("toughness"); + } + + @Override + public int getTier() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getTier(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("tier"); + } + + @Override + public int getMaxDurability() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getMaxDurability(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:durability") + .getInt("max_durability"); + } + + @Override + public ItemCustomArmor clone() { + return (ItemCustomArmor) super.clone(); + } } 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 fea5584a9..583d94a2a 100644 --- a/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java +++ b/src/main/java/cn/nukkit/item/customitem/ItemCustomTool.java @@ -1,11 +1,20 @@ package cn.nukkit.item.customitem; -import cn.nukkit.item.*; +import cn.nukkit.block.Block; +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.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; +import java.util.HashMap; +import java.util.Map; + /** * @author lt_name */ @@ -13,6 +22,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; @@ -23,22 +35,190 @@ public ItemCustomTool(@NotNull String id, @Nullable String name, @NotNull String this.textureName = textureName; } + @Override + public String getTextureName() { + return textureName; + } + + @Override + public boolean canBePutInOffhandSlot() { + return CustomItem.isAllowedInOffHand(this); + } + + /** + * 判断物品是否含有指定的 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; + //构建期间回退 super 以避免 getDefinitionNbt() → getDefinition() → build() 递归(见 CustomItemDefinition.BUILDING)。 + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getMaxDurability(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("minecraft:durability") + .getInt("max_durability"); } @Override - public String getTextureName() { - return textureName; + public int getTier() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getTier(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("tier"); + } + + @Override + public int getAttackDamage() { + if (CustomItemDefinition.isBuilding(this.getNamespaceId())) { + return super.getAttackDamage(); + } + return this.getDefinitionNbt() + .getCompound("components") + .getCompound("item_properties") + .getInt("damage"); + } + + @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() + .getCompound("components") + .getCompound("item_properties") + .getString("enchantable_slot"); + return "sword".equals(slot); } + /** + * 返回 destroy_speeds 首项速度。固定取第一项、不区分方块,已过时,保留仅为 API 兼容。 + * + * @deprecated 改用 {@link #getSpeedFor(Block)}。 + */ + @Deprecated @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; - return nbt.getCompound("components") + var nbt = this.getDefinitionNbt(); + if (!nbt.getCompound("components").contains("minecraft:digger")) return null; + 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 + 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/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java new file mode 100644 index 000000000..7a39fe939 --- /dev/null +++ b/src/main/java/cn/nukkit/item/enchantment/EnchantmentHelper.java @@ -0,0 +1,110 @@ +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.isTreasure() + || enchantment.isCurse() + || !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 419506675..a2d8f5a14 100644 --- a/src/main/java/cn/nukkit/network/process/DataPacketManager.java +++ b/src/main/java/cn/nukkit/network/process/DataPacketManager.java @@ -171,6 +171,7 @@ public static void registerDefaultProcessors() { ClientToServerHandshakeProcessor.INSTANCE, EmotePacketProcessor.INSTANCE, ItemFrameDropItemProcessor.INSTANCE, + InventoryTransactionProcessor.INSTANCE, LevelSoundEventProcessor.INSTANCE, LevelSoundEventProcessorV1.INSTANCE, LevelSoundEventProcessorV2.INSTANCE, @@ -226,6 +227,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/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 new file mode 100644 index 000000000..ab05f8023 --- /dev/null +++ b/src/main/java/cn/nukkit/network/process/processor/common/ItemStackRequestProcessor.java @@ -0,0 +1,59 @@ +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.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 419+) + */ +@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; + + if (!player.isAlive() || !player.spawned) { + return; + } + + // Only process if server authoritative inventory is enabled + if (!player.isInventoryServerAuthoritative()) { + return; + } + + // Handle the requests + if (!pk.getRequests().isEmpty()) { + player.handleItemStackRequests(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) { + // 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/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/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java b/src/main/java/cn/nukkit/network/protocol/CraftingDataPacket.java index 89d005dac..00d3486d8 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<>(); @@ -277,7 +278,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/main/java/cn/nukkit/network/protocol/StartGamePacket.java b/src/main/java/cn/nukkit/network/protocol/StartGamePacket.java index 73b8f158c..7272abdcb 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(); @@ -417,7 +422,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(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/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/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/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<>(); diff --git a/src/main/java/cn/nukkit/utils/BinaryStream.java b/src/main/java/cn/nukkit/utils/BinaryStream.java index 9b02c4177..dd9cf5147 100644 --- a/src/main/java/cn/nukkit/utils/BinaryStream.java +++ b/src/main/java/cn/nukkit/utils/BinaryStream.java @@ -724,6 +724,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) { @@ -762,7 +766,7 @@ private Item getSlotNew(GameVersion gameVersion) { } int stackNetId = 0; - if (this.getBoolean()) { // hasStackNetId + if (!instanceItem && this.getBoolean()) { // hasStackNetId stackNetId = this.getVarInt(); } @@ -857,7 +861,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; @@ -904,7 +908,7 @@ private Item getSlotNew(GameVersion gameVersion) { item.setNamedTag(namedTag); } - if (stackNetId != 0) { + if (!instanceItem && stackNetId != 0) { item.setStackNetId(stackNetId); } return item; @@ -2143,7 +2147,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/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/MockServer.java b/src/test/java/cn/nukkit/MockServer.java index 6104d81f5..acfc1c1a1 100644 --- a/src/test/java/cn/nukkit/MockServer.java +++ b/src/test/java/cn/nukkit/MockServer.java @@ -8,6 +8,7 @@ import cn.nukkit.item.enchantment.Enchantment; import cn.nukkit.level.GlobalBlockPalette; import cn.nukkit.level.Level; +import cn.nukkit.level.vibration.VibrationManager; import cn.nukkit.math.Vector3; import cn.nukkit.plugin.PluginManager; import cn.nukkit.utils.MainLogger; @@ -206,6 +207,9 @@ private static void setupLevelBlockStub(Level mockLevel) { Vector3 pos = invocation.getArgument(0); return createSimpleBlock(pos); }); + // BaseInventory#onOpen/onClose fires container vibrations; avoid NPEs in inventory tests. + Mockito.lenient().when(mockLevel.getVibrationManager()) + .thenReturn(Mockito.mock(VibrationManager.class)); } /** 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/BundleInventoryTest.java b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java new file mode 100644 index 000000000..56bd2fff9 --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/BundleInventoryTest.java @@ -0,0 +1,211 @@ +package cn.nukkit.inventory; + +import cn.nukkit.GameVersion; +import cn.nukkit.MockServer; +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.*; + +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()); + } + + @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/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/InventoryServerAuthoritativeSyncTest.java b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java new file mode 100644 index 000000000..b8fd8ecbb --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/InventoryServerAuthoritativeSyncTest.java @@ -0,0 +1,297 @@ +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.entity.item.EntityMinecartHopper; +import cn.nukkit.item.Item; +import cn.nukkit.level.Position; +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 { + + @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()); + // 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; + Mockito.when(player.getWindowId(chest)).thenReturn(7); + chest.sendContents(player); + InventoryContentPacket contentPacket = capturePacket(player, InventoryContentPacket.class); + assertEquals(ContainerSlotType.LEVEL_ENTITY, contentPacket.containerNameData.getContainer()); + assertNull(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()); + assertNull(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()); + assertNull(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()); + assertNull(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()); + } + + @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); + PlayerUIInventory ui = new PlayerUIInventory(player); + + ui.getCursorInventory().setItemForce(0, Item.get(Item.DIAMOND, 0, 1)); + assertEquals(Item.DIAMOND, ui.getItem(0).getId()); + + ui.getCraftingGrid().setItemForce(1, Item.get(Item.STONE, 0, 2)); + assertEquals(Item.STONE, ui.getItem(29).getId()); + assertEquals(2, ui.getItem(29).getCount()); + + AnvilInventory anvil = new AnvilInventory(ui, new Position()); + anvil.setItemForce(0, Item.get(Item.IRON_INGOT, 0, 3)); + assertEquals(Item.IRON_INGOT, ui.getItem(1).getId()); + assertEquals(3, ui.getItem(1).getCount()); + + anvil.setItemForce(0, Item.get(Item.AIR)); + assertTrue(ui.getItem(1).isNull()); + } + + 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()); + } + + 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; + } + } +} 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..a3ad1684a --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/request/ItemStackRequestProcessorTest.java @@ -0,0 +1,1543 @@ +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.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; +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.ItemFirework; +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; +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.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; +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 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(); + 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 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(); + 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 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 offhandInventoryAllowsDirectApiForCompatibility() { + Player player = mockPlayer(); + PlayerOffhandInventory offhand = new PlayerOffhandInventory(player); + + 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 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()); + } + + @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()); + } + + /** + * {@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 + * {@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(); + } + } + + /** + * 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(); + } + } + + /** + * 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(); + 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(); + 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); + // 1 份火药 -> flight 1;canExecute 要求客户端 output 与服务端按材料计算的结果精确匹配(含 Flight NBT) + ((ItemFirework) output).setFlight(1); + + 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 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(); + 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 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)); + } + + @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); + 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()); + } + + @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; + 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 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) { + 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/inventory/request/NetworkMappingTest.java b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java new file mode 100644 index 000000000..4888611fe --- /dev/null +++ b/src/test/java/cn/nukkit/inventory/request/NetworkMappingTest.java @@ -0,0 +1,294 @@ +package cn.nukkit.inventory.request; + +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; +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 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)); + 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); + // 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 + 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); + 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()); + } + + @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/item/customitem/CustomItemPropertyTest.java b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java new file mode 100644 index 000000000..cdfee8ee0 --- /dev/null +++ b/src/test/java/cn/nukkit/item/customitem/CustomItemPropertyTest.java @@ -0,0 +1,645 @@ +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; +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"); + } + + @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()); + //maxDurability 未设 → 默认 DURABILITY_DEFAULT(56),避免首次受击即摧毁护甲。 + assertEquals(CustomItemDefinition.ArmorBuilder.DURABILITY_DEFAULT, 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()); + } + + // ===== 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() { + 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 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 + 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"); + //hoe leaves 走 tier 默认值(tier=0 → 1);原版锄头挖树叶本就慢,保持一致 + assertEquals(1, hoe.getSpeedFor(Block.get(Block.LEAVES))); + } + + @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"); + } + + // ===== 旧式覆写契约回归测试 ===== + //旧插件不调用 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 { + 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) + .maxDurability(165) + .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(); + } + } + + /** + * 最小配置的自定义盔甲:只设 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(); + } + } + + 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(); + } + } + + /** 可配置 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(); + } + } + + /** 旧式覆写契约盔甲:不调用 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; + } + } +} 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())); + } +} 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/regression/decode/MiscDecodeRegressionTest.java b/src/test/java/cn/nukkit/network/protocol/regression/decode/MiscDecodeRegressionTest.java index 2fdb35a94..ed7279849 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 @@ -170,6 +170,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); } @@ -3153,6 +3163,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) { @@ -3774,6 +3824,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); 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)); } }