feat(crafting): add cobweb recipe from string
CI / build (pull_request) Successful in 2m19s
CI / build (push) Successful in 6m46s

This commit is contained in:
dmg committed 2026-10-07 19:54:57 -04:00
1 parent 534cab061c
commit ae899926fe
4 files changed
+804 -1

No files matched your search

@@ -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";
}
}
@@ -1,13 +1,25 @@
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 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() {
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
@@ -24,7 +36,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
getLogger()
);
recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe));
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(
@@ -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) {}
}