From 55cb68e5b5b1783b3ce227b3981fe0627e80d2e4 Mon Sep 17 00:00:00 2001 From: "Dylan K. Taylor" Date: Mon, 27 Jun 2022 13:33:26 +0100 Subject: [PATCH] Burn meta wildcards from Item, allow more dynamic recipe inputs this was an obstacle for getting rid of legacy item IDs. --- src/crafting/CraftingManager.php | 8 +- .../CraftingManagerFromDataHelper.php | 60 +++++++-- src/crafting/CraftingRecipe.php | 2 +- src/crafting/ExactRecipeIngredient.php | 56 +++++++++ src/crafting/FurnaceRecipe.php | 7 +- src/crafting/FurnaceRecipeManager.php | 26 +++- src/crafting/MetaWildcardRecipeIngredient.php | 57 +++++++++ src/crafting/RecipeIngredient.php | 31 +++++ src/crafting/ShapedRecipe.php | 27 +++-- src/crafting/ShapelessRecipe.php | 31 ++--- src/data/bedrock/item/ItemSerializer.php | 6 - .../bedrock/item/upgrade/ItemDataUpgrader.php | 41 +++++++ src/inventory/BaseInventory.php | 15 +-- .../transaction/CraftingTransaction.php | 114 +++++++++++++++++- src/item/Item.php | 4 +- src/item/ItemFactory.php | 45 +++---- src/item/ItemIdentifier.php | 5 +- src/item/LegacyStringToItemParser.php | 3 + src/network/mcpe/cache/CraftingDataCache.php | 11 +- src/network/mcpe/convert/TypeConverter.php | 51 ++++---- 20 files changed, 473 insertions(+), 127 deletions(-) create mode 100644 src/crafting/ExactRecipeIngredient.php create mode 100644 src/crafting/MetaWildcardRecipeIngredient.php create mode 100644 src/crafting/RecipeIngredient.php diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index f473a739d..f23ba53ac 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -184,7 +184,7 @@ class CraftingManager{ public function registerPotionTypeRecipe(PotionTypeRecipe $recipe) : void{ $input = $recipe->getInput(); $ingredient = $recipe->getIngredient(); - $this->potionTypeRecipes[$input->getId() . ":" . $input->getMeta()][$ingredient->getId() . ":" . ($ingredient->hasAnyDamageValue() ? "?" : $ingredient->getMeta())] = $recipe; + $this->potionTypeRecipes[$input->getId() . ":" . $input->getMeta()][$ingredient->getId() . ":" . $ingredient->getMeta()] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); @@ -193,7 +193,7 @@ class CraftingManager{ public function registerPotionContainerChangeRecipe(PotionContainerChangeRecipe $recipe) : void{ $ingredient = $recipe->getIngredient(); - $this->potionContainerChangeRecipes[$recipe->getInputItemId()][$ingredient->getId() . ":" . ($ingredient->hasAnyDamageValue() ? "?" : $ingredient->getMeta())] = $recipe; + $this->potionContainerChangeRecipes[$recipe->getInputItemId()][$ingredient->getId() . ":" . $ingredient->getMeta()] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback(); @@ -253,8 +253,6 @@ class CraftingManager{ public function matchBrewingRecipe(Item $input, Item $ingredient) : ?BrewingRecipe{ return $this->potionTypeRecipes[$input->getId() . ":" . $input->getMeta()][$ingredient->getId() . ":" . $ingredient->getMeta()] ?? - $this->potionTypeRecipes[$input->getId() . ":" . $input->getMeta()][$ingredient->getId() . ":?"] ?? - $this->potionContainerChangeRecipes[$input->getId()][$ingredient->getId() . ":" . $ingredient->getMeta()] ?? - $this->potionContainerChangeRecipes[$input->getId()][$ingredient->getId() . ":?"] ?? null; + $this->potionContainerChangeRecipes[$input->getId()][$ingredient->getId() . ":" . $ingredient->getMeta()] ?? null; } } diff --git a/src/crafting/CraftingManagerFromDataHelper.php b/src/crafting/CraftingManagerFromDataHelper.php index b09607d54..d6d7608da 100644 --- a/src/crafting/CraftingManagerFromDataHelper.php +++ b/src/crafting/CraftingManagerFromDataHelper.php @@ -23,14 +23,17 @@ declare(strict_types=1); namespace pocketmine\crafting; +use pocketmine\data\bedrock\item\ItemTypeDeserializeException; use pocketmine\item\Durable; use pocketmine\item\Item; use pocketmine\item\ItemFactory; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\Utils; +use pocketmine\world\format\io\GlobalItemDataHandlers; use function array_map; use function file_get_contents; use function is_array; +use function is_int; use function json_decode; final class CraftingManagerFromDataHelper{ @@ -41,7 +44,7 @@ final class CraftingManagerFromDataHelper{ private static function containsUnknownItems(array $items) : bool{ $factory = ItemFactory::getInstance(); foreach($items as $item){ - if($item instanceof Durable || $item->hasAnyDamageValue()){ + if($item instanceof Durable){ //TODO: this check is imperfect and might cause problems if meta 0 isn't used for some reason if(!$factory->isRegistered($item->getId())){ return true; @@ -54,6 +57,30 @@ final class CraftingManagerFromDataHelper{ return false; } + /** + * @param mixed[] $data + */ + private static function deserializeIngredient(array $data) : ?RecipeIngredient{ + if(!isset($data["id"]) || !is_int($data["id"])){ + throw new \InvalidArgumentException("Invalid input data, expected int ID"); + } + if(isset($data["damage"]) && $data["damage"] === -1){ + try{ + $typeData = GlobalItemDataHandlers::getUpgrader()->upgradeItemTypeDataInt($data["id"], 0, 1, null); + }catch(ItemTypeDeserializeException){ + //probably unknown item + return null; + } + + return new MetaWildcardRecipeIngredient($typeData->getTypeData()->getName()); + } + + //TODO: we need to stop using jsonDeserialize for this + $item = Item::jsonDeserialize($data); + + return self::containsUnknownItems([$item]) ? null : new ExactRecipeIngredient($item); + } + public static function make(string $filePath) : CraftingManager{ $recipes = json_decode(Utils::assumeNotFalse(file_get_contents($filePath), "Missing required resource file"), true); if(!is_array($recipes)){ @@ -61,6 +88,7 @@ final class CraftingManagerFromDataHelper{ } $result = new CraftingManager(); + $ingredientDeserializerFunc = \Closure::fromCallable([self::class, "deserializeIngredient"]); $itemDeserializerFunc = \Closure::fromCallable([Item::class, 'jsonDeserialize']); foreach($recipes["shapeless"] as $recipe){ @@ -73,9 +101,16 @@ final class CraftingManagerFromDataHelper{ if($recipeType === null){ continue; } - $inputs = array_map($itemDeserializerFunc, $recipe["input"]); + $inputs = []; + foreach($recipe["input"] as $inputData){ + $input = $ingredientDeserializerFunc($inputData); + if($input === null){ //unknown input item + continue; + } + $inputs[] = $input; + } $outputs = array_map($itemDeserializerFunc, $recipe["output"]); - if(self::containsUnknownItems($inputs) || self::containsUnknownItems($outputs)){ + if(self::containsUnknownItems($outputs)){ continue; } $result->registerShapelessRecipe(new ShapelessRecipe( @@ -88,9 +123,16 @@ final class CraftingManagerFromDataHelper{ if($recipe["block"] !== "crafting_table"){ //TODO: filter others out for now to avoid breaking economics continue; } - $inputs = array_map($itemDeserializerFunc, $recipe["input"]); + $inputs = []; + foreach($recipe["input"] as $symbol => $inputData){ + $input = $ingredientDeserializerFunc($inputData); + if($input === null){ //unknown input item + continue; + } + $inputs[$symbol] = $input; + } $outputs = array_map($itemDeserializerFunc, $recipe["output"]); - if(self::containsUnknownItems($inputs) || self::containsUnknownItems($outputs)){ + if(self::containsUnknownItems($outputs)){ continue; } $result->registerShapedRecipe(new ShapedRecipe( @@ -111,8 +153,8 @@ final class CraftingManagerFromDataHelper{ continue; } $output = Item::jsonDeserialize($recipe["output"]); - $input = Item::jsonDeserialize($recipe["input"]); - if(self::containsUnknownItems([$output, $input])){ + $input = self::deserializeIngredient($recipe["input"]); + if($input === null || self::containsUnknownItems([$output])){ continue; } $result->getFurnaceRecipeManager($furnaceType)->register(new FurnaceRecipe( @@ -135,9 +177,9 @@ final class CraftingManagerFromDataHelper{ )); } foreach($recipes["potion_container_change"] as $recipe){ - $input = ItemFactory::getInstance()->get($recipe["input_item_id"], -1); + $input = ItemFactory::getInstance()->get($recipe["input_item_id"]); $ingredient = Item::jsonDeserialize($recipe["ingredient"]); - $output = ItemFactory::getInstance()->get($recipe["output_item_id"], -1); + $output = ItemFactory::getInstance()->get($recipe["output_item_id"]); if(self::containsUnknownItems([$input, $ingredient, $output])){ continue; diff --git a/src/crafting/CraftingRecipe.php b/src/crafting/CraftingRecipe.php index 8666235a8..02e6fce04 100644 --- a/src/crafting/CraftingRecipe.php +++ b/src/crafting/CraftingRecipe.php @@ -29,7 +29,7 @@ interface CraftingRecipe{ /** * Returns a list of items needed to craft this recipe. This MUST NOT include Air items or items with a zero count. * - * @return Item[] + * @return RecipeIngredient[] */ public function getIngredientList() : array; diff --git a/src/crafting/ExactRecipeIngredient.php b/src/crafting/ExactRecipeIngredient.php new file mode 100644 index 000000000..76543b0ac --- /dev/null +++ b/src/crafting/ExactRecipeIngredient.php @@ -0,0 +1,56 @@ +isNull()){ + throw new \InvalidArgumentException("Recipe ingredients must not be air items"); + } + if($item->getCount() !== 1){ + throw new \InvalidArgumentException("Recipe ingredients cannot require count"); + } + $this->item = clone $item; + } + + public function getItem() : Item{ return clone $this->item; } + + public function accepts(Item $item) : bool{ + //client-side, recipe inputs can't actually require NBT + //but on the PM side, we currently check for it if the input requires it, so we have to continue to do so for + //the sake of consistency + return $item->getCount() >= 1 && $this->item->equals($item, true, $this->item->hasNamedTag()); + } + + public function __toString() : string{ + return "ExactRecipeIngredient(" . $this->item . ")"; + } +} diff --git a/src/crafting/FurnaceRecipe.php b/src/crafting/FurnaceRecipe.php index e719aa07b..f54dd2334 100644 --- a/src/crafting/FurnaceRecipe.php +++ b/src/crafting/FurnaceRecipe.php @@ -29,14 +29,13 @@ class FurnaceRecipe{ public function __construct( private Item $result, - private Item $ingredient + private RecipeIngredient $ingredient ){ $this->result = clone $result; - $this->ingredient = clone $ingredient; } - public function getInput() : Item{ - return clone $this->ingredient; + public function getInput() : RecipeIngredient{ + return $this->ingredient; } public function getResult() : Item{ diff --git a/src/crafting/FurnaceRecipeManager.php b/src/crafting/FurnaceRecipeManager.php index f715468dd..5d46f0e19 100644 --- a/src/crafting/FurnaceRecipeManager.php +++ b/src/crafting/FurnaceRecipeManager.php @@ -25,11 +25,18 @@ namespace pocketmine\crafting; use pocketmine\item\Item; use pocketmine\utils\ObjectSet; +use function morton2d_encode; final class FurnaceRecipeManager{ /** @var FurnaceRecipe[] */ protected array $furnaceRecipes = []; + /** + * @var FurnaceRecipe[] + * @phpstan-var array + */ + private array $lookupCache = []; + /** @phpstan-var ObjectSet<\Closure(FurnaceRecipe) : void> */ private ObjectSet $recipeRegisteredCallbacks; @@ -52,14 +59,27 @@ final class FurnaceRecipeManager{ } public function register(FurnaceRecipe $recipe) : void{ - $input = $recipe->getInput(); - $this->furnaceRecipes[$input->getId() . ":" . ($input->hasAnyDamageValue() ? "?" : $input->getMeta())] = $recipe; + $this->furnaceRecipes[] = $recipe; foreach($this->recipeRegisteredCallbacks as $callback){ $callback($recipe); } } public function match(Item $input) : ?FurnaceRecipe{ - return $this->furnaceRecipes[$input->getId() . ":" . $input->getMeta()] ?? $this->furnaceRecipes[$input->getId() . ":?"] ?? null; + $index = morton2d_encode($input->getId(), $input->getMeta()); + $simpleRecipe = $this->lookupCache[$index] ?? null; + if($simpleRecipe !== null){ + return $simpleRecipe; + } + + foreach($this->furnaceRecipes as $recipe){ + if($recipe->getInput()->accepts($input)){ + //remember that this item is accepted by this recipe, so we don't need to bruteforce it again + $this->lookupCache[$index] = $recipe; + return $recipe; + } + } + + return null; } } diff --git a/src/crafting/MetaWildcardRecipeIngredient.php b/src/crafting/MetaWildcardRecipeIngredient.php new file mode 100644 index 000000000..8aa6a1f17 --- /dev/null +++ b/src/crafting/MetaWildcardRecipeIngredient.php @@ -0,0 +1,57 @@ +itemId; } + + public function accepts(Item $item) : bool{ + if($item->getCount() < 1){ + return false; + } + + return GlobalItemDataHandlers::getSerializer()->serializeType($item)->getName() === $this->itemId; + } + + public function __toString() : string{ + return "MetaWildcardRecipeIngredient($this->itemId)"; + } +} diff --git a/src/crafting/RecipeIngredient.php b/src/crafting/RecipeIngredient.php new file mode 100644 index 000000000..19a7b6085 --- /dev/null +++ b/src/crafting/RecipeIngredient.php @@ -0,0 +1,31 @@ + Item map */ + /** @var RecipeIngredient[] char => RecipeIngredient map */ private array $ingredientList = []; /** @var Item[] */ private array $results = []; @@ -46,15 +45,15 @@ class ShapedRecipe implements CraftingRecipe{ /** * Constructs a ShapedRecipe instance. * - * @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 RecipeIngredient[] $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[] $results List of items that this recipe produces when crafted. + * @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. */ @@ -119,7 +118,7 @@ class ShapedRecipe implements CraftingRecipe{ } /** - * @return Item[][] + * @return (RecipeIngredient|null)[][] */ public function getIngredientMap() : array{ $ingredients = []; @@ -134,7 +133,7 @@ class ShapedRecipe implements CraftingRecipe{ } /** - * @return Item[] + * @return RecipeIngredient[] */ public function getIngredientList() : array{ $ingredients = []; @@ -142,7 +141,7 @@ class ShapedRecipe implements CraftingRecipe{ for($y = 0; $y < $this->height; ++$y){ for($x = 0; $x < $this->width; ++$x){ $ingredient = $this->getIngredient($x, $y); - if(!$ingredient->isNull()){ + if($ingredient !== null){ $ingredients[] = $ingredient; } } @@ -151,9 +150,8 @@ class ShapedRecipe implements CraftingRecipe{ return $ingredients; } - public function getIngredient(int $x, int $y) : Item{ - $exists = $this->ingredientList[$this->shape[$y][$x]] ?? null; - return $exists !== null ? clone $exists : VanillaItems::AIR(); + public function getIngredient(int $x, int $y) : ?RecipeIngredient{ + return $this->ingredientList[$this->shape[$y][$x]] ?? null; } /** @@ -170,7 +168,12 @@ class ShapedRecipe implements CraftingRecipe{ $given = $grid->getIngredient($reverse ? $this->width - $x - 1 : $x, $y); $required = $this->getIngredient($x, $y); - if(!$required->equals($given, !$required->hasAnyDamageValue(), $required->hasNamedTag()) || $required->getCount() > $given->getCount()){ + + if($required === null){ + if(!$given->isNull()){ + return false; //hole, such as that in the center of a chest recipe, should not be filled + } + }elseif(!$required->accepts($given)){ return false; } } diff --git a/src/crafting/ShapelessRecipe.php b/src/crafting/ShapelessRecipe.php index 2c399fe04..5c78bc21e 100644 --- a/src/crafting/ShapelessRecipe.php +++ b/src/crafting/ShapelessRecipe.php @@ -28,30 +28,24 @@ use pocketmine\utils\Utils; use function count; class ShapelessRecipe implements CraftingRecipe{ - /** @var Item[] */ + /** @var RecipeIngredient[] */ private array $ingredients = []; /** @var Item[] */ private array $results; private ShapelessRecipeType $type; /** - * @param Item[] $ingredients No more than 9 total. This applies to sum of item stack counts, not count of array. + * @param RecipeIngredient[] $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. * TODO: we'll want to make the type parameter mandatory in PM5 */ public function __construct(array $ingredients, array $results, ?ShapelessRecipeType $type = null){ $this->type = $type ?? ShapelessRecipeType::CRAFTING(); - foreach($ingredients as $item){ - //Ensure they get split up properly - if(count($this->ingredients) + $item->getCount() > 9){ - throw new \InvalidArgumentException("Shapeless recipes cannot have more than 9 ingredients"); - } - while($item->getCount() > 0){ - $this->ingredients[] = $item->pop(); - } + if(count($ingredients) > 9){ + throw new \InvalidArgumentException("Shapeless recipes cannot have more than 9 ingredients"); } - + $this->ingredients = $ingredients; $this->results = Utils::cloneObjectArray($results); } @@ -71,28 +65,23 @@ class ShapelessRecipe implements CraftingRecipe{ } /** - * @return Item[] + * @return RecipeIngredient[] */ public function getIngredientList() : array{ - return Utils::cloneObjectArray($this->ingredients); + return $this->ingredients; } public function getIngredientCount() : int{ - $count = 0; - foreach($this->ingredients as $ingredient){ - $count += $ingredient->getCount(); - } - - return $count; + return count($this->ingredients); } 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(); - foreach($this->ingredients as $needItem){ + foreach($this->ingredients as $ingredient){ foreach($input as $j => $haveItem){ - if($haveItem->equals($needItem, !$needItem->hasAnyDamageValue(), $needItem->hasNamedTag()) && $haveItem->getCount() >= $needItem->getCount()){ + if($ingredient->accepts($haveItem)){ unset($input[$j]); continue 2; } diff --git a/src/data/bedrock/item/ItemSerializer.php b/src/data/bedrock/item/ItemSerializer.php index 5c6ed2b44..ed8417734 100644 --- a/src/data/bedrock/item/ItemSerializer.php +++ b/src/data/bedrock/item/ItemSerializer.php @@ -72,9 +72,6 @@ final class ItemSerializer{ * @phpstan-param \Closure(TItemType) : Data $serializer */ public function map(Item $item, \Closure $serializer) : void{ - if($item->hasAnyDamageValue()){ - throw new \InvalidArgumentException("Cannot serialize a recipe wildcard"); - } $index = $item->getTypeId(); if(isset($this->itemSerializers[$index])){ //TODO: REMOVE ME @@ -106,9 +103,6 @@ final class ItemSerializer{ if($item->isNull()){ throw new \InvalidArgumentException("Cannot serialize a null itemstack"); } - if($item->hasAnyDamageValue()){ - throw new \InvalidArgumentException("Cannot serialize a recipe input as a saved itemstack"); - } if($item instanceof ItemBlock){ $data = $this->serializeBlockItem($item->getBlock()); }else{ diff --git a/src/data/bedrock/item/upgrade/ItemDataUpgrader.php b/src/data/bedrock/item/upgrade/ItemDataUpgrader.php index 905abb208..6e73c02b9 100644 --- a/src/data/bedrock/item/upgrade/ItemDataUpgrader.php +++ b/src/data/bedrock/item/upgrade/ItemDataUpgrader.php @@ -70,6 +70,47 @@ final class ItemDataUpgrader{ ksort($this->idMetaUpgradeSchemas, SORT_NUMERIC); } + /** + * This function replaces the legacy ItemFactory::get(). + * + * Unlike ItemFactory::get(), it returns a SavedItemStackData which you can do with as you please. + * If you want to deserialize it into a PocketMine-MP itemstack, pass it to the ItemDeserializer. + * + * @see ItemDataUpgrader::upgradeItemTypeDataInt() + */ + public function upgradeItemTypeDataString(string $rawNameId, int $meta, int $count, ?CompoundTag $nbt) : SavedItemStackData{ + if(($r12BlockId = $this->r12ItemIdToBlockIdMap->itemIdToBlockId($rawNameId)) !== null){ + $blockStateData = $this->blockDataUpgrader->upgradeStringIdMeta($r12BlockId, $meta); + }else{ + //probably a standard item + $blockStateData = null; + } + + [$newNameId, $newMeta] = $this->upgradeItemStringIdMeta($rawNameId, $meta); + + //TODO: this won't account for spawn eggs from before 1.16.100 - perhaps we're lucky and they just left the meta in there anyway? + + return new SavedItemStackData( + new SavedItemData($newNameId, $newMeta, $blockStateData, $nbt), + $count, + null, + null, + [], + [] + ); + } + + /** + * This function replaces the legacy ItemFactory::get(). + */ + public function upgradeItemTypeDataInt(int $legacyNumericId, int $meta, int $count, ?CompoundTag $nbt) : SavedItemStackData{ + $rawNameId = $this->legacyIntToStringIdMap->legacyToString($legacyNumericId); + if($rawNameId === null){ + throw new SavedDataLoadingException("Unmapped legacy item ID $legacyNumericId"); + } + return $this->upgradeItemTypeDataString($rawNameId, $meta, $count, $nbt); + } + /** * @throws SavedDataLoadingException */ diff --git a/src/inventory/BaseInventory.php b/src/inventory/BaseInventory.php index 8d7d5491c..a37064e83 100644 --- a/src/inventory/BaseInventory.php +++ b/src/inventory/BaseInventory.php @@ -105,10 +105,9 @@ abstract class BaseInventory implements Inventory{ public function contains(Item $item) : bool{ $count = max(1, $item->getCount()); - $checkDamage = !$item->hasAnyDamageValue(); $checkTags = $item->hasNamedTag(); foreach($this->getContents() as $i){ - if($item->equals($i, $checkDamage, $checkTags)){ + if($item->equals($i, true, $checkTags)){ $count -= $i->getCount(); if($count <= 0){ return true; @@ -121,23 +120,22 @@ abstract class BaseInventory implements Inventory{ public function all(Item $item) : array{ $slots = []; - $checkDamage = !$item->hasAnyDamageValue(); $checkTags = $item->hasNamedTag(); foreach($this->getContents() as $index => $i){ - if($item->equals($i, $checkDamage, $checkTags)){ + if($item->equals($i, true, $checkTags)){ $slots[$index] = $i; } } return $slots; } + public function first(Item $item, bool $exact = false) : int{ $count = $exact ? $item->getCount() : max(1, $item->getCount()); - $checkDamage = $exact || !$item->hasAnyDamageValue(); $checkTags = $exact || $item->hasNamedTag(); foreach($this->getContents() as $index => $i){ - if($item->equals($i, $checkDamage, $checkTags) && ($i->getCount() === $count || (!$exact && $i->getCount() > $count))){ + if($item->equals($i, true, $checkTags) && ($i->getCount() === $count || (!$exact && $i->getCount() > $count))){ return $index; } } @@ -245,11 +243,10 @@ abstract class BaseInventory implements Inventory{ } public function remove(Item $item) : void{ - $checkDamage = !$item->hasAnyDamageValue(); $checkTags = $item->hasNamedTag(); foreach($this->getContents() as $index => $i){ - if($item->equals($i, $checkDamage, $checkTags)){ + if($item->equals($i, true, $checkTags)){ $this->clear($index); } } @@ -272,7 +269,7 @@ abstract class BaseInventory implements Inventory{ } foreach($itemSlots as $index => $slot){ - if($slot->equals($item, !$slot->hasAnyDamageValue(), $slot->hasNamedTag())){ + if($slot->equals($item, true, $slot->hasNamedTag())){ $amount = min($item->getCount(), $slot->getCount()); $slot->setCount($slot->getCount() - $amount); $item->setCount($item->getCount() - $amount); diff --git a/src/inventory/transaction/CraftingTransaction.php b/src/inventory/transaction/CraftingTransaction.php index bea88b187..a5b60a12f 100644 --- a/src/inventory/transaction/CraftingTransaction.php +++ b/src/inventory/transaction/CraftingTransaction.php @@ -25,12 +25,18 @@ namespace pocketmine\inventory\transaction; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\CraftingRecipe; +use pocketmine\crafting\RecipeIngredient; use pocketmine\event\inventory\CraftItemEvent; use pocketmine\item\Item; use pocketmine\player\Player; +use pocketmine\utils\Utils; +use function array_fill_keys; +use function array_keys; use function array_pop; use function count; use function intdiv; +use function min; +use function uasort; /** * This transaction type is specialized for crafting validation. It shares most of the same semantics of the base @@ -63,13 +69,110 @@ class CraftingTransaction extends InventoryTransaction{ $this->craftingManager = $craftingManager; } + /** + * @param Item[] $providedItems + * @return Item[] + */ + private static function packItems(array $providedItems) : array{ + $packedProvidedItems = []; + while(count($providedItems) > 0){ + $item = array_pop($providedItems); + foreach($providedItems as $k => $otherItem){ + if($item->canStackWith($otherItem)){ + $item->setCount($item->getCount() + $otherItem->getCount()); + unset($providedItems[$k]); + } + } + $packedProvidedItems[] = $item; + } + + return $packedProvidedItems; + } + + /** + * @param Item[] $providedItems + * @param RecipeIngredient[] $recipeIngredients + */ + public static function matchIngredients(array $providedItems, array $recipeIngredients, int $expectedIterations) : void{ + if(count($recipeIngredients) === 0){ + throw new TransactionValidationException("No recipe ingredients given"); + } + if(count($providedItems) === 0){ + throw new TransactionValidationException("No transaction items given"); + } + + $packedProvidedItems = self::packItems(Utils::cloneObjectArray($providedItems)); + $packedProvidedItemMatches = array_fill_keys(array_keys($packedProvidedItems), 0); + + $recipeIngredientMatches = []; + + foreach($recipeIngredients as $ingredientIndex => $recipeIngredient){ + $acceptedItems = []; + foreach($packedProvidedItems as $itemIndex => $packedItem){ + if($recipeIngredient->accepts($packedItem)){ + $packedProvidedItemMatches[$itemIndex]++; + $acceptedItems[$itemIndex] = $itemIndex; + } + } + + if(count($acceptedItems) === 0){ + throw new TransactionValidationException("No provided items satisfy ingredient requirement $recipeIngredient"); + } + + $recipeIngredientMatches[$ingredientIndex] = $acceptedItems; + } + + foreach($packedProvidedItemMatches as $itemIndex => $itemMatchCount){ + if($itemMatchCount === 0){ + $item = $packedProvidedItems[$itemIndex]; + throw new TransactionValidationException("Provided item $item is not accepted by any recipe ingredient"); + } + } + + //Most picky ingredients first - avoid picky ingredient getting their items stolen by wildcard ingredients + //TODO: this is still insufficient when multiple wildcard ingredients have overlaps, but we don't (yet) have to + //worry about those. + uasort($recipeIngredientMatches, fn(array $a, array $b) => count($a) <=> count($b)); + + foreach($recipeIngredientMatches as $ingredientIndex => $acceptedItems){ + $needed = $expectedIterations; + + foreach($packedProvidedItems as $itemIndex => $item){ + if(!isset($acceptedItems[$itemIndex])){ + continue; + } + + $taken = min($needed, $item->getCount()); + $needed -= $taken; + $item->setCount($item->getCount() - $taken); + + if($item->getCount() === 0){ + unset($packedProvidedItems[$itemIndex]); + } + + if($needed === 0){ + //validation passed! + continue 2; + } + } + + $recipeIngredient = $recipeIngredients[$ingredientIndex]; + $actualIterations = $expectedIterations - $needed; + throw new TransactionValidationException("Not enough items to satisfy recipe ingredient $recipeIngredient for $expectedIterations (only have enough items for $actualIterations iterations)"); + } + + if(count($packedProvidedItems) > 0){ + throw new TransactionValidationException("Not all provided items were used"); + } + } + /** * @param Item[] $txItems * @param Item[] $recipeItems * * @throws TransactionValidationException */ - protected function matchRecipeItems(array $txItems, array $recipeItems, bool $wildcards, int $iterations = 0) : int{ + protected function matchOutputs(array $txItems, array $recipeItems) : int{ if(count($recipeItems) === 0){ throw new TransactionValidationException("No recipe items given"); } @@ -77,6 +180,7 @@ class CraftingTransaction extends InventoryTransaction{ throw new TransactionValidationException("No transaction items given"); } + $iterations = 0; while(count($recipeItems) > 0){ /** @var Item $recipeItem */ $recipeItem = array_pop($recipeItems); @@ -90,7 +194,7 @@ class CraftingTransaction extends InventoryTransaction{ $haveCount = 0; foreach($txItems as $j => $txItem){ - if($txItem->equals($recipeItem, !$wildcards || !$recipeItem->hasAnyDamageValue(), !$wildcards || $recipeItem->hasNamedTag())){ + if($txItem->canStackWith($recipeItem)){ $haveCount += $txItem->getCount(); unset($txItems[$j]); } @@ -115,7 +219,7 @@ class CraftingTransaction extends InventoryTransaction{ if(count($txItems) > 0){ //all items should be destroyed in this process - throw new TransactionValidationException("Expected 0 ingredients left over, have " . count($txItems)); + throw new TransactionValidationException("Expected 0 items left over, have " . count($txItems)); } return $iterations; @@ -133,9 +237,9 @@ class CraftingTransaction extends InventoryTransaction{ 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); + $this->repetitions = $this->matchOutputs($this->outputs, $recipe->getResultsFor($this->source->getCraftingGrid())); //assert that $repetitions x recipe ingredients should be consumed - $this->matchRecipeItems($this->inputs, $recipe->getIngredientList(), true, $this->repetitions); + self::matchIngredients($this->inputs, $recipe->getIngredientList(), $this->repetitions); //Success! $this->recipe = $recipe; diff --git a/src/item/Item.php b/src/item/Item.php index abafc6e6c..3819b1f1e 100644 --- a/src/item/Item.php +++ b/src/item/Item.php @@ -581,7 +581,7 @@ class Item implements \JsonSerializable{ } final public function __toString() : string{ - return "Item " . $this->name . " (" . $this->getId() . ":" . ($this->hasAnyDamageValue() ? "?" : $this->getMeta()) . ")x" . $this->count . ($this->hasNamedTag() ? " tags:0x" . base64_encode((new LittleEndianNbtSerializer())->write(new TreeRoot($this->getNamedTag()))) : ""); + return "Item " . $this->name . " (" . $this->getId() . ":" . $this->getMeta() . ")x" . $this->count . ($this->hasNamedTag() ? " tags:0x" . base64_encode((new LittleEndianNbtSerializer())->write(new TreeRoot($this->getNamedTag()))) : ""); } /** @@ -647,7 +647,7 @@ class Item implements \JsonSerializable{ * @param int $slot optional, the inventory slot of the item */ public function nbtSerialize(int $slot = -1) : CompoundTag{ - return GlobalItemDataHandlers::getSerializer()->serializeStack($this, $slot !== -1 ? $slot : null); + return GlobalItemDataHandlers::getSerializer()->serializeStack($this, $slot !== -1 ? $slot : null)->toNbt(); } /** diff --git a/src/item/ItemFactory.php b/src/item/ItemFactory.php index 6cb668993..432913335 100644 --- a/src/item/ItemFactory.php +++ b/src/item/ItemFactory.php @@ -457,27 +457,30 @@ class ItemFactory{ public function get(int $id, int $meta = 0, int $count = 1, ?CompoundTag $tags = null) : Item{ /** @var Item|null $item */ $item = null; - if($meta !== -1){ - if(isset($this->list[$offset = self::getListOffset($id, $meta)])){ - $item = clone $this->list[$offset]; - }elseif(isset($this->list[$zero = self::getListOffset($id, 0)]) && $this->list[$zero] instanceof Durable){ - if($meta <= $this->list[$zero]->getMaxDurability()){ - $item = clone $this->list[$zero]; - $item->setDamage($meta); - }else{ - $item = new Item(new IID($id, $meta)); - } - }elseif($id < 256){ //intentionally includes negatives, for extended block IDs - //TODO: do not assume that item IDs and block IDs are the same or related - $blockStateData = GlobalBlockStateHandlers::getUpgrader()->upgradeIntIdMeta(self::itemToBlockId($id), $meta & 0xf); - if($blockStateData !== null){ - try{ - $blockStateId = GlobalBlockStateHandlers::getDeserializer()->deserialize($blockStateData); - $item = new ItemBlock(new IID($id, $meta), BlockFactory::getInstance()->fromFullBlock($blockStateId)); - }catch(BlockStateDeserializeException $e){ - \GlobalLogger::get()->logException($e); - //fallthru - } + + if($meta < 0 || $meta > 0x7ffe){ //0x7fff would cause problems with recipe wildcards + throw new \InvalidArgumentException("Meta cannot be negative or larger than " . 0x7ffe); + } + + if(isset($this->list[$offset = self::getListOffset($id, $meta)])){ + $item = clone $this->list[$offset]; + }elseif(isset($this->list[$zero = self::getListOffset($id, 0)]) && $this->list[$zero] instanceof Durable){ + if($meta <= $this->list[$zero]->getMaxDurability()){ + $item = clone $this->list[$zero]; + $item->setDamage($meta); + }else{ + $item = new Item(new IID($id, $meta)); + } + }elseif($id < 256){ //intentionally includes negatives, for extended block IDs + //TODO: do not assume that item IDs and block IDs are the same or related + $blockStateData = GlobalBlockStateHandlers::getUpgrader()->upgradeIntIdMeta(self::itemToBlockId($id), $meta & 0xf); + if($blockStateData !== null){ + try{ + $blockStateId = GlobalBlockStateHandlers::getDeserializer()->deserialize($blockStateData); + $item = new ItemBlock(new IID($id, $meta), BlockFactory::getInstance()->fromFullBlock($blockStateId)); + }catch(BlockStateDeserializeException $e){ + \GlobalLogger::get()->logException($e); + //fallthru } } } diff --git a/src/item/ItemIdentifier.php b/src/item/ItemIdentifier.php index 44cb77420..9681a4b0d 100644 --- a/src/item/ItemIdentifier.php +++ b/src/item/ItemIdentifier.php @@ -31,8 +31,11 @@ class ItemIdentifier{ if($id < -0x8000 || $id > 0x7fff){ //signed short range throw new \InvalidArgumentException("ID must be in range " . -0x8000 . " - " . 0x7fff); } + if($meta < 0 || $meta > 0x7ffe){ + throw new \InvalidArgumentException("Meta must be in range 0 - " . 0x7ffe); + } $this->id = $id; - $this->meta = $meta !== -1 ? $meta & 0x7FFF : -1; + $this->meta = $meta; } public function getId() : int{ diff --git a/src/item/LegacyStringToItemParser.php b/src/item/LegacyStringToItemParser.php index e05f4d031..6fe1f6063 100644 --- a/src/item/LegacyStringToItemParser.php +++ b/src/item/LegacyStringToItemParser.php @@ -104,6 +104,9 @@ final class LegacyStringToItemParser{ $meta = 0; }elseif(is_numeric($b[1])){ $meta = (int) $b[1]; + if($meta < 0 || $meta > 0x7ffe){ + throw new LegacyStringToItemParserException("Meta value $meta is outside the range 0 - " . 0x7ffe); + } }else{ throw new LegacyStringToItemParserException("Unable to parse \"" . $b[1] . "\" from \"" . $input . "\" as a valid meta value"); } diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index a84a6b81c..8c1cd532d 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -25,6 +25,7 @@ namespace pocketmine\network\mcpe\cache; use pocketmine\crafting\CraftingManager; use pocketmine\crafting\FurnaceType; +use pocketmine\crafting\RecipeIngredient; use pocketmine\crafting\ShapelessRecipeType; use pocketmine\item\Item; use pocketmine\item\ItemFactory; @@ -37,7 +38,7 @@ use pocketmine\network\mcpe\protocol\types\recipe\FurnaceRecipe as ProtocolFurna use pocketmine\network\mcpe\protocol\types\recipe\FurnaceRecipeBlockName; use pocketmine\network\mcpe\protocol\types\recipe\PotionContainerChangeRecipe as ProtocolPotionContainerChangeRecipe; use pocketmine\network\mcpe\protocol\types\recipe\PotionTypeRecipe as ProtocolPotionTypeRecipe; -use pocketmine\network\mcpe\protocol\types\recipe\RecipeIngredient; +use pocketmine\network\mcpe\protocol\types\recipe\RecipeIngredient as ProtocolRecipeIngredient; use pocketmine\network\mcpe\protocol\types\recipe\ShapedRecipe as ProtocolShapedRecipe; use pocketmine\network\mcpe\protocol\types\recipe\ShapelessRecipe as ProtocolShapelessRecipe; use pocketmine\timings\Timings; @@ -91,8 +92,8 @@ final class CraftingDataCache{ $recipesWithTypeIds[] = new ProtocolShapelessRecipe( CraftingDataPacket::ENTRY_SHAPELESS, Binary::writeInt(++$counter), - array_map(function(Item $item) use ($converter) : RecipeIngredient{ - return $converter->coreItemStackToRecipeIngredient($item); + array_map(function(RecipeIngredient $item) use ($converter) : ProtocolRecipeIngredient{ + return $converter->coreRecipeIngredientToNet($item); }, $recipe->getIngredientList()), array_map(function(Item $item) use ($converter) : ItemStack{ return $converter->coreItemStackToNet($item); @@ -110,7 +111,7 @@ final class CraftingDataCache{ for($row = 0, $height = $recipe->getHeight(); $row < $height; ++$row){ for($column = 0, $width = $recipe->getWidth(); $column < $width; ++$column){ - $inputs[$row][$column] = $converter->coreItemStackToRecipeIngredient($recipe->getIngredient($column, $row)); + $inputs[$row][$column] = $converter->coreRecipeIngredientToNet($recipe->getIngredient($column, $row)); } } $recipesWithTypeIds[] = $r = new ProtocolShapedRecipe( @@ -136,7 +137,7 @@ final class CraftingDataCache{ default => throw new AssumptionFailedError("Unreachable"), }; foreach($manager->getFurnaceRecipeManager($furnaceType)->getAll() as $recipe){ - $input = $converter->coreItemStackToRecipeIngredient($recipe->getInput()); + $input = $converter->coreRecipeIngredientToNet($recipe->getInput()); $recipesWithTypeIds[] = new ProtocolFurnaceRecipe( CraftingDataPacket::ENTRY_FURNACE_DATA, $input->getId(), diff --git a/src/network/mcpe/convert/TypeConverter.php b/src/network/mcpe/convert/TypeConverter.php index 44a75c20b..8300fa794 100644 --- a/src/network/mcpe/convert/TypeConverter.php +++ b/src/network/mcpe/convert/TypeConverter.php @@ -29,6 +29,9 @@ use pocketmine\block\inventory\EnchantInventory; use pocketmine\block\inventory\LoomInventory; use pocketmine\block\inventory\StonecutterInventory; use pocketmine\block\VanillaBlocks; +use pocketmine\crafting\ExactRecipeIngredient; +use pocketmine\crafting\MetaWildcardRecipeIngredient; +use pocketmine\crafting\RecipeIngredient; use pocketmine\inventory\transaction\action\CreateItemAction; use pocketmine\inventory\transaction\action\DestroyItemAction; use pocketmine\inventory\transaction\action\DropItemAction; @@ -48,11 +51,12 @@ 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\RecipeIngredient; +use pocketmine\network\mcpe\protocol\types\recipe\RecipeIngredient as ProtocolRecipeIngredient; use pocketmine\player\GameMode; use pocketmine\player\Player; use pocketmine\utils\AssumptionFailedError; use pocketmine\utils\SingletonTrait; +use function get_class; class TypeConverter{ use SingletonTrait; @@ -115,39 +119,40 @@ class TypeConverter{ } } - public function coreItemStackToRecipeIngredient(Item $itemStack) : RecipeIngredient{ - if($itemStack->isNull()){ - return new RecipeIngredient(0, 0, 0); + public function coreRecipeIngredientToNet(?RecipeIngredient $ingredient) : ProtocolRecipeIngredient{ + if($ingredient === null){ + return new ProtocolRecipeIngredient(0, 0, 0); } - if($itemStack->hasAnyDamageValue()){ - [$id, ] = ItemTranslator::getInstance()->toNetworkId(ItemFactory::getInstance()->get($itemStack->getId())); + if($ingredient instanceof MetaWildcardRecipeIngredient){ + $id = GlobalItemTypeDictionary::getInstance()->getDictionary()->fromStringId($ingredient->getItemId()); $meta = self::RECIPE_INPUT_WILDCARD_META; - }else{ - [$id, $meta] = ItemTranslator::getInstance()->toNetworkId($itemStack); + }elseif($ingredient instanceof ExactRecipeIngredient){ + $item = $ingredient->getItem(); + [$id, $meta] = ItemTranslator::getInstance()->toNetworkId($item); if($id < 256){ //TODO: this is needed for block crafting recipes to work - we need to replace this with some kind of //blockstate <-> meta mapping table so that we can remove the legacy code from the core - $meta = $itemStack->getMeta(); + $meta = $item->getMeta(); } + }else{ + throw new \LogicException("Unsupported recipe ingredient type " . get_class($ingredient) . ", only " . ExactRecipeIngredient::class . " and " . MetaWildcardRecipeIngredient::class . " are supported"); } - return new RecipeIngredient($id, $meta, $itemStack->getCount()); + return new ProtocolRecipeIngredient($id, $meta, 1); } - public function recipeIngredientToCoreItemStack(RecipeIngredient $ingredient) : Item{ + public function netRecipeIngredientToCore(ProtocolRecipeIngredient $ingredient) : ?RecipeIngredient{ if($ingredient->getId() === 0){ - return VanillaItems::AIR(); + return null; + } + + if($ingredient->getMeta() === self::RECIPE_INPUT_WILDCARD_META){ + $itemId = GlobalItemTypeDictionary::getInstance()->getDictionary()->fromIntId($ingredient->getId()); + return new MetaWildcardRecipeIngredient($itemId); } //TODO: this won't be handled properly for blockitems because a block runtimeID is expected rather than a meta value - - if($ingredient->getMeta() === self::RECIPE_INPUT_WILDCARD_META){ - $idItem = ItemTranslator::getInstance()->fromNetworkId($ingredient->getId(), 0, 0); - $result = ItemFactory::getInstance()->get($idItem->getId(), -1); - }else{ - $result = ItemTranslator::getInstance()->fromNetworkId($ingredient->getId(), $ingredient->getMeta(), 0); - } - $result->setCount($ingredient->getCount()); - return $result; + $result = ItemTranslator::getInstance()->fromNetworkId($ingredient->getId(), $ingredient->getMeta(), 0); + return new ExactRecipeIngredient($result); } public function coreItemStackToNet(Item $itemStack) : ItemStack{ @@ -237,8 +242,8 @@ class TypeConverter{ if($id !== null && ($id < -0x8000 || $id >= 0x7fff)){ throw new TypeConversionException("Item ID must be in range " . -0x8000 . " ... " . 0x7fff . " (received $id)"); } - if($meta < 0 || $meta >= 0x7fff){ //this meta value may have been restored from the NBT - throw new TypeConversionException("Item meta must be in range 0 ... " . 0x7fff . " (received $meta)"); + if($meta < 0 || $meta >= 0x7ffe){ //this meta value may have been restored from the NBT + throw new TypeConversionException("Item meta must be in range 0 ... " . 0x7ffe . " (received $meta)"); } $itemResult = ItemFactory::getInstance()->get($id ?? $itemResult->getId(), $meta); }