feat(crafting): craft powder snow from snow and a bucket #3

Merged
dmg merged 1 commits from us-008-craft-powder-snow-from-snow into main 2026-10-12 13:22:37 +00:00
9 changed files with 1305 additions and 5 deletions

No files matched your search

+17 -1
View File
@@ -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:
@@ -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<NamespacedKey> 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";
}
}
@@ -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(
@@ -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<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
private static final Set<NamespacedKey> 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()))
@@ -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<net.minecraft.network.protocol.Packet<?>> 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<net.minecraft.world.item.crafting.display.RecipeDisplayEntry>();
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");
}
}
}
@@ -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<NamespacedKey, Recipe> registered = new HashMap<>();
final Map<NamespacedKey, RecipeHolder<CraftingRecipe>> holders = new HashMap<>();
final List<PlayerRecipeDiscoverEvent> 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<net.minecraft.network.protocol.Packet<?>>();
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<net.minecraft.network.protocol.Packet<?>> sent) {}
RecipeHolder<CraftingRecipe> 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<org.bukkit.inventory.ItemStack> 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<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(NamespacedKey key) {
return CraftNamespacedKey.toResourceKey(Registries.RECIPE, key);
}
private void refreshManager() {
manager.recipes = RecipeMap.create(new ArrayList<RecipeHolder<?>>(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<Character, net.minecraft.world.item.crafting.Ingredient> 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();
}
}
@@ -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<NamespacedKey> 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<NamespacedKey, Recipe> registry;
private PaperPluginManagerImpl events;
private SimpleCommandMap commands;
private Logger logger;
private List<LogRecord> logs;
private Handler logHandler;
private SpigotInventoryHelperPlugin createdPlugin;
private com.comphenix.protocol.ProtocolManager protocol;
private List<com.comphenix.protocol.events.PacketListener> 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<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(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<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(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<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(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<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(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<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(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<NamespacedKey> 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<NamespacedKey>();
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<NamespacedKey> 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<NamespacedKey> 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<NamespacedKey>();
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<NamespacedKey> 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<NamespacedKey> 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<NamespacedKey> expectedKnown() {
Set<NamespacedKey> 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);
}
}
@@ -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<org.bukkit.inventory.ItemStack> 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();
}
}
}
@@ -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"));