diff --git a/src/block/inventory/AnvilInventory.php b/src/block/inventory/AnvilInventory.php index 7d906a6326e..1b6ee210ab1 100644 --- a/src/block/inventory/AnvilInventory.php +++ b/src/block/inventory/AnvilInventory.php @@ -25,6 +25,7 @@ use pocketmine\inventory\SimpleInventory; use pocketmine\inventory\TemporaryInventory; +use pocketmine\item\Item; use pocketmine\world\Position; class AnvilInventory extends SimpleInventory implements BlockInventory, TemporaryInventory{ @@ -37,4 +38,12 @@ public function __construct(Position $holder){ $this->holder = $holder; parent::__construct(2); } + + public function getInput() : Item { + return $this->getItem(self::SLOT_INPUT); + } + + public function getMaterial() : Item { + return $this->getItem(self::SLOT_MATERIAL); + } } diff --git a/src/block/utils/AnvilHelper.php b/src/block/utils/AnvilHelper.php new file mode 100644 index 00000000000..af0dc735c23 --- /dev/null +++ b/src/block/utils/AnvilHelper.php @@ -0,0 +1,65 @@ +getCraftingManager()->matchAnvilRecipe($base, $material); + if($recipe === null){ + return null; + } + $result = $recipe->getResultFor($base, $material); + + if($result !== null){ + $resultItem = $result->getOutput(); + $xpCost = $result->getXpCost(); + if(($customName === null || $customName === "") && $resultItem->hasCustomName()){ + $xpCost++; + $resultItem->clearCustomName(); + }elseif($customName !== null && $resultItem->getCustomName() !== $customName){ + $xpCost++; + $resultItem->setCustomName($customName); + } + + $result = new AnvilCraftResult($xpCost, $resultItem, $result->getSacrificeResult()); + } + + if($result === null || $result->getXpCost() <= 0 || ($result->getXpCost() > self::COST_LIMIT && !$isCreative)){ + return null; + } + + return $result; + } +} diff --git a/src/crafting/AnvilCraftResult.php b/src/crafting/AnvilCraftResult.php new file mode 100644 index 00000000000..24b770b4a66 --- /dev/null +++ b/src/crafting/AnvilCraftResult.php @@ -0,0 +1,71 @@ +xpCost < 0){ + throw new \InvalidArgumentException("XP cost cannot be negative"); + } + if($this->sacrificeResult !== null && $this->sacrificeResult->isNull()){ + $this->sacrificeResult = null; + } + } + + /** + * Represent the amount of experience points required to craft the output item. + */ + public function getXpCost() : int{ + return $this->xpCost; + } + + /** + * Represent the item given as output of the crafting process. + */ + public function getOutput() : Item{ + return $this->output; + } + + /** + * This result has to be null if the sacrifice slot need to be emptied. + * If not null, it represent the item that will be left in the sacrifice slot after the crafting process. + */ + public function getSacrificeResult() : ?Item{ + return $this->sacrificeResult; + } +} diff --git a/src/crafting/AnvilCraftingManagerDataFiller.php b/src/crafting/AnvilCraftingManagerDataFiller.php new file mode 100644 index 00000000000..3035324cbc9 --- /dev/null +++ b/src/crafting/AnvilCraftingManagerDataFiller.php @@ -0,0 +1,87 @@ +registerAnvilRecipe(new MaterialRepairRecipe( + new ArmorRecipeIngredient($armorMaterial), + new ExactRecipeIngredient($item) + )); + if($toolTier !== null){ + $manager->registerAnvilRecipe(new MaterialRepairRecipe( + new TieredToolRecipeIngredient($toolTier), + new ExactRecipeIngredient($item) + )); + } + } + + $manager->registerAnvilRecipe(new ItemSelfCombineRecipe( + new MetaWildcardRecipeIngredient(ItemTypeNames::ENCHANTED_BOOK) + )); + + foreach(VanillaItems::getAll() as $item){ + if($item instanceof Durable){ + $itemId = GlobalItemDataHandlers::getSerializer()->serializeType($item)->getName(); + $manager->registerAnvilRecipe(new ItemSelfCombineRecipe( + new MetaWildcardRecipeIngredient($itemId) + )); + $manager->registerAnvilRecipe(new ItemDifferentCombineRecipe( + new MetaWildcardRecipeIngredient($itemId), + new MetaWildcardRecipeIngredient(ItemTypeNames::ENCHANTED_BOOK) + )); + } + } + + return $manager; + } +} diff --git a/src/crafting/AnvilRecipe.php b/src/crafting/AnvilRecipe.php new file mode 100644 index 00000000000..6da3e90f4b9 --- /dev/null +++ b/src/crafting/AnvilRecipe.php @@ -0,0 +1,30 @@ +material; } + + public function accepts(Item $item) : bool{ + if($item->getCount() < 1){ + return false; + } + + return $item instanceof Armor && $item->getMaterial() === $this->material; + } + + public function __toString() : string{ + return "ArmorRecipeIngredient(ArmorMaterial@" . spl_object_id($this->material) . ")"; + } +} diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 673095c6ed2..93c85ec0390 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -81,6 +81,18 @@ class CraftingManager{ */ private array $brewingRecipeCache = []; + /** + * @var AnvilRecipe[] + * @phpstan-var list + */ + private array $anvilRecipes = []; + + /** + * @var AnvilRecipe[][] + * @phpstan-var array> + */ + private array $anvilRecipeCache = []; + /** @phpstan-var ObjectSet<\Closure() : void> */ private ObjectSet $recipeRegisteredCallbacks; @@ -190,6 +202,14 @@ public function getPotionContainerChangeRecipes() : array{ return $this->potionContainerChangeRecipes; } + /** + * @return AnvilRecipe[][] + * @phpstan-return list + */ + public function getAnvilRecipes() : array{ + return $this->anvilRecipes; + } + public function registerShapedRecipe(ShapedRecipe $recipe) : void{ $this->shapedRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; $this->craftingRecipeIndex[] = $recipe; @@ -224,6 +244,14 @@ public function registerPotionContainerChangeRecipe(PotionContainerChangeRecipe } } + public function registerAnvilRecipe(AnvilRecipe $recipe) : void{ + $this->anvilRecipes[] = $recipe; + + foreach($this->recipeRegisteredCallbacks as $callback){ + $callback(); + } + } + /** * @param Item[] $outputs */ @@ -297,4 +325,21 @@ public function matchBrewingRecipe(Item $input, Item $ingredient) : ?BrewingReci return null; } + + public function matchAnvilRecipe(Item $input, Item $material) : ?AnvilRecipe{ + $inputHash = $input->getStateId(); + $materialHash = $material->getStateId(); + $cached = $this->anvilRecipeCache[$inputHash][$materialHash] ?? null; + if($cached !== null){ + return $cached; + } + + foreach($this->anvilRecipes as $recipe){ + if($recipe->getResultFor($input, $material) !== null){ + return $this->anvilRecipeCache[$inputHash][$materialHash] = $recipe; + } + } + + return null; + } } diff --git a/src/crafting/CraftingManagerFromDataHelper.php b/src/crafting/CraftingManagerFromDataHelper.php index 7c9cdd58b4d..41f599fdf2d 100644 --- a/src/crafting/CraftingManagerFromDataHelper.php +++ b/src/crafting/CraftingManagerFromDataHelper.php @@ -185,6 +185,7 @@ private static function loadJsonObjectIntoModel(\JsonMapper $mapper, string $mod /** * @param mixed[] $data + * * @return object[] * * @phpstan-template TRecipeData of object @@ -333,6 +334,8 @@ public static function make(string $directoryPath) : CraftingManager{ )); } + $result = AnvilCraftingManagerDataFiller::fillData($result); + //TODO: smithing return $result; diff --git a/src/crafting/ItemCombineRecipe.php b/src/crafting/ItemCombineRecipe.php new file mode 100644 index 00000000000..11248626655 --- /dev/null +++ b/src/crafting/ItemCombineRecipe.php @@ -0,0 +1,123 @@ +validate($input, $material)){ + $result = (clone $input); + $xpCost = 0; + if($result instanceof Durable && $material instanceof Durable && $this->repair($result, $material)){ + // The two items are compatible for repair + $xpCost = 2; + } + + // combining enchantments + foreach($material->getEnchantments() as $instance){ + $enchantment = $instance->getType(); + $level = $instance->getLevel(); + if(!AvailableEnchantmentRegistry::getInstance()->isAvailableForItem($enchantment, $input)){ + continue; + } + if(($targetEnchantment = $input->getEnchantment($enchantment)) !== null){ + // Enchant already present on the target item + $targetLevel = $targetEnchantment->getLevel(); + $newLevel = ($targetLevel === $level ? $targetLevel + 1 : max($targetLevel, $level)); + $level = min($newLevel, $enchantment->getMaxLevel()); + $instance = new EnchantmentInstance($enchantment, $level); + }else{ + // Check if the enchantment is compatible with the existing enchantments + foreach($input->getEnchantments() as $testedInstance){ + $testedEnchantment = $testedInstance->getType(); + if(!$testedEnchantment->isCompatibleWith($enchantment)){ + $xpCost++; + //TODO: XP COST + continue 2; + } + } + } + + $costAddition = match ($enchantment->getRarity()) { + Rarity::COMMON => 1, + Rarity::UNCOMMON => 2, + Rarity::RARE => 4, + Rarity::MYTHIC => 8, + default => throw new TransactionValidationException("Invalid rarity " . $enchantment->getRarity() . " found") + }; + + if($material instanceof EnchantedBook){ + // Enchanted books are half as expensive to combine + $costAddition = max(1, $costAddition / 2); + } + $levelDifference = $instance->getLevel() - $input->getEnchantmentLevel($instance->getType()); + $xpCost += (int) floor($costAddition * $levelDifference); + $result->addEnchantment($instance); + + $xpCost += (2 ** $input->getAnvilRepairCost()) - 1; + $xpCost += (2 ** $material->getAnvilRepairCost()) - 1; + $result->setAnvilRepairCost( + max($result->getAnvilRepairCost(), $material->getAnvilRepairCost()) + 1 + ); + } + + if($xpCost !== 0){ + return new AnvilCraftResult($xpCost, $result, null); + } + } + + return null; + } + + private function repair(Durable $result, Durable $material) : bool{ + $damage = $result->getDamage(); + if($damage === 0){ + return false; + } + + $baseMaxDurability = $result->getMaxDurability(); + $baseDurability = $baseMaxDurability - $damage; + $materialDurability = $material->getMaxDurability() - $material->getDamage(); + $addDurability = (int) ($baseMaxDurability * 12 / 100); + + $result->setDamage($baseMaxDurability - min( + $baseMaxDurability, + $baseDurability + $materialDurability + $addDurability + )); + + return true; + } +} diff --git a/src/crafting/ItemDifferentCombineRecipe.php b/src/crafting/ItemDifferentCombineRecipe.php new file mode 100644 index 00000000000..acd7f39c3a2 --- /dev/null +++ b/src/crafting/ItemDifferentCombineRecipe.php @@ -0,0 +1,41 @@ +base->accepts($input) && $this->material->accepts($material); + } +} diff --git a/src/crafting/ItemSelfCombineRecipe.php b/src/crafting/ItemSelfCombineRecipe.php new file mode 100644 index 00000000000..a5f0a8335b2 --- /dev/null +++ b/src/crafting/ItemSelfCombineRecipe.php @@ -0,0 +1,44 @@ +target->accepts($input) && $this->target->accepts($material); + } +} diff --git a/src/crafting/MaterialRepairRecipe.php b/src/crafting/MaterialRepairRecipe.php new file mode 100644 index 00000000000..66f332fb545 --- /dev/null +++ b/src/crafting/MaterialRepairRecipe.php @@ -0,0 +1,69 @@ +input; + } + + public function getMaterial() : RecipeIngredient{ + return $this->material; + } + + public function getResultFor(Item $input, Item $material) : ?AnvilCraftResult{ + if($this->input->accepts($input) && $this->material->accepts($material) && $input instanceof Durable){ + $damage = $input->getDamage(); + if($damage !== 0){ + $quarter = min($damage, (int) floor($input->getMaxDurability() / 4)); + $numberRepair = min($material->getCount(), (int) ceil($damage / $quarter)); + $damage -= $quarter * $numberRepair; + + return new AnvilCraftResult( + $numberRepair, + (clone $input)->setDamage(max(0, $damage)), + (clone $material)->setCount($material->getCount() - $numberRepair) + ); + } + } + + return null; + } +} diff --git a/src/crafting/TieredToolRecipeIngredient.php b/src/crafting/TieredToolRecipeIngredient.php new file mode 100644 index 00000000000..0b187b106f2 --- /dev/null +++ b/src/crafting/TieredToolRecipeIngredient.php @@ -0,0 +1,49 @@ +tier; } + + public function accepts(Item $item) : bool{ + if($item->getCount() < 1){ + return false; + } + + return $item instanceof TieredTool && $item->getTier() === $this->tier; + } + + public function __toString() : string{ + return "TieredToolRecipeIngredient(" . $this->tier->name . ")"; + } +} diff --git a/src/event/block/AnvilUseEvent.php b/src/event/block/AnvilUseEvent.php new file mode 100644 index 00000000000..5756c694159 --- /dev/null +++ b/src/event/block/AnvilUseEvent.php @@ -0,0 +1,30 @@ +anvil); + } + + public function getAnvil() : Anvil{ + return $this->anvil; + } + + public function shouldTakeDamage() : bool{ + return $this->takeDamage; + } + + public function setTakeDamage(bool $takeDamage) : void{ + $this->takeDamage = $takeDamage; + } +} \ No newline at end of file diff --git a/src/event/player/PlayerUseAnvilEvent.php b/src/event/player/PlayerUseAnvilEvent.php new file mode 100644 index 00000000000..4a7c4ca8dfc --- /dev/null +++ b/src/event/player/PlayerUseAnvilEvent.php @@ -0,0 +1,86 @@ +player = $player; + } + + /** + * Returns the item that the player is using as the base item (left slot). + */ + public function getBaseItem() : Item{ + return $this->baseItem; + } + + /** + * Returns the item that the player is using as the material item (right slot), or null if there is no material item + * (e.g. when renaming an item). + */ + public function getMaterialItem() : ?Item{ + return $this->materialItem; + } + + /** + * Returns the item that the player will receive as a result of the anvil operation. + */ + public function getResultItem() : Item{ + return $this->resultItem; + } + + /** + * Returns the custom name that the player is setting on the item, or null if the player is not renaming the item. + * + * This value is defined when the base item is already renamed. + */ + public function getCustomName() : ?string{ + return $this->customName; + } + + /** + * Returns the amount of XP levels that the player will spend on this anvil operation. + */ + public function getXpCost() : int{ + return $this->xpCost; + } +} diff --git a/src/inventory/transaction/AnvilTransaction.php b/src/inventory/transaction/AnvilTransaction.php new file mode 100644 index 00000000000..d793bc909ff --- /dev/null +++ b/src/inventory/transaction/AnvilTransaction.php @@ -0,0 +1,160 @@ +expectedResult->getXpCost(); + if($xpSpent !== $expectedXpCost){ + throw new TransactionValidationException("Expected the amount of xp spent to be $expectedXpCost, but received $xpSpent"); + } + + $xpLevel = $this->source->getXpManager()->getXpLevel(); + if($xpLevel < $expectedXpCost){ + throw new TransactionValidationException("Player's XP level $xpLevel is less than the required XP level $expectedXpCost"); + } + } + + private function validateInputs(Item $base, Item $material, Item $expectedOutput) : ?int{ + $calculAttempt = AnvilHelper::calculateResult($base, $material, $this->customName, $this->source->isCreative()); + if($calculAttempt === null){ + return null; + } + $result = $calculAttempt->getOutput(); + if(!$result->equalsExact($expectedOutput)){ + return null; + } + + $this->baseItem = $base; + $this->materialItem = $material; + + return $calculAttempt->getXpCost(); + } + + public function validate() : void{ + if(count($this->actions) < 1){ + throw new TransactionValidationException("Transaction must have at least one action to be executable"); + } + + /** @var Item[] $inputs */ + $inputs = []; + /** @var Item[] $outputs */ + $outputs = []; + $this->matchItems($outputs, $inputs); + + if(($outputCount = count($outputs)) !== 1){ + throw new TransactionValidationException("Expected 1 output item, but received $outputCount"); + } + $outputItem = $outputs[0]; + + if(($inputCount = count($inputs)) < 1){ + throw new TransactionValidationException("Expected at least 1 input item, but received $inputCount"); + } + if($inputCount > 2){ + throw new TransactionValidationException("Expected at most 2 input items, but received $inputCount"); + } + + if(count($inputs) < 2){ + $xpCost = $this->validateInputs($inputs[0], VanillaItems::AIR(), $outputItem) ?? + throw new TransactionValidationException("Inputs do not match expected result"); + }else{ + $xpCost = $this->validateInputs($inputs[0], $inputs[1], $outputItem) ?? + $this->validateInputs($inputs[1], $inputs[0], $outputItem) ?? + throw new TransactionValidationException("Inputs do not match expected result"); + } + + if($this->source->hasFiniteResources()){ + $this->validateFiniteResources($xpCost); + } + } + + public function execute() : void{ + parent::execute(); + + if($this->source->hasFiniteResources()){ + $this->source->getXpManager()->subtractXpLevels($this->expectedResult->getXpCost()); + } + + $inventory = $this->source->getCurrentWindow(); + if($inventory instanceof AnvilInventory){ + $world = $inventory->getHolder()->getWorld(); + $anvilBlock = $world->getBlock($inventory->getHolder()); + $event = new AnvilUseEvent($anvilBlock, mt_rand(0, 12) === 0); + $event->call(); + if(!$event->isCancelled()){ + if($event->shouldTakeDamage()){ + if($anvilBlock instanceof Anvil){ + $newDamage = $anvilBlock->getDamage() + 1; + if($newDamage > Anvil::VERY_DAMAGED){ + $newBlock = VanillaBlocks::AIR(); + $world->addSound($inventory->getHolder(), new AnvilBreakSound()); + }else{ + $newBlock = $anvilBlock->setDamage($newDamage); + } + $world->setBlock($inventory->getHolder(), $newBlock); + } + + } + $world->addSound($inventory->getHolder(), new AnvilUseSound()); + } + } + } + + protected function callExecuteEvent() : bool{ + if($this->baseItem === null){ + throw new AssumptionFailedError("Expected that baseItem is not null before executing the event"); + } + + $ev = new PlayerUseAnvilEvent($this->source, $this->baseItem, $this->materialItem, $this->expectedResult->getOutput(), $this->customName, $this->expectedResult->getXpCost()); + $ev->call(); + return !$ev->isCancelled(); + } +} diff --git a/src/item/Item.php b/src/item/Item.php index 6f7e010c944..97e2fa59d9a 100644 --- a/src/item/Item.php +++ b/src/item/Item.php @@ -70,6 +70,7 @@ class Item implements \JsonSerializable{ public const TAG_DISPLAY_NAME = "Name"; public const TAG_DISPLAY_LORE = "Lore"; + public const TAG_REPAIR_COST = "RepairCost"; public const TAG_KEEP_ON_DEATH = "minecraft:keep_on_death"; @@ -85,6 +86,7 @@ class Item implements \JsonSerializable{ protected string $customName = ""; /** @var string[] */ protected array $lore = []; + protected int $anvilRepairCost = 0; /** TODO: this needs to die in a fire */ protected ?CompoundTag $blockEntityTag = null; @@ -283,6 +285,30 @@ public function clearNamedTag() : Item{ return $this; } + /** + * Returns the anvil repair cost of the item. + * This value is used in anvil to determine the XP cost of repairing the item. + * + * In vanilla, this value is stored in the "RepairCost" tag. + */ + public function getAnvilRepairCost() : int{ + return $this->anvilRepairCost; + } + + /** + * Sets the anvil repair cost value of the item. + * This value is used in anvil to determine the XP cost of repairing the item. + * Higher cost means more XP is required to repair the item. + * + * In vanilla, this value is stored in the "RepairCost" tag. + * + * @return $this + */ + public function setAnvilRepairCost(int $cost) : self{ + $this->anvilRepairCost = $cost; + return $this; + } + /** * @throws NbtException */ @@ -335,6 +361,7 @@ protected function deserializeCompoundTag(CompoundTag $tag) : void{ } $this->keepOnDeath = $tag->getByte(self::TAG_KEEP_ON_DEATH, 0) !== 0; + $this->anvilRepairCost = $tag->getInt(self::TAG_REPAIR_COST, 0); } protected function serializeCompoundTag(CompoundTag $tag) : void{ @@ -403,6 +430,12 @@ protected function serializeCompoundTag(CompoundTag $tag) : void{ }else{ $tag->removeTag(self::TAG_KEEP_ON_DEATH); } + + if($this->anvilRepairCost > 0){ + $tag->setInt(self::TAG_REPAIR_COST, $this->anvilRepairCost); + }else{ + $tag->removeTag(self::TAG_REPAIR_COST); + } } public function getCount() : int{ diff --git a/src/network/mcpe/handler/ItemStackRequestExecutor.php b/src/network/mcpe/handler/ItemStackRequestExecutor.php index 4eddf3100ae..ee261dc14b5 100644 --- a/src/network/mcpe/handler/ItemStackRequestExecutor.php +++ b/src/network/mcpe/handler/ItemStackRequestExecutor.php @@ -23,11 +23,14 @@ namespace pocketmine\network\mcpe\handler; +use pocketmine\block\inventory\AnvilInventory; use pocketmine\block\inventory\EnchantInventory; +use pocketmine\block\utils\AnvilHelper; use pocketmine\inventory\Inventory; use pocketmine\inventory\transaction\action\CreateItemAction; use pocketmine\inventory\transaction\action\DestroyItemAction; use pocketmine\inventory\transaction\action\DropItemAction; +use pocketmine\inventory\transaction\AnvilTransaction; use pocketmine\inventory\transaction\CraftingTransaction; use pocketmine\inventory\transaction\EnchantingTransaction; use pocketmine\inventory\transaction\InventoryTransaction; @@ -42,6 +45,7 @@ use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CraftingConsumeInputStackRequestAction; use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CraftingCreateSpecificResultStackRequestAction; use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CraftRecipeAutoStackRequestAction; +use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CraftRecipeOptionalStackRequestAction; use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CraftRecipeStackRequestAction; use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\CreativeCreateStackRequestAction; use pocketmine\network\mcpe\protocol\types\inventory\stackrequest\DeprecatedCraftingResultsStackRequestAction; @@ -295,7 +299,7 @@ protected function takeCreatedItem(int $count) : Item{ * @throws ItemStackRequestProcessException */ private function assertDoingCrafting() : void{ - if(!$this->specialTransaction instanceof CraftingTransaction && !$this->specialTransaction instanceof EnchantingTransaction){ + if(!$this->specialTransaction instanceof CraftingTransaction && !$this->specialTransaction instanceof EnchantingTransaction && !$this->specialTransaction instanceof AnvilTransaction){ if($this->specialTransaction === null){ throw new ItemStackRequestProcessException("Expected CraftRecipe or CraftRecipeAuto action to precede this action"); }else{ @@ -353,6 +357,15 @@ protected function processItemStackRequestAction(ItemStackRequestAction $action) } }elseif($action instanceof CraftRecipeAutoStackRequestAction){ $this->beginCrafting($action->getRecipeId(), $action->getRepetitions()); + }elseif($action instanceof CraftRecipeOptionalStackRequestAction){ + $window = $this->player->getCurrentWindow(); + if($window instanceof AnvilInventory){ + $result = AnvilHelper::calculateResult($window->getInput(), $window->getMaterial(), $this->request->getFilterStrings()[0] ?? null, $this->player->isCreative()); + if($result !== null){ + $this->specialTransaction = new AnvilTransaction($this->player, $result, $this->request->getFilterStrings()[0] ?? null); + $this->setNextCreatedItem($result->getOutput()); + } + } }elseif($action instanceof CraftingConsumeInputStackRequestAction){ $this->assertDoingCrafting(); $this->removeItemFromSlot($action->getSource(), $action->getCount()); //output discarded - we allow CraftingTransaction to verify the balance diff --git a/tests/phpunit/crafting/AnvilCraftTest.php b/tests/phpunit/crafting/AnvilCraftTest.php new file mode 100644 index 00000000000..5d5379b932b --- /dev/null +++ b/tests/phpunit/crafting/AnvilCraftTest.php @@ -0,0 +1,194 @@ + [ + VanillaItems::DIAMOND_PICKAXE(), + VanillaItems::DIAMOND(), + null + ]; + + yield "Repair one damage" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage(1), + VanillaItems::DIAMOND(), + new AnvilCraftResult(1, VanillaItems::DIAMOND_PICKAXE(), null) + ]; + + yield "Repair one damage with more materials than expected" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage(1), + VanillaItems::DIAMOND()->setCount(2), + new AnvilCraftResult(1, VanillaItems::DIAMOND_PICKAXE(), VanillaItems::DIAMOND()) + ]; + + $diamondPickaxeQuarter = (int) floor(VanillaItems::DIAMOND_PICKAXE()->getMaxDurability() / 4); + yield "Repair one quarter" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage($diamondPickaxeQuarter), + VanillaItems::DIAMOND()->setCount(1), + new AnvilCraftResult(1, VanillaItems::DIAMOND_PICKAXE(), null) + ]; + + yield "Repair one quarter plus 1" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage($diamondPickaxeQuarter + 1), + VanillaItems::DIAMOND()->setCount(1), + new AnvilCraftResult(1, VanillaItems::DIAMOND_PICKAXE()->setDamage(1), null) + ]; + + yield "Repair more than one quarter" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage($diamondPickaxeQuarter * 2), + VanillaItems::DIAMOND()->setCount(2), + new AnvilCraftResult(2, VanillaItems::DIAMOND_PICKAXE(), null) + ]; + + yield "Repair more than one quarter with more materials than expected" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage($diamondPickaxeQuarter * 2), + VanillaItems::DIAMOND()->setCount(3), + new AnvilCraftResult(2, VanillaItems::DIAMOND_PICKAXE(), VanillaItems::DIAMOND()->setCount(1)) + ]; + } + + /** + * @dataProvider materialRepairRecipeProvider + */ + public function testMaterialRepairRecipe(Item $base, Item $material, ?AnvilCraftResult $expected) : void{ + $recipe = new MaterialRepairRecipe( + new WildcardRecipeIngredient(), + new WildcardRecipeIngredient() + ); + self::assertAnvilCraftResultEquals($expected, $recipe->getResultFor($base, $material)); + } + + public static function itemSelfCombineRecipeProvider() : Generator{ + yield "Combine two identical items without damage and enchants" => [ + VanillaItems::DIAMOND_PICKAXE(), + VanillaItems::DIAMOND_PICKAXE(), + null + ]; + + yield "Enchant on base item and no enchants on material with no damage" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + VanillaItems::DIAMOND_PICKAXE(), + null + ]; + + yield "No enchant on base item and one enchant on material" => [ + VanillaItems::DIAMOND_PICKAXE(), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + new AnvilCraftResult(2, VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), null) + ]; + + yield "Combine two identical items with damage" => [ + VanillaItems::DIAMOND_PICKAXE()->setDamage(10), + VanillaItems::DIAMOND_PICKAXE(), + new AnvilCraftResult(1, VanillaItems::DIAMOND_PICKAXE()->setDamage(0), null) + ]; + + yield "Combine two identical items with damage for material" => [ + VanillaItems::DIAMOND_PICKAXE(), + VanillaItems::DIAMOND_PICKAXE()->setDamage(10), + null + ]; + + yield "Combine two identical items with different enchantments" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2)), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + new AnvilCraftResult(2, VanillaItems::DIAMOND_PICKAXE() + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2)) + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + null) + ]; + + yield "Combine two identical items with different enchantments with damage" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2))->setDamage(10), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + new AnvilCraftResult(4, VanillaItems::DIAMOND_PICKAXE() + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2)) + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + null) + ]; + + yield "Combine two identical items with different enchantments with damage for material" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2)), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1))->setDamage(10), + new AnvilCraftResult(2, VanillaItems::DIAMOND_PICKAXE() + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::EFFICIENCY(), 2)) + ->addEnchantment(new EnchantmentInstance(VanillaEnchantments::UNBREAKING(), 1)), + null) + ]; + + yield "Combine two identical items with same enchantment level" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1)), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1)), + new AnvilCraftResult(8, VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 2)), null) + ]; + + yield "Combine two identical items with same enchantment level and damage" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1))->setDamage(10), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1)), + new AnvilCraftResult(10, VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 2))->setDamage(0), null) + ]; + + yield "Combine two identical items with same enchantment level and damage for material" => [ + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1)), + VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 1))->setDamage(10), + new AnvilCraftResult(8, VanillaItems::DIAMOND_PICKAXE()->addEnchantment(new EnchantmentInstance(VanillaEnchantments::FORTUNE(), 2)), null) + ]; + } + + /** + * @dataProvider itemSelfCombineRecipeProvider + */ + public function testItemSelfCombineRecipe(Item $base, Item $combined, ?AnvilCraftResult $expected) : void{ + $recipe = new ItemSelfCombineRecipe(new WildcardRecipeIngredient()); + self::assertAnvilCraftResultEquals($expected, $recipe->getResultFor($base, $combined)); + } + + private static function assertAnvilCraftResultEquals(?AnvilCraftResult $expected, ?AnvilCraftResult $actual) : void{ + if($expected === null){ + self::assertNull($actual, "Recipe did not match expected result"); + return; + }else{ + self::assertNotNull($actual, "Recipe did not match expected result"); + } + self::assertEquals($expected->getXpCost(), $actual->getXpCost(), "XP cost did not match expected result"); + self::assertTrue($expected->getOutput()->equalsExact($actual->getOutput()), "Recipe output did not match expected result"); + $sacrificeResult = $expected->getSacrificeResult(); + if($sacrificeResult !== null){ + $resultExpected = $actual->getSacrificeResult(); + self::assertNotNull($resultExpected, "Recipe sacrifice result did not match expected result"); + self::assertTrue($sacrificeResult->equalsExact($resultExpected), "Recipe sacrifice result did not match expected result"); + }else{ + self::assertNull($actual->getSacrificeResult(), "Recipe sacrifice result did not match expected result"); + } + } +} diff --git a/tests/phpunit/crafting/WildcardRecipeIngredient.php b/tests/phpunit/crafting/WildcardRecipeIngredient.php new file mode 100644 index 00000000000..32642402ebe --- /dev/null +++ b/tests/phpunit/crafting/WildcardRecipeIngredient.php @@ -0,0 +1,37 @@ +