Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 534cab061c feat(crafting): add stackable dispenser recipe
Release / release (push) Successful in 4m12s
CI / build (push) Successful in 3m23s
2026-09-22 21:20:47 -04:00
dmg 7854824f01 feat(inventory): discover sorting stick recipes
Release / release (push) Successful in 6m9s
CI / build (push) Successful in 2m2s
2026-09-20 14:48:40 -04:00
20 changed files with 1868 additions and 15 deletions

No files matched your search

+20 -2
View File
@@ -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).
@@ -19,6 +19,16 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
The plugin JAR is written to `build/libs/`.
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.2 build 2618 launcher,
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 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.
## Sorting Stick recipe
Place these ingredients vertically in the crafting grid's center column:
@@ -29,7 +39,15 @@ Redstone Dust
Stick
```
Either storage-block variant produces one Sorting Stick.
Either storage-block variant produces one Sorting Stick. Both variants are universally discovered in
the recipe book's Equipment category for online and joining players. Discovery is persistent and
idempotent; reloading the plugin retains player recipe-book knowledge and ordinary vanilla state.
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
+59
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -40,6 +43,62 @@ tasks.test {
systemProperty("distribution.version", project.version.toString())
}
// Exercise the declared runtime's native recipe-book and crafting engine without starting a server.
// Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved.
val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime")
val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
val launcher = inventoryRuntime.map { it.file("purpur-26.2-2618.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download")
.toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") {
dependsOn(downloadInventoryRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(inventoryRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(inventoryRuntime)
outputs.dir(inventoryRuntime.map { it.dir("versions") })
outputs.dir(inventoryRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(inventoryRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareInventoryRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeInventoryTest = tasks.register<Test>("nativeInventoryTest") {
description = "Runs pinned Purpur recipe-book and crafting regressions without starting a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(inventoryRuntime)
}
tasks.check { dependsOn(nativeInventoryTest) }
val pluginVersion = version
tasks.processResources {
@@ -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<ItemStack> 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<ItemStack> 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<NamespacedKey> 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);
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class RecipeDiscovery implements Listener {
private final List<NamespacedKey> recipeKeys;
private final String description;
private final Logger logger;
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
this(recipeKeys, "recipes", logger);
}
public RecipeDiscovery(List<NamespacedKey> recipeKeys, String description, Logger logger) {
this.recipeKeys = List.copyOf(recipeKeys);
this.description = Objects.requireNonNull(description, "description");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void discoverAll(Iterable<? extends Player> players) {
players.forEach(this::discover);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
}
public void discover(Player player) {
try {
List<NamespacedKey> unknown = recipeKeys.stream()
.filter(key -> !player.hasDiscoveredRecipe(key))
.toList();
if (unknown.isEmpty()) {
return;
}
player.discoverRecipes(unknown);
List<NamespacedKey> stillUnknown = unknown.stream()
.filter(key -> !player.hasDiscoveredRecipe(key))
.toList();
if (!stillUnknown.isEmpty()) {
logger.warning("Could not discover " + description + " for "
+ player.getName() + ": " + stillUnknown);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not discover " + description + " for " + player.getName(), exception);
}
}
}
@@ -0,0 +1,72 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.Plugin;
public final class RecipeLifecycle {
private final Server server;
private final Plugin plugin;
private final RecipeRegistrar recipes;
private final Logger logger;
private RecipeDiscovery discovery;
public RecipeLifecycle(
Server server,
Plugin plugin,
RecipeRegistrar recipes,
Logger logger
) {
this.server = Objects.requireNonNull(server, "server");
this.plugin = Objects.requireNonNull(plugin, "plugin");
this.recipes = Objects.requireNonNull(recipes, "recipes");
this.logger = Objects.requireNonNull(logger, "logger");
}
public boolean enable() {
if (discovery != null) {
return true;
}
try {
if (!recipes.register(server)) {
logger.severe(recipes.description()
+ " were not registered; discovery is disabled.");
return false;
}
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
discovery = new RecipeDiscovery(
recipes.recipeKeys(), recipes.description(), logger);
server.getPluginManager().registerEvents(discovery, plugin);
discovery.discoverAll(onlinePlayers);
return true;
} catch (RuntimeException exception) {
unregisterDiscovery();
try {
recipes.unregister();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
logger.log(Level.SEVERE,
recipes.description() + " were not registered; discovery is disabled.",
exception);
return false;
}
}
public void disable() {
unregisterDiscovery();
recipes.unregister();
}
private void unregisterDiscovery() {
if (discovery != null) {
HandlerList.unregisterAll(discovery);
discovery = null;
}
}
}
@@ -0,0 +1,33 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
public final class RecipeLifecycleGroup {
private final List<RecipeLifecycle> lifecycles;
public RecipeLifecycleGroup(List<RecipeLifecycle> 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;
}
}
}
@@ -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<NamespacedKey> recipeKeys();
void unregister();
String description();
}
@@ -1,18 +1,23 @@
package games.dmg.spigotinventoryhelper;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
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;
public final class SortingStickRecipeRegistrar {
public final class SortingStickRecipeRegistrar implements RecipeRegistrar {
private final NamespacedKey recipeKey;
private final SortingStickItemFactory itemFactory;
private final SortingStickRecipeSpec recipeSpec;
private final RecipeFactory recipeFactory;
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
private Server registeredServer;
public SortingStickRecipeRegistrar(
NamespacedKey recipeKey,
@@ -36,29 +41,100 @@ public final class SortingStickRecipeRegistrar {
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
boolean registered = true;
if (!ownedRecipeKeys.isEmpty()) {
return true;
}
registeredServer = server;
for (Material storage : List.of(Material.CHEST, Material.BARREL)) {
ShapedRecipe recipe = createRecipe(storage);
registered = server.addRecipe(recipe) && registered;
NamespacedKey key = variantKey(storage);
if (server.getRecipe(key) != null) {
rollback();
return false;
}
try {
if (!server.addRecipe(recipe)) {
rollback();
return false;
}
} catch (RuntimeException exception) {
try {
if (server.getRecipe(key) != null) {
ownedRecipeKeys.add(key);
}
rollback();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
throw exception;
}
ownedRecipeKeys.add(key);
}
return registered;
return true;
}
public List<NamespacedKey> recipeKeys() {
return List.of(variantKey(Material.CHEST), variantKey(Material.BARREL));
}
public void unregister() {
rollback();
}
@Override
public String description() {
return "Sorting Stick recipes";
}
private void rollback() {
if (registeredServer == null) {
return;
}
RuntimeException failure = null;
for (NamespacedKey key : List.copyOf(ownedRecipeKeys)) {
try {
if (registeredServer.removeRecipe(key)) {
ownedRecipeKeys.remove(key);
} else {
failure = addFailure(failure,
new IllegalStateException("Could not remove owned recipe " + key));
}
} catch (RuntimeException exception) {
failure = addFailure(failure, exception);
}
}
if (failure != null) {
throw failure;
}
}
private static RuntimeException addFailure(RuntimeException current, RuntimeException next) {
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
}
private ShapedRecipe createRecipe(Material storage) {
ItemStack result = itemFactory.create();
result.setAmount(recipeSpec.outputAmount());
NamespacedKey variantKey = new NamespacedKey(
recipeKey.getNamespace(),
recipeKey.getKey() + "_" + storage.name().toLowerCase(java.util.Locale.ROOT)
);
ShapedRecipe recipe = recipeFactory.create(variantKey, result);
ShapedRecipe recipe = recipeFactory.create(variantKey(storage), result);
recipe.shape(recipeSpec.shape().toArray(String[]::new));
recipe.setIngredient('C', storage);
recipe.setIngredient('R', Material.REDSTONE);
recipe.setIngredient('S', Material.STICK);
recipe.setCategory(CraftingBookCategory.EQUIPMENT);
return recipe;
}
private NamespacedKey variantKey(Material storage) {
return new NamespacedKey(
recipeKey.getNamespace(),
recipeKey.getKey() + "_" + storage.name().toLowerCase(Locale.ROOT)
);
}
@FunctionalInterface
interface RecipeFactory {
ShapedRecipe create(NamespacedKey key, ItemStack result);
@@ -1,10 +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 RecipeLifecycleGroup recipeLifecycles;
@Override
public void onEnable() {
@@ -14,9 +16,16 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
sortingSticks,
SortingStickRecipeSpec.defaultRecipe()
);
if (!recipes.register(getServer())) {
getLogger().warning("One or more Sorting Stick recipes could not be registered.");
}
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),
@@ -29,6 +38,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
getLogger().info("Spigot Inventory Helper enabled.");
}
@Override
public void onDisable() {
if (recipeLifecycles != null) {
recipeLifecycles.disableAll();
recipeLifecycles = null;
}
}
SortingStickItemFactory sortingSticks() {
if (sortingSticks == null) {
throw new IllegalStateException("Sorting Stick service is unavailable");
@@ -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<NamespacedKey, Recipe> 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<NamespacedKey> 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<ShapelessRecipe> 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<net.minecraft.world.inventory.Slot> 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<net.minecraft.world.item.crafting.Recipe<?>>
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<net.minecraft.world.item.crafting.Ingredient> 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<NamespacedKey> 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<NamespacedKey> 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);
}
}
@@ -0,0 +1,475 @@
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.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
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.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
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.Keyed;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile;
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;
/**
* Pinned Purpur recipe-book and crafting behavior with CraftServer, CraftPlayer, and client
* boundaries doubled. No listening server, world, or client is started by this harness.
*/
class NativeRecipeBookTest {
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);
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
@Test
void bothSortingStickVariantsAppearInTheEquipmentCategory() {
RegisteredRecipes registered = registerRecipes();
assertEquals(2, registered.bukkit().size());
assertTrue(registered.bukkit().stream()
.allMatch(recipe -> recipe.getCategory() == CraftingBookCategory.EQUIPMENT));
assertTrue(registered.bukkit().stream().allMatch(recipe ->
registered.items().isSortingStick(recipe.getResult())
&& recipe.getResult().getAmount() == 1));
}
@Test
void serverRecipeBookPersistsKnownHighlightsAndVanillaStateWithOneNotification() {
RegisteredRecipes registered = registerRecipes();
Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
for (ShapedRecipe bukkit : registered.bukkit()) {
var key = nativeRecipeKey(bukkit.getKey());
var nativeRecipe = nativeRecipe(bukkit);
nativeRecipes.put(key, nativeRecipe);
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
}
AtomicInteger nextDisplayId = new AtomicInteger();
Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
Integer> displayIds = new HashMap<>();
nativeRecipes.keySet().forEach(key -> displayIds.put(key, nextDisplayId.getAndIncrement()));
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
var recipe = nativeRecipes.get(key);
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
Optional.of(recipe.placementInfo().ingredients())));
});
PluginManager events = mock(PluginManager.class);
List<org.bukkit.event.Event> fired = new ArrayList<>();
doAnswer(invocation -> {
fired.add(invocation.getArgument(0));
return null;
}).when(events).callEvent(any());
when(serverUnderTest.getPluginManager()).thenReturn(events);
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
when(nativePlayer.getBukkitEntity()).thenReturn(
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
assertEquals(2, book.addRecipes(holders, nativePlayer));
assertEquals(0, book.addRecipes(holders, nativePlayer));
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
book.add(vanillaKey);
book.highlight(vanillaKey);
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
new LinkedHashSet<>(book.pack().known().stream().filter(nativeRecipes::containsKey).toList()));
assertEquals(2, fired.size());
assertTrue(fired.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
TestServerRecipeBook restored = new TestServerRecipeBook();
restored.loadUntrusted(decoded, key -> true);
assertTrue(restored.contains(vanillaKey));
nativeRecipes.keySet().forEach(key -> assertTrue(restored.contains(key)));
assertEquals(new LinkedHashSet<>(decoded.known()), new LinkedHashSet<>(restored.pack().known()));
assertEquals(new LinkedHashSet<>(decoded.highlight()),
new LinkedHashSet<>(restored.pack().highlight()));
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
int knownBefore = restored.pack().known().size();
nativeRecipes.keySet().forEach(restored::add);
assertEquals(knownBefore, restored.pack().known().size());
}
@Test
void nativeLifecycleDiscoversOnlineAndJoinersThenReenablesWithoutLosingKnowledge() {
Map<NamespacedKey, Recipe> registry = new HashMap<>();
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
registry.put(unrelatedKey, 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);
PluginManager events = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
when(serverUnderTest.getPluginManager()).thenReturn(events);
Plugin plugin = enabledPlugin("NativeLifecycle");
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
org.bukkit.entity.Player online = discoveryPlayer("Online", onlineKnown);
org.bukkit.entity.Player joining = discoveryPlayer("Joining", joiningKnown);
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
RegisteredRecipes firstRegistration = recipeServices();
RecipeLifecycle first = new RecipeLifecycle(serverUnderTest, plugin,
firstRegistration.registrar(), plugin.getLogger());
assertTrue(first.enable());
events.callEvent(new org.bukkit.event.player.PlayerJoinEvent(joining, "joined"));
first.disable();
assertEquals(Set.of(unrelatedKey), registry.keySet());
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), onlineKnown);
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), joiningKnown);
RegisteredRecipes secondRegistration = recipeServices();
assertTrue(new RecipeLifecycle(serverUnderTest, plugin,
secondRegistration.registrar(), plugin.getLogger()).enable());
assertTrue(registry.keySet().containsAll(secondRegistration.registrar().recipeKeys()));
assertTrue(registry.containsKey(unrelatedKey));
verify(online, times(1)).discoverRecipes(any());
verify(online, never()).undiscoverRecipes(any());
}
@Test
void nativeLifecycleConflictRollsBackOwnedVariantAndAwardsNobody() {
Map<NamespacedKey, Recipe> registry = new HashMap<>();
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
NamespacedKey conflictKey = new NamespacedKey(
"spigotinventoryhelper", "sorting_stick_recipe_barrel");
Recipe conflict = mock(Recipe.class);
registry.put(unrelatedKey, mock(Recipe.class));
registry.put(conflictKey, conflict);
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);
PluginManager events = mock(PluginManager.class);
when(serverUnderTest.getPluginManager()).thenReturn(events);
Set<NamespacedKey> known = new LinkedHashSet<>();
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
RegisteredRecipes services = recipeServices();
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest,
mock(Plugin.class), services.registrar(), Logger.getAnonymousLogger());
assertFalse(lifecycle.enable());
assertEquals(Set.of(unrelatedKey, conflictKey), registry.keySet());
assertTrue(known.isEmpty());
assertEquals(conflict, registry.get(conflictKey));
verify(events, never()).registerEvents(any(), any());
}
@Test
void nativeLifecycleFailureRemovesPartiallyRegisteredListenerAndRecipes() {
Map<NamespacedKey, Recipe> registry = new HashMap<>();
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);
registry.put(((Keyed) recipe).getKey(), recipe);
return true;
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
PluginManager registeredEvents = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
PluginManager throwingEvents = mock(PluginManager.class);
doAnswer(invocation -> {
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
throw new IllegalStateException("simulated post-registration failure");
}).when(throwingEvents).registerEvents(any(), any());
when(serverUnderTest.getPluginManager()).thenReturn(throwingEvents);
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
Plugin plugin = enabledPlugin("NativeLifecycleFailure");
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
RegisteredRecipes services = recipeServices();
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
services.registrar(), plugin.getLogger());
assertFalse(lifecycle.enable());
registeredEvents.callEvent(new org.bukkit.event.player.PlayerJoinEvent(
discoveryPlayer("Joining", joiningKnown), "joined"));
assertTrue(registry.isEmpty());
assertTrue(joiningKnown.isEmpty());
}
@Test
void nativeAutofillAndResultSlotConsumeEachVariantIntoOneAuthenticatedResult() {
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
RegisteredRecipes registered = registerRecipes();
for (int index = 0; index < registered.bukkit().size(); index++) {
ShapedRecipe bukkit = registered.bukkit().get(index);
Material storage = index == 0 ? Material.CHEST : Material.BARREL;
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(storage)));
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.REDSTONE)));
inventory.setItem(2, CraftItemStack.asNMSCopy(new ItemStack(Material.STICK)));
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, 3)));
List<net.minecraft.world.inventory.Slot> gridSlots =
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.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),
mock(net.minecraft.world.level.Level.class));
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
access, 3, 3, gridSlots, gridSlots, inventory, holder, false, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING,
action);
assertTrue(nativeRecipe.matches(nativeInput(grid),
mock(net.minecraft.world.level.Level.class)));
assertEquals(1, count(grid, storage));
assertEquals(1, count(grid, Material.REDSTONE));
assertEquals(1, count(grid, Material.STICK));
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertEquals(3, inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == Material.DIRT)
.mapToInt(ItemStack::getAmount).sum());
List<ItemStack> gridBeforePreview = grid.getItems().stream()
.map(CraftItemStack::asBukkitCopy).toList();
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(grid));
ItemStack preview = CraftItemStack.asBukkitCopy(nativeResult);
assertTrue(registered.items().isSortingStick(preview));
assertEquals(1, preview.getAmount());
assertEquals(gridBeforePreview, grid.getItems().stream()
.map(CraftItemStack::asBukkitCopy).toList());
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
resultContainer.setRecipeUsed(holder);
resultContainer.setItem(0, nativeResult);
var resultSlot = new net.minecraft.world.inventory.ResultSlot(
nativePlayer, grid, resultContainer, 0, 0, 0);
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
resultSlot.onTake(nativePlayer, taken);
ItemStack crafted = CraftItemStack.asBukkitCopy(taken);
assertTrue(registered.items().isSortingStick(crafted));
assertEquals(1, crafted.getAmount());
assertTrue(resultContainer.isEmpty());
assertTrue(grid.isEmpty());
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertFalse(inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.anyMatch(registered.items()::isSortingStick),
"Preview, autofill, and completion must not create an extra result");
}
}
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
net.minecraft.world.Container grid) {
List<net.minecraft.world.item.ItemStack> items =
java.util.stream.IntStream.range(0, grid.getContainerSize())
.mapToObj(grid::getItem).toList();
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
}
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 Plugin enabledPlugin(String loggerName) {
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
PluginDescriptionFile description = new PluginDescriptionFile(
"SpigotInventoryHelper", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
return plugin;
}
private static org.bukkit.entity.Player discoveryPlayer(
String name, Set<NamespacedKey> 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<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
known.addAll(requested);
return requested.size();
});
return player;
}
private static RegisteredRecipes recipeServices() {
SortingStickItemFactory items = new SortingStickItemFactory(
new NamespacedKey("spigotinventoryhelper", "sorting_stick"));
SortingStickRecipeRegistrar recipes = new SortingStickRecipeRegistrar(
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe"),
items, SortingStickRecipeSpec.defaultRecipe());
return new RegisteredRecipes(items, recipes, List.of());
}
private static RegisteredRecipes registerRecipes() {
List<ShapedRecipe> registered = new ArrayList<>();
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add((ShapedRecipe) invocation.getArgument(0));
return true;
});
RegisteredRecipes services = recipeServices();
assertTrue(services.registrar().register(serverUnderTest));
return new RegisteredRecipes(services.items(), services.registrar(), registered);
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>
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.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
recipe.getChoiceMap().forEach((symbol, choice) ->
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(
ingredients, recipe.getShape());
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()),
pattern, CraftItemStack.asTemplate(recipe.getResult()));
}
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
TestServerRecipeBook() {
this((key, consumer) -> { });
}
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
super(resolver);
}
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
highlight.add(key);
}
}
private record RegisteredRecipes(
SortingStickItemFactory items,
SortingStickRecipeRegistrar registrar,
List<ShapedRecipe> bukkit
) {}
}
@@ -0,0 +1,67 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries, tags, and components using the server test bootstrap path. */
final class NativeRuntime {
private static boolean bootstrapped;
private NativeRuntime() {}
static synchronized void bootstrap() throws Exception {
if (bootstrapped) {
return;
}
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(),
List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(
resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(
layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
bootstrapped = true;
}
}
}
@@ -0,0 +1 @@
mock-maker-subclass
@@ -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<ItemStack> 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());
}
}
@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
@@ -23,4 +25,13 @@ final class PluginFoundationTest {
assertEquals("1.20", plugin.get("api-version"));
assertNotNull(Class.forName((String) plugin.get("main")));
}
@Test
void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception {
Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles");
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
assertEquals(RecipeLifecycleGroup.class, lifecycles.getType());
assertEquals(void.class, disable.getReturnType());
}
}
@@ -25,11 +25,33 @@ class PurpurDistributionTest {
assertFalse(metadata.containsKey("commands"));
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
assertNotNull(jar.getJarEntry(
"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().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|| entry.getName().contains("NativeRuntime")));
}
}
@Test
void documentsPinnedNativeCoverageAndDoubledBoundaries() throws Exception {
String readme = Files.readString(Path.of("README.md"));
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"));
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
@@ -46,6 +68,9 @@ class PurpurDistributionTest {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
assertTrue(build.contains("nativeInventoryTest"));
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
@@ -0,0 +1,152 @@
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;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
final class RecipeDiscoveryTest {
private final List<NamespacedKey> keys = List.of(
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
@Test
void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() {
Set<NamespacedKey> known = new LinkedHashSet<>();
Player player = player("Partial", known);
doAnswer(invocation -> 0).when(player).discoverRecipes(any());
List<LogRecord> 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<NamespacedKey> known = new LinkedHashSet<>();
known.add(keys.getFirst());
Player player = player("Online", known);
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
discovery.discoverAll(List.of(player));
discovery.discoverAll(List.of(player));
assertEquals(new LinkedHashSet<>(keys), known);
verify(player, times(1)).discoverRecipes(List.of(keys.getLast()));
}
@Test
void joiningPlayerReceivesBothUnknownRecipes() {
Set<NamespacedKey> known = new LinkedHashSet<>();
Player player = player("Joining", known);
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
assertEquals(new LinkedHashSet<>(keys), known);
verify(player).discoverRecipes(keys);
}
@Test
void missingPostconditionIsLoggedAndOnlyTheMissingRecipeRetries() {
Set<NamespacedKey> known = new LinkedHashSet<>();
Player player = player("Partial", known);
AtomicBoolean firstAward = new AtomicBoolean(true);
doAnswer(invocation -> {
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
if (firstAward.getAndSet(false)) {
known.add(requested.getFirst());
return 1;
}
known.addAll(requested);
return requested.size();
}).when(player).discoverRecipes(any());
List<LogRecord> warnings = new ArrayList<>();
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
discovery.discover(player);
discovery.discover(player);
assertEquals(new LinkedHashSet<>(keys), known);
verify(player).discoverRecipes(keys);
verify(player).discoverRecipes(List.of(keys.getLast()));
assertEquals(1, warnings.size());
}
@Test
void onePlayerExceptionIsLoggedIsolatedAndRetriedLater() {
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
AtomicBoolean firstAttempt = new AtomicBoolean(true);
Player failing = player("Failing", failedKnown);
doAnswer(invocation -> {
if (firstAttempt.getAndSet(false)) {
throw new IllegalStateException("simulated discovery failure");
}
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
failedKnown.addAll(requested);
return requested.size();
}).when(failing).discoverRecipes(any());
List<LogRecord> warnings = new ArrayList<>();
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
assertDoesNotThrow(() -> discovery.discover(failing));
discovery.discover(player("Healthy", healthyKnown));
discovery.discover(failing);
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
assertEquals(new LinkedHashSet<>(keys), failedKnown);
assertEquals(1, warnings.size());
assertEquals(IllegalStateException.class, warnings.getFirst().getThrown().getClass());
}
private static Logger logger(List<LogRecord> records) {
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { records.add(record); }
@Override public void flush() {}
@Override public void close() {}
});
return logger;
}
private static Player player(String name, Set<NamespacedKey> known) {
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<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
known.addAll(requested);
return requested.size();
});
return player;
}
}
@@ -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();
}
}
@@ -0,0 +1,162 @@
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.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.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class RecipeLifecycleTest {
private final List<NamespacedKey> keys = List.of(
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<NamespacedKey> 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<NamespacedKey> 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);
Plugin plugin = mock(Plugin.class);
PluginManager events = mock(PluginManager.class);
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
when(recipes.register(server)).thenThrow(new IllegalStateException("registry failed"));
when(server.getPluginManager()).thenReturn(events);
RecipeLifecycle lifecycle = new RecipeLifecycle(
server, plugin, recipes, Logger.getAnonymousLogger());
boolean enabled = assertDoesNotThrow(lifecycle::enable);
assertFalse(enabled);
verify(recipes).unregister();
verify(events, never()).registerEvents(any(), any());
verify(server, never()).getOnlinePlayers();
}
@Test
void disableRemovesCurrentLifecycleRecipes() {
Server server = mock(Server.class);
Plugin plugin = mock(Plugin.class);
PluginManager events = mock(PluginManager.class);
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(
server, plugin, recipes, Logger.getAnonymousLogger());
assertTrue(lifecycle.enable());
lifecycle.disable();
verify(recipes).unregister();
}
@Test
void successfulRegistrationDiscoversForOnlineAndLaterJoiningPlayers() {
Server server = mock(Server.class);
Plugin plugin = mock(Plugin.class);
PluginManager events = mock(PluginManager.class);
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<NamespacedKey> onlineKnown = new LinkedHashSet<>();
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
doReturn(List.of(player("Online", onlineKnown))).when(server).getOnlinePlayers();
RecipeLifecycle lifecycle = new RecipeLifecycle(
server, plugin, recipes, Logger.getAnonymousLogger());
assertTrue(lifecycle.enable());
ArgumentCaptor<Listener> listener = ArgumentCaptor.forClass(Listener.class);
verify(events).registerEvents(listener.capture(), org.mockito.ArgumentMatchers.eq(plugin));
((RecipeDiscovery) listener.getValue()).onJoin(
new PlayerJoinEvent(player("Joining", joiningKnown), "joined"));
assertEquals(new LinkedHashSet<>(keys), onlineKnown);
assertEquals(new LinkedHashSet<>(keys), joiningKnown);
}
private static Player player(String name, Set<NamespacedKey> known) {
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<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
known.addAll(requested);
return requested.size();
});
return player;
}
}
@@ -1,9 +1,12 @@
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.junit.jupiter.api.Assertions.assertThrows;
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;
@@ -59,4 +62,96 @@ final class SortingStickRecipeRegistrarTest {
keys.getAllValues().stream().map(NamespacedKey::getKey).toList()
);
}
@Test
void disableRemovesBothCurrentLifecycleRecipesAndNothingElse() {
Server server = mock(Server.class);
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
ItemStack result = mock(ItemStack.class);
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
SortingStickRecipeRegistrar.RecipeFactory recipes =
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
when(items.create()).thenReturn(result);
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
.thenReturn(chestRecipe, barrelRecipe);
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
when(server.removeRecipe(any(NamespacedKey.class))).thenReturn(true);
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
new NamespacedKey("test", "sorting_stick_recipe"),
items,
SortingStickRecipeSpec.defaultRecipe(),
recipes
);
assertTrue(registrar.register(server));
registrar.unregister();
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_chest"));
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_barrel"));
verify(server, times(2)).removeRecipe(any(NamespacedKey.class));
}
@Test
void conflictingVariantRollsBackOnlyTheRecipeAddedByThisLifecycle() {
Server server = mock(Server.class);
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
ItemStack result = mock(ItemStack.class);
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
SortingStickRecipeRegistrar.RecipeFactory recipes =
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
when(items.create()).thenReturn(result);
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
.thenReturn(chestRecipe, barrelRecipe);
when(server.addRecipe(chestRecipe)).thenReturn(true);
when(server.getRecipe(barrelKey)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
when(server.removeRecipe(chestKey)).thenReturn(true);
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
new NamespacedKey("test", "sorting_stick_recipe"),
items,
SortingStickRecipeSpec.defaultRecipe(),
recipes
);
assertFalse(registrar.register(server));
verify(server).removeRecipe(chestKey);
verify(server, never()).removeRecipe(barrelKey);
verify(server, never()).addRecipe(barrelRecipe);
}
@Test
void failedRemovalRetainsOwnershipForLaterRetry() {
Server server = mock(Server.class);
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
ItemStack result = mock(ItemStack.class);
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
SortingStickRecipeRegistrar.RecipeFactory recipes =
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
when(items.create()).thenReturn(result);
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
.thenReturn(chestRecipe, barrelRecipe);
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
when(server.removeRecipe(chestKey)).thenReturn(false, true);
when(server.removeRecipe(barrelKey)).thenReturn(true);
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
new NamespacedKey("test", "sorting_stick_recipe"),
items,
SortingStickRecipeSpec.defaultRecipe(),
recipes
);
assertTrue(registrar.register(server));
assertThrows(IllegalStateException.class, registrar::unregister);
registrar.unregister();
verify(server, times(2)).removeRecipe(chestKey);
verify(server).removeRecipe(barrelKey);
}
}