Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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,12 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private SortingStickItemFactory sortingSticks;
|
||||
private RecipeLifecycle recipeLifecycle;
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -15,8 +16,16 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
sortingSticks,
|
||||
SortingStickRecipeSpec.defaultRecipe()
|
||||
);
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, recipes, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
RecipeLifecycle sortingRecipes = new RecipeLifecycle(
|
||||
getServer(), this, recipes, getLogger());
|
||||
RecipeLifecycle dispenserRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new SortingStickListener(sortingSticks, sorting),
|
||||
@@ -31,9 +40,9 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (recipeLifecycle != null) {
|
||||
recipeLifecycle.disable();
|
||||
recipeLifecycle = null;
|
||||
if (recipeLifecycles != null) {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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