From 8572e9e560ed05d3f28f3d56f4b80118c6bfe18b Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Mon, 26 Mar 2018 13:23:28 +0100 Subject: [PATCH] Crafting: nuke This commit brings in a much-needed rewrite of crafting transaction handling. The following classes have been removed: - CraftingTransferMaterialAction - CraftingTakeResultAction The following classes have significant changes: - CraftingTransaction - All API methods have been removed and are now handled in CraftItemEvent - CraftItemEvent - added the following: - getInputs() - getOutputs() - getRepetitions() (tells how many times a recipe was crafted in this event) - Recipe interface: - Removed getResult() (individual recipes may handle this differently) - CraftingRecipe interface - removed the following: - matchItems() - getExtraResults() - getAllResults() - added the following - getResults() - getIngredientList() : Item[], which must return a 1D array of items that should be consumed (wildcards accepted). - matchesCraftingGrid(CraftingGrid) - ShapedRecipe - constructor now accepts string[], Item[], Item[] - ShapelessRecipe - constructor now accepts Item[], Item[] --- src/pocketmine/Player.php | 12 +- .../event/inventory/CraftItemEvent.php | 64 ++++- src/pocketmine/inventory/CraftingGrid.php | 87 ++++++ src/pocketmine/inventory/CraftingManager.php | 107 ++++---- src/pocketmine/inventory/CraftingRecipe.php | 32 +-- src/pocketmine/inventory/Recipe.php | 7 - src/pocketmine/inventory/ShapedRecipe.php | 154 ++++------- src/pocketmine/inventory/ShapelessRecipe.php | 91 ++----- .../transaction/CraftingTransaction.php | 253 +++++++++--------- .../action/CraftingTakeResultAction.php | 59 ---- .../action/CraftingTransferMaterialAction.php | 73 ----- .../mcpe/protocol/CraftingDataPacket.php | 4 +- .../protocol/InventoryTransactionPacket.php | 6 + .../protocol/types/NetworkInventoryAction.php | 9 +- 14 files changed, 437 insertions(+), 521 deletions(-) delete mode 100644 src/pocketmine/inventory/transaction/action/CraftingTakeResultAction.php delete mode 100644 src/pocketmine/inventory/transaction/action/CraftingTransferMaterialAction.php diff --git a/src/pocketmine/Player.php b/src/pocketmine/Player.php index 6365dd959..59ae68979 100644 --- a/src/pocketmine/Player.php +++ b/src/pocketmine/Player.php @@ -2302,11 +2302,17 @@ class Player extends Human implements CommandSender, ChunkLoader, IPlayer{ } } - if($this->craftingTransaction->getPrimaryOutput() !== null){ - //we get the actions for this in several packets, so we can't execute it until we get the result + if($packet->isFinalCraftingPart){ + //we get the actions for this in several packets, so we need to wait until we have all the pieces before + //trying to execute it + + $result = $this->craftingTransaction->execute(); + if(!$result){ + $this->server->getLogger()->debug("Failed to execute crafting transaction from " . $this->getName()); + } - $this->craftingTransaction->execute(); $this->craftingTransaction = null; + return $result; } return true; diff --git a/src/pocketmine/event/inventory/CraftItemEvent.php b/src/pocketmine/event/inventory/CraftItemEvent.php index f5bedd51f..68a9b56c9 100644 --- a/src/pocketmine/event/inventory/CraftItemEvent.php +++ b/src/pocketmine/event/inventory/CraftItemEvent.php @@ -25,35 +25,83 @@ namespace pocketmine\event\inventory; use pocketmine\event\Cancellable; use pocketmine\event\Event; +use pocketmine\inventory\CraftingRecipe; use pocketmine\inventory\Recipe; use pocketmine\inventory\transaction\CraftingTransaction; +use pocketmine\item\Item; use pocketmine\Player; class CraftItemEvent extends Event implements Cancellable{ /** @var CraftingTransaction */ private $transaction; + /** @var CraftingRecipe */ + private $recipe; + /** @var int */ + private $repetitions; + /** @var Item[] */ + private $inputs; + /** @var Item[] */ + private $outputs; /** * @param CraftingTransaction $transaction + * @param CraftingRecipe $recipe + * @param int $repetitions + * @param Item[] $inputs + * @param Item[] $outputs */ - public function __construct(CraftingTransaction $transaction){ + public function __construct(CraftingTransaction $transaction, CraftingRecipe $recipe, int $repetitions, array $inputs, array $outputs){ $this->transaction = $transaction; + $this->recipe = $recipe; + $this->repetitions = $repetitions; + $this->inputs = $inputs; + $this->outputs = $outputs; } + /** + * Returns the inventory transaction involved in this crafting event. + * + * @return CraftingTransaction + */ public function getTransaction() : CraftingTransaction{ return $this->transaction; } /** - * @return Recipe + * Returns the recipe crafted. + * + * @return CraftingRecipe */ - public function getRecipe() : Recipe{ - $recipe = $this->transaction->getRecipe(); - if($recipe === null){ - throw new \RuntimeException("This shouldn't be called if the transaction can't be executed"); - } + public function getRecipe() : CraftingRecipe{ + return $this->recipe; + } - return $recipe; + /** + * Returns the number of times the recipe was crafted. This is usually 1, but might be more in the case of recipe + * book shift-clicks (which craft lots of items in a batch). + * + * @return int + */ + public function getRepetitions() : int{ + return $this->repetitions; + } + + /** + * Returns a list of items destroyed as ingredients of the recipe. + * + * @return Item[] + */ + public function getInputs() : array{ + return $this->inputs; + } + + /** + * Returns a list of items created by crafting the recipe. + * + * @return Item[] + */ + public function getOutputs() : array{ + return $this->outputs; } /** diff --git a/src/pocketmine/inventory/CraftingGrid.php b/src/pocketmine/inventory/CraftingGrid.php index 898a490af..5b4143557 100644 --- a/src/pocketmine/inventory/CraftingGrid.php +++ b/src/pocketmine/inventory/CraftingGrid.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace pocketmine\inventory; +use pocketmine\item\Item; +use pocketmine\item\ItemFactory; use pocketmine\Player; class CraftingGrid extends BaseInventory{ @@ -34,6 +36,15 @@ class CraftingGrid extends BaseInventory{ /** @var int */ private $gridWidth; + /** @var int|null */ + private $startX; + /** @var int|null */ + private $xLen; + /** @var int|null */ + private $startY; + /** @var int|null */ + private $yLen; + public function __construct(Player $holder, int $gridWidth){ $this->holder = $holder; $this->gridWidth = $gridWidth; @@ -56,6 +67,16 @@ class CraftingGrid extends BaseInventory{ return "Crafting"; } + public function setItem(int $index, Item $item, bool $send = true) : bool{ + if(parent::setItem($index, $item, $send)){ + $this->seekRecipeBounds(); + + return true; + } + + return false; + } + public function sendSlot(int $index, $target) : void{ //we can't send a slot of a client-sided inventory window } @@ -70,4 +91,70 @@ class CraftingGrid extends BaseInventory{ public function getHolder(){ return $this->holder; } + + private function seekRecipeBounds() : void{ + $minX = PHP_INT_MAX; + $maxX = 0; + + $minY = PHP_INT_MAX; + $maxY = 0; + + $empty = true; + + for($y = 0; $y < $this->gridWidth; ++$y){ + for($x = 0; $x < $this->gridWidth; ++$x){ + if(!$this->isSlotEmpty($y * $this->gridWidth + $x)){ + $minX = min($minX, $x); + $maxX = max($maxX, $x); + + $minY = min($minY, $y); + $maxY = max($maxY, $y); + + $empty = false; + } + } + } + + if(!$empty){ + $this->startX = $minX; + $this->xLen = $maxX - $minX + 1; + $this->startY = $minY; + $this->yLen = $maxY - $minY + 1; + }else{ + $this->startX = $this->xLen = $this->startY = $this->yLen = null; + } + } + + /** + * Returns the item at offset x,y, offset by where the starts of the recipe rectangle are. + * + * @param int $x + * @param int $y + * + * @return Item + */ + public function getIngredient(int $x, int $y) : Item{ + if($this->startX !== null and $this->startY !== null){ + return $this->getItem(($y + $this->startY) * $this->gridWidth + ($x + $this->startX)); + } + + throw new \InvalidStateException("No ingredients found in grid"); + } + + /** + * Returns the width of the recipe we're trying to craft, based on items currently in the grid. + * + * @return int + */ + public function getRecipeWidth() : int{ + return $this->xLen ?? 0; + } + + /** + * Returns the height of the recipe we're trying to craft, based on items currently in the grid. + * @return int + */ + public function getRecipeHeight() : int{ + return $this->yLen ?? 0; + } } diff --git a/src/pocketmine/inventory/CraftingManager.php b/src/pocketmine/inventory/CraftingManager.php index 3181a6fcf..68a4cc8a5 100644 --- a/src/pocketmine/inventory/CraftingManager.php +++ b/src/pocketmine/inventory/CraftingManager.php @@ -59,20 +59,13 @@ class CraftingManager{ foreach($recipes->getAll() as $recipe){ switch($recipe["type"]){ case 0: - // TODO: handle multiple result items - $first = $recipe["output"][0]; - $result = new ShapelessRecipe(Item::jsonDeserialize($first)); - - foreach($recipe["input"] as $ingredient){ - $result->addIngredient(Item::jsonDeserialize($ingredient)); - } - $this->registerRecipe($result); + $this->registerRecipe(new ShapelessRecipe( + array_map(function(array $data) : Item{ return Item::jsonDeserialize($data); }, $recipe["input"]), + array_map(function(array $data) : Item{ return Item::jsonDeserialize($data); }, $recipe["output"]) + )); break; case 1: - $first = array_shift($recipe["output"]); - $this->registerRecipe(new ShapedRecipe( - Item::jsonDeserialize($first), $recipe["shape"], array_map(function(array $data) : Item{ return Item::jsonDeserialize($data); }, $recipe["input"]), array_map(function(array $data) : Item{ return Item::jsonDeserialize($data); }, $recipe["output"]) @@ -151,6 +144,41 @@ class CraftingManager{ return $retval; } + /** + * @param Item[] $items + * + * @return Item[] + */ + private static function pack(array $items) : array{ + /** @var Item[] $result */ + $result = []; + + foreach($items as $i => $item){ + foreach($result as $otherItem){ + if($item->equals($otherItem)){ + $otherItem->setCount($otherItem->getCount() + $item->getCount()); + continue 2; + } + } + + //No matching item found + $result[] = clone $item; + } + + return $result; + } + + private static function hashOutputs(array $outputs) : string{ + $outputs = self::pack($outputs); + usort($outputs, [self::class, "sort"]); + foreach($outputs as $o){ + //this reduces accuracy of hash, but it's necessary to deal with recipe book shift-clicking stupidity + $o->setCount(1); + } + + return json_encode($outputs); + } + /** * @param UUID $id * @return CraftingRecipe|null @@ -192,7 +220,7 @@ class CraftingManager{ * @param ShapedRecipe $recipe */ public function registerShapedRecipe(ShapedRecipe $recipe) : void{ - $this->shapedRecipes[json_encode($recipe->getResult())][json_encode($recipe->getIngredientMap())] = $recipe; + $this->shapedRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; $this->craftingDataCache = null; } @@ -200,9 +228,7 @@ class CraftingManager{ * @param ShapelessRecipe $recipe */ public function registerShapelessRecipe(ShapelessRecipe $recipe) : void{ - $ingredients = $recipe->getIngredientList(); - usort($ingredients, [self::class, "sort"]); - $this->shapelessRecipes[json_encode($recipe->getResult())][json_encode($ingredients)] = $recipe; + $this->shapelessRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; $this->craftingDataCache = null; } @@ -216,61 +242,27 @@ class CraftingManager{ } /** - * Clones a map of Item objects to avoid accidental modification. - * - * @param Item[][] $map - * @return Item[][] - */ - private function cloneItemMap(array $map) : array{ - /** @var Item[] $row */ - foreach($map as $y => $row){ - foreach($row as $x => $item){ - $map[$y][$x] = clone $item; - } - } - - return $map; - } - - /** - * @param Item[][] $inputMap - * @param Item $primaryOutput - * @param Item[][] $extraOutputMap + * @param CraftingGrid $grid + * @param Item[] $outputs * * @return CraftingRecipe|null */ - public function matchRecipe(array $inputMap, Item $primaryOutput, array $extraOutputMap) : ?CraftingRecipe{ + public function matchRecipe(CraftingGrid $grid, array $outputs) : ?CraftingRecipe{ //TODO: try to match special recipes before anything else (first they need to be implemented!) - $outputHash = json_encode($primaryOutput); + $outputHash = self::hashOutputs($outputs); + if(isset($this->shapedRecipes[$outputHash])){ - $inputHash = json_encode($inputMap); - $recipe = $this->shapedRecipes[$outputHash][$inputHash] ?? null; - - if($recipe !== null and $recipe->matchItems($this->cloneItemMap($inputMap), $this->cloneItemMap($extraOutputMap))){ //matched a recipe by hash - return $recipe; - } - foreach($this->shapedRecipes[$outputHash] as $recipe){ - if($recipe->matchItems($this->cloneItemMap($inputMap), $this->cloneItemMap($extraOutputMap))){ + if($recipe->matchesCraftingGrid($grid)){ return $recipe; } } } if(isset($this->shapelessRecipes[$outputHash])){ - $list = array_merge(...$inputMap); - usort($list, [self::class, "sort"]); - - $inputHash = json_encode($list); - $recipe = $this->shapelessRecipes[$outputHash][$inputHash] ?? null; - - if($recipe !== null and $recipe->matchItems($this->cloneItemMap($inputMap), $this->cloneItemMap($extraOutputMap))){ - return $recipe; - } - foreach($this->shapelessRecipes[$outputHash] as $recipe){ - if($recipe->matchItems($this->cloneItemMap($inputMap), $this->cloneItemMap($extraOutputMap))){ + if($recipe->matchesCraftingGrid($grid)){ return $recipe; } } @@ -293,8 +285,7 @@ class CraftingManager{ */ public function registerRecipe(Recipe $recipe) : void{ if($recipe instanceof CraftingRecipe){ - $result = $recipe->getResult(); - $recipe->setId($uuid = UUID::fromData((string) ++self::$RECIPE_COUNT, (string) $result->getId(), (string) $result->getDamage(), (string) $result->getCount(), $result->getCompoundTag())); + $recipe->setId($uuid = UUID::fromData((string) ++self::$RECIPE_COUNT, json_encode(self::pack($recipe->getResults())))); $this->recipes[$uuid->toBinary()] = $recipe; } diff --git a/src/pocketmine/inventory/CraftingRecipe.php b/src/pocketmine/inventory/CraftingRecipe.php index d82a634f4..d0e31ff9e 100644 --- a/src/pocketmine/inventory/CraftingRecipe.php +++ b/src/pocketmine/inventory/CraftingRecipe.php @@ -39,23 +39,25 @@ interface CraftingRecipe extends Recipe{ public function setId(UUID $id); /** - * @return Item[] - */ - public function getExtraResults() : array; - - /** - * @return Item[] - */ - public function getAllResults() : array; - - /** - * Returns whether the specified list of crafting grid inputs and outputs matches this recipe. Outputs DO NOT - * include the primary result item. + * Returns a list of items needed to craft this recipe. This MUST NOT include Air items or items with a zero count. * - * @param Item[][] $input 2D array of items taken from the crafting grid - * @param Item[][] $output 2D array of items put back into the crafting grid (secondary results) + * @return Item[] + */ + public function getIngredientList() : array; + + /** + * Returns a list of items created by crafting this recipe. + * + * @return Item[] + */ + public function getResults() : array; + + /** + * Returns whether the given crafting grid meets the requirements to craft this recipe. + * + * @param CraftingGrid $grid * * @return bool */ - public function matchItems(array $input, array $output) : bool; + public function matchesCraftingGrid(CraftingGrid $grid) : bool; } diff --git a/src/pocketmine/inventory/Recipe.php b/src/pocketmine/inventory/Recipe.php index a16e22e4f..883eea6d9 100644 --- a/src/pocketmine/inventory/Recipe.php +++ b/src/pocketmine/inventory/Recipe.php @@ -23,14 +23,7 @@ declare(strict_types=1); namespace pocketmine\inventory; -use pocketmine\item\Item; - interface Recipe{ - /** - * @return Item - */ - public function getResult() : Item; - public function registerToCraftingManager(CraftingManager $manager) : void; } diff --git a/src/pocketmine/inventory/ShapedRecipe.php b/src/pocketmine/inventory/ShapedRecipe.php index 2356b93b7..2b7386cd7 100644 --- a/src/pocketmine/inventory/ShapedRecipe.php +++ b/src/pocketmine/inventory/ShapedRecipe.php @@ -28,11 +28,6 @@ use pocketmine\item\ItemFactory; use pocketmine\utils\UUID; class ShapedRecipe implements CraftingRecipe{ - /** @var Item */ - private $primaryResult; - /** @var Item[] */ - private $extraResults = []; - /** @var UUID|null */ private $id = null; @@ -40,90 +35,76 @@ class ShapedRecipe implements CraftingRecipe{ private $shape = []; /** @var Item[] char => Item map */ private $ingredientList = []; + /** @var Item[] */ + private $results = []; + + /** @var int */ + private $height; + /** @var int */ + private $width; /** * Constructs a ShapedRecipe instance. * - * @param Item $primaryResult - * @param string[] $shape
+ * @param string[] $shape
* Array of 1, 2, or 3 strings representing the rows of the recipe. * This accepts an array of 1, 2 or 3 strings. Each string should be of the same length and must be at most 3 * characters long. Each character represents a unique type of ingredient. Spaces are interpreted as air. - * @param Item[] $ingredients
+ * @param Item[] $ingredients
* Char => Item map of items to be set into the shape. * This accepts an array of Items, indexed by character. Every unique character (except space) in the shape * array MUST have a corresponding item in this list. Space character is automatically treated as air. - * @param Item[] $extraResults
- * List of additional result items to leave in the crafting grid afterwards. Used for things like cake recipe - * empty buckets. + * @param Item[] $results List of items that this recipe produces when crafted. * * Note: Recipes **do not** need to be square. Do NOT add padding for empty rows/columns. */ - public function __construct(Item $primaryResult, array $shape, array $ingredients, array $extraResults = []){ - $rowCount = count($shape); - if($rowCount > 3 or $rowCount <= 0){ - throw new \InvalidArgumentException("Shaped recipes may only have 1, 2 or 3 rows, not $rowCount"); + public function __construct(array $shape, array $ingredients, array $results){ + $this->height = count($shape); + if($this->height > 3 or $this->height <= 0){ + throw new \InvalidArgumentException("Shaped recipes may only have 1, 2 or 3 rows, not $this->height"); } $shape = array_values($shape); - $columnCount = strlen($shape[0]); - if($columnCount > 3 or $columnCount <= 0){ - throw new \InvalidArgumentException("Shaped recipes may only have 1, 2 or 3 columns, not $columnCount"); + $this->width = strlen($shape[0]); + if($this->width > 3 or $this->width <= 0){ + throw new \InvalidArgumentException("Shaped recipes may only have 1, 2 or 3 columns, not $this->width"); } foreach($shape as $y => $row){ - if(strlen($row) !== $columnCount){ - throw new \InvalidArgumentException("Shaped recipe rows must all have the same length (expected $columnCount, got " . strlen($row) . ")"); + if(strlen($row) !== $this->width){ + throw new \InvalidArgumentException("Shaped recipe rows must all have the same length (expected $this->width, got " . strlen($row) . ")"); } - for($x = 0; $x < $columnCount; ++$x){ + for($x = 0; $x < $this->width; ++$x){ if($row{$x} !== ' ' and !isset($ingredients[$row{$x}])){ throw new \InvalidArgumentException("No item specified for symbol '" . $row{$x} . "'"); } } } - $this->primaryResult = clone $primaryResult; - foreach($extraResults as $item){ - $this->extraResults[] = clone $item; - } $this->shape = $shape; foreach($ingredients as $char => $i){ $this->setIngredient($char, $i); } + + $this->results = array_map(function(Item $item) : Item{ return clone $item; }, $results); } public function getWidth() : int{ - return strlen($this->shape[0]); + return $this->width; } public function getHeight() : int{ - return count($this->shape); - } - - /** - * @return Item - */ - public function getResult() : Item{ - return $this->primaryResult; + return $this->height; } /** * @return Item[] */ - public function getExtraResults() : array{ - return $this->extraResults; - } - - /** - * @return Item[] - */ - public function getAllResults() : array{ - $results = $this->extraResults; - array_unshift($results, $this->primaryResult); - return $results; + public function getResults() : array{ + return array_map(function(Item $item) : Item{ return clone $item; }, $this->results); } /** @@ -164,8 +145,8 @@ class ShapedRecipe implements CraftingRecipe{ public function getIngredientMap() : array{ $ingredients = []; - for($y = 0, $y2 = $this->getHeight(); $y < $y2; ++$y){ - for($x = 0, $x2 = $this->getWidth(); $x < $x2; ++$x){ + for($y = 0; $y < $this->height; ++$y){ + for($x = 0; $x < $this->width; ++$x){ $ingredients[$y][$x] = $this->getIngredient($x, $y); } } @@ -173,6 +154,24 @@ class ShapedRecipe implements CraftingRecipe{ return $ingredients; } + /** + * @return Item[] + */ + public function getIngredientList() : array{ + $ingredients = []; + + for($y = 0; $y < $this->height; ++$y){ + for($x = 0; $x < $this->width; ++$x){ + $ingredient = $this->getIngredient($x, $y); + if(!$ingredient->isNull()){ + $ingredients[] = $ingredient; + } + } + } + + return $ingredients; + } + /** * @param int $x * @param int $y @@ -197,34 +196,20 @@ class ShapedRecipe implements CraftingRecipe{ } /** - * @param Item[][] $input + * @param CraftingGrid $grid + * @param bool $reverse * * @return bool */ - private function matchInputMap(array $input) : bool{ - $map = $this->getIngredientMap(); + private function matchInputMap(CraftingGrid $grid, bool $reverse) : bool{ + for($y = 0; $y < $this->height; ++$y){ + for($x = 0; $x < $this->width; ++$x){ - //match the given items to the requested items - for($y = 0, $y2 = $this->getHeight(); $y < $y2; ++$y){ - for($x = 0, $x2 = $this->getWidth(); $x < $x2; ++$x){ - $given = $input[$y][$x] ?? null; - $required = $map[$y][$x]; - - if($given === null or !$required->equals($given, !$required->hasAnyDamageValue(), $required->hasCompoundTag()) or $required->getCount() !== $given->getCount()){ + $given = $grid->getIngredient($reverse ? $this->width - $x - 1 : $x, $y); + $required = $this->getIngredient($x, $y); + if(!$required->equals($given, !$required->hasAnyDamageValue(), $required->hasCompoundTag()) or $required->getCount() > $given->getCount()){ return false; } - - unset($input[$y][$x]); - } - } - - //check if there are any items left in the grid outside of the recipe - /** @var Item[] $row */ - foreach($input as $y => $row){ - foreach($row as $x => $needItem){ - if(!$needItem->isNull()){ - return false; //too many input ingredients - } } } @@ -232,38 +217,15 @@ class ShapedRecipe implements CraftingRecipe{ } /** - * @param Item[][] $input - * @param Item[][] $output + * @param CraftingGrid $grid * * @return bool */ - public function matchItems(array $input, array $output) : bool{ - if( - !$this->matchInputMap($input) and //as-is - !$this->matchInputMap(array_map(function(array $row) : array{ return array_reverse($row, false); }, $input)) //mirrored - ){ + public function matchesCraftingGrid(CraftingGrid $grid) : bool{ + if($this->width !== $grid->getRecipeWidth() or $this->height !== $grid->getRecipeHeight()){ return false; } - //and then, finally, check that the output items are good: - - /** @var Item[] $haveItems */ - $haveItems = array_merge(...$output); - $needItems = $this->getExtraResults(); - foreach($haveItems as $j => $haveItem){ - if($haveItem->isNull()){ - unset($haveItems[$j]); - continue; - } - - foreach($needItems as $i => $needItem){ - if($needItem->equals($haveItem, !$needItem->hasAnyDamageValue(), $needItem->hasCompoundTag()) and $needItem->getCount() === $haveItem->getCount()){ - unset($haveItems[$j], $needItems[$i]); - break; - } - } - } - - return count($haveItems) === 0 and count($needItems) === 0; + return $this->matchInputMap($grid, false) or $this->matchInputMap($grid, true); } } diff --git a/src/pocketmine/inventory/ShapelessRecipe.php b/src/pocketmine/inventory/ShapelessRecipe.php index c591a0b20..405a33c71 100644 --- a/src/pocketmine/inventory/ShapelessRecipe.php +++ b/src/pocketmine/inventory/ShapelessRecipe.php @@ -27,17 +27,25 @@ use pocketmine\item\Item; use pocketmine\utils\UUID; class ShapelessRecipe implements CraftingRecipe{ - /** @var Item */ - private $output; - /** @var UUID|null */ private $id = null; /** @var Item[] */ private $ingredients = []; + /** @var Item[] */ + private $results; - public function __construct(Item $result){ - $this->output = clone $result; + /** + * @param Item[] $ingredients No more than 9 total. This applies to sum of item stack counts, not count of array. + * @param Item[] $results List of result items created by this recipe. + */ + public function __construct(array $ingredients, array $results){ + foreach($ingredients as $item){ + //Ensure they get split up properly + $this->addIngredient($item); + } + + $this->results = array_map(function(Item $item) : Item{ return clone $item; }, $results); } /** @@ -58,16 +66,8 @@ class ShapelessRecipe implements CraftingRecipe{ $this->id = $id; } - public function getResult() : Item{ - return clone $this->output; - } - - public function getExtraResults() : array{ - return []; //TODO - } - - public function getAllResults() : array{ - return [$this->getResult()]; //TODO + public function getResults() : array{ + return array_map(function(Item $item) : Item{ return clone $item; }, $this->results); } /** @@ -78,7 +78,7 @@ class ShapelessRecipe implements CraftingRecipe{ * @throws \InvalidArgumentException */ public function addIngredient(Item $item) : ShapelessRecipe{ - if(count($this->ingredients) >= 9){ + if(count($this->ingredients) + $item->getCount() > 9){ throw new \InvalidArgumentException("Shapeless recipes cannot have more than 9 ingredients"); } @@ -112,12 +112,7 @@ class ShapelessRecipe implements CraftingRecipe{ * @return Item[] */ public function getIngredientList() : array{ - $ingredients = []; - foreach($this->ingredients as $ingredient){ - $ingredients[] = clone $ingredient; - } - - return $ingredients; + return array_map(function(Item $item) : Item{ return clone $item; }, $this->ingredients); } /** @@ -137,53 +132,25 @@ class ShapelessRecipe implements CraftingRecipe{ } /** - * @param Item[][] $input - * @param Item[][] $output + * @param CraftingGrid $grid * * @return bool */ - public function matchItems(array $input, array $output) : bool{ - /** @var Item[] $haveInputs */ - $haveInputs = array_merge(...$input); //we don't care how the items were arranged - $needInputs = $this->getIngredientList(); + public function matchesCraftingGrid(CraftingGrid $grid) : bool{ + //don't pack the ingredients - shapeless recipes require that each ingredient be in a separate slot + $input = $grid->getContents(); - if(!$this->matchItemList($haveInputs, $needInputs)){ - return false; - } - - /** @var Item[] $haveOutputs */ - $haveOutputs = array_merge(...$output); - $needOutputs = $this->getExtraResults(); - - if(!$this->matchItemList($haveOutputs, $needOutputs)){ - return false; - } - - return true; - } - - /** - * @param Item[] $haveItems - * @param Item[] $needItems - * - * @return bool - */ - private function matchItemList(array $haveItems, array $needItems) : bool{ - foreach($haveItems as $j => $haveItem){ - if($haveItem->isNull()){ - unset($haveItems[$j]); - continue; - } - - - foreach($needItems as $i => $needItem){ - if($needItem->equals($haveItem, !$needItem->hasAnyDamageValue(), $needItem->hasCompoundTag()) and $needItem->getCount() === $haveItem->getCount()){ - unset($haveItems[$j], $needItems[$i]); - break; + foreach($this->ingredients as $needItem){ + foreach($input as $j => $haveItem){ + if($haveItem->equals($needItem, !$needItem->hasAnyDamageValue(), $needItem->hasCompoundTag()) and $haveItem->getCount() >= $needItem->getCount()){ + unset($input[$j]); + continue 2; } } + + return false; //failed to match the needed item to a given item } - return count($haveItems) === 0 and count($needItems) === 0; + return empty($input); //crafting grid should be empty apart from the given ingredient stacks } } diff --git a/src/pocketmine/inventory/transaction/CraftingTransaction.php b/src/pocketmine/inventory/transaction/CraftingTransaction.php index cd446367c..48f549b75 100644 --- a/src/pocketmine/inventory/transaction/CraftingTransaction.php +++ b/src/pocketmine/inventory/transaction/CraftingTransaction.php @@ -26,132 +26,117 @@ namespace pocketmine\inventory\transaction; use pocketmine\event\inventory\CraftItemEvent; use pocketmine\inventory\CraftingRecipe; use pocketmine\item\Item; -use pocketmine\item\ItemFactory; use pocketmine\network\mcpe\protocol\ContainerClosePacket; use pocketmine\network\mcpe\protocol\types\ContainerIds; -use pocketmine\Player; +use pocketmine\utils\MainLogger; class CraftingTransaction extends InventoryTransaction{ - - protected $gridSize; - /** @var Item[][] */ - protected $inputs; - /** @var Item[][] */ - protected $secondaryOutputs; - /** @var Item|null */ - protected $primaryOutput; - /** @var CraftingRecipe|null */ - protected $recipe = null; + protected $recipe; + /** @var int|null */ + protected $repetitions; - public function __construct(Player $source, $actions = []){ - $this->gridSize = $source->getCraftingGrid()->getGridWidth(); + /** @var Item[] */ + protected $inputs = []; + /** @var Item[] */ + protected $outputs = []; - $air = ItemFactory::get(Item::AIR, 0, 0); - $this->inputs = array_fill(0, $this->gridSize, array_fill(0, $this->gridSize, $air)); - $this->secondaryOutputs = array_fill(0, $this->gridSize, array_fill(0, $this->gridSize, $air)); - - parent::__construct($source, $actions); - } - - public function setInput(int $index, Item $item) : void{ - $y = (int) ($index / $this->gridSize); - $x = $index % $this->gridSize; - - if(!isset($this->inputs[$y][$x])){ - return; + /** + * @param Item[] $txItems + * @param Item[] $recipeItems + * @param bool $wildcards + * + * @return int + * @throws \InvalidStateException + * @throws \InvalidArgumentException + */ + protected function matchRecipeItems(array $txItems, array $recipeItems, bool $wildcards) : int{ + if(empty($recipeItems)){ + throw new \InvalidArgumentException("No recipe items given"); + } + if(empty($txItems)){ + throw new \InvalidArgumentException("No transaction items given"); } - if($this->inputs[$y][$x]->isNull()){ - $this->inputs[$y][$x] = clone $item; - }elseif(!$this->inputs[$y][$x]->equals($item)){ - throw new \RuntimeException("Input $index has already been set and does not match the current item (expected " . $this->inputs[$y][$x] . ", got " . $item . ")"); - } - } - - public function getInputMap() : array{ - return $this->inputs; - } - - public function setExtraOutput(int $index, Item $item) : void{ - $y = (int) ($index / $this->gridSize); - $x = $index % $this->gridSize; - - if(!isset($this->secondaryOutputs[$y][$x])){ - return; - } - - if($this->secondaryOutputs[$y][$x]->isNull()){ - $this->secondaryOutputs[$y][$x] = clone $item; - }elseif(!$this->secondaryOutputs[$y][$x]->equals($item)){ - throw new \RuntimeException("Output $index has already been set and does not match the current item (expected " . $this->secondaryOutputs[$y][$x] . ", got " . $item . ")"); - } - } - - public function getPrimaryOutput() : ?Item{ - return $this->primaryOutput; - } - - public function setPrimaryOutput(Item $item) : void{ - if($this->primaryOutput === null){ - $this->primaryOutput = clone $item; - }elseif(!$this->primaryOutput->equals($item)){ - throw new \RuntimeException("Primary result item has already been set and does not match the current item (expected " . $this->primaryOutput . ", got " . $item . ")"); - } - } - - public function getRecipe() : ?CraftingRecipe{ - return $this->recipe; - } - - private function reindexInputs() : array{ - $minX = PHP_INT_MAX; - $maxX = 0; - - $minY = PHP_INT_MAX; - $maxY = 0; - - $empty = true; - - foreach($this->inputs as $y => $row){ - foreach($row as $x => $item){ - if(!$item->isNull()){ - $minX = min($minX, $x); - $maxX = max($maxX, $x); - - $minY = min($minY, $y); - $maxY = max($maxY, $y); - - $empty = false; + $iterations = 0; + while(!empty($recipeItems)){ + /** @var Item $recipeItem */ + $recipeItem = array_pop($recipeItems); + $needCount = $recipeItem->getCount(); + foreach($recipeItems as $i => $otherRecipeItem){ + if($otherRecipeItem->equals($recipeItem)){ //make sure they have the same wildcards set + $needCount += $otherRecipeItem->getCount(); + unset($recipeItems[$i]); } } - } - if($empty){ - return []; - } + $haveCount = 0; + foreach($txItems as $j => $txItem){ + if($txItem->equals($recipeItem, !$wildcards or !$recipeItem->hasAnyDamageValue(), !$wildcards or $recipeItem->hasCompoundTag())){ + $haveCount += $txItem->getCount(); + unset($txItems[$j]); + } + } - $air = ItemFactory::get(Item::AIR, 0, 0); - $reindexed = array_fill(0, $maxY - $minY + 1, array_fill(0, $maxX - $minX + 1, $air)); - foreach($reindexed as $y => $row){ - foreach($row as $x => $item){ - $reindexed[$y][$x] = $this->inputs[$y + $minY][$x + $minX]; + if($haveCount % $needCount !== 0){ + //wrong count for this output, should divide exactly + throw new \InvalidStateException("Expected an exact multiple of required $recipeItem (given: $haveCount, needed: $needCount)"); + } + + $multiplier = intdiv($haveCount, $needCount); + if($multiplier < 1){ + throw new \InvalidStateException("Expected more than zero items matching $recipeItem (given: $haveCount, needed: $needCount)"); + } + if($iterations === 0){ + $iterations = $multiplier; + }elseif($multiplier !== $iterations){ + //wrong count for this output, should match previous outputs + throw new \InvalidStateException("Expected $recipeItem x$iterations, but found x$multiplier"); } } - return $reindexed; + if($iterations < 1){ + throw new \InvalidStateException("Tried to craft zero times"); + } + if(!empty($txItems)){ + //all items should be destroyed in this process + throw new \InvalidStateException("Expected 0 ingredients left over, have " . count($txItems)); + } + + return $iterations; } + public function canExecute() : bool{ - $inputs = $this->reindexInputs(); + $this->squashDuplicateSlotChanges(); + if(count($this->actions) < 1){ + return false; + } - $this->recipe = $this->source->getServer()->getCraftingManager()->matchRecipe($inputs, $this->primaryOutput, $this->secondaryOutputs); + $this->matchItems($this->outputs, $this->inputs); - return $this->recipe !== null and parent::canExecute(); + $this->recipe = $this->source->getServer()->getCraftingManager()->matchRecipe($this->source->getCraftingGrid(), $this->outputs); + if($this->recipe === null){ + return false; + } + + try{ + $this->repetitions = $this->matchRecipeItems($this->outputs, $this->recipe->getResults(), false); + + if(($inputIterations = $this->matchRecipeItems($this->inputs, $this->recipe->getIngredientList(), true)) !== $this->repetitions){ + throw new \InvalidStateException("Tried to craft recipe $this->repetitions times in batch, but have enough inputs for $inputIterations"); + } + + return true; + }catch(\InvalidStateException $e){ + MainLogger::getLogger()->debug("Failed to validate crafting transaction for " . $this->source->getName() . ": " . $e->getMessage()); + } + + return false; } protected function callExecuteEvent() : bool{ - $this->source->getServer()->getPluginManager()->callEvent($ev = new CraftItemEvent($this)); + $this->source->getServer()->getPluginManager()->callEvent($ev = new CraftItemEvent($this, $this->recipe, $this->repetitions, $this->inputs, $this->outputs)); return !$ev->isCancelled(); } @@ -171,37 +156,39 @@ class CraftingTransaction extends InventoryTransaction{ public function execute() : bool{ if(parent::execute()){ - switch($this->primaryOutput->getId()){ - case Item::CRAFTING_TABLE: - $this->source->awardAchievement("buildWorkBench"); - break; - case Item::WOODEN_PICKAXE: - $this->source->awardAchievement("buildPickaxe"); - break; - case Item::FURNACE: - $this->source->awardAchievement("buildFurnace"); - break; - case Item::WOODEN_HOE: - $this->source->awardAchievement("buildHoe"); - break; - case Item::BREAD: - $this->source->awardAchievement("makeBread"); - break; - case Item::CAKE: - $this->source->awardAchievement("bakeCake"); - break; - case Item::STONE_PICKAXE: - case Item::GOLDEN_PICKAXE: - case Item::IRON_PICKAXE: - case Item::DIAMOND_PICKAXE: - $this->source->awardAchievement("buildBetterPickaxe"); - break; - case Item::WOODEN_SWORD: - $this->source->awardAchievement("buildSword"); - break; - case Item::DIAMOND: - $this->source->awardAchievement("diamond"); - break; + foreach($this->outputs as $item){ + switch($item->getId()){ + case Item::CRAFTING_TABLE: + $this->source->awardAchievement("buildWorkBench"); + break; + case Item::WOODEN_PICKAXE: + $this->source->awardAchievement("buildPickaxe"); + break; + case Item::FURNACE: + $this->source->awardAchievement("buildFurnace"); + break; + case Item::WOODEN_HOE: + $this->source->awardAchievement("buildHoe"); + break; + case Item::BREAD: + $this->source->awardAchievement("makeBread"); + break; + case Item::CAKE: + $this->source->awardAchievement("bakeCake"); + break; + case Item::STONE_PICKAXE: + case Item::GOLDEN_PICKAXE: + case Item::IRON_PICKAXE: + case Item::DIAMOND_PICKAXE: + $this->source->awardAchievement("buildBetterPickaxe"); + break; + case Item::WOODEN_SWORD: + $this->source->awardAchievement("buildSword"); + break; + case Item::DIAMOND: + $this->source->awardAchievement("diamond"); + break; + } } return true; diff --git a/src/pocketmine/inventory/transaction/action/CraftingTakeResultAction.php b/src/pocketmine/inventory/transaction/action/CraftingTakeResultAction.php deleted file mode 100644 index 3ac7fd302..000000000 --- a/src/pocketmine/inventory/transaction/action/CraftingTakeResultAction.php +++ /dev/null @@ -1,59 +0,0 @@ -setPrimaryOutput($this->getSourceItem()); - }else{ - throw new \InvalidStateException(get_class($this) . " can only be added to CraftingTransactions"); - } - } - - public function isValid(Player $source) : bool{ - return true; - } - - public function execute(Player $source) : bool{ - return true; - } - - public function onExecuteSuccess(Player $source) : void{ - - } - - public function onExecuteFail(Player $source) : void{ - - } - -} diff --git a/src/pocketmine/inventory/transaction/action/CraftingTransferMaterialAction.php b/src/pocketmine/inventory/transaction/action/CraftingTransferMaterialAction.php deleted file mode 100644 index 3d3267007..000000000 --- a/src/pocketmine/inventory/transaction/action/CraftingTransferMaterialAction.php +++ /dev/null @@ -1,73 +0,0 @@ -slot = $slot; - } - - public function onAddToTransaction(InventoryTransaction $transaction) : void{ - if($transaction instanceof CraftingTransaction){ - if($this->sourceItem->isNull()){ - $transaction->setInput($this->slot, $this->targetItem); - }elseif($this->targetItem->isNull()){ - $transaction->setExtraOutput($this->slot, $this->sourceItem); - }else{ - throw new \InvalidStateException("Invalid " . get_class($this) . ", either source or target item must be air, got source: " . $this->sourceItem . ", target: " . $this->targetItem); - } - }else{ - throw new \InvalidStateException(get_class($this) . " can only be added to CraftingTransactions"); - } - } - - public function isValid(Player $source) : bool{ - return true; - } - - public function execute(Player $source) : bool{ - return true; - } - - public function onExecuteSuccess(Player $source) : void{ - - } - - public function onExecuteFail(Player $source) : void{ - - } -} diff --git a/src/pocketmine/network/mcpe/protocol/CraftingDataPacket.php b/src/pocketmine/network/mcpe/protocol/CraftingDataPacket.php index 78fa3f2f2..15ec5013a 100644 --- a/src/pocketmine/network/mcpe/protocol/CraftingDataPacket.php +++ b/src/pocketmine/network/mcpe/protocol/CraftingDataPacket.php @@ -133,7 +133,7 @@ class CraftingDataPacket extends DataPacket{ $stream->putSlot($item); } - $results = $recipe->getAllResults(); + $results = $recipe->getResults(); $stream->putUnsignedVarInt(count($results)); foreach($results as $item){ $stream->putSlot($item); @@ -154,7 +154,7 @@ class CraftingDataPacket extends DataPacket{ } } - $results = $recipe->getAllResults(); + $results = $recipe->getResults(); $stream->putUnsignedVarInt(count($results)); foreach($results as $item){ $stream->putSlot($item); diff --git a/src/pocketmine/network/mcpe/protocol/InventoryTransactionPacket.php b/src/pocketmine/network/mcpe/protocol/InventoryTransactionPacket.php index 6b107b200..7a3556b70 100644 --- a/src/pocketmine/network/mcpe/protocol/InventoryTransactionPacket.php +++ b/src/pocketmine/network/mcpe/protocol/InventoryTransactionPacket.php @@ -56,6 +56,12 @@ class InventoryTransactionPacket extends DataPacket{ * determine whether we're doing a crafting transaction. */ public $isCraftingPart = false; + /** + * @var bool + * NOTE: THIS FIELD DOES NOT EXIST IN THE PROTOCOL, it's merely used for convenience for PocketMine-MP to easily + * determine whether we're doing a crafting transaction. + */ + public $isFinalCraftingPart = false; /** @var NetworkInventoryAction[] */ public $actions = []; diff --git a/src/pocketmine/network/mcpe/protocol/types/NetworkInventoryAction.php b/src/pocketmine/network/mcpe/protocol/types/NetworkInventoryAction.php index fcb820717..0373ee7c5 100644 --- a/src/pocketmine/network/mcpe/protocol/types/NetworkInventoryAction.php +++ b/src/pocketmine/network/mcpe/protocol/types/NetworkInventoryAction.php @@ -23,8 +23,6 @@ declare(strict_types=1); namespace pocketmine\network\mcpe\protocol\types; -use pocketmine\inventory\transaction\action\CraftingTakeResultAction; -use pocketmine\inventory\transaction\action\CraftingTransferMaterialAction; use pocketmine\inventory\transaction\action\CreativeInventoryAction; use pocketmine\inventory\transaction\action\DropItemAction; use pocketmine\inventory\transaction\action\InventoryAction; @@ -111,8 +109,10 @@ class NetworkInventoryAction{ case self::SOURCE_TODO: $this->windowId = $packet->getVarInt(); switch($this->windowId){ - case self::SOURCE_TYPE_CRAFTING_USE_INGREDIENT: + /** @noinspection PhpMissingBreakStatementInspection */ case self::SOURCE_TYPE_CRAFTING_RESULT: + $packet->isFinalCraftingPart = true; + case self::SOURCE_TYPE_CRAFTING_USE_INGREDIENT: $packet->isCraftingPart = true; break; } @@ -193,9 +193,8 @@ class NetworkInventoryAction{ $window = $player->getCraftingGrid(); return new SlotChangeAction($window, $this->inventorySlot, $this->oldItem, $this->newItem); case self::SOURCE_TYPE_CRAFTING_RESULT: - return new CraftingTakeResultAction($this->oldItem, $this->newItem); case self::SOURCE_TYPE_CRAFTING_USE_INGREDIENT: - return new CraftingTransferMaterialAction($this->oldItem, $this->newItem, $this->inventorySlot); + return null; case self::SOURCE_TYPE_CONTAINER_DROP_CONTENTS: //TODO: this type applies to all fake windows, not just crafting