diff --git a/composer.json b/composer.json index 23bf5f126..4a6ac4f5e 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "pocketmine/bedrock-block-upgrade-schema": "~1.1.1+bedrock-1.19.70", "pocketmine/bedrock-data": "~2.1.1+bedrock-1.19.70", "pocketmine/bedrock-item-upgrade-schema": "~1.1.0+bedrock-1.19.70", - "pocketmine/bedrock-protocol": "~20.0.0+bedrock-1.19.70", + "pocketmine/bedrock-protocol": "~20.1.0+bedrock-1.19.70", "pocketmine/binaryutils": "^0.2.1", "pocketmine/callback-validator": "^1.0.2", "pocketmine/classloader": "^0.2.0", diff --git a/composer.lock b/composer.lock index 4f149eee9..33fb06dfd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1d0c1d2fe668d85ae87110a1e3cfac05", + "content-hash": "01afa65b40f95ad9378c8cd999e6098d", "packages": [ { "name": "adhocore/json-comment", @@ -328,16 +328,16 @@ }, { "name": "pocketmine/bedrock-protocol", - "version": "20.0.0+bedrock-1.19.70", + "version": "20.1.0+bedrock-1.19.70", "source": { "type": "git", "url": "https://github.com/pmmp/BedrockProtocol.git", - "reference": "4892a5020187da805d7b46ab522d8185b0283726" + "reference": "91d67c8b1bced3c82d0841b1041c0c1f4e93eb68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/4892a5020187da805d7b46ab522d8185b0283726", - "reference": "4892a5020187da805d7b46ab522d8185b0283726", + "url": "https://api.github.com/repos/pmmp/BedrockProtocol/zipball/91d67c8b1bced3c82d0841b1041c0c1f4e93eb68", + "reference": "91d67c8b1bced3c82d0841b1041c0c1f4e93eb68", "shasum": "" }, "require": { @@ -351,7 +351,7 @@ "ramsey/uuid": "^4.1" }, "require-dev": { - "phpstan/phpstan": "1.10.1", + "phpstan/phpstan": "1.10.7", "phpstan/phpstan-phpunit": "^1.0.0", "phpstan/phpstan-strict-rules": "^1.0.0", "phpunit/phpunit": "^9.5" @@ -369,9 +369,9 @@ "description": "An implementation of the Minecraft: Bedrock Edition protocol in PHP", "support": { "issues": "https://github.com/pmmp/BedrockProtocol/issues", - "source": "https://github.com/pmmp/BedrockProtocol/tree/20.0.0+bedrock-1.19.70" + "source": "https://github.com/pmmp/BedrockProtocol/tree/20.1.0+bedrock-1.19.70" }, - "time": "2023-03-14T17:06:38+00:00" + "time": "2023-03-20T01:17:00+00:00" }, { "name": "pocketmine/binaryutils", diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 8edfaf3aa..3b2bd173a 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -45,6 +45,12 @@ class CraftingManager{ */ protected $shapelessRecipes = []; + /** + * @var CraftingRecipe[] + * @phpstan-var array + */ + private array $craftingRecipeIndex = []; + /** * @var FurnaceRecipeManager[] * @phpstan-var array @@ -153,6 +159,18 @@ class CraftingManager{ return $this->shapedRecipes; } + /** + * @return CraftingRecipe[] + * @phpstan-return array + */ + public function getCraftingRecipeIndex() : array{ + return $this->craftingRecipeIndex; + } + + public function getCraftingRecipeFromIndex(int $index) : ?CraftingRecipe{ + return $this->craftingRecipeIndex[$index] ?? null; + } + public function getFurnaceRecipeManager(FurnaceType $furnaceType) : FurnaceRecipeManager{ return $this->furnaceRecipeManagers[$furnaceType->id()]; } @@ -175,6 +193,7 @@ class CraftingManager{ public function registerShapedRecipe(ShapedRecipe $recipe) : void{ $this->shapedRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; + $this->craftingRecipeIndex[] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); @@ -183,6 +202,7 @@ class CraftingManager{ public function registerShapelessRecipe(ShapelessRecipe $recipe) : void{ $this->shapelessRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; + $this->craftingRecipeIndex[] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); diff --git a/src/inventory/BaseInventory.php b/src/inventory/BaseInventory.php index 06b3b9800..daf41c579 100644 --- a/src/inventory/BaseInventory.php +++ b/src/inventory/BaseInventory.php @@ -348,7 +348,7 @@ abstract class BaseInventory implements Inventory{ if($invManager === null){ continue; } - $invManager->syncSlot($this, $index); + $invManager->onSlotChange($this, $index); } } diff --git a/src/inventory/transaction/CraftingTransaction.php b/src/inventory/transaction/CraftingTransaction.php index 792984e0f..5771038ed 100644 --- a/src/inventory/transaction/CraftingTransaction.php +++ b/src/inventory/transaction/CraftingTransaction.php @@ -60,9 +60,11 @@ class CraftingTransaction extends InventoryTransaction{ private CraftingManager $craftingManager; - public function __construct(Player $source, CraftingManager $craftingManager, array $actions = []){ + public function __construct(Player $source, CraftingManager $craftingManager, array $actions = [], ?CraftingRecipe $recipe = null, ?int $repetitions = null){ parent::__construct($source, $actions); $this->craftingManager = $craftingManager; + $this->recipe = $recipe; + $this->repetitions = $repetitions; } /** @@ -123,6 +125,18 @@ class CraftingTransaction extends InventoryTransaction{ return $iterations; } + private function validateRecipe(CraftingRecipe $recipe, ?int $expectedRepetitions) : int{ + //compute number of times recipe was crafted + $repetitions = $this->matchRecipeItems($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid()), false); + if($expectedRepetitions !== null && $repetitions !== $expectedRepetitions){ + throw new TransactionValidationException("Expected $expectedRepetitions repetitions, got $repetitions"); + } + //assert that $repetitions x recipe ingredients should be consumed + $this->matchRecipeItems($this->inputs, $recipe->getIngredientList(), true, $repetitions); + + return $repetitions; + } + public function validate() : void{ $this->squashDuplicateSlotChanges(); if(count($this->actions) < 1){ @@ -131,25 +145,24 @@ class CraftingTransaction extends InventoryTransaction{ $this->matchItems($this->outputs, $this->inputs); - $failed = 0; - foreach($this->craftingManager->matchRecipeByOutputs($this->outputs) as $recipe){ - try{ - //compute number of times recipe was crafted - $this->repetitions = $this->matchRecipeItems($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid()), false); - //assert that $repetitions x recipe ingredients should be consumed - $this->matchRecipeItems($this->inputs, $recipe->getIngredientList(), true, $this->repetitions); - - //Success! - $this->recipe = $recipe; - break; - }catch(TransactionValidationException $e){ - //failed - ++$failed; - } - } - if($this->recipe === null){ - throw new TransactionValidationException("Unable to match a recipe to transaction (tried to match against $failed recipes)"); + $failed = 0; + foreach($this->craftingManager->matchRecipeByOutputs($this->outputs) as $recipe){ + try{ + $this->repetitions = $this->validateRecipe($recipe, $this->repetitions); + $this->recipe = $recipe; + break; + }catch(TransactionValidationException $e){ + //failed + ++$failed; + } + } + + if($this->recipe === null){ + throw new TransactionValidationException("Unable to match a recipe to transaction (tried to match against $failed recipes)"); + } + }else{ + $this->repetitions = $this->validateRecipe($this->recipe, $this->repetitions); } } diff --git a/src/inventory/transaction/TransactionBuilderInventory.php b/src/inventory/transaction/TransactionBuilderInventory.php index 4995284a0..95b6c4a14 100644 --- a/src/inventory/transaction/TransactionBuilderInventory.php +++ b/src/inventory/transaction/TransactionBuilderInventory.php @@ -50,6 +50,10 @@ final class TransactionBuilderInventory extends BaseInventory{ $this->changedSlots = new \SplFixedArray($this->actualInventory->getSize()); } + public function getActualInventory() : Inventory{ + return $this->actualInventory; + } + protected function internalSetContents(array $items) : void{ for($i = 0, $size = $this->getSize(); $i < $size; ++$i){ if(!isset($items[$i])){ diff --git a/src/network/mcpe/ComplexWindowMapEntry.php b/src/network/mcpe/ComplexInventoryMapEntry.php similarity index 97% rename from src/network/mcpe/ComplexWindowMapEntry.php rename to src/network/mcpe/ComplexInventoryMapEntry.php index c2792297b..dfd3e999a 100644 --- a/src/network/mcpe/ComplexWindowMapEntry.php +++ b/src/network/mcpe/ComplexInventoryMapEntry.php @@ -25,7 +25,7 @@ namespace pocketmine\network\mcpe; use pocketmine\inventory\Inventory; -final class ComplexWindowMapEntry{ +final class ComplexInventoryMapEntry{ /** * @var int[] diff --git a/src/network/mcpe/InventoryManager.php b/src/network/mcpe/InventoryManager.php index fff1e10c7..5b5708b8a 100644 --- a/src/network/mcpe/InventoryManager.php +++ b/src/network/mcpe/InventoryManager.php @@ -38,7 +38,6 @@ use pocketmine\inventory\Inventory; use pocketmine\inventory\transaction\action\SlotChangeAction; use pocketmine\inventory\transaction\InventoryTransaction; use pocketmine\item\Item; -use pocketmine\network\mcpe\convert\TypeConversionException; use pocketmine\network\mcpe\convert\TypeConverter; use pocketmine\network\mcpe\protocol\ClientboundPacket; use pocketmine\network\mcpe\protocol\ContainerClosePacket; @@ -51,6 +50,7 @@ use pocketmine\network\mcpe\protocol\MobEquipmentPacket; use pocketmine\network\mcpe\protocol\types\BlockPosition; use pocketmine\network\mcpe\protocol\types\inventory\ContainerIds; use pocketmine\network\mcpe\protocol\types\inventory\CreativeContentEntry; +use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; use pocketmine\network\mcpe\protocol\types\inventory\ItemStackWrapper; use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; @@ -59,9 +59,11 @@ use pocketmine\network\PacketHandlingException; use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\ObjectSet; -use function array_map; +use function array_keys; use function array_search; +use function count; use function get_class; +use function implode; use function is_int; use function max; use function spl_object_id; @@ -70,26 +72,25 @@ use function spl_object_id; * @phpstan-type ContainerOpenClosure \Closure(int $id, Inventory $inventory) : (list|null) */ class InventoryManager{ - /** @var Inventory[] */ - private array $windowMap = []; /** - * @var ComplexWindowMapEntry[] - * @phpstan-var array + * @var InventoryManagerEntry[] spl_object_id(Inventory) => InventoryManagerEntry + * @phpstan-var array */ - private array $complexWindows = []; + private array $inventories = []; + /** - * @var ComplexWindowMapEntry[] - * @phpstan-var array + * @var Inventory[] network window ID => Inventory + * @phpstan-var array */ - private array $complexSlotToWindowMap = []; + private array $networkIdToInventoryMap = []; + /** + * @var ComplexInventoryMapEntry[] net slot ID => ComplexWindowMapEntry + * @phpstan-var array + */ + private array $complexSlotToInventoryMap = []; private int $lastInventoryNetworkId = ContainerIds::FIRST; - /** - * @var Item[][] - * @phpstan-var array> - */ - private array $initiatedSlotChanges = []; private int $clientSelectedHotbarSlot = -1; /** @phpstan-var ObjectSet */ @@ -99,6 +100,11 @@ class InventoryManager{ /** @phpstan-var \Closure() : void */ private ?\Closure $pendingOpenWindowCallback = null; + private int $nextItemStackId = 1; + private ?int $currentItemStackRequestId = null; + + private bool $fullSyncRequested = false; + public function __construct( private Player $player, private NetworkSession $session @@ -117,14 +123,27 @@ class InventoryManager{ }); } + private function associateIdWithInventory(int $id, Inventory $inventory) : void{ + $this->networkIdToInventoryMap[$id] = $inventory; + } + + private function getNewWindowId() : int{ + $this->lastInventoryNetworkId = max(ContainerIds::FIRST, ($this->lastInventoryNetworkId + 1) % ContainerIds::LAST); + return $this->lastInventoryNetworkId; + } + private function add(int $id, Inventory $inventory) : void{ - $this->windowMap[$id] = $inventory; + if(isset($this->inventories[spl_object_id($inventory)])){ + throw new \InvalidArgumentException("Inventory " . get_class($inventory) . " is already tracked"); + } + $this->inventories[spl_object_id($inventory)] = new InventoryManagerEntry($inventory); + $this->associateIdWithInventory($id, $inventory); } private function addDynamic(Inventory $inventory) : int{ - $this->lastInventoryNetworkId = max(ContainerIds::FIRST, ($this->lastInventoryNetworkId + 1) % ContainerIds::LAST); - $this->add($this->lastInventoryNetworkId, $inventory); - return $this->lastInventoryNetworkId; + $id = $this->getNewWindowId(); + $this->add($id, $inventory); + return $id; } /** @@ -132,26 +151,45 @@ class InventoryManager{ * @phpstan-param array|int $slotMap */ private function addComplex(array|int $slotMap, Inventory $inventory) : void{ - $entry = new ComplexWindowMapEntry($inventory, is_int($slotMap) ? [$slotMap => 0] : $slotMap); - $this->complexWindows[spl_object_id($inventory)] = $entry; - foreach($entry->getSlotMap() as $netSlot => $coreSlot){ - $this->complexSlotToWindowMap[$netSlot] = $entry; + if(isset($this->inventories[spl_object_id($inventory)])){ + throw new \InvalidArgumentException("Inventory " . get_class($inventory) . " is already tracked"); + } + $complexSlotMap = new ComplexInventoryMapEntry($inventory, is_int($slotMap) ? [$slotMap => 0] : $slotMap); + $this->inventories[spl_object_id($inventory)] = new InventoryManagerEntry( + $inventory, + $complexSlotMap + ); + foreach($complexSlotMap->getSlotMap() as $netSlot => $coreSlot){ + $this->complexSlotToInventoryMap[$netSlot] = $complexSlotMap; } } + /** + * @param int[]|int $slotMap + * @phpstan-param array|int $slotMap + */ + private function addComplexDynamic(array|int $slotMap, Inventory $inventory) : int{ + $this->addComplex($slotMap, $inventory); + $id = $this->getNewWindowId(); + $this->associateIdWithInventory($id, $inventory); + return $id; + } + private function remove(int $id) : void{ - $inventory = $this->windowMap[$id]; - $splObjectId = spl_object_id($inventory); - unset($this->windowMap[$id], $this->initiatedSlotChanges[$id], $this->complexWindows[$splObjectId]); - foreach($this->complexSlotToWindowMap as $netSlot => $entry){ - if($entry->getInventory() === $inventory){ - unset($this->complexSlotToWindowMap[$netSlot]); + $inventory = $this->networkIdToInventoryMap[$id]; + unset($this->networkIdToInventoryMap[$id]); + if($this->getWindowId($inventory) === null){ + unset($this->inventories[spl_object_id($inventory)]); + foreach($this->complexSlotToInventoryMap as $netSlot => $entry){ + if($entry->getInventory() === $inventory){ + unset($this->complexSlotToInventoryMap[$netSlot]); + } } } } public function getWindowId(Inventory $inventory) : ?int{ - return ($id = array_search($inventory, $this->windowMap, true)) !== false ? $id : null; + return ($id = array_search($inventory, $this->networkIdToInventoryMap, true)) !== false ? $id : null; } public function getCurrentWindowId() : int{ @@ -159,28 +197,33 @@ class InventoryManager{ } /** - * @phpstan-return array{Inventory, int} + * @phpstan-return array{Inventory, int}|null */ public function locateWindowAndSlot(int $windowId, int $netSlotId) : ?array{ if($windowId === ContainerIds::UI){ - $entry = $this->complexSlotToWindowMap[$netSlotId] ?? null; + $entry = $this->complexSlotToInventoryMap[$netSlotId] ?? null; if($entry === null){ return null; } $coreSlotId = $entry->mapNetToCore($netSlotId); return $coreSlotId !== null ? [$entry->getInventory(), $coreSlotId] : null; } - if(isset($this->windowMap[$windowId])){ - return [$this->windowMap[$windowId], $netSlotId]; + if(isset($this->networkIdToInventoryMap[$windowId])){ + return [$this->networkIdToInventoryMap[$windowId], $netSlotId]; } return null; } - public function onTransactionStart(InventoryTransaction $tx) : void{ + private function addPredictedSlotChange(Inventory $inventory, int $slot, ItemStack $item) : void{ + $this->inventories[spl_object_id($inventory)]->predictions[$slot] = $item; + } + + public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{ foreach($tx->getActions() as $action){ - if($action instanceof SlotChangeAction && ($windowId = $this->getWindowId($action->getInventory())) !== null){ - //in some cases the inventory might not have a window ID, but still be referenced by a transaction (e.g. crafting grid changes), so we can't unconditionally record the change here or we might leak things - $this->initiatedSlotChanges[$windowId][$action->getSlot()] = $action->getTargetItem(); + if($action instanceof SlotChangeAction){ + //TODO: ItemStackRequestExecutor can probably build these predictions with much lower overhead + $itemStack = TypeConverter::getInstance()->coreItemStackToNet($action->getTargetItem()); + $this->addPredictedSlotChange($action->getInventory(), $action->getSlot(), $itemStack); } } } @@ -189,22 +232,34 @@ class InventoryManager{ * @param NetworkInventoryAction[] $networkInventoryActions * @throws PacketHandlingException */ - public function addPredictedSlotChanges(array $networkInventoryActions) : void{ + public function addRawPredictedSlotChanges(array $networkInventoryActions) : void{ foreach($networkInventoryActions as $action){ - if($action->sourceType === NetworkInventoryAction::SOURCE_CONTAINER && ( - isset($this->windowMap[$action->windowId]) || - ($action->windowId === ContainerIds::UI && isset($this->complexSlotToWindowMap[$action->inventorySlot])) - )){ - try{ - $item = TypeConverter::getInstance()->netItemStackToCore($action->newItem->getItemStack()); - }catch(TypeConversionException $e){ - throw new PacketHandlingException($e->getMessage(), 0, $e); - } - $this->initiatedSlotChanges[$action->windowId][$action->inventorySlot] = $item; + if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){ + continue; } + + //legacy transactions should not modify or predict anything other than these inventories, since these are + //the only ones accessible when not in-game (ItemStackRequest is used for everything else) + if(match($action->windowId){ + ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false, + default => true + }){ + throw new PacketHandlingException("Legacy transactions cannot predict changes to inventory with ID " . $action->windowId); + } + $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot); + if($info === null){ + continue; + } + + [$inventory, $slot] = $info; + $this->addPredictedSlotChange($inventory, $slot, $action->newItem->getItemStack()); } } + public function setCurrentItemStackRequestId(?int $id) : void{ + $this->currentItemStackRequestId = $id; + } + /** * When the server initiates a window close, it does so by sending a ContainerClose to the client, which causes the * client to behave as if it initiated the close itself. It responds by sending a ContainerClose back to the server, @@ -248,9 +303,10 @@ class InventoryManager{ $this->onCurrentWindowRemove(); $this->openWindowDeferred(function() use ($inventory) : void{ - $windowId = $this->addDynamic($inventory); if(($slotMap = $this->createComplexSlotMapping($inventory)) !== null){ - $this->addComplex($slotMap, $inventory); + $windowId = $this->addComplexDynamic($slotMap, $inventory); + }else{ + $windowId = $this->addDynamic($inventory); } foreach($this->containerOpenCallbacks as $callback){ @@ -304,7 +360,8 @@ class InventoryManager{ $this->onCurrentWindowRemove(); $this->openWindowDeferred(function() : void{ - $windowId = $this->addDynamic($this->player->getInventory()); + $windowId = $this->getNewWindowId(); + $this->associateIdWithInventory($windowId, $this->player->getInventory()); $this->session->sendDataPacket(ContainerOpenPacket::entityInv( $windowId, @@ -315,7 +372,7 @@ class InventoryManager{ } public function onCurrentWindowRemove() : void{ - if(isset($this->windowMap[$this->lastInventoryNetworkId])){ + if(isset($this->networkIdToInventoryMap[$this->lastInventoryNetworkId])){ $this->remove($this->lastInventoryNetworkId); $this->session->sendDataPacket(ContainerClosePacket::create($this->lastInventoryNetworkId, true)); if($this->pendingCloseWindowId !== null){ @@ -327,7 +384,7 @@ class InventoryManager{ public function onClientRemoveWindow(int $id) : void{ if($id === $this->lastInventoryNetworkId){ - if(isset($this->windowMap[$id]) && $id !== $this->pendingCloseWindowId){ + if(isset($this->networkIdToInventoryMap[$id]) && $id !== $this->pendingCloseWindowId){ $this->remove($id); $this->player->removeCurrentWindow(); } @@ -349,96 +406,147 @@ class InventoryManager{ } } - public function syncSlot(Inventory $inventory, int $slot) : void{ - $slotMap = $this->complexWindows[spl_object_id($inventory)] ?? null; - if($slotMap !== null){ - $windowId = ContainerIds::UI; - $netSlot = $slotMap->mapCoreToNet($slot) ?? null; + public function onSlotChange(Inventory $inventory, int $slot) : void{ + $currentItem = TypeConverter::getInstance()->coreItemStackToNet($inventory->getItem($slot)); + $inventoryEntry = $this->inventories[spl_object_id($inventory)]; + $clientSideItem = $inventoryEntry->predictions[$slot] ?? null; + if($clientSideItem === null || !$clientSideItem->equals($currentItem)){ + //no prediction or incorrect - do not associate this with the currently active itemstack request + $this->trackItemStack($inventory, $slot, $currentItem, null); + $inventoryEntry->pendingSyncs[$slot] = $slot; }else{ - $windowId = $this->getWindowId($inventory); + //correctly predicted - associate the change with the currently active itemstack request + $this->trackItemStack($inventory, $slot, $currentItem, $this->currentItemStackRequestId); + } + + unset($inventoryEntry->predictions[$slot]); + } + + public function syncSlot(Inventory $inventory, int $slot) : void{ + $entry = $this->inventories[spl_object_id($inventory)] ?? null; + if($entry === null){ + throw new \LogicException("Cannot sync an untracked inventory"); + } + $itemStackInfo = $entry->itemStackInfos[$slot]; + if($itemStackInfo === null){ + throw new \LogicException("Cannot sync an untracked inventory slot"); + } + if($entry->complexSlotMap !== null){ + $windowId = ContainerIds::UI; + $netSlot = $entry->complexSlotMap->mapCoreToNet($slot) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null"); + }else{ + $windowId = $this->getWindowId($inventory) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null"); $netSlot = $slot; } - if($windowId !== null && $netSlot !== null){ - $currentItem = $inventory->getItem($slot); - $clientSideItem = $this->initiatedSlotChanges[$windowId][$netSlot] ?? null; - if($clientSideItem === null || !$clientSideItem->equalsExact($currentItem)){ - $itemStackWrapper = ItemStackWrapper::legacy(TypeConverter::getInstance()->coreItemStackToNet($currentItem)); - if($windowId === ContainerIds::OFFHAND){ - //TODO: HACK! - //The client may sometimes ignore the InventorySlotPacket for the offhand slot. - //This can cause a lot of problems (totems, arrows, and more...). - //The workaround is to send an InventoryContentPacket instead - //BDS (Bedrock Dedicated Server) also seems to work this way. - $this->session->sendDataPacket(InventoryContentPacket::create($windowId, [$itemStackWrapper])); - }else{ - $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, $itemStackWrapper)); - } + + $itemStackWrapper = new ItemStackWrapper($itemStackInfo->getStackId(), $itemStackInfo->getItemStack()); + if($windowId === ContainerIds::OFFHAND){ + //TODO: HACK! + //The client may sometimes ignore the InventorySlotPacket for the offhand slot. + //This can cause a lot of problems (totems, arrows, and more...). + //The workaround is to send an InventoryContentPacket instead + //BDS (Bedrock Dedicated Server) also seems to work this way. + $this->session->sendDataPacket(InventoryContentPacket::create($windowId, [$itemStackWrapper])); + }else{ + if($windowId === ContainerIds::ARMOR){ + //TODO: HACK! + //When right-clicking to equip armour, the client predicts the content of the armour slot, but + //doesn't report it in the transaction packet. The server then sends an InventorySlotPacket to + //the client, assuming the slot changed for some other reason, since there is no prediction for + //the slot. + //However, later requests involving that itemstack will refer to the request ID in which the + //armour was equipped, instead of the stack ID provided by the server in the outgoing + //InventorySlotPacket. (Perhaps because the item is already the same as the client actually + //predicted, but didn't tell us?) + //We work around this bug by setting the slot to air and then back to the correct item. In + //theory, setting a different count and then back again (or changing any other property) would + //also work, but this is simpler. + $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, new ItemStackWrapper(0, ItemStack::null()))); } - unset($this->initiatedSlotChanges[$windowId][$netSlot]); + $this->session->sendDataPacket(InventorySlotPacket::create($windowId, $netSlot, $itemStackWrapper)); } + unset($entry->predictions[$slot], $entry->pendingSyncs[$slot]); } public function syncContents(Inventory $inventory) : void{ - $slotMap = $this->complexWindows[spl_object_id($inventory)] ?? null; - if($slotMap !== null){ + $entry = $this->inventories[spl_object_id($inventory)]; + if($entry->complexSlotMap !== null){ $windowId = ContainerIds::UI; }else{ $windowId = $this->getWindowId($inventory); } - $typeConverter = TypeConverter::getInstance(); if($windowId !== null){ - if($slotMap !== null){ - foreach($inventory->getContents(true) as $slotId => $item){ - $packetSlot = $slotMap->mapCoreToNet($slotId) ?? null; + $entry->predictions = []; + $entry->pendingSyncs = []; + $contents = []; + foreach($inventory->getContents(true) as $slot => $item){ + $itemStack = TypeConverter::getInstance()->coreItemStackToNet($item); + $info = $this->trackItemStack($inventory, $slot, $itemStack, null); + $contents[] = new ItemStackWrapper($info->getStackId(), $info->getItemStack()); + } + if($entry->complexSlotMap !== null){ + foreach($contents as $slotId => $info){ + $packetSlot = $entry->complexSlotMap->mapCoreToNet($slotId) ?? null; if($packetSlot === null){ continue; } - unset($this->initiatedSlotChanges[$windowId][$packetSlot]); $this->session->sendDataPacket(InventorySlotPacket::create( $windowId, $packetSlot, - ItemStackWrapper::legacy($typeConverter->coreItemStackToNet($inventory->getItem($slotId))) + $info )); } }else{ - unset($this->initiatedSlotChanges[$windowId]); - $this->session->sendDataPacket(InventoryContentPacket::create($windowId, array_map(function(Item $itemStack) use ($typeConverter) : ItemStackWrapper{ - return ItemStackWrapper::legacy($typeConverter->coreItemStackToNet($itemStack)); - }, $inventory->getContents(true)))); + $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $contents)); } } } public function syncAll() : void{ - foreach($this->windowMap as $inventory){ - $this->syncContents($inventory); - } - foreach($this->complexWindows as $entry){ - $this->syncContents($entry->getInventory()); + foreach($this->inventories as $entry){ + $this->syncContents($entry->inventory); } } - public function syncMismatchedPredictedSlotChanges() : void{ - foreach($this->initiatedSlotChanges as $windowId => $slots){ - foreach($slots as $netSlot => $expectedItem){ - $located = $this->locateWindowAndSlot($windowId, $netSlot); - if($located === null){ - continue; - } - [$inventory, $slot] = $located; + public function requestSyncAll() : void{ + $this->fullSyncRequested = true; + } - if(!$inventory->slotExists($slot)){ + public function syncMismatchedPredictedSlotChanges() : void{ + foreach($this->inventories as $entry){ + $inventory = $entry->inventory; + foreach($entry->predictions as $slot => $expectedItem){ + if(!$inventory->slotExists($slot) || $entry->itemStackInfos[$slot] === null){ continue; //TODO: size desync ??? } - $actualItem = $inventory->getItem($slot); - if(!$actualItem->equalsExact($expectedItem)){ - $this->session->getLogger()->debug("Detected prediction mismatch in inventory " . get_class($inventory) . "#" . spl_object_id($inventory) . " slot $slot"); + + //any prediction that still exists at this point is a slot that was predicted to change but didn't + $this->session->getLogger()->debug("Detected prediction mismatch in inventory " . get_class($inventory) . "#" . spl_object_id($inventory) . " slot $slot"); + $entry->pendingSyncs[$slot] = $slot; + } + + $entry->predictions = []; + } + } + + public function flushPendingUpdates() : void{ + if($this->fullSyncRequested){ + $this->fullSyncRequested = false; + $this->session->getLogger()->debug("Full inventory sync requested, sending contents of " . count($this->inventories) . " inventories"); + $this->syncAll(); + }else{ + foreach($this->inventories as $entry){ + if(count($entry->pendingSyncs) === 0){ + continue; + } + $inventory = $entry->inventory; + $this->session->getLogger()->debug("Syncing slots " . implode(", ", array_keys($entry->pendingSyncs)) . " in inventory " . get_class($inventory) . "#" . spl_object_id($inventory)); + foreach($entry->pendingSyncs as $slot){ $this->syncSlot($inventory, $slot); } + $entry->pendingSyncs = []; } } - - $this->initiatedSlotChanges = []; } public function syncData(Inventory $inventory, int $propertyId, int $value) : void{ @@ -453,11 +561,17 @@ class InventoryManager{ } public function syncSelectedHotbarSlot() : void{ - $selected = $this->player->getInventory()->getHeldItemIndex(); + $playerInventory = $this->player->getInventory(); + $selected = $playerInventory->getHeldItemIndex(); if($selected !== $this->clientSelectedHotbarSlot){ + $itemStackInfo = $this->getItemStackInfo($playerInventory, $selected); + if($itemStackInfo === null){ + throw new AssumptionFailedError("Player inventory slots should always be tracked"); + } + $this->session->sendDataPacket(MobEquipmentPacket::create( $this->player->getId(), - ItemStackWrapper::legacy(TypeConverter::getInstance()->coreItemStackToNet($this->player->getInventory()->getItemInHand())), + new ItemStackWrapper($itemStackInfo->getStackId(), $itemStackInfo->getItemStack()), $selected, $selected, ContainerIds::INVENTORY @@ -469,9 +583,37 @@ class InventoryManager{ public function syncCreative() : void{ $typeConverter = TypeConverter::getInstance(); - $nextEntryId = 1; - $this->session->sendDataPacket(CreativeContentPacket::create(array_map(function(Item $item) use($typeConverter, &$nextEntryId) : CreativeContentEntry{ - return new CreativeContentEntry($nextEntryId++, $typeConverter->coreItemStackToNet($item)); - }, $this->player->isSpectator() ? [] : CreativeInventory::getInstance()->getAll()))); + $entries = []; + if(!$this->player->isSpectator()){ + //creative inventory may have holes if items were unregistered - ensure network IDs used are always consistent + foreach(CreativeInventory::getInstance()->getAll() as $k => $item){ + $entries[] = new CreativeContentEntry($k, $typeConverter->coreItemStackToNet($item)); + } + } + $this->session->sendDataPacket(CreativeContentPacket::create($entries)); + } + + private function newItemStackId() : int{ + return $this->nextItemStackId++; + } + + public function getItemStackInfo(Inventory $inventory, int $slot) : ?ItemStackInfo{ + $entry = $this->inventories[spl_object_id($inventory)] ?? null; + return $entry?->itemStackInfos[$slot] ?? null; + } + + private function trackItemStack(Inventory $inventory, int $slotId, ItemStack $itemStack, ?int $itemStackRequestId) : ItemStackInfo{ + $entry = $this->inventories[spl_object_id($inventory)] ?? null; + if($entry === null){ + throw new \LogicException("Cannot track an item stack for an untracked inventory"); + } + $existing = $entry->itemStackInfos[$slotId] ?? null; + if($existing !== null && $existing->getItemStack()->equals($itemStack) && $existing->getRequestId() === $itemStackRequestId){ + return $existing; + } + + //TODO: ItemStack->isNull() would be nice to have here + $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId(), $itemStack); + return $entry->itemStackInfos[$slotId] = $info; } } diff --git a/src/network/mcpe/InventoryManagerEntry.php b/src/network/mcpe/InventoryManagerEntry.php new file mode 100644 index 000000000..fa49baf87 --- /dev/null +++ b/src/network/mcpe/InventoryManagerEntry.php @@ -0,0 +1,52 @@ + + */ + public array $predictions = []; + + /** + * @var ItemStackInfo[] + * @phpstan-var array + */ + public array $itemStackInfos = []; + + /** + * @var int[] + * @phpstan-var array + */ + public array $pendingSyncs = []; + + public function __construct( + public Inventory $inventory, + public ?ComplexInventoryMapEntry $complexSlotMap = null + ){} +} diff --git a/src/network/mcpe/ItemStackInfo.php b/src/network/mcpe/ItemStackInfo.php new file mode 100644 index 000000000..630765bfa --- /dev/null +++ b/src/network/mcpe/ItemStackInfo.php @@ -0,0 +1,41 @@ +requestId; } + + public function getStackId() : int{ return $this->stackId; } + + public function getItemStack() : ItemStack{ return $this->itemStack; } +} diff --git a/src/network/mcpe/NetworkSession.php b/src/network/mcpe/NetworkSession.php index 900c32f28..f40f2085f 100644 --- a/src/network/mcpe/NetworkSession.php +++ b/src/network/mcpe/NetworkSession.php @@ -1146,6 +1146,7 @@ class NetworkSession{ $attribute->markSynchronized(); } } + $this->invManager?->flushPendingUpdates(); $this->flushSendBuffer(); } diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index 8589f1881..c1dc4820c 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -25,6 +25,8 @@ namespace pocketmine\network\mcpe\cache; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\FurnaceType; +use pocketmine\crafting\ShapedRecipe; +use pocketmine\crafting\ShapelessRecipe; use pocketmine\crafting\ShapelessRecipeType; use pocketmine\item\Item; use pocketmine\network\mcpe\convert\ItemTranslator; @@ -76,12 +78,12 @@ final class CraftingDataCache{ private function buildCraftingDataCache(CraftingManager $manager) : CraftingDataPacket{ Timings::$craftingDataCacheRebuild->startTiming(); - $counter = 0; $nullUUID = Uuid::fromString(Uuid::NIL); $converter = TypeConverter::getInstance(); $recipesWithTypeIds = []; - foreach($manager->getShapelessRecipes() as $list){ - foreach($list as $recipe){ + + foreach($manager->getCraftingRecipeIndex() as $index => $recipe){ + if($recipe instanceof ShapelessRecipe){ $typeTag = match($recipe->getType()->id()){ ShapelessRecipeType::CRAFTING()->id() => CraftingRecipeBlockName::CRAFTING_TABLE, ShapelessRecipeType::STONECUTTER()->id() => CraftingRecipeBlockName::STONECUTTER, @@ -89,7 +91,7 @@ final class CraftingDataCache{ }; $recipesWithTypeIds[] = new ProtocolShapelessRecipe( CraftingDataPacket::ENTRY_SHAPELESS, - Binary::writeInt(++$counter), + Binary::writeInt($index), array_map(function(Item $item) use ($converter) : RecipeIngredient{ return $converter->coreItemStackToRecipeIngredient($item); }, $recipe->getIngredientList()), @@ -99,12 +101,9 @@ final class CraftingDataCache{ $nullUUID, $typeTag, 50, - $counter + $index ); - } - } - foreach($manager->getShapedRecipes() as $list){ - foreach($list as $recipe){ + }elseif($recipe instanceof ShapedRecipe){ $inputs = []; for($row = 0, $height = $recipe->getHeight(); $row < $height; ++$row){ @@ -114,7 +113,7 @@ final class CraftingDataCache{ } $recipesWithTypeIds[] = $r = new ProtocolShapedRecipe( CraftingDataPacket::ENTRY_SHAPED, - Binary::writeInt(++$counter), + Binary::writeInt($index), $inputs, array_map(function(Item $item) use ($converter) : ItemStack{ return $converter->coreItemStackToNet($item); @@ -122,8 +121,10 @@ final class CraftingDataCache{ $nullUUID, CraftingRecipeBlockName::CRAFTING_TABLE, 50, - $counter + $index ); + }else{ + //TODO: probably special recipe types } } diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index 2c7a3415e..e8826e591 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -24,11 +24,6 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\convert; use pocketmine\block\BlockLegacyIds; -use pocketmine\inventory\transaction\action\CreateItemAction; -use pocketmine\inventory\transaction\action\DestroyItemAction; -use pocketmine\inventory\transaction\action\DropItemAction; -use pocketmine\inventory\transaction\action\InventoryAction; -use pocketmine\inventory\transaction\action\SlotChangeAction; use pocketmine\item\Durable; use pocketmine\item\Item; use pocketmine\item\ItemFactory; @@ -37,17 +32,12 @@ use pocketmine\item\VanillaItems; use pocketmine\nbt\NbtException; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\IntTag; -use pocketmine\network\mcpe\InventoryManager; use pocketmine\network\mcpe\protocol\types\GameMode as ProtocolGameMode; -use pocketmine\network\mcpe\protocol\types\inventory\ContainerIds; use pocketmine\network\mcpe\protocol\types\inventory\ItemStack; -use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; -use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; use pocketmine\network\mcpe\protocol\types\recipe\IntIdMetaItemDescriptor; use pocketmine\network\mcpe\protocol\types\recipe\RecipeIngredient; use pocketmine\network\mcpe\protocol\types\recipe\StringIdMetaItemDescriptor; use pocketmine\player\GameMode; -use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\SingletonTrait; @@ -261,60 +251,4 @@ class TypeConverter{ throw TypeConversionException::wrap($e, "Bad itemstack NBT data"); } } - - /** - * @throws TypeConversionException - */ - public function createInventoryAction(NetworkInventoryAction $action, Player $player, InventoryManager $inventoryManager) : ?InventoryAction{ - if($action->oldItem->getItemStack()->equals($action->newItem->getItemStack())){ - //filter out useless noise in 1.13 - return null; - } - try{ - $old = $this->netItemStackToCore($action->oldItem->getItemStack()); - }catch(TypeConversionException $e){ - throw TypeConversionException::wrap($e, "Inventory action: oldItem"); - } - try{ - $new = $this->netItemStackToCore($action->newItem->getItemStack()); - }catch(TypeConversionException $e){ - throw TypeConversionException::wrap($e, "Inventory action: newItem"); - } - switch($action->sourceType){ - case NetworkInventoryAction::SOURCE_CONTAINER: - if($action->windowId === ContainerIds::UI && $action->inventorySlot === UIInventorySlotOffset::CREATED_ITEM_OUTPUT){ - return null; //useless noise - } - $located = $inventoryManager->locateWindowAndSlot($action->windowId, $action->inventorySlot); - if($located !== null){ - [$window, $slot] = $located; - return new SlotChangeAction($window, $slot, $old, $new); - } - - throw new TypeConversionException("No open container with window ID $action->windowId"); - case NetworkInventoryAction::SOURCE_WORLD: - if($action->inventorySlot !== NetworkInventoryAction::ACTION_MAGIC_SLOT_DROP_ITEM){ - throw new TypeConversionException("Only expecting drop-item world actions from the client!"); - } - - return new DropItemAction($new); - case NetworkInventoryAction::SOURCE_CREATIVE: - switch($action->inventorySlot){ - case NetworkInventoryAction::ACTION_MAGIC_SLOT_CREATIVE_DELETE_ITEM: - return new DestroyItemAction($new); - case NetworkInventoryAction::ACTION_MAGIC_SLOT_CREATIVE_CREATE_ITEM: - return new CreateItemAction($old); - default: - throw new TypeConversionException("Unexpected creative action type $action->inventorySlot"); - - } - case NetworkInventoryAction::SOURCE_TODO: - //These are used to balance a transaction that involves special actions, like crafting, enchanting, etc. - //The vanilla server just accepted these without verifying them. We don't need to care about them since - //we verify crafting by checking for imbalances anyway. - return null; - default: - throw new TypeConversionException("Unknown inventory source type $action->sourceType"); - } - } } diff --git a/src/network/mcpe/handler/InGamePacketHandler.php b/src/network/mcpe/handler/InGamePacketHandler.php index 40e77f410..68e939195 100644 --- a/src/network/mcpe/handler/InGamePacketHandler.php +++ b/src/network/mcpe/handler/InGamePacketHandler.php @@ -32,10 +32,10 @@ use pocketmine\entity\animation\ConsumingItemAnimation; use pocketmine\entity\Attribute; use pocketmine\entity\InvalidSkinException; use pocketmine\event\player\PlayerEditBookEvent; -use pocketmine\inventory\transaction\action\InventoryAction; -use pocketmine\inventory\transaction\CraftingTransaction; +use pocketmine\inventory\transaction\action\DropItemAction; use pocketmine\inventory\transaction\InventoryTransaction; -use pocketmine\inventory\transaction\TransactionException; +use pocketmine\inventory\transaction\TransactionBuilder; +use pocketmine\inventory\transaction\TransactionCancelledException; use pocketmine\inventory\transaction\TransactionValidationException; use pocketmine\item\VanillaItems; use pocketmine\item\WritableBook; @@ -46,7 +46,6 @@ use pocketmine\math\Vector3; use pocketmine\nbt\tag\CompoundTag; use pocketmine\nbt\tag\StringTag; use pocketmine\network\mcpe\convert\SkinAdapterSingleton; -use pocketmine\network\mcpe\convert\TypeConversionException; use pocketmine\network\mcpe\convert\TypeConverter; use pocketmine\network\mcpe\InventoryManager; use pocketmine\network\mcpe\NetworkSession; @@ -65,6 +64,8 @@ use pocketmine\network\mcpe\protocol\EmotePacket; use pocketmine\network\mcpe\protocol\InteractPacket; use pocketmine\network\mcpe\protocol\InventoryTransactionPacket; use pocketmine\network\mcpe\protocol\ItemFrameDropItemPacket; +use pocketmine\network\mcpe\protocol\ItemStackRequestPacket; +use pocketmine\network\mcpe\protocol\ItemStackResponsePacket; use pocketmine\network\mcpe\protocol\LabTablePacket; use pocketmine\network\mcpe\protocol\LecternUpdatePacket; use pocketmine\network\mcpe\protocol\LevelSoundEventPacket; @@ -96,7 +97,8 @@ use pocketmine\network\mcpe\protocol\types\inventory\MismatchTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\NetworkInventoryAction; use pocketmine\network\mcpe\protocol\types\inventory\NormalTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\ReleaseItemTransactionData; -use pocketmine\network\mcpe\protocol\types\inventory\UIInventorySlotOffset; +use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\ItemStackRequest; +use pocketmine\network\mcpe\protocol\types\inventory\stackresponse\ItemStackResponse; use pocketmine\network\mcpe\protocol\types\inventory\UseItemOnEntityTransactionData; use pocketmine\network\mcpe\protocol\types\inventory\UseItemTransactionData; use pocketmine\network\mcpe\protocol\types\PlayerAction; @@ -108,17 +110,18 @@ use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Limits; use pocketmine\utils\TextFormat; +use pocketmine\utils\Utils; use pocketmine\world\format\Chunk; use function array_push; use function base64_encode; use function count; use function fmod; +use function implode; use function in_array; use function is_bool; use function is_infinite; use function is_nan; use function json_decode; -use function json_encode; use function max; use function mb_strlen; use function microtime; @@ -133,9 +136,6 @@ use const JSON_THROW_ON_ERROR; class InGamePacketHandler extends PacketHandler{ private const MAX_FORM_RESPONSE_DEPTH = 2; //modal/simple will be 1, custom forms 2 - they will never contain anything other than string|int|float|bool|null - /** @var CraftingTransaction|null */ - protected $craftingTransaction = null; - /** @var float */ protected $lastRightClickTime = 0.0; /** @var UseItemTransactionData|null */ @@ -276,13 +276,22 @@ class InGamePacketHandler extends PacketHandler{ if(count($useItemTransaction->getTransactionData()->getActions()) > 100){ throw new PacketHandlingException("Too many actions in item use transaction"); } - $this->inventoryManager->addPredictedSlotChanges($useItemTransaction->getTransactionData()->getActions()); + + $this->inventoryManager->setCurrentItemStackRequestId($useItemTransaction->getRequestId()); + $this->inventoryManager->addRawPredictedSlotChanges($useItemTransaction->getTransactionData()->getActions()); if(!$this->handleUseItemTransaction($useItemTransaction->getTransactionData())){ $packetHandled = false; $this->session->getLogger()->debug("Unhandled transaction in PlayerAuthInputPacket (type " . $useItemTransaction->getTransactionData()->getActionType() . ")"); }else{ $this->inventoryManager->syncMismatchedPredictedSlotChanges(); } + $this->inventoryManager->setCurrentItemStackRequestId(null); + } + + $itemStackRequest = $packet->getItemStackRequest(); + if($itemStackRequest !== null){ + $result = $this->handleSingleItemStackRequest($itemStackRequest); + $this->session->sendDataPacket(ItemStackResponsePacket::create([$result])); } return $packetHandled; @@ -316,17 +325,18 @@ class InGamePacketHandler extends PacketHandler{ public function handleInventoryTransaction(InventoryTransactionPacket $packet) : bool{ $result = true; - if(count($packet->trData->getActions()) > 100){ + if(count($packet->trData->getActions()) > 50){ throw new PacketHandlingException("Too many actions in inventory transaction"); } - $this->inventoryManager->addPredictedSlotChanges($packet->trData->getActions()); + $this->inventoryManager->setCurrentItemStackRequestId($packet->requestId); + $this->inventoryManager->addRawPredictedSlotChanges($packet->trData->getActions()); if($packet->trData instanceof NormalTransactionData){ - $result = $this->handleNormalTransaction($packet->trData); + $result = $this->handleNormalTransaction($packet->trData, $packet->requestId); }elseif($packet->trData instanceof MismatchTransactionData){ $this->session->getLogger()->debug("Mismatch transaction received"); - $this->inventoryManager->syncAll(); + $this->inventoryManager->requestSyncAll(); $result = true; }elseif($packet->trData instanceof UseItemTransactionData){ $result = $this->handleUseItemTransaction($packet->trData); @@ -336,96 +346,80 @@ class InGamePacketHandler extends PacketHandler{ $result = $this->handleReleaseItemTransaction($packet->trData); } - if($this->craftingTransaction === null){ //don't sync if we're waiting to complete a crafting transaction - $this->inventoryManager->syncMismatchedPredictedSlotChanges(); - } + $this->inventoryManager->syncMismatchedPredictedSlotChanges(); + $this->inventoryManager->setCurrentItemStackRequestId(null); return $result; } - private function handleNormalTransaction(NormalTransactionData $data) : bool{ - /** @var InventoryAction[] $actions */ - $actions = []; + private function executeInventoryTransaction(InventoryTransaction $transaction, int $requestId) : bool{ + $this->player->setUsingItem(false); - $isCraftingPart = false; - $converter = TypeConverter::getInstance(); - foreach($data->getActions() as $networkInventoryAction){ - if( - $networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_TODO || ( - $this->craftingTransaction !== null && - !$networkInventoryAction->oldItem->getItemStack()->equals($networkInventoryAction->newItem->getItemStack()) && - $networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_CONTAINER && - $networkInventoryAction->windowId === ContainerIds::UI && - $networkInventoryAction->inventorySlot === UIInventorySlotOffset::CREATED_ITEM_OUTPUT - ) - ){ - $isCraftingPart = true; - } + $this->inventoryManager->setCurrentItemStackRequestId($requestId); + $this->inventoryManager->addTransactionPredictedSlotChanges($transaction); + try{ + $transaction->execute(); + }catch(TransactionValidationException $e){ + $this->inventoryManager->requestSyncAll(); + $logger = $this->session->getLogger(); + $logger->debug("Invalid inventory transaction $requestId: " . $e->getMessage()); - try{ - $action = $converter->createInventoryAction($networkInventoryAction, $this->player, $this->inventoryManager); - if($action !== null){ - $actions[] = $action; - } - }catch(TypeConversionException $e){ - $this->session->getLogger()->debug("Error unpacking inventory action: " . $e->getMessage()); - return false; - } - } + return false; + }catch(TransactionCancelledException){ + $this->session->getLogger()->debug("Inventory transaction $requestId cancelled by a plugin"); - if($isCraftingPart){ - if($this->craftingTransaction === null){ - //TODO: this might not be crafting if there is a special inventory open (anvil, enchanting, loom etc) - $this->craftingTransaction = new CraftingTransaction($this->player, $this->player->getServer()->getCraftingManager(), $actions); - }else{ - foreach($actions as $action){ - $this->craftingTransaction->addAction($action); - } - } - - try{ - $this->craftingTransaction->validate(); - }catch(TransactionValidationException $e){ - //transaction is incomplete - crafting transaction comes in lots of little bits, so we have to collect - //all of the parts before we can execute it - return true; - } - $this->player->setUsingItem(false); - try{ - $this->craftingTransaction->execute(); - }catch(TransactionException $e){ - $this->session->getLogger()->debug("Failed to execute crafting transaction: " . $e->getMessage()); - return false; - }finally{ - $this->craftingTransaction = null; - } - }else{ - //normal transaction fallthru - if($this->craftingTransaction !== null){ - $this->session->getLogger()->debug("Got unexpected normal inventory action with incomplete crafting transaction, refusing to execute crafting"); - $this->craftingTransaction = null; - return false; - } - - if(count($actions) === 0){ - //TODO: 1.13+ often sends transactions with nothing but useless crap in them, no need for the debug noise - return true; - } - - $this->player->setUsingItem(false); - $transaction = new InventoryTransaction($this->player, $actions); - try{ - $transaction->execute(); - }catch(TransactionException $e){ - $logger = $this->session->getLogger(); - $logger->debug("Failed to execute inventory transaction: " . $e->getMessage()); - $logger->debug("Actions: " . json_encode($data->getActions())); - return false; - } + return false; + }finally{ + $this->inventoryManager->syncMismatchedPredictedSlotChanges(); + $this->inventoryManager->setCurrentItemStackRequestId(null); } return true; } + private function handleNormalTransaction(NormalTransactionData $data, int $itemStackRequestId) : bool{ + //When the ItemStackRequest system is used, this transaction type is only used for dropping items by pressing Q. + //I don't know why they don't just use ItemStackRequest for that too, which already supports dropping items by + //clicking them outside an open inventory menu, but for now it is what it is. + //Fortunately, this means we can be extremely strict about the validation criteria. + + if(count($data->getActions()) > 2){ + throw new PacketHandlingException("Expected exactly 2 actions for dropping an item"); + } + + foreach($data->getActions() as $networkInventoryAction){ + if($networkInventoryAction->sourceType === NetworkInventoryAction::SOURCE_WORLD){ + //drop item - we don't need to validate this, we only care about the count + //if the resulting actions don't match the client for some reason, it will trigger an automatic + //prediction rollback anyway. + //it's technically possible to see this more than once, but a normal client should never do that. + $inventory = $this->player->getInventory(); + + $heldItemStack = $this->inventoryManager->getItemStackInfo($inventory, $inventory->getHeldItemIndex())?->getItemStack(); + if($heldItemStack === null){ + throw new AssumptionFailedError("Missing itemstack info for held item"); + } + $droppedItemStack = $networkInventoryAction->newItem->getItemStack(); + //because the client doesn't tell us the expected itemstack ID, we have to deep-compare our known + //itemstack info with the one the client sent. This is costly, but we don't have any other option :( + if(!$heldItemStack->equalsWithoutCount($droppedItemStack) || $heldItemStack->getCount() < $droppedItemStack->getCount()){ + return false; + } + + $newHeldItem = $inventory->getItemInHand(); + $droppedItem = $newHeldItem->pop($droppedItemStack->getCount()); + + $builder = new TransactionBuilder(); + $builder->getInventory($inventory)->setItem($inventory->getHeldItemIndex(), $newHeldItem); + $builder->addAction(new DropItemAction($droppedItem)); + + $transaction = new InventoryTransaction($this->player, $builder->generateActions()); + return $this->executeInventoryTransaction($transaction, $itemStackRequestId); + } + } + + throw new PacketHandlingException("Legacy 'normal' transactions should only be used for dropping items"); + } + private function handleUseItemTransaction(UseItemTransactionData $data) : bool{ $this->player->selectHotbarSlot($data->getHotbarSlot()); @@ -537,6 +531,43 @@ class InGamePacketHandler extends PacketHandler{ return false; } + private function handleSingleItemStackRequest(ItemStackRequest $request) : ItemStackResponse{ + if(count($request->getActions()) > 20){ + //TODO: we can probably lower this limit, but this will do for now + throw new PacketHandlingException("Too many actions in ItemStackRequest"); + } + $executor = new ItemStackRequestExecutor($this->player, $this->inventoryManager, $request); + try{ + $transaction = $executor->generateInventoryTransaction(); + $result = $this->executeInventoryTransaction($transaction, $request->getRequestId()); + }catch(ItemStackRequestProcessException $e){ + $result = false; + $this->session->getLogger()->debug("ItemStackRequest #" . $request->getRequestId() . " failed: " . $e->getMessage()); + $this->session->getLogger()->debug(implode("\n", Utils::printableExceptionInfo($e))); + $this->inventoryManager->requestSyncAll(); + } + + if(!$result){ + return new ItemStackResponse(ItemStackResponse::RESULT_ERROR, $request->getRequestId()); + } + return $executor->buildItemStackResponse(); + } + + public function handleItemStackRequest(ItemStackRequestPacket $packet) : bool{ + $responses = []; + if(count($packet->getRequests()) > 80){ + //TODO: we can probably lower this limit, but this will do for now + throw new PacketHandlingException("Too many requests in ItemStackRequestPacket"); + } + foreach($packet->getRequests() as $request){ + $responses[] = $this->handleSingleItemStackRequest($request); + } + + $this->session->sendDataPacket(ItemStackResponsePacket::create($responses)); + + return true; + } + public function handleMobEquipment(MobEquipmentPacket $packet) : bool{ if($packet->windowId === ContainerIds::OFFHAND){ return true; //this happens when we put an item into the offhand diff --git a/src/network/mcpe/handler/ItemStackContainerIdTranslator.php b/src/network/mcpe/handler/ItemStackContainerIdTranslator.php new file mode 100644 index 000000000..a0ad3b26c --- /dev/null +++ b/src/network/mcpe/handler/ItemStackContainerIdTranslator.php @@ -0,0 +1,90 @@ + ContainerIds::ARMOR, + + ContainerUIIds::HOTBAR, + ContainerUIIds::INVENTORY, + ContainerUIIds::COMBINED_HOTBAR_AND_INVENTORY => ContainerIds::INVENTORY, + + ContainerUIIds::OFFHAND => ContainerIds::OFFHAND, + + ContainerUIIds::ANVIL_INPUT, + ContainerUIIds::ANVIL_MATERIAL, + ContainerUIIds::BEACON_PAYMENT, + ContainerUIIds::CARTOGRAPHY_ADDITIONAL, + ContainerUIIds::CARTOGRAPHY_INPUT, + ContainerUIIds::COMPOUND_CREATOR_INPUT, + ContainerUIIds::CRAFTING_INPUT, + ContainerUIIds::CREATED_OUTPUT, + ContainerUIIds::CURSOR, + ContainerUIIds::ENCHANTING_INPUT, + ContainerUIIds::ENCHANTING_MATERIAL, + ContainerUIIds::GRINDSTONE_ADDITIONAL, + ContainerUIIds::GRINDSTONE_INPUT, + ContainerUIIds::LAB_TABLE_INPUT, + ContainerUIIds::LOOM_DYE, + ContainerUIIds::LOOM_INPUT, + ContainerUIIds::LOOM_MATERIAL, + ContainerUIIds::MATERIAL_REDUCER_INPUT, + ContainerUIIds::MATERIAL_REDUCER_OUTPUT, + ContainerUIIds::SMITHING_TABLE_INPUT, + ContainerUIIds::SMITHING_TABLE_MATERIAL, + ContainerUIIds::STONECUTTER_INPUT, + ContainerUIIds::TRADE2_INGREDIENT1, + ContainerUIIds::TRADE2_INGREDIENT2, + ContainerUIIds::TRADE_INGREDIENT1, + ContainerUIIds::TRADE_INGREDIENT2 => ContainerIds::UI, + + ContainerUIIds::BARREL, + ContainerUIIds::BLAST_FURNACE_INGREDIENT, + ContainerUIIds::BREWING_STAND_FUEL, + ContainerUIIds::BREWING_STAND_INPUT, + ContainerUIIds::BREWING_STAND_RESULT, + ContainerUIIds::FURNACE_FUEL, + ContainerUIIds::FURNACE_INGREDIENT, + ContainerUIIds::FURNACE_RESULT, + ContainerUIIds::LEVEL_ENTITY, //chest + ContainerUIIds::SHULKER_BOX, + ContainerUIIds::SMOKER_INGREDIENT => $currentWindowId, + + //all preview slots are ignored, since the client shouldn't be modifying those directly + + default => throw new PacketHandlingException("Unexpected container UI ID $containerInterfaceId") + }; + } +} diff --git a/src/network/mcpe/handler/ItemStackRequestExecutor.php b/src/network/mcpe/handler/ItemStackRequestExecutor.php new file mode 100644 index 000000000..15f1776f0 --- /dev/null +++ b/src/network/mcpe/handler/ItemStackRequestExecutor.php @@ -0,0 +1,360 @@ +builder = new TransactionBuilder(); + } + + private function prettyInventoryAndSlot(Inventory $inventory, int $slot) : string{ + if($inventory instanceof TransactionBuilderInventory){ + $inventory = $inventory->getActualInventory(); + } + return (new \ReflectionClass($inventory))->getShortName() . "#" . spl_object_id($inventory) . ", slot: $slot"; + } + + /** + * @throws ItemStackRequestProcessException + */ + private function matchItemStack(Inventory $inventory, int $slotId, int $clientItemStackId) : void{ + $info = $this->inventoryManager->getItemStackInfo($inventory, $slotId); + if($info === null){ + throw new AssumptionFailedError("The inventory is tracked and the slot is valid, so this should not be null"); + } + + if(!($clientItemStackId < 0 ? $info->getRequestId() === $clientItemStackId : $info->getStackId() === $clientItemStackId)){ + throw new ItemStackRequestProcessException( + $this->prettyInventoryAndSlot($inventory, $slotId) . ": " . + "Mismatched expected itemstack, " . + "client expected: $clientItemStackId, server actual: " . $info->getStackId() . ", last modified by request: " . ($info->getRequestId() ?? "none") + ); + } + } + + /** + * @phpstan-return array{TransactionBuilderInventory, int} + * + * @throws ItemStackRequestProcessException + */ + private function getBuilderInventoryAndSlot(ItemStackRequestSlotInfo $info) : array{ + $windowId = ItemStackContainerIdTranslator::translate($info->getContainerId(), $this->inventoryManager->getCurrentWindowId()); + $windowAndSlot = $this->inventoryManager->locateWindowAndSlot($windowId, $info->getSlotId()); + if($windowAndSlot === null){ + throw new ItemStackRequestProcessException("No open inventory matches container UI ID: " . $info->getContainerId() . ", slot ID: " . $info->getSlotId()); + } + [$inventory, $slot] = $windowAndSlot; + if(!$inventory->slotExists($slot)){ + throw new ItemStackRequestProcessException("No such inventory slot :" . $this->prettyInventoryAndSlot($inventory, $slot)); + } + + if($info->getStackId() !== $this->request->getRequestId()){ //the itemstack may have been modified by the current request + $this->matchItemStack($inventory, $slot, $info->getStackId()); + } + + return [$this->builder->getInventory($inventory), $slot]; + } + + private function transferItems(ItemStackRequestSlotInfo $source, ItemStackRequestSlotInfo $destination, int $count) : void{ + $removed = $this->removeItemFromSlot($source, $count); + $this->addItemToSlot($destination, $removed, $count); + } + + /** + * Deducts items from an inventory slot, returning a stack containing the removed items. + * @throws ItemStackRequestProcessException + */ + private function removeItemFromSlot(ItemStackRequestSlotInfo $slotInfo, int $count) : Item{ + $this->requestSlotInfos[] = $slotInfo; + [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); + + $existingItem = $inventory->getItem($slot); + if($existingItem->getCount() < $count){ + throw new ItemStackRequestProcessException($this->prettyInventoryAndSlot($inventory, $slot) . ": Cannot take $count items from a stack of " . $existingItem->getCount()); + } + + $removed = $existingItem->pop($count); + $inventory->setItem($slot, $existingItem); + + return $removed; + } + + /** + * Adds items to the target slot, if they are stackable. + */ + private function addItemToSlot(ItemStackRequestSlotInfo $slotInfo, Item $item, int $count) : void{ + $this->requestSlotInfos[] = $slotInfo; + [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); + + $existingItem = $inventory->getItem($slot); + if(!$existingItem->isNull() && !$existingItem->canStackWith($item)){ + throw new ItemStackRequestProcessException($this->prettyInventoryAndSlot($inventory, $slot) . ": Can only add items to an empty slot, or a slot containing the same item"); + } + + //we can't use the existing item here; it may be an empty stack + $newItem = clone $item; + $newItem->setCount($existingItem->getCount() + $count); + $inventory->setItem($slot, $newItem); + } + + /** + * @throws ItemStackRequestProcessException + */ + private function setNextCreatedItem(?Item $item, bool $creative = false) : void{ + if($item !== null && $item->isNull()){ + $item = null; + } + if($this->nextCreatedItem !== null){ + //while this is more complicated than simply adding the action when the item is taken, this ensures that + //plugins can tell the difference between 1 item that got split into 2 slots, vs 2 separate items. + if($this->createdItemFromCreativeInventory && $this->createdItemsTakenCount > 0){ + $this->nextCreatedItem->setCount($this->createdItemsTakenCount); + $this->builder->addAction(new CreateItemAction($this->nextCreatedItem)); + }elseif($this->createdItemsTakenCount < $this->nextCreatedItem->getCount()){ + throw new ItemStackRequestProcessException("Not all of the previous created item was taken"); + } + } + $this->nextCreatedItem = $item; + $this->createdItemFromCreativeInventory = $creative; + $this->createdItemsTakenCount = 0; + } + + /** + * @throws ItemStackRequestProcessException + */ + private function beginCrafting(int $recipeId, int $repetitions) : void{ + if($this->specialTransaction !== null){ + throw new ItemStackRequestProcessException("Another special transaction is already in progress"); + } + if($repetitions < 1){ //TODO: upper bound? + throw new ItemStackRequestProcessException("Cannot craft a recipe less than 1 time"); + } + $craftingManager = $this->player->getServer()->getCraftingManager(); + $recipe = $craftingManager->getCraftingRecipeFromIndex($recipeId); + if($recipe === null){ + throw new ItemStackRequestProcessException("No such crafting recipe index: $recipeId"); + } + + $this->specialTransaction = new CraftingTransaction($this->player, $craftingManager, [], $recipe, $repetitions); + + $currentWindow = $this->player->getCurrentWindow(); + if($currentWindow !== null && !($currentWindow instanceof CraftingGrid)){ + throw new ItemStackRequestProcessException("Player's current window is not a crafting grid"); + } + $craftingGrid = $currentWindow ?? $this->player->getCraftingGrid(); + + $craftingResults = $recipe->getResultsFor($craftingGrid); + foreach($craftingResults as $k => $craftingResult){ + $craftingResult->setCount($craftingResult->getCount() * $repetitions); + $this->craftingResults[$k] = $craftingResult; + } + if(count($this->craftingResults) === 1){ + //for multi-output recipes, later actions will tell us which result to create and when + $this->setNextCreatedItem($this->craftingResults[array_key_first($this->craftingResults)]); + } + } + + private function takeCreatedItem(ItemStackRequestSlotInfo $destination, int $count) : void{ + $createdItem = $this->nextCreatedItem; + if($createdItem === null){ + throw new ItemStackRequestProcessException("No created item is waiting to be taken"); + } + + if(!$this->createdItemFromCreativeInventory){ + $availableCount = $createdItem->getCount() - $this->createdItemsTakenCount; + if($count > $availableCount){ + throw new ItemStackRequestProcessException("Not enough created items available to be taken (have $availableCount, tried to take $count)"); + } + } + + $this->createdItemsTakenCount += $count; + $this->addItemToSlot($destination, $createdItem, $count); + if(!$this->createdItemFromCreativeInventory && $this->createdItemsTakenCount >= $createdItem->getCount()){ + $this->setNextCreatedItem(null); + } + } + + /** + * @throws ItemStackRequestProcessException + */ + private function assertDoingCrafting() : void{ + if(!$this->specialTransaction instanceof CraftingTransaction){ + if($this->specialTransaction === null){ + throw new ItemStackRequestProcessException("Expected CraftRecipe or CraftRecipeAuto action to precede this action"); + }else{ + throw new ItemStackRequestProcessException("A different special transaction is already in progress"); + } + } + } + + /** + * @throws ItemStackRequestProcessException + */ + private function processItemStackRequestAction(ItemStackRequestAction $action) : void{ + if( + $action instanceof TakeStackRequestAction || + $action instanceof PlaceStackRequestAction + ){ + $source = $action->getSource(); + $destination = $action->getDestination(); + + if($source->getContainerId() === ContainerUIIds::CREATED_OUTPUT && $source->getSlotId() === UIInventorySlotOffset::CREATED_ITEM_OUTPUT){ + $this->takeCreatedItem($destination, $action->getCount()); + }else{ + $this->transferItems($source, $destination, $action->getCount()); + } + }elseif($action instanceof SwapStackRequestAction){ + $this->requestSlotInfos[] = $action->getSlot1(); + $this->requestSlotInfos[] = $action->getSlot2(); + + [$inventory1, $slot1] = $this->getBuilderInventoryAndSlot($action->getSlot1()); + [$inventory2, $slot2] = $this->getBuilderInventoryAndSlot($action->getSlot2()); + + $item1 = $inventory1->getItem($slot1); + $item2 = $inventory2->getItem($slot2); + $inventory1->setItem($slot1, $item2); + $inventory2->setItem($slot2, $item1); + }elseif($action instanceof DropStackRequestAction){ + //TODO: this action has a "randomly" field, I have no idea what it's used for + $dropped = $this->removeItemFromSlot($action->getSource(), $action->getCount()); + $this->builder->addAction(new DropItemAction($dropped)); + + }elseif($action instanceof DestroyStackRequestAction){ + $destroyed = $this->removeItemFromSlot($action->getSource(), $action->getCount()); + $this->builder->addAction(new DestroyItemAction($destroyed)); + + }elseif($action instanceof CreativeCreateStackRequestAction){ + $item = CreativeInventory::getInstance()->getItem($action->getCreativeItemId()); + if($item === null){ + throw new ItemStackRequestProcessException("No such creative item index: " . $action->getCreativeItemId()); + } + + $this->setNextCreatedItem($item, true); + }elseif($action instanceof CraftRecipeStackRequestAction){ + $this->beginCrafting($action->getRecipeId(), 1); + }elseif($action instanceof CraftRecipeAutoStackRequestAction){ + $this->beginCrafting($action->getRecipeId(), $action->getRepetitions()); + }elseif($action instanceof CraftingConsumeInputStackRequestAction){ + $this->assertDoingCrafting(); + $this->removeItemFromSlot($action->getSource(), $action->getCount()); //output discarded - we allow CraftingTransaction to verify the balance + + }elseif($action instanceof CraftingCreateSpecificResultStackRequestAction){ + $this->assertDoingCrafting(); + + $nextResultItem = $this->craftingResults[$action->getResultIndex()] ?? null; + if($nextResultItem === null){ + throw new ItemStackRequestProcessException("No such crafting result index: " . $action->getResultIndex()); + } + $this->setNextCreatedItem($nextResultItem); + }elseif($action instanceof DeprecatedCraftingResultsStackRequestAction){ + //no obvious use + }else{ + throw new ItemStackRequestProcessException("Unhandled item stack request action"); + } + } + + /** + * @throws ItemStackRequestProcessException + */ + public function generateInventoryTransaction() : InventoryTransaction{ + foreach($this->request->getActions() as $k => $action){ + try{ + $this->processItemStackRequestAction($action); + }catch(ItemStackRequestProcessException $e){ + throw new ItemStackRequestProcessException("Error processing action $k (" . (new \ReflectionClass($action))->getShortName() . "): " . $e->getMessage(), 0, $e); + } + } + $this->setNextCreatedItem(null); + $inventoryActions = $this->builder->generateActions(); + + $transaction = $this->specialTransaction ?? new InventoryTransaction($this->player); + foreach($inventoryActions as $action){ + $transaction->addAction($action); + } + + return $transaction; + } + + public function buildItemStackResponse() : ItemStackResponse{ + $builder = new ItemStackResponseBuilder($this->request->getRequestId(), $this->inventoryManager); + foreach($this->requestSlotInfos as $requestInfo){ + $builder->addSlot($requestInfo->getContainerId(), $requestInfo->getSlotId()); + } + + return $builder->build(); + } +} diff --git a/src/network/mcpe/handler/ItemStackRequestProcessException.php b/src/network/mcpe/handler/ItemStackRequestProcessException.php new file mode 100644 index 000000000..7d5c667cf --- /dev/null +++ b/src/network/mcpe/handler/ItemStackRequestProcessException.php @@ -0,0 +1,31 @@ +> + */ + private array $changedSlots = []; + + public function __construct( + private int $requestId, + private InventoryManager $inventoryManager + ){} + + public function addSlot(int $containerInterfaceId, int $slotId) : void{ + $this->changedSlots[$containerInterfaceId][$slotId] = $slotId; + } + + /** + * @phpstan-return array{Inventory, int} + */ + private function getInventoryAndSlot(int $containerInterfaceId, int $slotId) : ?array{ + $windowId = ItemStackContainerIdTranslator::translate($containerInterfaceId, $this->inventoryManager->getCurrentWindowId()); + $windowAndSlot = $this->inventoryManager->locateWindowAndSlot($windowId, $slotId); + if($windowAndSlot === null){ + return null; + } + [$inventory, $slot] = $windowAndSlot; + if(!$inventory->slotExists($slot)){ + return null; + } + + return [$inventory, $slot]; + } + + public function build() : ItemStackResponse{ + $responseInfosByContainer = []; + foreach($this->changedSlots as $containerInterfaceId => $slotIds){ + if($containerInterfaceId === ContainerUIIds::CREATED_OUTPUT){ + continue; + } + foreach($slotIds as $slotId){ + $inventoryAndSlot = $this->getInventoryAndSlot($containerInterfaceId, $slotId); + if($inventoryAndSlot === null){ + //a plugin may have closed the inventory during an event, or the slot may have been invalid + continue; + } + [$inventory, $slot] = $inventoryAndSlot; + + $itemStackInfo = $this->inventoryManager->getItemStackInfo($inventory, $slot); + if($itemStackInfo === null){ + throw new AssumptionFailedError("ItemStackInfo should never be null for an open inventory"); + } + if($itemStackInfo->getRequestId() !== $this->requestId){ + //the itemstack may have been synced due to transaction producing results that the client did not + //predict correctly, which will wipe out the tracked request ID (intentionally) + //TODO: is this the correct behaviour? + continue; + } + $item = $inventory->getItem($slot); + + $responseInfosByContainer[$containerInterfaceId][] = new ItemStackResponseSlotInfo( + $slotId, + $slotId, + $item->getCount(), + $itemStackInfo->getStackId(), + $item->getCustomName(), + 0 + ); + } + } + + $responseContainerInfos = []; + foreach($responseInfosByContainer as $containerInterfaceId => $responseInfos){ + $responseContainerInfos[] = new ItemStackResponseContainerInfo($containerInterfaceId, $responseInfos); + } + + return new ItemStackResponse(ItemStackResponse::RESULT_OK, $this->requestId, $responseContainerInfos); + } +} diff --git a/src/network/mcpe/handler/PreSpawnPacketHandler.php b/src/network/mcpe/handler/PreSpawnPacketHandler.php index 3eeecbb86..8c47f6583 100644 --- a/src/network/mcpe/handler/PreSpawnPacketHandler.php +++ b/src/network/mcpe/handler/PreSpawnPacketHandler.php @@ -98,7 +98,7 @@ class PreSpawnPacketHandler extends PacketHandler{ 0, 0, "", - false, + true, sprintf("%s %s", VersionInfo::NAME, VersionInfo::VERSION()->getFullVersion(true)), Uuid::fromString(Uuid::NIL), false,