diff --git a/README.md b/README.md index 22f09bd..9b83cdb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Purpur Inventory Helper -A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory. +A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe. The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md). @@ -24,7 +24,7 @@ verifies SHA-256 `4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook` -known/highlight codec persistence, and `ResultSlot` ingredient consumption for both variants. +known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion. CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client rendering, search interaction, packet transport, or a full server lifecycle. Those remain non-blocking live checks. @@ -45,6 +45,10 @@ idempotent; reloading the plugin retains player recipe-book knowledge and ordina If the two recipes cannot register as one complete group, recipe-book discovery is disabled while existing manual crafting and inventory behavior remain available. +## Alternative Dispenser recipe + +One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration. + ## Sorting inventories Hold an authentic Sorting Stick and right-click: diff --git a/src/main/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.java new file mode 100644 index 0000000..655421b --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.java @@ -0,0 +1,86 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; + +public final class DispenserRecipeRegistrar implements RecipeRegistrar { + private final NamespacedKey recipeKey; + private final Supplier resultFactory; + private final RecipeFactory recipeFactory; + private Server registeredServer; + private boolean owned; + + public DispenserRecipeRegistrar(NamespacedKey recipeKey) { + this(recipeKey, () -> new ItemStack(Material.DISPENSER), ShapelessRecipe::new); + } + + DispenserRecipeRegistrar( + NamespacedKey recipeKey, + Supplier resultFactory, + RecipeFactory recipeFactory + ) { + this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey"); + this.resultFactory = Objects.requireNonNull(resultFactory, "resultFactory"); + this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory"); + } + + public boolean register(Server server) { + Objects.requireNonNull(server, "server"); + if (owned) { + return true; + } + registeredServer = server; + if (server.getRecipe(recipeKey) != null) { + return false; + } + ItemStack result = resultFactory.get(); + ShapelessRecipe recipe = recipeFactory.create(recipeKey, result); + recipe.addIngredient(Material.DROPPER); + recipe.addIngredient(Material.TRIPWIRE_HOOK); + recipe.setCategory(CraftingBookCategory.REDSTONE); + 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); + } + + 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 "alternative Dispenser recipe"; + } + + @FunctionalInterface + interface RecipeFactory { + ShapelessRecipe create(NamespacedKey key, ItemStack result); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/RecipeDiscovery.java b/src/main/java/games/dmg/spigotinventoryhelper/RecipeDiscovery.java index c6e7eea..54e07a7 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/RecipeDiscovery.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/RecipeDiscovery.java @@ -12,10 +12,16 @@ import org.bukkit.event.player.PlayerJoinEvent; public final class RecipeDiscovery implements Listener { private final List recipeKeys; + private final String description; private final Logger logger; public RecipeDiscovery(List recipeKeys, Logger logger) { + this(recipeKeys, "recipes", logger); + } + + public RecipeDiscovery(List recipeKeys, String description, Logger logger) { this.recipeKeys = List.copyOf(recipeKeys); + this.description = Objects.requireNonNull(description, "description"); this.logger = Objects.requireNonNull(logger, "logger"); } @@ -41,12 +47,12 @@ public final class RecipeDiscovery implements Listener { .filter(key -> !player.hasDiscoveredRecipe(key)) .toList(); if (!stillUnknown.isEmpty()) { - logger.warning("Could not discover Sorting Stick recipes for " + logger.warning("Could not discover " + description + " for " + player.getName() + ": " + stillUnknown); } } catch (RuntimeException exception) { logger.log(Level.WARNING, - "Could not discover Sorting Stick recipes for " + player.getName(), exception); + "Could not discover " + description + " for " + player.getName(), exception); } } } diff --git a/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycle.java b/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycle.java index 79ee14e..9a4f978 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycle.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycle.java @@ -12,14 +12,14 @@ import org.bukkit.plugin.Plugin; public final class RecipeLifecycle { private final Server server; private final Plugin plugin; - private final SortingStickRecipeRegistrar recipes; + private final RecipeRegistrar recipes; private final Logger logger; private RecipeDiscovery discovery; public RecipeLifecycle( Server server, Plugin plugin, - SortingStickRecipeRegistrar recipes, + RecipeRegistrar recipes, Logger logger ) { this.server = Objects.requireNonNull(server, "server"); @@ -34,12 +34,13 @@ public final class RecipeLifecycle { } try { if (!recipes.register(server)) { - logger.severe("Sorting Stick recipes were not registered as a complete group; " - + "discovery is disabled."); + logger.severe(recipes.description() + + " were not registered; discovery is disabled."); return false; } List onlinePlayers = List.copyOf(server.getOnlinePlayers()); - discovery = new RecipeDiscovery(recipes.recipeKeys(), logger); + discovery = new RecipeDiscovery( + recipes.recipeKeys(), recipes.description(), logger); server.getPluginManager().registerEvents(discovery, plugin); discovery.discoverAll(onlinePlayers); return true; @@ -51,8 +52,7 @@ public final class RecipeLifecycle { exception.addSuppressed(cleanupFailure); } logger.log(Level.SEVERE, - "Sorting Stick recipes were not registered as a complete group; " - + "discovery is disabled.", + recipes.description() + " were not registered; discovery is disabled.", exception); return false; } diff --git a/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.java b/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.java new file mode 100644 index 0000000..73c6ad5 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.java @@ -0,0 +1,33 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.List; + +public final class RecipeLifecycleGroup { + private final List lifecycles; + + public RecipeLifecycleGroup(List lifecycles) { + this.lifecycles = List.copyOf(lifecycles); + } + + public void enableAll() { + lifecycles.forEach(RecipeLifecycle::enable); + } + + public void disableAll() { + RuntimeException failure = null; + for (int index = lifecycles.size() - 1; index >= 0; index--) { + try { + lifecycles.get(index).disable(); + } catch (RuntimeException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } + } + if (failure != null) { + throw failure; + } + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/RecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/RecipeRegistrar.java new file mode 100644 index 0000000..92753f0 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/RecipeRegistrar.java @@ -0,0 +1,15 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.List; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; + +public interface RecipeRegistrar { + boolean register(Server server); + + List recipeKeys(); + + void unregister(); + + String description(); +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java index 9e626d0..13d67a3 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SortingStickRecipeRegistrar.java @@ -11,7 +11,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.recipe.CraftingBookCategory; -public final class SortingStickRecipeRegistrar { +public final class SortingStickRecipeRegistrar implements RecipeRegistrar { private final NamespacedKey recipeKey; private final SortingStickItemFactory itemFactory; private final SortingStickRecipeSpec recipeSpec; @@ -81,6 +81,11 @@ public final class SortingStickRecipeRegistrar { rollback(); } + @Override + public String description() { + return "Sorting Stick recipes"; + } + private void rollback() { if (registeredServer == null) { return; diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index c8277a8..ad53336 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -1,11 +1,12 @@ package games.dmg.spigotinventoryhelper; +import java.util.List; import org.bukkit.NamespacedKey; import org.bukkit.plugin.java.JavaPlugin; public final class SpigotInventoryHelperPlugin extends JavaPlugin { private SortingStickItemFactory sortingSticks; - private RecipeLifecycle recipeLifecycle; + private RecipeLifecycleGroup recipeLifecycles; @Override public void onEnable() { @@ -15,8 +16,16 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { sortingSticks, SortingStickRecipeSpec.defaultRecipe() ); - recipeLifecycle = new RecipeLifecycle(getServer(), this, recipes, getLogger()); - recipeLifecycle.enable(); + RecipeLifecycle sortingRecipes = new RecipeLifecycle( + getServer(), this, recipes, getLogger()); + RecipeLifecycle dispenserRecipe = new RecipeLifecycle( + getServer(), + this, + new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")), + getLogger() + ); + recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe)); + recipeLifecycles.enableAll(); InventorySortingService sorting = new InventorySortingService(new InventorySorter()); getServer().getPluginManager().registerEvents( new SortingStickListener(sortingSticks, sorting), @@ -31,9 +40,9 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { @Override public void onDisable() { - if (recipeLifecycle != null) { - recipeLifecycle.disable(); - recipeLifecycle = null; + if (recipeLifecycles != null) { + recipeLifecycles.disableAll(); + recipeLifecycles = null; } } diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeDispenserRecipeTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeDispenserRecipeTest.java new file mode 100644 index 0000000..16f6d4e --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeDispenserRecipeTest.java @@ -0,0 +1,273 @@ +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +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.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.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Keyed; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.command.SimpleCommandMap; +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.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Pinned Purpur coverage for the alternative Dispenser's registered native path. */ +class NativeDispenserRecipeTest { + 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); + } + + @AfterAll + static void restoreServer() throws Exception { + setServer(previousServer); + } + + @Test + void registeredRecipeDiscoversIndependentlyAndPreservesVanillaDispenserRecipe() { + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + NamespacedKey vanilla = new NamespacedKey("minecraft", "dispenser"); + Map registry = new HashMap<>(); + Recipe vanillaRecipe = mock(Recipe.class); + registry.put(vanilla, vanillaRecipe); + 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); + var events = new PaperPluginManagerImpl(serverUnderTest, + new SimpleCommandMap(serverUnderTest, new HashMap<>()), null); + when(serverUnderTest.getPluginManager()).thenReturn(events); + Set known = new LinkedHashSet<>(); + doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers(); + Plugin plugin = enabledPlugin(); + DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(key); + RecipeLifecycle lifecycle = new RecipeLifecycle( + serverUnderTest, plugin, registrar, plugin.getLogger()); + + assertTrue(lifecycle.enable()); + + ShapelessRecipe recipe = (ShapelessRecipe) registry.get(key); + assertEquals(CraftingBookCategory.REDSTONE, recipe.getCategory()); + assertEquals(Material.DISPENSER, recipe.getResult().getType()); + assertEquals(1, recipe.getResult().getAmount()); + assertFalse(recipe.getResult().hasItemMeta()); + assertEquals(List.of(Material.DROPPER, Material.TRIPWIRE_HOOK), + recipe.getChoiceList().stream() + .map(org.bukkit.inventory.RecipeChoice.MaterialChoice.class::cast) + .map(choice -> choice.getChoices().getFirst()) + .toList()); + assertEquals(Set.of(key), known); + + lifecycle.disable(); + + assertEquals(Set.of(vanilla), registry.keySet()); + assertEquals(vanillaRecipe, registry.get(vanilla)); + assertEquals(Set.of(key), known, "Disable must retain native recipe-book knowledge"); + } + + @Test + void nativeAutofillAndRepeatedCompletionConsumeOnePairPerDispenser() { + when(serverUnderTest.getPluginManager()).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + List registered = new ArrayList<>(); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + registered.add((ShapelessRecipe) invocation.getArgument(0)); + return true; + }); + assertTrue(new DispenserRecipeRegistrar(new NamespacedKey( + "spigotinventoryhelper", "dropper_dispenser")).register(serverUnderTest)); + ShapelessRecipe bukkit = registered.getFirst(); + var nativeRecipe = nativeRecipe(bukkit); + var holder = new net.minecraft.world.item.crafting.RecipeHolder<>( + nativeRecipeKey(bukkit.getKey()), nativeRecipe); + var nativePlayer = mock(net.minecraft.world.entity.player.Player.class); + when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class)); + when(nativePlayer.getBukkitEntity()).thenReturn( + mock(org.bukkit.craftbukkit.entity.CraftPlayer.class)); + var inventory = new net.minecraft.world.entity.player.Inventory( + nativePlayer, new net.minecraft.world.entity.EntityEquipment()); + when(nativePlayer.getInventory()).thenReturn(inventory); + inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DROPPER, 3))); + inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.TRIPWIRE_HOOK, 3))); + ItemStack unrelated = new ItemStack(Material.DIAMOND, 7); + inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated)); + var grid = new net.minecraft.world.inventory.TransientCraftingContainer( + mock(net.minecraft.world.inventory.AbstractContainerMenu.class), + 3, 3, nativePlayer); + grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2))); + List slots = java.util.stream.IntStream.range(0, 9) + .mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0)) + .toList(); + var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess< + net.minecraft.world.item.crafting.ShapelessRecipe>() { + @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.ShapelessRecipe> candidate) { + return candidate.value().matches(nativeInput(grid), + mock(net.minecraft.world.level.Level.class)); + } + }; + + var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe( + access, 3, 3, slots, slots, inventory, holder, true, false); + + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action); + assertEquals(3, count(grid, Material.DROPPER)); + assertEquals(3, count(grid, Material.TRIPWIRE_HOOK)); + assertEquals(2, inventory.getNonEquipmentItems().stream() + .map(CraftItemStack::asBukkitCopy) + .filter(item -> item.getType() == Material.DIRT) + .mapToInt(ItemStack::getAmount).sum()); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20))); + int crafted = 0; + for (int attempt = 0; attempt < 3; attempt++) { + assertTrue(nativeRecipe.matches(nativeInput(grid), + mock(net.minecraft.world.level.Level.class))); + net.minecraft.world.item.ItemStack assembled = nativeRecipe.assemble(nativeInput(grid)); + ItemStack preview = CraftItemStack.asBukkitCopy(assembled); + assertEquals(Material.DISPENSER, preview.getType()); + assertEquals(1, preview.getAmount()); + assertFalse(preview.hasItemMeta()); + grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder< + net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value())); + var result = new net.minecraft.world.inventory.ResultContainer(); + result.setRecipeUsed(holder); + result.setItem(0, assembled); + var slot = new net.minecraft.world.inventory.ResultSlot( + nativePlayer, grid, result, 0, 0, 0); + net.minecraft.world.item.ItemStack taken = slot.remove(1); + slot.onTake(nativePlayer, taken); + crafted += taken.getCount(); + } + + assertEquals(3, crafted); + assertTrue(grid.isEmpty()); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20))); + assertFalse(inventory.getNonEquipmentItems().stream() + .map(CraftItemStack::asBukkitCopy) + .anyMatch(item -> item.getType() == Material.DISPENSER), + "Preview and autofill must not create an extra result"); + } + + private static net.minecraft.world.item.crafting.CraftingInput nativeInput( + net.minecraft.world.Container grid) { + return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, + java.util.stream.IntStream.range(0, grid.getContainerSize()) + .mapToObj(grid::getItem).toList()); + } + + private static long count(net.minecraft.world.Container grid, Material material) { + return java.util.stream.IntStream.range(0, grid.getContainerSize()) + .mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy) + .filter(item -> item.getType() == material) + .mapToInt(ItemStack::getAmount).sum(); + } + + private static net.minecraft.resources.ResourceKey> + nativeRecipeKey(NamespacedKey key) { + return net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.RECIPE, + net.minecraft.resources.Identifier.fromNamespaceAndPath( + key.getNamespace(), key.getKey())); + } + + private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe( + ShapelessRecipe recipe) { + List ingredients = recipe.getChoiceList() + .stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList(); + return new net.minecraft.world.item.crafting.ShapelessRecipe( + new net.minecraft.world.item.crafting.Recipe.CommonInfo(true), + new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo( + CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()), + CraftItemStack.asTemplate(recipe.getResult()), ingredients); + } + + private static org.bukkit.entity.Player discoveryPlayer( + String name, Set known) { + org.bukkit.entity.Player player = mock(org.bukkit.entity.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(); + known.addAll(requested); + return requested.size(); + }); + return player; + } + + private static Plugin enabledPlugin() { + Plugin plugin = mock(Plugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getName()).thenReturn("SpigotInventoryHelper"); + when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeDispenserRecipe")); + PluginDescriptionFile description = new PluginDescriptionFile( + "SpigotInventoryHelper", "test", "unused.Main"); + when(plugin.getDescription()).thenReturn(description); + when(plugin.getPluginMeta()).thenReturn(description); + return plugin; + } + + 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/test/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrarTest.java b/src/test/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrarTest.java new file mode 100644 index 0000000..8647936 --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/DispenserRecipeRegistrarTest.java @@ -0,0 +1,113 @@ +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +final class DispenserRecipeRegistrarTest { + @Test + void registersOrdinaryDispenserRecipeInRedstoneCategory() { + Server server = mock(Server.class); + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + ItemStack dispenser = mock(ItemStack.class); + when(dispenser.getType()).thenReturn(Material.DISPENSER); + when(dispenser.getAmount()).thenReturn(1); + DispenserRecipeRegistrar.RecipeFactory recipes = + mock(DispenserRecipeRegistrar.RecipeFactory.class); + when(recipes.create(any(), any())).thenReturn(recipe); + when(server.addRecipe(recipe)).thenReturn(true); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar( + key, () -> dispenser, recipes); + + assertTrue(registrar.register(server)); + + ArgumentCaptor result = ArgumentCaptor.forClass(ItemStack.class); + verify(recipes).create(org.mockito.ArgumentMatchers.eq(key), result.capture()); + assertEquals(Material.DISPENSER, result.getValue().getType()); + assertEquals(1, result.getValue().getAmount()); + assertFalse(result.getValue().hasItemMeta()); + verify(recipe).addIngredient(Material.DROPPER); + verify(recipe).addIngredient(Material.TRIPWIRE_HOOK); + verify(recipe).setCategory(CraftingBookCategory.REDSTONE); + verify(server).addRecipe(recipe); + } + + @Test + void registrationExceptionAfterInsertionRemovesOnlyTheOwnedRecipe() { + Server server = mock(Server.class); + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + ItemStack result = mock(ItemStack.class); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + when(server.getRecipe(key)).thenReturn(null, recipe); + when(server.addRecipe(recipe)).thenThrow(new IllegalStateException("registry failed")); + when(server.removeRecipe(key)).thenReturn(true); + DispenserRecipeRegistrar.RecipeFactory recipes = + mock(DispenserRecipeRegistrar.RecipeFactory.class); + when(recipes.create(any(), any())).thenReturn(recipe); + DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar( + key, () -> result, recipes); + + assertThrows(IllegalStateException.class, () -> registrar.register(server)); + registrar.unregister(); + + verify(server).removeRecipe(key); + } + + @Test + void failedRemovalRetainsOwnershipForRetry() { + Server server = mock(Server.class); + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + ItemStack result = mock(ItemStack.class); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + when(server.addRecipe(recipe)).thenReturn(true); + when(server.removeRecipe(key)).thenReturn(false, true); + DispenserRecipeRegistrar.RecipeFactory recipes = + mock(DispenserRecipeRegistrar.RecipeFactory.class); + when(recipes.create(any(), any())).thenReturn(recipe); + DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar( + key, () -> result, recipes); + + assertTrue(registrar.register(server)); + assertThrows(IllegalStateException.class, registrar::unregister); + registrar.unregister(); + + verify(server, times(2)).removeRecipe(key); + } + + @Test + void conflictingRecipeIsPreservedAndNotClaimed() { + Server server = mock(Server.class); + ShapelessRecipe recipe = mock(ShapelessRecipe.class); + ItemStack result = mock(ItemStack.class); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + when(server.getRecipe(key)).thenReturn(mock(org.bukkit.inventory.Recipe.class)); + DispenserRecipeRegistrar.RecipeFactory recipes = + mock(DispenserRecipeRegistrar.RecipeFactory.class); + when(recipes.create(any(), any())).thenReturn(recipe); + DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar( + key, () -> result, recipes); + + assertFalse(registrar.register(server)); + registrar.unregister(); + + verify(server, never()).addRecipe(any()); + verify(server, never()).removeRecipe(any()); + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/PluginFoundationTest.java b/src/test/java/games/dmg/spigotinventoryhelper/PluginFoundationTest.java index 7cc1a45..5e36561 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/PluginFoundationTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/PluginFoundationTest.java @@ -27,11 +27,11 @@ final class PluginFoundationTest { } @Test - void pluginEntrypointOwnsEnableDisableRecipeLifecycleWiring() throws Exception { - Field lifecycle = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycle"); + void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception { + Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles"); Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable"); - assertEquals(RecipeLifecycle.class, lifecycle.getType()); + assertEquals(RecipeLifecycleGroup.class, lifecycles.getType()); assertEquals(void.class, disable.getReturnType()); } } diff --git a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java index 036898f..c7bff2f 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java @@ -29,6 +29,10 @@ class PurpurDistributionTest { "games/dmg/spigotinventoryhelper/RecipeDiscovery.class")); assertNotNull(jar.getJarEntry( "games/dmg/spigotinventoryhelper/RecipeLifecycle.class")); + assertNotNull(jar.getJarEntry( + "games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.class")); + assertNotNull(jar.getJarEntry( + "games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class")); assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") || entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class") || entry.getName().contains("NativeRuntime"))); @@ -42,6 +46,8 @@ class PurpurDistributionTest { assertTrue(readme.contains("ServerPlaceRecipe")); assertTrue(readme.contains("ServerRecipeBook")); assertTrue(readme.contains("ResultSlot")); + assertTrue(readme.contains("Dropper + Tripwire Hook")); + assertTrue(readme.contains("Redstone category")); assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled")); assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31")); } diff --git a/src/test/java/games/dmg/spigotinventoryhelper/RecipeDiscoveryTest.java b/src/test/java/games/dmg/spigotinventoryhelper/RecipeDiscoveryTest.java index 6386bc0..8d2f454 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/RecipeDiscoveryTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/RecipeDiscoveryTest.java @@ -2,6 +2,7 @@ package games.dmg.spigotinventoryhelper; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +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; @@ -28,6 +29,21 @@ final class RecipeDiscoveryTest { new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"), new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel")); + @Test + void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() { + Set known = new LinkedHashSet<>(); + Player player = player("Partial", known); + doAnswer(invocation -> 0).when(player).discoverRecipes(any()); + List warnings = new ArrayList<>(); + RecipeDiscovery discovery = new RecipeDiscovery( + keys, "alternative Dispenser recipe", logger(warnings)); + + discovery.discover(player); + + assertEquals(1, warnings.size()); + assertTrue(warnings.getFirst().getMessage().contains("alternative Dispenser recipe")); + } + @Test void onlineDiscoveryAwardsOnlyUnknownRecipesAndRepeatedDiscoveryIsIdempotent() { Set known = new LinkedHashSet<>(); diff --git a/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroupTest.java b/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroupTest.java new file mode 100644 index 0000000..6e08c66 --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleGroupTest.java @@ -0,0 +1,45 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +final class RecipeLifecycleGroupTest { + @Test + void cleanupFailureDoesNotPreventOtherRecipeCleanup() { + RecipeLifecycle sorting = mock(RecipeLifecycle.class); + RecipeLifecycle dispenser = mock(RecipeLifecycle.class); + org.mockito.Mockito.doThrow(new IllegalStateException("cleanup failed")) + .when(dispenser).disable(); + RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser)); + + assertThrows(IllegalStateException.class, group::disableAll); + + verify(dispenser).disable(); + verify(sorting).disable(); + } + + @Test + void oneFailedRecipeLifecycleDoesNotBlockTheOther() { + RecipeLifecycle sorting = mock(RecipeLifecycle.class); + RecipeLifecycle dispenser = mock(RecipeLifecycle.class); + when(sorting.enable()).thenReturn(true); + when(dispenser.enable()).thenReturn(false); + RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser)); + + group.enableAll(); + group.disableAll(); + + verify(sorting).enable(); + verify(dispenser).enable(); + InOrder disabled = inOrder(dispenser, sorting); + disabled.verify(dispenser).disable(); + disabled.verify(sorting).disable(); + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleTest.java b/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleTest.java index 42e2a18..af863cb 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/RecipeLifecycleTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -31,6 +32,55 @@ final class RecipeLifecycleTest { new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"), new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel")); + @Test + void failedRegistrationWithholdsDiscoveryUntilALaterRetrySucceeds() { + Server server = mock(Server.class); + Plugin plugin = mock(Plugin.class); + PluginManager events = mock(PluginManager.class); + RecipeRegistrar recipes = mock(RecipeRegistrar.class); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + when(recipes.register(server)).thenReturn(false, true); + when(recipes.recipeKeys()).thenReturn(List.of(key)); + when(recipes.description()).thenReturn("alternative Dispenser recipe"); + when(server.getPluginManager()).thenReturn(events); + Set known = new LinkedHashSet<>(); + doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers(); + RecipeLifecycle lifecycle = new RecipeLifecycle( + server, plugin, recipes, Logger.getAnonymousLogger()); + + assertFalse(lifecycle.enable()); + assertTrue(known.isEmpty()); + assertTrue(lifecycle.enable()); + + assertEquals(Set.of(key), known); + verify(recipes, times(2)).register(server); + verify(events).registerEvents(any(RecipeDiscovery.class), + org.mockito.ArgumentMatchers.eq(plugin)); + } + + @Test + void independentlyDiscoversARegisteredAlternativeRecipe() { + Server server = mock(Server.class); + Plugin plugin = mock(Plugin.class); + PluginManager events = mock(PluginManager.class); + RecipeRegistrar recipes = mock(RecipeRegistrar.class); + NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser"); + when(recipes.register(server)).thenReturn(true); + when(recipes.recipeKeys()).thenReturn(List.of(key)); + when(recipes.description()).thenReturn("alternative Dispenser recipe"); + when(server.getPluginManager()).thenReturn(events); + Set known = new LinkedHashSet<>(); + doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers(); + RecipeLifecycle lifecycle = new RecipeLifecycle( + server, plugin, recipes, Logger.getAnonymousLogger()); + + assertTrue(lifecycle.enable()); + + assertEquals(Set.of(key), known); + verify(events).registerEvents(any(RecipeDiscovery.class), + org.mockito.ArgumentMatchers.eq(plugin)); + } + @Test void registrationExceptionCleansUpAndLeavesDiscoveryInactive() { Server server = mock(Server.class); @@ -58,6 +108,7 @@ final class RecipeLifecycleTest { SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class); when(recipes.register(server)).thenReturn(true); when(recipes.recipeKeys()).thenReturn(keys); + when(recipes.description()).thenReturn("Sorting Stick recipes"); when(server.getPluginManager()).thenReturn(events); doReturn(List.of()).when(server).getOnlinePlayers(); RecipeLifecycle lifecycle = new RecipeLifecycle( @@ -77,6 +128,7 @@ final class RecipeLifecycleTest { SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class); when(recipes.register(server)).thenReturn(true); when(recipes.recipeKeys()).thenReturn(keys); + when(recipes.description()).thenReturn("Sorting Stick recipes"); when(server.getPluginManager()).thenReturn(events); Set onlineKnown = new LinkedHashSet<>(); Set joiningKnown = new LinkedHashSet<>();