builder = new TransactionBuilder(); } protected 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 */ protected 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]; } /** * @throws ItemStackRequestProcessException */ protected 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 */ protected function removeItemFromSlot(ItemStackRequestSlotInfo $slotInfo, int $count) : Item{ $this->requestSlotInfos[] = $slotInfo; [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); if($count < 1){ //this should be impossible at the protocol level, but in case of buggy core code this will prevent exploits throw new ItemStackRequestProcessException($this->prettyInventoryAndSlot($inventory, $slot) . ": Cannot take less than 1 items from a stack"); } $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. * @throws ItemStackRequestProcessException */ protected function addItemToSlot(ItemStackRequestSlotInfo $slotInfo, Item $item, int $count) : void{ $this->requestSlotInfos[] = $slotInfo; [$inventory, $slot] = $this->getBuilderInventoryAndSlot($slotInfo); if($count < 1){ //this should be impossible at the protocol level, but in case of buggy core code this will prevent exploits throw new ItemStackRequestProcessException($this->prettyInventoryAndSlot($inventory, $slot) . ": Cannot take less than 1 items from a stack"); } $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 */ protected 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 */ protected function beginCrafting(int $recipeId, int $repetitions) : void{ if($this->specialTransaction !== null){ throw new ItemStackRequestProcessException("Another special transaction is already in progress"); } if($repetitions < 1){ throw new ItemStackRequestProcessException("Cannot craft a recipe less than 1 time"); } if($repetitions > 256){ //TODO: we can probably lower this limit to 64, but I'm unsure if there are cases where the client may //request more than 64 repetitions of a recipe. //It's already hard-limited to 256 repetitions in the protocol, so this is just a sanity check. throw new ItemStackRequestProcessException("Cannot craft a recipe more than 256 times"); } $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)]); } } /** * @throws ItemStackRequestProcessException */ protected function takeCreatedItem(ItemStackRequestSlotInfo $destination, int $count) : void{ if($count < 1){ //this should be impossible at the protocol level, but in case of buggy core code this will prevent exploits throw new ItemStackRequestProcessException("Cannot take less than 1 created item"); } $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 */ protected 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(); } }