feat(crafting): craft powder snow from snow and a bucket (#3)
This commit was merged in pull request #3.
This commit is contained in:
commit
a08754a3aa
9 files changed
+1305
-5
No files matched your search
@@ -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(
|
||||
|
||||
+5
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
+513
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user