diff --git a/README.md b/README.md index 95ffe3f..fdf831c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Purpur Inventory Helper -A Purpur 26.2 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, and risky over-level enchantment combining in ordinary anvils. +A Purpur 26.2 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils. 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). @@ -52,6 +52,22 @@ existing manual crafting and inventory behavior remain available. 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. +## Powder Snow recipe + +One ordinary Snow Block + empty Bucket produces one ordinary Powder Snow Bucket through a +shapeless recipe in both the personal 2×2 and crafting-table 3×3 grids. All players discover it +in the recipe book's `MISC` category after successful registration. Native autofill and ordinary +or shift/batch crafting consume one Snow Block and one Bucket per output, with **no additional +empty Bucket remainder**. The filled Bucket is unstackable; native output-space and cancellation +rules remain authoritative. Placement, recovery and Powder Snow hazards are unchanged. + +The recipe owns only `spigotinventoryhelper:snow_powder_snow` and its discovery listener. +Conflicts/failures withhold its discovery without disabling other features; discovery can retry +on later joins and successful re-enable. Disable removes owned registration/listeners, not native +recipe-book knowledge or unrelated recipes. Native automated tests cover both grids, actual +registered events/packet completion, metadata/quantity conservation and failure boundaries; +real-client visuals and live placement/recovery remain unperformed non-blocking follow-ups. + ## Sorting inventories Hold an authentic Sorting Stick and right-click: diff --git a/src/main/java/games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.java new file mode 100644 index 0000000..03bbba5 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.java @@ -0,0 +1,72 @@ +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.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; + +/** Independently owns the fixed Snow Block and empty Bucket conversion. */ +public final class PowderSnowRecipeRegistrar implements RecipeRegistrar { + private final NamespacedKey recipeKey; + private Server registeredServer; + private boolean owned; + + public PowderSnowRecipeRegistrar(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; + } + ShapelessRecipe recipe = new ShapelessRecipe(recipeKey, new ItemStack(Material.POWDER_SNOW_BUCKET)); + recipe.addIngredient(Material.SNOW_BLOCK); + recipe.addIngredient(Material.BUCKET); + recipe.setCategory(CraftingBookCategory.MISC); + 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 recipeKeys() { + return List.of(recipeKey); + } + + @Override + public void unregister() { + if (!owned || registeredServer == null) { + return; + } + // A prior removal may have succeeded before its port threw. Retain ownership on + // failure, but let a later cleanup confirm absence instead of stranding the lifecycle. + if (!registeredServer.removeRecipe(recipeKey) && registeredServer.getRecipe(recipeKey) != null) { + throw new IllegalStateException("Could not remove owned recipe " + recipeKey); + } + owned = false; + } + + @Override + public String description() { + return "Powder Snow crafting recipe"; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index 9f6dcd4..d028cb2 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -69,8 +69,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")), getLogger() ); + RecipeLifecycle powderSnowRecipe = new RecipeLifecycle( + getServer(), + this, + new PowderSnowRecipeRegistrar(new NamespacedKey(this, "snow_powder_snow")), + getLogger() + ); recipeLifecycles = new RecipeLifecycleGroup( - List.of(sortingRecipes, dispenserRecipe, cobwebRecipe)); + List.of(sortingRecipes, dispenserRecipe, cobwebRecipe, powderSnowRecipe)); recipeLifecycles.enableAll(); InventorySortingService sorting = new InventorySortingService(new InventorySorter()); getServer().getPluginManager().registerEvents( diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java index f3bd983..2269a2a 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java @@ -52,7 +52,7 @@ 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 EXISTING = Set.of(key("dropper_dispenser"), + private static final Set EXISTING = Set.of(key("snow_powder_snow"), 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; @@ -72,7 +72,9 @@ class NativeCobwebPluginWiringTest { static void bootstrap() throws Exception { NativeRuntime.bootstrap(); previousServer = Bukkit.getServer(); - serverUnderTest = mock(Server.class); + // ProtocolLib derives CraftBukkit's package from this external server's concrete class. + serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class, + org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE)); setServer(serverUnderTest); } @@ -112,7 +114,7 @@ class NativeCobwebPluginWiringTest { commands = new SimpleCommandMap(serverUnderTest, new HashMap<>()); when(serverUnderTest.getPluginCommand(any(String.class))).thenAnswer(invocation -> commands.getCommand(invocation.getArgument(0))); - var scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class); when(serverUnderTest.getScheduler()).thenReturn(scheduler); when(scheduler.runTaskTimer(any(), any(Runnable.class), org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong())) diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java new file mode 100644 index 0000000..08e0374 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java @@ -0,0 +1,187 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket; +import net.minecraft.stats.ServerRecipeBook; +import net.minecraft.world.item.crafting.RecipeBookCategories; +import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay; +import net.minecraft.world.item.crafting.display.SlotDisplay; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerRecipeDiscoverEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Real native book/display codecs and actual registered join handlers, not a set-based book fake. */ +class NativePowderSnowBookTest { + @TempDir Path directory; + private static final NamespacedKey KEY = NativePowderSnowRecipeTest.KEY; + private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick"); + + private static long addedEntries(List> packets) { + return packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance) + .map(ClientboundRecipeBookAddPacket.class::cast).mapToLong(packet -> packet.entries().size()).sum(); + } + + private static boolean powderEntry(ClientboundRecipeBookAddPacket.Entry entry) { + return entry.contents().display().result() instanceof SlotDisplay.ItemStackSlotDisplay result + && result.stack().create().is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET); + } + + private static void assertPackedEquals(ServerRecipeBook.Packed expected, ServerRecipeBook.Packed actual) { + assertEquals(ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, expected).getOrThrow(), + ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, actual).getOrThrow()); + } + + @Test + void onlineDiscoverySendsOrdinarySearchableMiscDisplayWithOneNotificationAndHighlight() throws Exception { + try (var f = new NativePowderSnowFixture(directory, 2)) { + var recipe = f.powder().value(); + assertEquals(RecipeBookCategories.CRAFTING_MISC, recipe.recipeBookCategory()); + var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, recipe.display().getFirst()); + var context = new net.minecraft.util.context.ContextMap.Builder() + .withParameter(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES, + org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()) + .create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT); + var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList(); + assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList()); + assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream() + .map(items -> CraftItemStack.asBukkitCopy(items.getFirst()).getType()).toList()); + var result = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, display.result()).stack().create(); + assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(result).getType()); + assertFalse(CraftItemStack.asBukkitCopy(result).hasItemMeta()); + assertEquals(1, result.getCount()); + // Search presentation is supplied by the ordinary native translated output, not a custom name. + assertEquals("item.minecraft.powder_snow_bucket", assertInstanceOf( + net.minecraft.network.chat.contents.TranslatableContents.class, result.getHoverName().getContents()).getKey()); + var displays = new java.util.ArrayList(); + f.manager.listDisplaysForRecipe(f.powder().id(), displays::add); + var id = displays.getFirst().id(); + var powderPackets = f.packets.packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance) + .map(ClientboundRecipeBookAddPacket.class::cast) + .filter(packet -> packet.entries().stream().anyMatch(NativePowderSnowBookTest::powderEntry)).toList(); + assertEquals(1, powderPackets.size()); + var packet = powderPackets.getFirst(); + var entry = packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).findFirst().orElseThrow(); + assertEquals(id, entry.contents().id()); + assertEquals(1, packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).count()); + assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category()); + assertTrue(entry.notification()); + assertTrue(entry.highlight()); + assertEquals(2, entry.contents().craftingRequirements().orElseThrow().size()); + var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), + org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()); + try { + ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, packet); + var decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer); + assertEquals(packet, decoded); + assertFalse(buffer.isReadable()); + } finally { + buffer.release(); + } + var powderEvents = f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).toList(); + assertEquals(1, powderEvents.size()); + assertTrue(powderEvents.getFirst().shouldShowNotification()); + f.actor.events.callEvent(new PlayerJoinEvent(f.actor.player, "repeat")); + assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).count()); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)); + assertTrue(f.menu.craftSlots.isEmpty()); + } + } + + @Test + void nativeKnownHighlightCodecJoinReconnectDisableAndActualReenableRetainUnrelatedState() throws Exception { + try (var f = new NativePowderSnowFixture(directory, 3)) { + var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA); + f.book.add(vanillaKey); + f.book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true); + var joined = f.joiningPlayer(null); + joined.book().add(vanillaKey); + f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "join")); + assertTrue(joined.book().contains(NativePowderSnowFixture.nativeKey(KEY))); + assertTrue(joined.book().contains(vanillaKey)); + assertEquals(5, addedEntries(joined.sent())); + f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "idempotent")); + assertEquals(5, addedEntries(joined.sent())); + var packed = f.book.pack(); + assertTrue(packed.highlight().contains(NativePowderSnowFixture.nativeKey(KEY))); + var nbt = ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, packed).getOrThrow(); + var decoded = ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, nbt).getOrThrow(); + var reconnected = f.joiningPlayer(decoded); + assertPackedEquals(packed, reconnected.book().pack()); + assertTrue(reconnected.book().isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING)); + assertTrue(reconnected.book().isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING)); + f.actor.events.callEvent(new PlayerJoinEvent(reconnected.player(), "reconnected")); + assertTrue(reconnected.sent().isEmpty(), "No second discovery/notification for known recipe"); + f.actor.plugin.setEnabled(false); + assertTrue(f.registered.isEmpty()); + assertPackedEquals(packed, f.book.pack()); + assertPackedEquals(packed, reconnected.book().pack()); + var disabledJoiner = f.joiningPlayer(null); + f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "disabled")); + assertTrue(disabledJoiner.book().pack().known().isEmpty()); + doReturn(List.of(f.actor.player, reconnected.player())).when(f.actor.server).getOnlinePlayers(); + f.actor.plugin.setEnabled(true); + assertEquals(5, f.registered.size()); + assertTrue(reconnected.sent().isEmpty()); + assertPackedEquals(packed, f.book.pack()); + assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY) && e.getPlayer() == f.actor.player).count()); + f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "enabled")); + assertTrue(disabledJoiner.book().contains(NativePowderSnowFixture.nativeKey(KEY))); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK))); + f.actor.realInventory.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.BUCKET))); + f.autofill(false); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType()); + f.actor.assertNoEventErrors(); + } + } + + @Test + void nativeDiscoveryCancellationAndPerPlayerPortFailureAreIsolatedAndRetryOnRealJoin() throws Exception { + try (var f = new NativePowderSnowFixture(directory, 2)) { + var cancelled = f.joiningPlayer(null); + var failed = f.joiningPlayer(null); + var healthy = f.joiningPlayer(null); + var cancel = new AtomicBoolean(true); + f.actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.HIGHEST, + (ignored, raw) -> { + var event = (PlayerRecipeDiscoverEvent) raw; + if (event.getPlayer() == cancelled.player() && event.getRecipe().equals(KEY)) event.setCancelled(cancel.get()); + }, f.actor.plugin, false); + var injected = new IllegalStateException("injected player transport failure"); + doThrow(injected).when(failed.player()).discoverRecipes(List.of(KEY)); + for (var player : List.of(cancelled, failed, healthy)) f.actor.events.callEvent(new PlayerJoinEvent(player.player(), "join")); + var key = NativePowderSnowFixture.nativeKey(KEY); + assertFalse(cancelled.book().contains(key)); + assertFalse(failed.book().contains(key)); + assertTrue(healthy.book().contains(key)); + assertEquals(4, cancelled.book().pack().known().size()); + assertEquals(4, failed.book().pack().known().size()); + assertEquals(5, healthy.book().pack().known().size()); + assertEquals(4, addedEntries(cancelled.sent())); + cancel.set(false); + doCallRealMethod().when(failed.player()).discoverRecipes(any()); + f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry")); + f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry")); + assertTrue(cancelled.book().contains(key)); + assertTrue(failed.book().contains(key)); + assertEquals(5, addedEntries(cancelled.sent())); + f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "known")); + assertEquals(5, addedEntries(cancelled.sent())); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)); + assertEquals(List.of(injected), f.actor.eventErrors, "Only the deliberately injected discovery failure is logged"); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java new file mode 100644 index 0000000..167dd1a --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java @@ -0,0 +1,191 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.stats.ServerRecipeBook; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.inventory.AbstractCraftingMenu; +import net.minecraft.world.inventory.ContainerLevelAccess; +import net.minecraft.world.inventory.CraftingMenu; +import net.minecraft.world.item.crafting.CraftingRecipe; +import net.minecraft.world.item.crafting.RecipeHolder; +import net.minecraft.world.item.crafting.RecipeManager; +import net.minecraft.world.item.crafting.RecipeMap; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.craftbukkit.CraftRegistry; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.craftbukkit.inventory.CraftRecipe; +import org.bukkit.craftbukkit.util.CraftNamespacedKey; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerRecipeDiscoverEvent; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.ShapelessRecipe; + +/** Reuses strict actor/packet ports. Native recipe manager/book/menu/result/event code stays real. + * The Server.addRecipe port converts the exact recipe submitted by actual onEnable, following + * pinned CraftShaped/ShapelessRecipe conversion (including MaterialChoice's native matching). + * No worlds or clients run; packets are captured by the existing native synchronizer port. + */ +final class NativePowderSnowFixture implements AutoCloseable { + final NativeAnvilFixture actor; + final NativeAnvilPacketFixture packets; + final RecipeManager manager; + final ServerRecipeBook book; + final Map registered = new HashMap<>(); + final Map> holders = new HashMap<>(); + final List discoveries = new ArrayList<>(); + final AbstractCraftingMenu menu; + + NativePowderSnowFixture(Path directory, int width) throws Exception { + actor = new NativeAnvilFixture(directory); + packets = new NativeAnvilPacketFixture(actor); + actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false); + actor.plugin.setEnabled(false); + manager = new RecipeManager(CraftRegistry.getMinecraftRegistry()); + when(packets.nativeServer.getRecipeManager()).thenReturn(manager); + when(actor.level.recipeAccess()).thenReturn(manager); + when(actor.level.getGameRules()).thenReturn( + new net.minecraft.world.level.gamerules.GameRules(FeatureFlags.VANILLA_SET)); + book = new ServerRecipeBook(manager::listDisplaysForRecipe); + var bookField = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook"); + assertEquals(ServerRecipeBook.class, bookField.getType()); + bookField.setAccessible(true); + bookField.set(actor.nativePlayer, book); + when(actor.nativePlayer.getRecipeBook()).thenReturn(book); + when(actor.nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + doCallRealMethod().when(actor.nativePlayer).awardRecipes(any()); + doCallRealMethod().when(actor.player).hasDiscoveredRecipe(any()); + doCallRealMethod().when(actor.player).discoverRecipes(any()); + when(actor.server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> registered.get(inv.getArgument(0))); + when(actor.server.addRecipe(any(Recipe.class))).thenAnswer(inv -> { + Recipe bukkit = inv.getArgument(0); + NamespacedKey key = ((Keyed) bukkit).getKey(); + if (registered.putIfAbsent(key, bukkit) != null) return false; + holders.put(key, new RecipeHolder<>(nativeKey(key), convert(bukkit))); + refreshManager(); + return true; + }); + when(actor.server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> { + NamespacedKey key = inv.getArgument(0); + holders.remove(key); + refreshManager(); + return registered.remove(key) != null; + }); + doReturn(List.of(actor.player)).when(actor.server).getOnlinePlayers(); + actor.plugin.setEnabled(true); + assertTrue(book.contains(nativeKey(NativePowderSnowRecipeTest.KEY))); + assertEquals(5, registered.size()); + assertEquals(5, discoveries.size()); + assertEquals(0, count(actor.realInventory, org.bukkit.Material.POWDER_SNOW_BUCKET)); + if (width == 2) { + menu = packets.inventoryMenu; + } else if (width == 3) { + when(actor.level.getBlockState(actor.position)).thenReturn( + net.minecraft.world.level.block.Blocks.CRAFTING_TABLE.defaultBlockState()); + menu = new CraftingMenu(8, actor.realInventory, ContainerLevelAccess.create(actor.level, actor.position)); + menu.setTitle(net.minecraft.network.chat.Component.literal("Crafting")); + } else { + throw new IllegalArgumentException("Unsupported grid width " + width); + } + actor.nativePlayer.containerMenu = menu; + menu.setSynchronizer(packets.synchronizer); + actor.assertNoEventErrors(); + } + + /** A later connection has a real native book, with only player/world/network ports doubled. */ + BookPlayer joiningPlayer(ServerRecipeBook.Packed saved) throws Exception { + var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class); + var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); + var recipeBook = new ServerRecipeBook(manager::listDisplaysForRecipe); + if (saved != null) recipeBook.loadUntrusted(saved, key -> true); + var field = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook"); + assertEquals(ServerRecipeBook.class, field.getType()); + field.setAccessible(true); + field.set(nativePlayer, recipeBook); + when(nativePlayer.getRecipeBook()).thenReturn(recipeBook); + when(nativePlayer.getBukkitEntity()).thenReturn(player); + when(nativePlayer.level()).thenReturn(actor.level); + when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class)); + when(player.getHandle()).thenReturn(nativePlayer); + when(player.getName()).thenReturn("JoiningPowderPlayer"); + doCallRealMethod().when(nativePlayer).awardRecipes(any()); + doCallRealMethod().when(player).hasDiscoveredRecipe(any()); + doCallRealMethod().when(player).discoverRecipes(any()); + nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class); + var sent = new ArrayList>(); + doAnswer(inv -> { sent.add(inv.getArgument(0)); return null; }) + .when(nativePlayer.connection).send(any(net.minecraft.network.protocol.Packet.class)); + return new BookPlayer(player, nativePlayer, recipeBook, sent); + } + + record BookPlayer(org.bukkit.craftbukkit.entity.CraftPlayer player, + net.minecraft.server.level.ServerPlayer nativePlayer, ServerRecipeBook book, + List> sent) {} + + RecipeHolder powder() { return holders.get(NativePowderSnowRecipeTest.KEY); } + + net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(boolean batch) { + var action = menu.handlePlacement(batch, false, powder(), actor.level, actor.realInventory); + actor.assertNoEventErrors(); + return action; + } + + List gridSnapshot() { + return menu.craftSlots.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(); + } + + static long count(net.minecraft.world.Container container, org.bukkit.Material material) { + return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem) + .map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material) + .mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum(); + } + + static ResourceKey> nativeKey(NamespacedKey key) { + return CraftNamespacedKey.toResourceKey(Registries.RECIPE, key); + } + + private void refreshManager() { + manager.recipes = RecipeMap.create(new ArrayList>(holders.values())); + manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET); + } + + private static CraftingRecipe convert(Recipe recipe) { + if (recipe instanceof ShapelessRecipe shapeless) { + return new net.minecraft.world.item.crafting.ShapelessRecipe( + new net.minecraft.world.item.crafting.Recipe.CommonInfo(true), + new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shapeless.getCategory()), shapeless.getGroup()), + CraftItemStack.asTemplate(shapeless.getResult()), shapeless.getChoiceList().stream() + .map(choice -> CraftRecipe.toIngredient(choice, true)).toList()); + } + if (recipe instanceof ShapedRecipe shaped) { + Map ingredients = new HashMap<>(); + shaped.getChoiceMap().forEach((symbol, choice) -> { + if (choice != null) ingredients.put(symbol, CraftRecipe.toIngredient(choice, true)); + }); + return new net.minecraft.world.item.crafting.ShapedRecipe( + new net.minecraft.world.item.crafting.Recipe.CommonInfo(true), + new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shaped.getCategory()), shaped.getGroup()), + net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, shaped.getShape()), + CraftItemStack.asTemplate(shaped.getResult())); + } + throw new AssertionError("Unexpected registered recipe " + recipe.getClass()); + } + + @Override public void close() { + packets.close(); + actor.close(); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java new file mode 100644 index 0000000..b7ee127 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java @@ -0,0 +1,513 @@ +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.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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 NativePowderSnowPluginWiringTest { + private static final NamespacedKey POWDER = key("snow_powder_snow"); + private static final Set EXISTING = Set.of(key("string_cobweb"), 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 registry; + private PaperPluginManagerImpl events; + private SimpleCommandMap commands; + private Logger logger; + private List logs; + private Handler logHandler; + private SpigotInventoryHelperPlugin createdPlugin; + private com.comphenix.protocol.ProtocolManager protocol; + private List protocolListeners; + + @BeforeAll + static void bootstrap() throws Exception { + NativeRuntime.bootstrap(); + previousServer = Bukkit.getServer(); + // ProtocolLib derives CraftBukkit's package from this external server's concrete class. + serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class, + org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE)); + setServer(serverUnderTest); + } + + @BeforeEach + void configureServer() { + reset(serverUnderTest); + protocol = mock(com.comphenix.protocol.ProtocolManager.class); + protocolListeners = new ArrayList<>(); + doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; }).when(protocol).addPacketListener(any()); + doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; }).when(protocol).removePacketListener(any()); + when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); + when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); + when(serverUnderTest.isPrimaryThread()).thenReturn(true); + when(serverUnderTest.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)"); + when(serverUnderTest.getMinecraftVersion()).thenReturn("26.2"); + when(serverUnderTest.getBukkitVersion()).thenReturn("26.2.build.2618-stable"); + when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT); + 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); + commands = new SimpleCommandMap(serverUnderTest, new HashMap<>()); + when(serverUnderTest.getPluginCommand(any(String.class))).thenAnswer(invocation -> + commands.getCommand(invocation.getArgument(0))); + var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class); + when(serverUnderTest.getScheduler()).thenReturn(scheduler); + when(scheduler.runTaskTimer(any(), any(Runnable.class), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong())) + .thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class)); + events = new PaperPluginManagerImpl(serverUnderTest, commands, 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 onlineKnown = new LinkedHashSet<>(Set.of(VANILLA)); + Set 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(POWDER)); + 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 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(POWDER)); + assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe"); + assertNoSevereLogs(); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void powderSnowKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() { + Recipe conflict = mock(Recipe.class); + registry.put(POWDER, conflict); + Set 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(POWDER)); + assertTrue(registry.keySet().containsAll(EXISTING)); + assertTrue(known.containsAll(EXISTING)); + assertFalse(known.contains(POWDER)); + assertEquals(0, grants.get()); + plugin.setEnabled(false); + assertEquals(Set.of(VANILLA, POWDER), registry.keySet()); + assertSame(conflict, registry.get(POWDER)); + + registry.remove(POWDER); + plugin.setEnabled(true); + assertTrue(registry.containsKey(POWDER)); + assertTrue(known.contains(POWDER)); + 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 failedKnown = new LinkedHashSet<>(Set.of(VANILLA)); + Set 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(POWDER)); + 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 partialDiscoveryListenerFailureRollsBackOnlyPowderSnowAndWithholdsJoinDiscovery() { + 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() == 4) { + 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(POWDER)); + assertTrue(registry.keySet().containsAll(EXISTING)); + Set 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(POWDER)); + 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); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"refused", "before-insert", "after-insert", "lookup"}) + void registrationPortFailuresPreserveOtherFeaturesAndRetryThroughActualEnable(String failure) { + var fail = new AtomicBoolean(true); + when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> { + NamespacedKey requested = invocation.getArgument(0); + if (requested.equals(POWDER) && failure.equals("lookup") && fail.get()) + throw new IllegalStateException("injected lookup failure"); + return registry.get(requested); + }); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + Recipe recipe = invocation.getArgument(0); + var key = ((Keyed) recipe).getKey(); + if (key.equals(POWDER) && fail.get()) { + if (failure.equals("refused")) return false; + if (failure.equals("before-insert")) throw new IllegalStateException("injected pre-insert failure"); + if (failure.equals("after-insert")) { + registry.put(key, recipe); + throw new IllegalStateException("injected post-insert failure"); + } + } + return registry.putIfAbsent(key, recipe) == null; + }); + var known = new LinkedHashSet<>(Set.of(VANILLA)); + Player online = player("Online", known, new AtomicInteger(), new AtomicBoolean()); + doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers(); + Recipe vanilla = registry.get(VANILLA); + var plugin = plugin(); + try { + plugin.setEnabled(true); + assertFalse(registry.containsKey(POWDER)); + assertFalse(known.contains(POWDER)); + assertTrue(registry.keySet().containsAll(EXISTING)); + assertTrue(known.containsAll(EXISTING)); + assertSame(vanilla, registry.get(VANILLA)); + // Sorting/admin/native-anvil registration is not withheld by a failed independent recipe. + assertNotNull(plugin.sortingSticks()); + assertFalse(protocolListeners.isEmpty()); + plugin.setEnabled(false); + assertEquals(Set.of(VANILLA), registry.keySet()); + assertSame(vanilla, registry.get(VANILLA)); + fail.set(false); + plugin.setEnabled(true); + assertTrue(registry.containsKey(POWDER)); + assertTrue(known.contains(POWDER)); + assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count()); + } finally { + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"false", "throw"}) + void realLifecycleRemovalFailureRetainsOwnershipForRetryWithoutListenersOrOtherRecipeLoss(String failure) { + var plugin = plugin(); + plugin.setEnabled(true); + var separateKey = key("powder_removal_probe"); + var lifecycle = new RecipeLifecycle(serverUnderTest, plugin, + new PowderSnowRecipeRegistrar(separateKey), plugin.getLogger()); + var fail = new AtomicBoolean(true); + try { + assertTrue(lifecycle.enable()); + Set before = new LinkedHashSet<>(registry.keySet()); + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> { + NamespacedKey requested = invocation.getArgument(0); + if (requested.equals(separateKey) && fail.get()) { + if (failure.equals("false")) return false; + throw new IllegalStateException("injected removal failure"); + } + return registry.remove(requested) != null; + }); + assertThrows(IllegalStateException.class, lifecycle::disable); + assertEquals(before, registry.keySet()); + var known = new LinkedHashSet(); + events.callEvent(new PlayerJoinEvent(player("After failed disable", known, + new AtomicInteger(), new AtomicBoolean()), "joined")); + assertFalse(known.contains(separateKey), "Unregistered discovery must not grant the stranded recipe"); + assertTrue(known.contains(POWDER), "Other independently active listeners remain usable"); + fail.set(false); + lifecycle.disable(); + before.remove(separateKey); + assertEquals(before, registry.keySet()); + assertTrue(lifecycle.enable()); + assertTrue(registry.containsKey(separateKey)); + lifecycle.disable(); + assertEquals(before, registry.keySet()); + } finally { + fail.set(false); + lifecycle.disable(); + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void exceptionAfterSuccessfulRemovalCanRetryCleanupAndRegisterAgain() { + var plugin = plugin(); + plugin.setEnabled(true); + var key = key("powder_post_removal_probe"); + var lifecycle = new RecipeLifecycle(serverUnderTest, plugin, + new PowderSnowRecipeRegistrar(key), plugin.getLogger()); + try { + assertTrue(lifecycle.enable()); + Set before = new LinkedHashSet<>(registry.keySet()); + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> { + NamespacedKey requested = invocation.getArgument(0); + boolean removed = registry.remove(requested) != null; + if (requested.equals(key) && removed) + throw new IllegalStateException("injected exception after successful removal"); + return removed; + }); + assertThrows(IllegalStateException.class, lifecycle::disable); + before.remove(key); + assertEquals(before, registry.keySet()); + assertDoesNotThrow(lifecycle::disable, "Absent owned registration must permit cleanup retry"); + assertTrue(lifecycle.enable()); + assertTrue(registry.containsKey(key)); + } finally { + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer( + invocation -> registry.remove(invocation.getArgument(0)) != null); + // A deliberately stranded absent registration must not mask the RED assertion in cleanup. + if (registry.containsKey(key)) lifecycle.disable(); + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + @Test + void postInsertionAndRollbackRemovalFailureKeepRealLifecycleRetryable() { + var plugin = plugin(); + plugin.setEnabled(true); + var key = key("powder_insertion_probe"); + var lifecycle = new RecipeLifecycle(serverUnderTest, plugin, + new PowderSnowRecipeRegistrar(key), plugin.getLogger()); + var failRemoval = new AtomicBoolean(true); + try { + Set before = new LinkedHashSet<>(registry.keySet()); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + Recipe recipe = invocation.getArgument(0); + var requested = ((Keyed) recipe).getKey(); + registry.put(requested, recipe); + if (requested.equals(key)) throw new IllegalStateException("injected post-insert failure"); + return true; + }); + when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> { + NamespacedKey requested = invocation.getArgument(0); + if (requested.equals(key) && failRemoval.get()) + throw new IllegalStateException("injected rollback removal failure"); + return registry.remove(requested) != null; + }); + assertFalse(lifecycle.enable()); + assertTrue(registry.containsKey(key), "Failed cleanup honestly retains ownership"); + assertTrue(registry.keySet().containsAll(before)); + var known = new LinkedHashSet(); + events.callEvent(new PlayerJoinEvent(player("During failure", known, + new AtomicInteger(), new AtomicBoolean()), "joined")); + assertFalse(known.contains(key)); + failRemoval.set(false); + lifecycle.disable(); + assertEquals(before, registry.keySet()); + when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> { + Recipe recipe = invocation.getArgument(0); + return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null; + }); + assertTrue(lifecycle.enable()); + events.callEvent(new PlayerJoinEvent(player("Retry", known, + new AtomicInteger(), new AtomicBoolean()), "joined")); + assertTrue(known.contains(key)); + assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count()); + } finally { + failRemoval.set(false); + lifecycle.disable(); + plugin.setEnabled(false); + HandlerList.unregisterAll(plugin); + } + } + + private SpigotInventoryHelperPlugin plugin() { + try { + createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest), + NativeAnvilFixture.description(), directory.resolve("data").toFile(), + directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol, + () -> "Purpur 26.2.build.2618-stable"); + commands.registerAll(createdPlugin.getName(), + org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin)); + 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 known, + AtomicInteger powderSnowGrants, AtomicBoolean failPowderSnow) { + 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 requested = ((Collection) invocation.getArgument(0)).stream() + .map(NamespacedKey.class::cast).toList(); + if (requested.contains(POWDER)) { + if (failPowderSnow.get()) { + throw new IllegalStateException("simulated player discovery failure"); + } + powderSnowGrants.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 expectedKnown() { + Set expected = new LinkedHashSet<>(EXISTING); + expected.add(POWDER); + 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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowRecipeTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowRecipeTest.java new file mode 100644 index 0000000..2f13fb0 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowRecipeTest.java @@ -0,0 +1,312 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.List; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Actual plugin registration, pinned native crafting engine; no replacement plugin logic. */ +class NativePowderSnowRecipeTest { + static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "snow_powder_snow"); + @TempDir Path directory; + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void discoveredNativeAutofillAndActualPacketTakePayOnceInBothMenus(int width) throws Exception { + try (var fixture = new NativePowderSnowFixture(directory, width)) { + var inventory = fixture.actor.realInventory; + inventory.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy( + new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK, 2))); + inventory.setItem(1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy( + new org.bukkit.inventory.ItemStack(Material.BUCKET, 2))); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, fixture.autofill(false)); + assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.SNOW_BLOCK)); + assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.BUCKET)); + assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET)); + assertEquals(Material.POWDER_SNOW_BUCKET, + org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getResultSlot().getItem()).getType()); + fixture.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, fixture.packets.clicks.getLast()); + assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey()); + assertFalse(event.isCancelled()); + assertEquals(Material.POWDER_SNOW_BUCKET, + org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getCarried()).getType()); + assertTrue(fixture.menu.craftSlots.isEmpty()); + assertEquals(1, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK)); + assertEquals(1, NativePowderSnowFixture.count(inventory, Material.BUCKET)); + fixture.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void shapelessNativeMatchingRequiresExactlyBothMaterialsInEveryPairOfPositions(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + var recipe = f.powder().value(); + for (int snow = 0; snow < width * width; snow++) { + for (int bucket = 0; bucket < width * width; bucket++) { + if (snow == bucket) continue; + var items = new java.util.ArrayList<>(java.util.Collections.nCopies(width * width, + net.minecraft.world.item.ItemStack.EMPTY)); + items.set(snow, nms(Material.SNOW_BLOCK, 2)); + items.set(bucket, nms(Material.BUCKET, 3)); + assertTrue(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + items.set(snow, net.minecraft.world.item.ItemStack.EMPTY); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.DIRT)) { + items.set(snow, nms(wrong, 1)); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + } + items.set(snow, nms(Material.SNOW_BLOCK, 2)); + items.set(bucket, net.minecraft.world.item.ItemStack.EMPTY); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.WATER_BUCKET, Material.POWDER_SNOW_BUCKET)) { + items.set(bucket, nms(wrong, 1)); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + } + items.set(bucket, nms(Material.BUCKET, 1)); + int extra = java.util.stream.IntStream.range(0, items.size()) + .filter(i -> items.get(i).isEmpty()).findFirst().orElseThrow(); + for (Material material : List.of(Material.BUCKET, Material.SNOW_BLOCK, Material.DIRT)) { + items.set(extra, nms(material, 1)); + assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level)); + } + } + } + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"2,1", "2,3", "2,16", "3,1", "3,3", "3,16"}) + void nativeBatchPacketConservesEachBucketAndSnowWithUnstackableOutput(int width, int crafts) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + var inventory = f.actor.realInventory; + var keep = marked(Material.DIAMOND, 7); + inventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep)); + inventory.setItem(0, nms(Material.SNOW_BLOCK, crafts)); + inventory.setItem(1, nms(Material.BUCKET, crafts)); + assertEquals(16, nms(Material.BUCKET, 1).getMaxStackSize()); + assertEquals(1, nms(Material.POWDER_SNOW_BUCKET, 1).getMaxStackSize()); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(true)); + assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET)); + assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK)); + assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET)); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE); + var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertFalse(event.isCancelled()); + assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey()); + assertEquals(crafts, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET)); + assertEquals(0, NativePowderSnowFixture.count(inventory, Material.BUCKET)); + assertEquals(0, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK)); + assertTrue(f.menu.craftSlots.isEmpty()); + assertTrue(f.menu.getCarried().isEmpty()); + assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(inventory.getItem(20))); + for (var item : inventory.getNonEquipmentItems()) { + if (item.is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET)) ordinaryOutput(item); + } + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Native empty-stack drop calls award nothing"); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void ordinaryPacketPreservesRemainingIngredientMetadataAndAwardsNoExtraBucket(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + var snow = marked(Material.SNOW_BLOCK, 3); + var buckets = marked(Material.BUCKET, 3); + var keep = marked(Material.DIAMOND, 7); + f.actor.realInventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep)); + f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(snow)); + f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(buckets)); + var before = f.gridSnapshot(); + ordinaryOutput(f.menu.getResultSlot().getItem()); + assertEquals(before, f.gridSnapshot(), "Preview is not payment"); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + ordinaryOutput(f.menu.getCarried()); + assertEquals(marked(Material.SNOW_BLOCK, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0))); + assertEquals(marked(Material.BUCKET, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(width * width - 1))); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET)); + assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(20))); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Native empty-stack drop calls award nothing"); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"2,PICKUP", "2,QUICK_MOVE", "3,PICKUP", "3,QUICK_MOVE"}) + void actualNativeCraftItemCancellationSpendsNothingAndLaterRetrySucceeds(int width, net.minecraft.world.inventory.ContainerInput input) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3)); + f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3)); + f.autofill(true); + var grid = f.gridSnapshot(); + var inventory = snapshot(f.actor.realInventory); + var cancel = new java.util.concurrent.atomic.AtomicBoolean(true); + f.packets.listen(org.bukkit.event.EventPriority.HIGHEST, event -> { + if (event instanceof org.bukkit.event.inventory.CraftItemEvent) event.setCancelled(cancel.get()); + }); + f.packets.click(0, 0, input); + assertTrue(assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()).isCancelled()); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, snapshot(f.actor.realInventory)); + assertTrue(f.menu.getCarried().isEmpty()); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Native empty-stack drop calls award nothing"); + cancel.set(false); + f.packets.click(0, 0, input); + assertFalse(f.packets.clicks.getLast().isCancelled()); + long outputs = NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET) + + (f.menu.getCarried().isEmpty() ? 0 : f.menu.getCarried().getCount()); + assertEquals(input == net.minecraft.world.inventory.ContainerInput.PICKUP ? 1 : 3, outputs); + assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET)); + assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK)); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void fullInventoryIncompatibleCursorAndWrongContainerRefuseNativeCompletion(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64)); + f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3)); + f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3)); + var grid = f.gridSnapshot(); + var inventory = snapshot(f.actor.realInventory); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, snapshot(f.actor.realInventory)); + assertTrue(f.menu.getCarried().isEmpty()); + f.menu.setCarried(nms(Material.DIAMOND, 1)); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, snapshot(f.actor.realInventory)); + assertEquals(Material.DIAMOND, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType()); + int events = f.packets.clicks.size(); + f.packets.click(f.menu.containerId + 99, 0, 0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertEquals(events, f.packets.clicks.size()); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, snapshot(f.actor.realInventory)); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Native empty-stack drop calls award nothing"); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void ghostAndAutofillRelocationNeverAwardOrLoseItems(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3)); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, f.autofill(false)); + assertEquals(3, NativePowderSnowFixture.count(f.actor.realInventory, Material.SNOW_BLOCK)); + assertTrue(f.menu.craftSlots.isEmpty()); + assertTrue(f.menu.resultSlots.isEmpty()); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertTrue(f.menu.getCarried().isEmpty()); + assertFalse(f.packets.clicks.getLast() instanceof org.bukkit.event.inventory.CraftItemEvent); + var displaced = marked(Material.DIRT, 2); + f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(displaced)); + f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3)); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false)); + assertEquals(2, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT)); + assertTrue(snapshot(f.actor.realInventory).contains(displaced)); + ordinaryOutput(f.menu.getResultSlot().getItem()); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)); + assertTrue(f.menu.getCarried().isEmpty()); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Native empty-stack drop calls award nothing"); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void batchWithOnlyOneOutputSlotPaysOnceAndRetainsAllUncraftedInputs(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + for (int slot = 0; slot < 35; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64)); + f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3)); + f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3)); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertEquals(1, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)); + assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK)); + assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET)); + assertEquals(35L * 64, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT)); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET)); + var remaining = f.gridSnapshot(); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE); + assertEquals(remaining, f.gridSnapshot(), "Now-full output space cannot spend again"); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty)); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void autofillCannotDiscardMetadataWhenExistingGridHasNoRelocationSpace(int width) throws Exception { + try (var f = new NativePowderSnowFixture(directory, width)) { + for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64)); + f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3)); + f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3)); + f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(marked(Material.DIRT, 2))); + var before = f.gridSnapshot(); + var inventory = snapshot(f.actor.realInventory); + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false)); + assertEquals(before, f.gridSnapshot()); + assertEquals(inventory, snapshot(f.actor.realInventory)); + assertTrue(f.menu.resultSlots.isEmpty()); + assertTrue(f.menu.getCarried().isEmpty()); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty)); + } + } + + private static List snapshot(net.minecraft.world.Container container) { + return java.util.stream.IntStream.range(0, container.getContainerSize()) + .mapToObj(i -> org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(container.getItem(i))).toList(); + } + + private static net.minecraft.world.item.ItemStack nms(Material material, int count) { + return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(material, count)); + } + + private static org.bukkit.inventory.ItemStack marked(Material material, int count) { + var item = new org.bukkit.inventory.ItemStack(material, count); + var meta = item.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("Keep metadata")); + meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"), + org.bukkit.persistence.PersistentDataType.STRING, "unchanged"); + item.setItemMeta(meta); + return item; + } + + private static void ordinaryOutput(net.minecraft.world.item.ItemStack item) { + var bukkit = org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(item); + assertEquals(Material.POWDER_SNOW_BUCKET, bukkit.getType()); + assertEquals(1, bukkit.getAmount()); + assertFalse(bukkit.hasItemMeta()); + } + + @Test + void actualEnableRegistersExactlySnowBlockAndEmptyBucketForOneOrdinaryMiscOutput() throws Exception { + try (var actor = new NativeAnvilFixture(directory)) { + var recipe = assertInstanceOf(ShapelessRecipe.class, actor.server.getRecipe(KEY)); + assertEquals(KEY, recipe.getKey()); + assertEquals(List.of(List.of(Material.SNOW_BLOCK), List.of(Material.BUCKET)), + recipe.getChoiceList().stream().map(choice -> + assertInstanceOf(RecipeChoice.MaterialChoice.class, choice).getChoices()).toList()); + assertEquals(CraftingBookCategory.MISC, recipe.getCategory()); + assertEquals(Material.POWDER_SNOW_BUCKET, recipe.getResult().getType()); + assertEquals(1, recipe.getResult().getAmount()); + assertFalse(recipe.getResult().hasItemMeta()); + actor.assertNoEventErrors(); + } + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java index 8060ad4..e3027a6 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java @@ -31,6 +31,7 @@ class PurpurDistributionTest { Map config = new Yaml().load(jar.getInputStream(jar.getJarEntry("config.yml"))); assertEquals(Map.of("advanced-anvil-base-failure-percent", 20, "advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99), config); + assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.class")); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilListener.class")); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class")); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/NativeAnvilGeneration.class"));