From ae899926fe2bc3cd645e21d519789df6e6eb3aa1 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Wed, 7 Oct 2026 19:54:57 -0400 Subject: [PATCH] feat(crafting): add cobweb recipe from string --- .../CobwebRecipeRegistrar.java | 70 +++ .../SpigotInventoryHelperPlugin.java | 21 +- .../NativeCobwebPluginWiringTest.java | 309 +++++++++++++ .../NativeCobwebRecipeTest.java | 405 ++++++++++++++++++ 4 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 src/main/java/games/dmg/spigotinventoryhelper/CobwebRecipeRegistrar.java create mode 100644 src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebRecipeTest.java diff --git a/src/main/java/games/dmg/spigotinventoryhelper/CobwebRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/CobwebRecipeRegistrar.java new file mode 100644 index 0000000..a8b6ab4 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/CobwebRecipeRegistrar.java @@ -0,0 +1,70 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.List; +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; + +/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */ +public final class CobwebRecipeRegistrar implements RecipeRegistrar { + private final NamespacedKey recipeKey; + private Server registeredServer; + private boolean owned; + + public CobwebRecipeRegistrar(NamespacedKey recipeKey) { + this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey"); + } + + @Override + public boolean register(Server server) { + Objects.requireNonNull(server, "server"); + if (owned) { + return true; + } + registeredServer = server; + if (server.getRecipe(recipeKey) != null) { + return false; + } + ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB)); + recipe.shape("S S", " S ", "S S"); + recipe.setIngredient('S', Material.STRING); + recipe.setCategory(CraftingBookCategory.BUILDING); + try { + owned = server.addRecipe(recipe); + return owned; + } catch (RuntimeException exception) { + try { + owned = server.getRecipe(recipeKey) != null; + unregister(); + } catch (RuntimeException cleanupFailure) { + exception.addSuppressed(cleanupFailure); + } + throw exception; + } + } + + @Override + public List recipeKeys() { + return List.of(recipeKey); + } + + @Override + public void unregister() { + if (!owned || registeredServer == null) { + return; + } + if (!registeredServer.removeRecipe(recipeKey)) { + throw new IllegalStateException("Could not remove owned recipe " + recipeKey); + } + owned = false; + } + + @Override + public String description() { + return "Cobweb crafting recipe"; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index ad53336..800b2d4 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -1,13 +1,25 @@ package games.dmg.spigotinventoryhelper; +import java.io.File; import java.util.List; import org.bukkit.NamespacedKey; +import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.java.JavaPluginLoader; public final class SpigotInventoryHelperPlugin extends JavaPlugin { private SortingStickItemFactory sortingSticks; private RecipeLifecycleGroup recipeLifecycles; + public SpigotInventoryHelperPlugin() {} + + // Bukkit's explicit initialization path keeps native tests on the real enable callback. + SpigotInventoryHelperPlugin( + JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file + ) { + super(loader, description, dataFolder, file); + } + @Override public void onEnable() { sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick")); @@ -24,7 +36,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")), getLogger() ); - recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe)); + RecipeLifecycle cobwebRecipe = new RecipeLifecycle( + getServer(), + this, + new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")), + getLogger() + ); + recipeLifecycles = new RecipeLifecycleGroup( + List.of(sortingRecipes, dispenserRecipe, cobwebRecipe)); recipeLifecycles.enableAll(); InventorySortingService sorting = new InventorySortingService(new InventorySorter()); getServer().getPluginManager().registerEvents( diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java new file mode 100644 index 0000000..9dbfccb --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java @@ -0,0 +1,309 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import io.papermc.paper.plugin.manager.PaperPluginManagerImpl; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.Recipe; +import org.bukkit.plugin.InvalidDescriptionException; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPluginLoader; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */ +class NativeCobwebPluginWiringTest { + private static final NamespacedKey COBWEB = key("string_cobweb"); + private static final Set EXISTING = Set.of(key("dropper_dispenser"), + key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel")); + private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick"); + private static Server previousServer; + private static Server serverUnderTest; + @TempDir Path directory; + private Map registry; + private PaperPluginManagerImpl events; + private Logger logger; + private List logs; + private Handler logHandler; + private SpigotInventoryHelperPlugin createdPlugin; + + @BeforeAll + static void bootstrap() throws Exception { + NativeRuntime.bootstrap(); + previousServer = Bukkit.getServer(); + serverUnderTest = mock(Server.class); + setServer(serverUnderTest); + } + + @BeforeEach + void configureServer() { + reset(serverUnderTest); + when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); + when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); + when(serverUnderTest.isPrimaryThread()).thenReturn(true); + logger = Logger.getAnonymousLogger(); + logs = new ArrayList<>(); + logHandler = new Handler() { + @Override public void publish(LogRecord record) { logs.add(record); } + @Override public void flush() {} + @Override public void close() {} + }; + logger.addHandler(logHandler); + when(serverUnderTest.getLogger()).thenReturn(logger); + registry = new HashMap<>(); + registry.put(VANILLA, mock(Recipe.class)); + when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> registry.get(invocation.getArgument(0))); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + Recipe recipe = invocation.getArgument(0); + return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null; + }); + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> registry.remove(invocation.getArgument(0)) != null); + events = new PaperPluginManagerImpl(serverUnderTest, + new SimpleCommandMap(serverUnderTest, new HashMap<>()), null); + when(serverUnderTest.getPluginManager()).thenReturn(events); + doReturn(List.of()).when(serverUnderTest).getOnlinePlayers(); + } + + @AfterEach + void removeLogHandler() { + logger.removeHandler(logHandler); + if (createdPlugin != null) { + createdPlugin.getLogger().removeHandler(logHandler); + } + } + + @AfterAll + static void restoreServer() throws Exception { + setServer(previousServer); + } + + @Test + void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() { + Set onlineKnown = new LinkedHashSet<>(Set.of(VANILLA)); + Set joiningKnown = new LinkedHashSet<>(Set.of(VANILLA)); + AtomicInteger onlineGrants = new AtomicInteger(); + AtomicInteger joiningGrants = new AtomicInteger(); + Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean()); + Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean()); + doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers(); + Recipe vanilla = registry.get(VANILLA); + var plugin = plugin(); + try { + plugin.setEnabled(true); + assertTrue(registry.keySet().containsAll(EXISTING)); + assertTrue(registry.containsKey(COBWEB)); + assertEquals(expectedKnown(), onlineKnown); + plugin.setEnabled(true); + events.callEvent(new PlayerJoinEvent(joining, "joined")); + events.callEvent(new PlayerJoinEvent(joining, "joined again")); + assertEquals(expectedKnown(), joiningKnown); + assertEquals(1, onlineGrants.get()); + assertEquals(1, joiningGrants.get()); + + plugin.setEnabled(false); + assertEquals(Set.of(VANILLA), registry.keySet()); + assertSame(vanilla, registry.get(VANILLA)); + assertEquals(expectedKnown(), onlineKnown); + assertEquals(expectedKnown(), joiningKnown); + Set afterDisable = new LinkedHashSet<>(); + events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable, + new AtomicInteger(), new AtomicBoolean()), "joined")); + assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes"); + + plugin.setEnabled(true); + assertTrue(registry.keySet().containsAll(EXISTING)); + assertTrue(registry.containsKey(COBWEB)); + assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe"); + assertNoSevereLogs(); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() { + Recipe conflict = mock(Recipe.class); + registry.put(COBWEB, conflict); + Set known = new LinkedHashSet<>(Set.of(VANILLA)); + AtomicInteger grants = new AtomicInteger(); + doReturn(List.of(player("Online", known, grants, new AtomicBoolean()))) + .when(serverUnderTest).getOnlinePlayers(); + var plugin = plugin(); + try { + plugin.setEnabled(true); + assertSame(conflict, registry.get(COBWEB)); + assertTrue(registry.keySet().containsAll(EXISTING)); + assertTrue(known.containsAll(EXISTING)); + assertFalse(known.contains(COBWEB)); + assertEquals(0, grants.get()); + plugin.setEnabled(false); + assertEquals(Set.of(VANILLA, COBWEB), registry.keySet()); + assertSame(conflict, registry.get(COBWEB)); + + registry.remove(COBWEB); + plugin.setEnabled(true); + assertTrue(registry.containsKey(COBWEB)); + assertTrue(known.contains(COBWEB)); + assertEquals(1, grants.get()); + assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(), + "Only the deliberately injected key conflict is severe"); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() { + Set failedKnown = new LinkedHashSet<>(Set.of(VANILLA)); + Set healthyKnown = new LinkedHashSet<>(Set.of(VANILLA)); + AtomicBoolean fail = new AtomicBoolean(true); + AtomicInteger failedGrants = new AtomicInteger(); + Player failed = player("Failed", failedKnown, failedGrants, fail); + Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean()); + doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers(); + var plugin = plugin(); + try { + plugin.setEnabled(true); + assertFalse(failedKnown.contains(COBWEB)); + assertEquals(expectedKnown(), healthyKnown); + assertTrue(failedKnown.containsAll(EXISTING)); + fail.set(false); + events.callEvent(new PlayerJoinEvent(failed, "retry")); + assertEquals(expectedKnown(), failedKnown); + assertEquals(1, failedGrants.get()); + assertNoSevereLogs(); + assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count()); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() { + PluginManager throwing = mock(PluginManager.class); + AtomicInteger discoveries = new AtomicInteger(); + doAnswer(invocation -> { + Listener listener = invocation.getArgument(0); + org.bukkit.plugin.Plugin owner = invocation.getArgument(1); + events.registerEvents(listener, owner); + if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 3) { + throw new IllegalStateException("simulated post-listener-registration failure"); + } + return null; + }).when(throwing).registerEvents(any(), any()); + when(serverUnderTest.getPluginManager()).thenReturn(throwing); + var plugin = plugin(); + try { + plugin.setEnabled(true); + assertFalse(registry.containsKey(COBWEB)); + assertTrue(registry.keySet().containsAll(EXISTING)); + Set known = new LinkedHashSet<>(Set.of(VANILLA)); + events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(), + new AtomicBoolean()), "joined")); + assertTrue(known.containsAll(EXISTING)); + assertFalse(known.contains(COBWEB)); + assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(), + "Only the deliberately injected listener failure is severe"); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + private SpigotInventoryHelperPlugin plugin() { + try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) { + createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest), + new PluginDescriptionFile(metadata), directory.resolve("data").toFile(), + directory.resolve("plugin.jar").toFile()); + createdPlugin.getLogger().addHandler(logHandler); + return createdPlugin; + } catch (java.io.IOException | InvalidDescriptionException exception) { + throw new AssertionError("Could not read generated plugin metadata", exception); + } + } + + private static Player player(String name, Set known, + AtomicInteger cobwebGrants, AtomicBoolean failCobweb) { + Player player = mock(Player.class); + when(player.getName()).thenReturn(name); + when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> known.contains(invocation.getArgument(0))); + when(player.discoverRecipes(any())).thenAnswer(invocation -> { + List requested = ((Collection) invocation.getArgument(0)).stream() + .map(NamespacedKey.class::cast).toList(); + if (requested.contains(COBWEB)) { + if (failCobweb.get()) { + throw new IllegalStateException("simulated player discovery failure"); + } + cobwebGrants.incrementAndGet(); + } + known.addAll(requested); + return requested.size(); + }); + return player; + } + + private void assertNoSevereLogs() { + assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count()); + } + + private static Set expectedKnown() { + Set expected = new LinkedHashSet<>(EXISTING); + expected.add(COBWEB); + expected.add(VANILLA); + return expected; + } + + private static NamespacedKey key(String name) { + return new NamespacedKey("spigotinventoryhelper", name); + } + + private static void setServer(Server server) throws Exception { + var field = Bukkit.class.getDeclaredField("server"); + field.setAccessible(true); + field.set(null, server); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebRecipeTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebRecipeTest.java new file mode 100644 index 0000000..3277a25 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebRecipeTest.java @@ -0,0 +1,405 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.craftbukkit.inventory.CraftRecipe; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */ +class NativeCobwebRecipeTest { + private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb"); + private static final List STRING_SLOTS = List.of(0, 2, 4, 6, 8); + private static Server previousServer; + private static Server serverUnderTest; + + @BeforeAll + static void bootstrap() throws Exception { + NativeRuntime.bootstrap(); + previousServer = Bukkit.getServer(); + serverUnderTest = mock(Server.class); + setServer(serverUnderTest); + } + + @BeforeEach + void configureServer() { + reset(serverUnderTest); + when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); + when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); + when(serverUnderTest.isPrimaryThread()).thenReturn(true); + when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class)); + } + + @AfterAll + static void restoreServer() throws Exception { + setServer(previousServer); + } + + @Test + void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() { + ShapedRecipe recipe = registeredRecipe(); + assertEquals(KEY, recipe.getKey()); + assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape())); + assertEquals(List.of(Material.STRING), + ((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices()); + assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory()); + assertOrdinaryCobweb(recipe.getResult(), 1); + var nativeRecipe = nativeRecipe(recipe); + assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS, + nativeRecipe.recipeBookCategory()); + var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay) + nativeRecipe.display().getFirst(); + assertEquals(3, display.width()); + assertEquals(3, display.height()); + for (int slot = 0; slot < 9; slot++) { + if (STRING_SLOTS.contains(slot)) { + var expected = CraftRecipe.toIngredient( + new RecipeChoice.MaterialChoice(Material.STRING), false).display(); + assertEquals(expected, display.ingredients().get(slot)); + } else { + assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE, + display.ingredients().get(slot)); + } + } + var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay) + display.result(); + assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1); + } + + @Test + void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() { + var recipe = nativeRecipe(registeredRecipe()); + var level = mock(net.minecraft.world.level.Level.class); + List valid = pattern(); + assertTrue(recipe.matches(input(valid), level)); + for (int slot = 0; slot < 9; slot++) { + List wrong = new ArrayList<>(valid); + wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY + : CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))); + assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot); + } + List wrongMaterial = new ArrayList<>(valid); + wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT))); + assertFalse(recipe.matches(input(wrongMaterial), level)); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2, + java.util.stream.IntStream.range(0, 4).mapToObj(i -> + CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level)); + } + + @Test + void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() { + var recipe = nativeRecipe(registeredRecipe()); + var fixture = crafting(); + ItemStack unrelated = marked(Material.DIAMOND, 7); + fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated)); + for (int slot : STRING_SLOTS) { + fixture.menu().craftSlots.setItem(slot, + CraftItemStack.asNMSCopy(marked(Material.STRING, 2))); + } + var grid = fixture.menu().craftSlots; + assertTrue(recipe.matches(nativeInput(grid), fixture.player().level())); + List before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(); + var assembled = recipe.assemble(nativeInput(grid)); + assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1); + assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(), + "Preview must neither consume ingredients nor mutate metadata"); + setResult(fixture.menu(), recipe, assembled); + var slot = fixture.menu().getResultSlot(); + var taken = slot.remove(1); + slot.onTake(fixture.player(), taken); + assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1); + assertEquals(5, count(grid, Material.STRING)); + for (int index : STRING_SLOTS) { + assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index))); + } + assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20))); + assertTrue(fixture.menu().resultSlots.isEmpty()); + assertEquals(0, count(fixture.inventory(), Material.COBWEB)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 3, 64}) + void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) { + var recipe = nativeRecipe(registeredRecipe()); + var fixture = crafting(); + for (int i = 0; i < 5; i++) { + fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts))); + } + ItemStack unrelated = marked(Material.DIAMOND, 7); + fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated)); + fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2))); + var action = autofill(fixture, recipe); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action); + assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING)); + assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output"); + assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved"); + for (int attempt = 0; attempt < crafts; attempt++) { + assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level())); + setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots))); + var moved = fixture.menu().quickMoveStack(fixture.player(), 0); + assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1); + assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING)); + } + assertTrue(fixture.menu().craftSlots.isEmpty()); + assertEquals(crafts, count(fixture.inventory(), Material.COBWEB)); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20))); + assertEquals(2, count(fixture.inventory(), Material.DIRT)); + } + + @Test + void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() { + var recipe = nativeRecipe(registeredRecipe()); + var fixture = crafting(); + for (int slot = 0; slot < 36; slot++) { + fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64))); + } + for (int slot : STRING_SLOTS) { + fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))); + } + setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots))); + assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty()); + assertEquals(5, count(fixture.menu().craftSlots, Material.STRING)); + assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT)); + assertEquals(0, count(fixture.inventory(), Material.COBWEB)); + assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1); + } + + @Test + void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() { + var fixture = crafting(); + fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4))); + var action = autofill(fixture, nativeRecipe(registeredRecipe())); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action); + assertEquals(4, count(fixture.inventory(), Material.STRING)); + assertEquals(0, count(fixture.inventory(), Material.COBWEB)); + assertTrue(fixture.menu().craftSlots.isEmpty()); + assertTrue(fixture.menu().resultSlots.isEmpty()); + } + + @Test + void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() { + var recipe = nativeRecipe(registeredRecipe()); + var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe); + var book = new TestRecipeBook((key, consumer) -> consumer.accept( + new net.minecraft.world.item.crafting.display.RecipeDisplayEntry( + new net.minecraft.world.item.crafting.display.RecipeDisplayId(0), + recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(), + Optional.of(recipe.placementInfo().ingredients())))); + var player = mock(net.minecraft.server.level.ServerPlayer.class); + when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class)); + when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + PluginManager events = mock(PluginManager.class); + when(serverUnderTest.getPluginManager()).thenReturn(events); + AtomicBoolean cancel = new AtomicBoolean(true); + doAnswer(invocation -> { + var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0); + event.setCancelled(cancel.get()); + return null; + }).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class)); + assertEquals(0, book.addRecipes(List.of(holder), player)); + assertFalse(book.contains(nativeKey())); + cancel.set(false); + assertEquals(1, book.addRecipes(List.of(holder), player)); + assertEquals(0, book.addRecipes(List.of(holder), player)); + assertTrue(book.contains(nativeKey())); + } + + @Test + void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() { + Map registry = new HashMap<>(); + NamespacedKey foreign = new NamespacedKey("minecraft", "stick"); + Recipe unrelated = mock(Recipe.class); + registry.put(foreign, unrelated); + when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> registry.get(invocation.getArgument(0))); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + registry.put(KEY, invocation.getArgument(0)); + throw new IllegalStateException("simulated post-insertion failure"); + }); + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> registry.remove(invocation.getArgument(0)) != null); + var registrar = new CobwebRecipeRegistrar(KEY); + assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest)); + registrar.unregister(); + assertEquals(Map.of(foreign, unrelated), registry); + } + + @Test + void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() { + AtomicInteger registrations = new AtomicInteger(); + AtomicBoolean allow = new AtomicBoolean(); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + registrations.incrementAndGet(); + return allow.get(); + }); + var registrar = new CobwebRecipeRegistrar(KEY); + assertFalse(registrar.register(serverUnderTest)); + registrar.unregister(); + allow.set(true); + assertTrue(registrar.register(serverUnderTest)); + assertTrue(registrar.register(serverUnderTest)); + assertEquals(2, registrations.get(), "Owned registration must be idempotent"); + AtomicInteger removals = new AtomicInteger(); + when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1); + assertThrows(IllegalStateException.class, registrar::unregister); + registrar.unregister(); + registrar.unregister(); + assertEquals(2, removals.get()); + } + + private static ShapedRecipe registeredRecipe() { + List registered = new ArrayList<>(); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + registered.add(invocation.getArgument(0)); + return true; + }); + assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest)); + assertEquals(1, registered.size()); + return (ShapedRecipe) registered.getFirst(); + } + + private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) { + Map ingredients = new HashMap<>(); + recipe.getChoiceMap().forEach((symbol, choice) -> { + if (choice != null) { + ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)); + } + }); + return new net.minecraft.world.item.crafting.ShapedRecipe( + new net.minecraft.world.item.crafting.Recipe.CommonInfo(true), + new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo( + CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()), + net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()), + CraftItemStack.asTemplate(recipe.getResult())); + } + + private static CraftingFixture crafting() { + var player = mock(net.minecraft.world.entity.player.Player.class); + when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class)); + when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class)); + var inventory = new net.minecraft.world.entity.player.Inventory( + player, new net.minecraft.world.entity.EntityEquipment()); + when(player.getInventory()).thenReturn(inventory); + return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory)); + } + + private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill( + CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) { + var grid = fixture.menu().craftSlots; + var slots = fixture.menu().getInputGridSlots(); + var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess< + net.minecraft.world.item.crafting.ShapedRecipe>() { + @Override public void fillCraftSlotsStackedContents( + net.minecraft.world.entity.player.StackedItemContents contents) { + grid.fillStackedContents(contents); + } + @Override public void clearCraftingContent() { grid.clearContent(); } + @Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder< + net.minecraft.world.item.crafting.ShapedRecipe> candidate) { + return candidate.value().matches(nativeInput(grid), fixture.player().level()); + } + }; + return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots, + fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe), + true, false); + } + + private static void setResult(net.minecraft.world.inventory.CraftingMenu menu, + net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) { + var holder = new net.minecraft.world.item.crafting.RecipeHolder< + net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe); + menu.craftSlots.setCurrentRecipe(holder); + menu.resultSlots.setRecipeUsed(holder); + menu.resultSlots.setItem(0, result); + } + + private static List pattern() { + return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot) + ? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)) + : net.minecraft.world.item.ItemStack.EMPTY).toList(); + } + + private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) { + return input(java.util.stream.IntStream.range(0, grid.getContainerSize()) + .mapToObj(grid::getItem).toList()); + } + + private static net.minecraft.world.item.crafting.CraftingInput input(List items) { + return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items); + } + + private static long count(net.minecraft.world.Container container, Material material) { + return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem) + .map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material) + .mapToInt(ItemStack::getAmount).sum(); + } + + private static ItemStack marked(Material material, int amount) { + ItemStack item = new ItemStack(material, amount); + var meta = item.getItemMeta(); + meta.displayName(Component.text("Keep metadata")); + meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"), + PersistentDataType.STRING, "unchanged"); + item.setItemMeta(meta); + return item; + } + + private static void assertOrdinaryCobweb(ItemStack item, int amount) { + assertEquals(Material.COBWEB, item.getType()); + assertEquals(amount, item.getAmount()); + assertFalse(item.hasItemMeta()); + } + + private static net.minecraft.resources.ResourceKey> nativeKey() { + return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, + net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey())); + } + + private static void setServer(Server server) throws Exception { + var field = Bukkit.class.getDeclaredField("server"); + field.setAccessible(true); + field.set(null, server); + } + + private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook { + TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); } + } + + private record CraftingFixture(net.minecraft.world.entity.player.Player player, + net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {} +} -- 2.54.0