diff --git a/src/crafting/CraftingManager.php b/src/crafting/CraftingManager.php index 5f78161e0f9..f09c529c483 100644 --- a/src/crafting/CraftingManager.php +++ b/src/crafting/CraftingManager.php @@ -31,6 +31,7 @@ use pocketmine\utils\DestructorCallbackTrait; use pocketmine\utils\ObjectSet; use function array_shift; +use function array_search; use function count; use function implode; use function ksort; @@ -84,25 +85,39 @@ class CraftingManager{ /** @phpstan-var ObjectSet<\Closure() : void> */ private ObjectSet $recipeRegisteredCallbacks; + /** @phpstan-var ObjectSet<\Closure() : void> */ + private ObjectSet $recipeUnregisteredCallbacks; + public function __construct(){ $this->recipeRegisteredCallbacks = new ObjectSet(); + $this->recipeUnregisteredCallbacks = new ObjectSet(); + foreach(FurnaceType::cases() as $furnaceType){ $this->furnaceRecipeManagers[spl_object_id($furnaceType)] = new FurnaceRecipeManager(); } $recipeRegisteredCallbacks = $this->recipeRegisteredCallbacks; + $recipeUnregisteredCallbacks = $this->recipeUnregisteredCallbacks; foreach($this->furnaceRecipeManagers as $furnaceRecipeManager){ $furnaceRecipeManager->getRecipeRegisteredCallbacks()->add(static function(FurnaceRecipe $recipe) use ($recipeRegisteredCallbacks) : void{ foreach($recipeRegisteredCallbacks as $callback){ $callback(); } }); + $furnaceRecipeManager->getRecipeUnregisteredCallbacks()->add(static function(FurnaceRecipe $recipe) use ($recipeUnregisteredCallbacks) : void{ + foreach($recipeUnregisteredCallbacks as $callback){ + $callback(); + } + }); } } /** @phpstan-return ObjectSet<\Closure() : void> */ public function getRecipeRegisteredCallbacks() : ObjectSet{ return $this->recipeRegisteredCallbacks; } + /** @phpstan-return ObjectSet<\Closure() : void> */ + public function getRecipeUnregisteredCallbacks() : ObjectSet{ return $this->recipeUnregisteredCallbacks; } + private static function hashOutput(Item $output) : string{ $write = new ByteBufferWriter(); VarInt::writeSignedInt($write, $output->getStateId()); @@ -188,6 +203,34 @@ public function registerShapedRecipe(ShapedRecipe $recipe) : void{ } } + public function unregisterShapedRecipe(ShapedRecipe $recipe) : void{ + $changed = false; + $hash = self::hashOutputs($recipe->getResults()); + + foreach($this->shapedRecipes[$hash] ?? [] as $i => $r){ + if($r === $recipe){ + array_splice($this->shapedRecipes[$hash], $i, 1); + if(count($this->shapedRecipes[$hash]) === 0){ + unset($this->shapedRecipes[$hash]); + $changed = true; + } + break; + } + } + + $index = array_search($recipe, $this->craftingRecipeIndex, true); + if($index !== false){ + unset($this->craftingRecipeIndex[$index]); + $changed = true; + } + + if($changed){ + foreach($this->recipeUnregisteredCallbacks as $callback){ + $callback(); + } + } + } + public function registerShapelessRecipe(ShapelessRecipe $recipe) : void{ $this->shapelessRecipes[self::hashOutputs($recipe->getResults())][] = $recipe; $this->craftingRecipeIndex[] = $recipe; @@ -197,6 +240,35 @@ public function registerShapelessRecipe(ShapelessRecipe $recipe) : void{ } } + public function unregisterShapelessRecipe(ShapelessRecipe $recipe) : void{ + $changed = false; + $hash = self::hashOutputs($recipe->getResults()); + + foreach($this->shapelessRecipes[$hash] ?? [] as $i => $r){ + if($r->isEquivalent($recipe)){ + array_splice($this->shapelessRecipes[$hash], $i, 1); + if(count($this->shapelessRecipes[$hash]) === 0){ + unset($this->shapelessRecipes[$hash]); + $changed = true; + } + // We don't break as it can have many similar recipes ? + } + } + + foreach($this->craftingRecipeIndex as $index => $testRecipe){ + if($testRecipe instanceof ShapelessRecipe && $recipe->isEquivalent($testRecipe)){ + unset($this->craftingRecipeIndex[$index]); + $changed = true; + } + } + + if($changed){ + foreach($this->recipeUnregisteredCallbacks as $callback){ + $callback(); + } + } + } + public function registerPotionTypeRecipe(PotionTypeRecipe $recipe) : void{ $this->potionTypeRecipes[] = $recipe; @@ -205,6 +277,17 @@ public function registerPotionTypeRecipe(PotionTypeRecipe $recipe) : void{ } } + public function unregisterPotionTypeRecipe(PotionTypeRecipe $recipe) : void{ + $recipeIndex = array_search($recipe, $this->potionTypeRecipes, true); + if($recipeIndex !== false){ + array_splice($this->potionTypeRecipes, $recipeIndex, 1); + + foreach($this->recipeUnregisteredCallbacks as $callback){ + $callback(); + } + } + } + public function registerPotionContainerChangeRecipe(PotionContainerChangeRecipe $recipe) : void{ $this->potionContainerChangeRecipes[] = $recipe; @@ -213,6 +296,17 @@ public function registerPotionContainerChangeRecipe(PotionContainerChangeRecipe } } + public function unregisterPotionContainerChangeRecipe(PotionContainerChangeRecipe $recipe) : void{ + $recipeIndex = array_search($recipe, $this->potionContainerChangeRecipes, true); + if($recipeIndex !== false){ + array_splice($this->potionContainerChangeRecipes, $recipeIndex, 1); + + foreach($this->recipeUnregisteredCallbacks as $callback){ + $callback(); + } + } + } + /** * @param Item[] $outputs */ diff --git a/src/crafting/ExactRecipeIngredient.php b/src/crafting/ExactRecipeIngredient.php index 76543b0acf5..bf3ef3b6085 100644 --- a/src/crafting/ExactRecipeIngredient.php +++ b/src/crafting/ExactRecipeIngredient.php @@ -53,4 +53,8 @@ public function accepts(Item $item) : bool{ public function __toString() : string{ return "ExactRecipeIngredient(" . $this->item . ")"; } + + public function isEquivalent(RecipeIngredient $other) : bool{ + return $other instanceof ExactRecipeIngredient && $this->item->equals($other->item, true, $this->item->hasNamedTag()); + } } diff --git a/src/crafting/FurnaceRecipeManager.php b/src/crafting/FurnaceRecipeManager.php index d13465b44a7..72fbf252632 100644 --- a/src/crafting/FurnaceRecipeManager.php +++ b/src/crafting/FurnaceRecipeManager.php @@ -25,6 +25,7 @@ use pocketmine\item\Item; use pocketmine\utils\ObjectSet; +use function array_search; final class FurnaceRecipeManager{ /** @var FurnaceRecipe[] */ @@ -39,8 +40,12 @@ final class FurnaceRecipeManager{ /** @phpstan-var ObjectSet<\Closure(FurnaceRecipe) : void> */ private ObjectSet $recipeRegisteredCallbacks; + /** @phpstan-var ObjectSet<\Closure(FurnaceRecipe) : void> */ + private ObjectSet $recipeUnregisteredCallbacks; + public function __construct(){ $this->recipeRegisteredCallbacks = new ObjectSet(); + $this->recipeUnregisteredCallbacks = new ObjectSet(); } /** @@ -50,6 +55,13 @@ public function getRecipeRegisteredCallbacks() : ObjectSet{ return $this->recipeRegisteredCallbacks; } + /** + * @phpstan-return ObjectSet<\Closure(FurnaceRecipe) : void> + */ + public function getRecipeUnregisteredCallbacks() : ObjectSet{ + return $this->recipeUnregisteredCallbacks; + } + /** * @return FurnaceRecipe[] */ @@ -64,6 +76,17 @@ public function register(FurnaceRecipe $recipe) : void{ } } + public function unregister(FurnaceRecipe $recipe) : void{ + $index = array_search($recipe, $this->furnaceRecipes, true); + if($index !== false){ + unset($this->furnaceRecipes[$index]); + + foreach($this->recipeUnregisteredCallbacks as $callback){ + $callback($recipe); + } + } + } + public function match(Item $input) : ?FurnaceRecipe{ $index = $input->getStateId(); $simpleRecipe = $this->lookupCache[$index] ?? null; diff --git a/src/crafting/MetaWildcardRecipeIngredient.php b/src/crafting/MetaWildcardRecipeIngredient.php index 8aa6a1f1779..666956ba31c 100644 --- a/src/crafting/MetaWildcardRecipeIngredient.php +++ b/src/crafting/MetaWildcardRecipeIngredient.php @@ -54,4 +54,8 @@ public function accepts(Item $item) : bool{ public function __toString() : string{ return "MetaWildcardRecipeIngredient($this->itemId)"; } + + public function isEquivalent(RecipeIngredient $other) : bool{ + return $other instanceof MetaWildcardRecipeIngredient && $other->itemId === $this->itemId; + } } diff --git a/src/crafting/RecipeIngredient.php b/src/crafting/RecipeIngredient.php index 19a7b6085f6..5831a7dc40c 100644 --- a/src/crafting/RecipeIngredient.php +++ b/src/crafting/RecipeIngredient.php @@ -28,4 +28,9 @@ interface RecipeIngredient extends \Stringable{ public function accepts(Item $item) : bool; + + /** + * Check if the given ingredient is equivalent to this one. + */ + public function isEquivalent(RecipeIngredient $other) : bool; } diff --git a/src/crafting/ShapelessRecipe.php b/src/crafting/ShapelessRecipe.php index b139439ef71..3f52e8bdc14 100644 --- a/src/crafting/ShapelessRecipe.php +++ b/src/crafting/ShapelessRecipe.php @@ -100,4 +100,29 @@ public function matchesCraftingGrid(CraftingGrid $grid) : bool{ return count($input) === 0; //crafting grid should be empty apart from the given ingredient stacks } + + public function isEquivalent(ShapelessRecipe $recipe) : bool{ + if($this->getType() !== $recipe->getType()){ + return false; + } + + if(!Utils::areUnorderedArraysEqual( + $this->getResults(), + $recipe->getResults(), + static fn(Item $a, Item $b) : bool => $a->equalsExact($b) + )){ + return false; + } + + + if(!Utils::areUnorderedArraysEqual( + $this->getIngredientList(), + $recipe->getIngredientList(), + static fn(RecipeIngredient $a, RecipeIngredient $b) : bool => $a->isEquivalent($b) + )){ + return false; + } + + return true; + } } diff --git a/src/crafting/TagWildcardRecipeIngredient.php b/src/crafting/TagWildcardRecipeIngredient.php index 32bcf08e1ae..4c66d50e0fc 100644 --- a/src/crafting/TagWildcardRecipeIngredient.php +++ b/src/crafting/TagWildcardRecipeIngredient.php @@ -52,4 +52,8 @@ public function accepts(Item $item) : bool{ public function __toString() : string{ return "TagWildcardRecipeIngredient($this->tagName)"; } + + public function isEquivalent(RecipeIngredient $other) : bool{ + return $other instanceof TagWildcardRecipeIngredient && $other->tagName === $this->tagName; + } } diff --git a/src/network/mcpe/cache/CraftingDataCache.php b/src/network/mcpe/cache/CraftingDataCache.php index d873a53f0b0..a0199ae7b39 100644 --- a/src/network/mcpe/cache/CraftingDataCache.php +++ b/src/network/mcpe/cache/CraftingDataCache.php @@ -71,6 +71,9 @@ public function getCache(CraftingManager $manager) : CraftingDataPacket{ $manager->getRecipeRegisteredCallbacks()->add(function() use ($id) : void{ unset($this->caches[$id]); }); + $manager->getRecipeUnregisteredCallbacks()->add(function() use ($id) : void{ + unset($this->caches[$id]); + }); $this->caches[$id] = $this->buildCraftingDataCache($manager); } return $this->caches[$id]; diff --git a/src/utils/Utils.php b/src/utils/Utils.php index cc250557321..632251014a5 100644 --- a/src/utils/Utils.php +++ b/src/utils/Utils.php @@ -685,4 +685,43 @@ function_exists('opcache_get_status') && public static function getRandomFloat() : float{ return mt_rand() / mt_getrandmax(); } + + /* + * Compares two arrays to determine whether they contain equivalent elements, + * regardless of their order. + * + * Two elements are considered equal if the provided `$check` callback returns `true`. + * Each element in `$tab1` must match exactly one unique element in `$tab2`, and vice versa. + * Duplicates are taken into account: two identical elements must each have a match in the other array. + * + * @template T + * + * @phpstan-param T[] $tab1 The first array to compare. + * @phpstan-param T[] $tab2 The second array to compare. + * @phpstan-param \Closure(T, T): bool $check A custom equality function to compare two elements. + */ + public static function areUnorderedArraysEqual(array $tab1, array $tab2, \Closure $check) : bool { + if(count($tab1) !== count($tab2)){ + return false; + } + + // Check that the two lists of results are identical, regardless of the order. + $used = []; + foreach($tab1 as $element1){ + $found = false; + foreach($tab2 as $i => $element2){ + if(!isset($used[$i]) && $check($element1, $element2)){ + $used[$i] = $element2; + $found = true; + break; + } + } + + if(!$found){ + return false; + } + } + + return true; + } } diff --git a/tests/phpunit/crafting/CraftingManagerTest.php b/tests/phpunit/crafting/CraftingManagerTest.php new file mode 100644 index 00000000000..59ecdf01aa9 --- /dev/null +++ b/tests/phpunit/crafting/CraftingManagerTest.php @@ -0,0 +1,105 @@ + [[$recipe1], $recipe1, 0]; + + yield "ShapelessRecipe not found" => [[$recipe1], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::IRON_INGOT()) + ], [ + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 1]; + + yield "Unordered same ingredients" => [[$recipe1], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()), + new ExactRecipeIngredient(VanillaItems::DIAMOND()) + ], [ + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 0]; + + yield "ShapelessRecipe with different ingredient count" => [[$recipe1], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()) + ], [ + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 1]; + + yield "ShapelessRecipe with different ingredient" => [[$recipe1], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::IRON_INGOT()) + ], [ + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 1]; + + $recipeMultiResult = new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()) + ], [ + VanillaItems::EMERALD(), + VanillaItems::IRON_INGOT() + ], ShapelessRecipeType::CRAFTING); + + yield "ShapelessRecipe with multiple results" => [[$recipeMultiResult], $recipeMultiResult, 0]; + + yield "ShapelessRecipe with different result" => [[$recipeMultiResult], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()) + ], [ + VanillaItems::EMERALD(), + VanillaItems::GOLD_INGOT() + ], ShapelessRecipeType::CRAFTING), 1]; + + yield "ShapelessRecipe with different count of same result" => [[$recipeMultiResult], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()) + ], [ + VanillaItems::EMERALD(), + VanillaItems::IRON_INGOT()->setCount(2) + ], ShapelessRecipeType::CRAFTING), 1]; + + yield "ShapelessRecipe with different result order" => [[$recipeMultiResult], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::DIAMOND()), + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()) + ], [ + VanillaItems::IRON_INGOT(), + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 0]; + + yield "ShapelessRecipe with different result and ingredient order" => [[$recipeMultiResult], new ShapelessRecipe([ + new ExactRecipeIngredient(VanillaItems::GOLD_INGOT()), + new ExactRecipeIngredient(VanillaItems::DIAMOND()) + ], [ + VanillaItems::IRON_INGOT(), + VanillaItems::EMERALD() + ], ShapelessRecipeType::CRAFTING), 0]; + } + + /** + * @param ShapelessRecipe[] $recipes + */ + #[DataProvider('shapelessRecipeProvider')] + public function testUnregisterShapelessRecipe(array $recipes, ShapelessRecipe $toRemove, int $expectedCount) : void { + $manager = new CraftingManager(); + foreach($recipes as $recipe){ + $manager->registerShapelessRecipe($recipe); + } + + self::assertCount(count($recipes), $manager->getShapelessRecipes()); + $manager->unregisterShapelessRecipe($toRemove); + self::assertCount($expectedCount, $manager->getShapelessRecipes(), "Failed to unregister shapeless recipe"); + } +} \ No newline at end of file