Compare commits

...
1 Commits
Author SHA1 Message Date
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
14 changed files with 1203 additions and 13 deletions

No files matched your search

+15 -1
View File
@@ -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 variants.
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,11 @@ 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.
## 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,52 @@
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 Logger logger;
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
this.recipeKeys = List.copyOf(recipeKeys);
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 Sorting Stick recipes for "
+ player.getName() + ": " + stillUnknown);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not discover Sorting Stick recipes 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 SortingStickRecipeRegistrar recipes;
private final Logger logger;
private RecipeDiscovery discovery;
public RecipeLifecycle(
Server server,
Plugin plugin,
SortingStickRecipeRegistrar 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("Sorting Stick recipes were not registered as a complete group; "
+ "discovery is disabled.");
return false;
}
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
discovery = new RecipeDiscovery(recipes.recipeKeys(), 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,
"Sorting Stick recipes were not registered as a complete group; "
+ "discovery is disabled.",
exception);
return false;
}
}
public void disable() {
unregisterDiscovery();
recipes.unregister();
}
private void unregisterDiscovery() {
if (discovery != null) {
HandlerList.unregisterAll(discovery);
discovery = null;
}
}
}
@@ -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 {
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,95 @@ 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();
}
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);
@@ -5,6 +5,7 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
private SortingStickItemFactory sortingSticks;
private RecipeLifecycle recipeLifecycle;
@Override
public void onEnable() {
@@ -14,9 +15,8 @@ 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 = new RecipeLifecycle(getServer(), this, recipes, getLogger());
recipeLifecycle.enable();
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
getServer().getPluginManager().registerEvents(
new SortingStickListener(sortingSticks, sorting),
@@ -29,6 +29,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
getLogger().info("Spigot Inventory Helper enabled.");
}
@Override
public void onDisable() {
if (recipeLifecycle != null) {
recipeLifecycle.disable();
recipeLifecycle = null;
}
}
SortingStickItemFactory sortingSticks() {
if (sortingSticks == null) {
throw new IllegalStateException("Sorting Stick service is unavailable");
@@ -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
@@ -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 pluginEntrypointOwnsEnableDisableRecipeLifecycleWiring() throws Exception {
Field lifecycle = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycle");
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
assertEquals(RecipeLifecycle.class, lifecycle.getType());
assertEquals(void.class, disable.getReturnType());
}
}
@@ -25,11 +25,27 @@ 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"));
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("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 +62,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,136 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 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,110 @@
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.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 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(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(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);
}
}