Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a220ae4a | ||
|
|
ae899926fe | ||
|
|
534cab061c |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Purpur Inventory Helper
|
||||
|
||||
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory.
|
||||
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
|
||||
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
|
||||
|
||||
@@ -24,7 +24,7 @@ verifies SHA-256
|
||||
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in
|
||||
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
|
||||
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
|
||||
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both variants.
|
||||
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
|
||||
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
|
||||
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
|
||||
non-blocking live checks.
|
||||
@@ -45,6 +45,10 @@ idempotent; reloading the plugin retains player recipe-book knowledge and ordina
|
||||
If the two recipes cannot register as one complete group, recipe-book discovery is disabled while
|
||||
existing manual crafting and inventory behavior remain available.
|
||||
|
||||
## Alternative Dispenser recipe
|
||||
|
||||
One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration.
|
||||
|
||||
## Sorting inventories
|
||||
|
||||
Hold an authentic Sorting Stick and right-click:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */
|
||||
public final class CobwebRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public CobwebRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB));
|
||||
recipe.shape("S S", " S ", "S S");
|
||||
recipe.setIngredient('S', Material.STRING);
|
||||
recipe.setCategory(CraftingBookCategory.BUILDING);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
owned = server.getRecipe(recipeKey) != null;
|
||||
unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(recipeKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Cobweb crafting recipe";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,16 @@ 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");
|
||||
}
|
||||
|
||||
@@ -41,12 +47,12 @@ public final class RecipeDiscovery implements Listener {
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (!stillUnknown.isEmpty()) {
|
||||
logger.warning("Could not discover Sorting Stick recipes for "
|
||||
logger.warning("Could not discover " + description + " for "
|
||||
+ player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING,
|
||||
"Could not discover Sorting Stick recipes for " + player.getName(), exception);
|
||||
"Could not discover " + description + " for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,14 @@ import org.bukkit.plugin.Plugin;
|
||||
public final class RecipeLifecycle {
|
||||
private final Server server;
|
||||
private final Plugin plugin;
|
||||
private final SortingStickRecipeRegistrar recipes;
|
||||
private final RecipeRegistrar recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(
|
||||
Server server,
|
||||
Plugin plugin,
|
||||
SortingStickRecipeRegistrar recipes,
|
||||
RecipeRegistrar recipes,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
@@ -34,12 +34,13 @@ public final class RecipeLifecycle {
|
||||
}
|
||||
try {
|
||||
if (!recipes.register(server)) {
|
||||
logger.severe("Sorting Stick recipes were not registered as a complete group; "
|
||||
+ "discovery is disabled.");
|
||||
logger.severe(recipes.description()
|
||||
+ " were not registered; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
|
||||
discovery = new RecipeDiscovery(
|
||||
recipes.recipeKeys(), recipes.description(), logger);
|
||||
server.getPluginManager().registerEvents(discovery, plugin);
|
||||
discovery.discoverAll(onlinePlayers);
|
||||
return true;
|
||||
@@ -51,8 +52,7 @@ public final class RecipeLifecycle {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
logger.log(Level.SEVERE,
|
||||
"Sorting Stick recipes were not registered as a complete group; "
|
||||
+ "discovery is disabled.",
|
||||
recipes.description() + " were not registered; discovery is disabled.",
|
||||
exception);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
public final class SortingStickRecipeRegistrar {
|
||||
public final class SortingStickRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private final SortingStickItemFactory itemFactory;
|
||||
private final SortingStickRecipeSpec recipeSpec;
|
||||
@@ -81,6 +81,11 @@ public final class SortingStickRecipeRegistrar {
|
||||
rollback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Sorting Stick recipes";
|
||||
}
|
||||
|
||||
private void rollback() {
|
||||
if (registeredServer == null) {
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
|
||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private SortingStickItemFactory sortingSticks;
|
||||
private RecipeLifecycle recipeLifecycle;
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
|
||||
public SpigotInventoryHelperPlugin() {}
|
||||
|
||||
// Bukkit's explicit initialization path keeps native tests on the real enable callback.
|
||||
SpigotInventoryHelperPlugin(
|
||||
JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file
|
||||
) {
|
||||
super(loader, description, dataFolder, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -15,8 +28,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
sortingSticks,
|
||||
SortingStickRecipeSpec.defaultRecipe()
|
||||
);
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, recipes, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
RecipeLifecycle sortingRecipes = new RecipeLifecycle(
|
||||
getServer(), this, recipes, getLogger());
|
||||
RecipeLifecycle dispenserRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(
|
||||
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new SortingStickListener(sortingSticks, sorting),
|
||||
@@ -31,9 +59,9 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (recipeLifecycle != null) {
|
||||
recipeLifecycle.disable();
|
||||
recipeLifecycle = null;
|
||||
if (recipeLifecycles != null) {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.InvalidDescriptionException;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
|
||||
class NativeCobwebPluginWiringTest {
|
||||
private static final NamespacedKey COBWEB = key("string_cobweb");
|
||||
private static final Set<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
|
||||
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
@TempDir Path directory;
|
||||
private Map<NamespacedKey, Recipe> registry;
|
||||
private PaperPluginManagerImpl events;
|
||||
private Logger logger;
|
||||
private List<LogRecord> logs;
|
||||
private Handler logHandler;
|
||||
private SpigotInventoryHelperPlugin createdPlugin;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
logHandler = new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
};
|
||||
logger.addHandler(logHandler);
|
||||
when(serverUnderTest.getLogger()).thenReturn(logger);
|
||||
registry = new HashMap<>();
|
||||
registry.put(VANILLA, mock(Recipe.class));
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void removeLogHandler() {
|
||||
logger.removeHandler(logHandler);
|
||||
if (createdPlugin != null) {
|
||||
createdPlugin.getLogger().removeHandler(logHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger onlineGrants = new AtomicInteger();
|
||||
AtomicInteger joiningGrants = new AtomicInteger();
|
||||
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
|
||||
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
plugin.setEnabled(true);
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined again"));
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
assertEquals(1, onlineGrants.get());
|
||||
assertEquals(1, joiningGrants.get());
|
||||
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA), registry.keySet());
|
||||
assertSame(vanilla, registry.get(VANILLA));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
Set<NamespacedKey> afterDisable = new LinkedHashSet<>();
|
||||
events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes");
|
||||
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(COBWEB, conflict);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger grants = new AtomicInteger();
|
||||
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
|
||||
.when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(0, grants.get());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA, COBWEB), registry.keySet());
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
|
||||
registry.remove(COBWEB);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertTrue(known.contains(COBWEB));
|
||||
assertEquals(1, grants.get());
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected key conflict is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
AtomicInteger failedGrants = new AtomicInteger();
|
||||
Player failed = player("Failed", failedKnown, failedGrants, fail);
|
||||
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
|
||||
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(failedKnown.contains(COBWEB));
|
||||
assertEquals(expectedKnown(), healthyKnown);
|
||||
assertTrue(failedKnown.containsAll(EXISTING));
|
||||
fail.set(false);
|
||||
events.callEvent(new PlayerJoinEvent(failed, "retry"));
|
||||
assertEquals(expectedKnown(), failedKnown);
|
||||
assertEquals(1, failedGrants.get());
|
||||
assertNoSevereLogs();
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() {
|
||||
PluginManager throwing = mock(PluginManager.class);
|
||||
AtomicInteger discoveries = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
Listener listener = invocation.getArgument(0);
|
||||
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
|
||||
events.registerEvents(listener, owner);
|
||||
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 3) {
|
||||
throw new IllegalStateException("simulated post-listener-registration failure");
|
||||
}
|
||||
return null;
|
||||
}).when(throwing).registerEvents(any(), any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(registry.containsKey(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
|
||||
new AtomicBoolean()), "joined"));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected listener failure is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private SpigotInventoryHelperPlugin plugin() {
|
||||
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
|
||||
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
|
||||
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile());
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
return createdPlugin;
|
||||
} catch (java.io.IOException | InvalidDescriptionException exception) {
|
||||
throw new AssertionError("Could not read generated plugin metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known,
|
||||
AtomicInteger cobwebGrants, AtomicBoolean failCobweb) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
if (requested.contains(COBWEB)) {
|
||||
if (failCobweb.get()) {
|
||||
throw new IllegalStateException("simulated player discovery failure");
|
||||
}
|
||||
cobwebGrants.incrementAndGet();
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private void assertNoSevereLogs() {
|
||||
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
}
|
||||
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(COBWEB);
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) {
|
||||
return new NamespacedKey("spigotinventoryhelper", name);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */
|
||||
class NativeCobwebRecipeTest {
|
||||
private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb");
|
||||
private static final List<Integer> STRING_SLOTS = List.of(0, 2, 4, 6, 8);
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() {
|
||||
ShapedRecipe recipe = registeredRecipe();
|
||||
assertEquals(KEY, recipe.getKey());
|
||||
assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape()));
|
||||
assertEquals(List.of(Material.STRING),
|
||||
((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices());
|
||||
assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory());
|
||||
assertOrdinaryCobweb(recipe.getResult(), 1);
|
||||
var nativeRecipe = nativeRecipe(recipe);
|
||||
assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
|
||||
nativeRecipe.recipeBookCategory());
|
||||
var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay)
|
||||
nativeRecipe.display().getFirst();
|
||||
assertEquals(3, display.width());
|
||||
assertEquals(3, display.height());
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
if (STRING_SLOTS.contains(slot)) {
|
||||
var expected = CraftRecipe.toIngredient(
|
||||
new RecipeChoice.MaterialChoice(Material.STRING), false).display();
|
||||
assertEquals(expected, display.ingredients().get(slot));
|
||||
} else {
|
||||
assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE,
|
||||
display.ingredients().get(slot));
|
||||
}
|
||||
}
|
||||
var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay)
|
||||
display.result();
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var level = mock(net.minecraft.world.level.Level.class);
|
||||
List<net.minecraft.world.item.ItemStack> valid = pattern();
|
||||
assertTrue(recipe.matches(input(valid), level));
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
List<net.minecraft.world.item.ItemStack> wrong = new ArrayList<>(valid);
|
||||
wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY
|
||||
: CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot);
|
||||
}
|
||||
List<net.minecraft.world.item.ItemStack> wrongMaterial = new ArrayList<>(valid);
|
||||
wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT)));
|
||||
assertFalse(recipe.matches(input(wrongMaterial), level));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2,
|
||||
java.util.stream.IntStream.range(0, 4).mapToObj(i ->
|
||||
CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot,
|
||||
CraftItemStack.asNMSCopy(marked(Material.STRING, 2)));
|
||||
}
|
||||
var grid = fixture.menu().craftSlots;
|
||||
assertTrue(recipe.matches(nativeInput(grid), fixture.player().level()));
|
||||
List<ItemStack> before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var assembled = recipe.assemble(nativeInput(grid));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1);
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(),
|
||||
"Preview must neither consume ingredients nor mutate metadata");
|
||||
setResult(fixture.menu(), recipe, assembled);
|
||||
var slot = fixture.menu().getResultSlot();
|
||||
var taken = slot.remove(1);
|
||||
slot.onTake(fixture.player(), taken);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1);
|
||||
assertEquals(5, count(grid, Material.STRING));
|
||||
for (int index : STRING_SLOTS) {
|
||||
assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index)));
|
||||
}
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 3, 64})
|
||||
void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts)));
|
||||
}
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
var action = autofill(fixture, recipe);
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output");
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved");
|
||||
for (int attempt = 0; attempt < crafts; attempt++) {
|
||||
assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level()));
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
var moved = fixture.menu().quickMoveStack(fixture.player(), 0);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1);
|
||||
assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING));
|
||||
}
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertEquals(crafts, count(fixture.inventory(), Material.COBWEB));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)));
|
||||
}
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
}
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty());
|
||||
assertEquals(5, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() {
|
||||
var fixture = crafting();
|
||||
fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4)));
|
||||
var action = autofill(fixture, nativeRecipe(registeredRecipe()));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action);
|
||||
assertEquals(4, count(fixture.inventory(), Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe);
|
||||
var book = new TestRecipeBook((key, consumer) -> consumer.accept(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(0),
|
||||
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
AtomicBoolean cancel = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0);
|
||||
event.setCancelled(cancel.get());
|
||||
return null;
|
||||
}).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertFalse(book.contains(nativeKey()));
|
||||
cancel.set(false);
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(nativeKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey foreign = new NamespacedKey("minecraft", "stick");
|
||||
Recipe unrelated = mock(Recipe.class);
|
||||
registry.put(foreign, unrelated);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registry.put(KEY, invocation.getArgument(0));
|
||||
throw new IllegalStateException("simulated post-insertion failure");
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
assertEquals(Map.of(foreign, unrelated), registry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() {
|
||||
AtomicInteger registrations = new AtomicInteger();
|
||||
AtomicBoolean allow = new AtomicBoolean();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registrations.incrementAndGet();
|
||||
return allow.get();
|
||||
});
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertFalse(registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
allow.set(true);
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertEquals(2, registrations.get(), "Owned registration must be idempotent");
|
||||
AtomicInteger removals = new AtomicInteger();
|
||||
when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1);
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
registrar.unregister();
|
||||
assertEquals(2, removals.get());
|
||||
}
|
||||
|
||||
private static ShapedRecipe registeredRecipe() {
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest));
|
||||
assertEquals(1, registered.size());
|
||||
return (ShapedRecipe) registered.getFirst();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> {
|
||||
if (choice != null) {
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false));
|
||||
}
|
||||
});
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static CraftingFixture crafting() {
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(
|
||||
CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) {
|
||||
var grid = fixture.menu().craftSlots;
|
||||
var slots = fixture.menu().getInputGridSlots();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
@Override public void clearCraftingContent() { grid.clearContent(); }
|
||||
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid), fixture.player().level());
|
||||
}
|
||||
};
|
||||
return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots,
|
||||
fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe),
|
||||
true, false);
|
||||
}
|
||||
|
||||
private static void setResult(net.minecraft.world.inventory.CraftingMenu menu,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) {
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
menu.resultSlots.setItem(0, result);
|
||||
}
|
||||
|
||||
private static List<net.minecraft.world.item.ItemStack> pattern() {
|
||||
return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot)
|
||||
? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))
|
||||
: net.minecraft.world.item.ItemStack.EMPTY).toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) {
|
||||
return input(java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(List<net.minecraft.world.item.ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container container, Material material) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static ItemStack marked(Material material, int amount) {
|
||||
ItemStack item = new ItemStack(material, amount);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text("Keep metadata"));
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
|
||||
PersistentDataType.STRING, "unchanged");
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void assertOrdinaryCobweb(ItemStack item, int amount) {
|
||||
assertEquals(Material.COBWEB, item.getType());
|
||||
assertEquals(amount, item.getAmount());
|
||||
assertFalse(item.hasItemMeta());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey() {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey()));
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); }
|
||||
}
|
||||
|
||||
private record CraftingFixture(net.minecraft.world.entity.player.Player player,
|
||||
net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {}
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,11 @@ final class PluginFoundationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginEntrypointOwnsEnableDisableRecipeLifecycleWiring() throws Exception {
|
||||
Field lifecycle = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycle");
|
||||
void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception {
|
||||
Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles");
|
||||
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
|
||||
|
||||
assertEquals(RecipeLifecycle.class, lifecycle.getType());
|
||||
assertEquals(RecipeLifecycleGroup.class, lifecycles.getType());
|
||||
assertEquals(void.class, disable.getReturnType());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ class PurpurDistributionTest {
|
||||
"games/dmg/spigotinventoryhelper/RecipeDiscovery.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycle.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|
||||
|| entry.getName().contains("NativeRuntime")));
|
||||
@@ -42,6 +46,8 @@ class PurpurDistributionTest {
|
||||
assertTrue(readme.contains("ServerPlaceRecipe"));
|
||||
assertTrue(readme.contains("ServerRecipeBook"));
|
||||
assertTrue(readme.contains("ResultSlot"));
|
||||
assertTrue(readme.contains("Dropper + Tripwire Hook"));
|
||||
assertTrue(readme.contains("Redstone category"));
|
||||
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
|
||||
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -28,6 +29,21 @@ final class RecipeDiscoveryTest {
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() {
|
||||
Set<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<>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -31,6 +32,55 @@ final class RecipeLifecycleTest {
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void failedRegistrationWithholdsDiscoveryUntilALaterRetrySucceeds() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(recipes.register(server)).thenReturn(false, true);
|
||||
when(recipes.recipeKeys()).thenReturn(List.of(key));
|
||||
when(recipes.description()).thenReturn("alternative Dispenser recipe");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<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);
|
||||
@@ -58,6 +108,7 @@ final class RecipeLifecycleTest {
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
@@ -77,6 +128,7 @@ final class RecipeLifecycleTest {
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
|
||||
Reference in new issue
Block a user