Compare commits

...
5 Commits
Author SHA1 Message Date
dmg f5298543d0 fix(inventory-helper): restore native and advanced anvil combining
Release / release (push) Successful in 1m49s
CI / build (push) Successful in 1m22s
US-010: 275 automated cases pass; exact candidate tree d0497330ba. Final security/release/live gates separate.
2026-10-13 01:16:27 +00:00
dmg 990028c728 fix(anvil): finalize queued dispatch ownership safely
CI / build (push) Successful in 1m26s
CI / build (pull_request) Successful in 1m28s
2026-10-12 21:11:48 -04:00
dmg 09bc15e979 fix(inventory-helper): restore native and advanced anvil combining
CI / build (push) Successful in 1m23s
CI / build (pull_request) Successful in 1m23s
2026-10-12 20:03:25 -04:00
dmg a08754a3aa feat(crafting): craft powder snow from snow and a bucket (#3)
Release / release (push) Successful in 1m44s
CI / build (push) Successful in 1m21s
2026-10-12 13:22:36 +00:00
dmg b4613d2abf feat(crafting): craft powder snow from snow and a bucket
CI / build (push) Successful in 1m4s
CI / build (pull_request) Successful in 2m3s
2026-10-12 09:18:37 -04:00
24 changed files with 2937 additions and 64 deletions

No files matched your search

+25 -8
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).
@@ -28,8 +28,7 @@ world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
The narrowly scoped anvil-generation adapter also uses this checksum-pinned native runtime as a
compile-only dependency. Server/API and ProtocolLib classes are not bundled.
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
CraftServer, CraftPlayer, and client boundaries are doubled; byte I/O is a controlled port. Anvil tests execute actual ProtocolLib forwarding, one registered local Netty pipeline/event loop, the declared library scheduler with a queued main-task port, and the real native packet queue, including final cancellation/replacement, skipped handling, owner disable and late-forward retirement. This is not evidence of live client rendering, search interaction, socket/installed-stack packet transport, or a full server lifecycle. Those remain
non-blocking live checks.
## Sorting Stick recipe
@@ -52,6 +51,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:
@@ -68,7 +83,9 @@ When a main-hand tool breaks, an item with the exact same material is moved into
## Advanced anvil combining
All players can combine two **same-material, non-book items** in an ordinary anvil. Equal enchantment levels become one higher, even above the normal maximum; unequal levels keep the higher. Compatible applicable enchantments carry over. Conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
All players can combine two **same-material, non-book items** in an ordinary anvil. Enhanced combining applies **only when the combined enchantments exceed a normal maximum**: equal levels become one higher; unequal levels keep the higher. Compatible applicable enchantments carry over. Enhanced conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
Within-limit combinations (for example Sharpness I + I → II or IV + IV → V) stay entirely vanilla, including normal XP/prior-work costs, naming, repair and refusals. Removing advanced inputs and inserting normal items switches back immediately; **no closing/reopening or confirmation is required**. Old advanced clicks cannot take a newly prepared normal result.
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
@@ -78,7 +95,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
| Excess | Failure chance |
| --- | --- |
| 0 | 0% |
| 0 | Native combining; no enhanced roll |
| 1 | 20% |
| 2 | 25% |
| 3 | 35% |
@@ -87,7 +104,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
| 6 | 95% |
| 7 or more | 99% |
ProtocolLib binds taking to the displayed menu generation before payment. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
@@ -104,9 +121,9 @@ Administrators with `spigotinventoryhelper.admin` (default OP) and authorized co
| `advanced-anvil-risk-step-percent` | 5 |
| `advanced-anvil-max-failure-percent` | 99 |
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 always has zero risk. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 stays native and never performs an enhanced roll. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, item serialization, packet/result-taking, stale client generations, and protected explosion boundaries. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, real declared-library forwarding/local Netty pipeline/native queue dispatch, item serialization, result-taking, native packet limits, stale client generations and protected explosion boundaries. Pipeline installation on a live server, real-client transport/rendering and installed-stack behavior are not established by these controlled fixtures. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
## Releases
@@ -140,7 +140,7 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
view.getTopInventory().setItem(2, null);
return;
}
if (preview == null && !protocol.watched(view)) return; // Never-owned vanilla stays native.
if (preview == null) return; // Normal output/XP/cancellation stay native, including after enhanced use.
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
var admission = protocol.consume(view, preview, event);
if (duplicate || admission == null) {
@@ -148,7 +148,6 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
if (preview != null) rerender(view);
return;
}
if (preview == null) return; // Fresh normal book/repair after enhanced context stays native.
boolean stale = !matches(view, preview)
|| !Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2));
AdvancedAnvilDelivery delivery = preview.proposal().output() == null ? null
@@ -267,6 +266,16 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
}
}
void protocolHookReady(AnvilView view) {
if (!closed && current(view) && !pending.containsKey(view)) recompute(view);
}
void protocolRejected(AnvilView view) {
// Exact native dispatch ended without a Bukkit admission (expiry/refusal/revocation).
// Correct client prediction only for this still-current window, never a reopened one.
if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory();
}
Object protocolPreview(AnvilView view) { return previews.get(view); }
boolean protocolReady(AnvilView view) {
@@ -35,6 +35,10 @@ final class AdvancedAnvilMerge {
} catch (ArithmeticException overflow) {
return refused("Enchantment level cannot be represented by this server.");
}
int excess = levels.entrySet().stream()
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
.max().orElse(0);
if (excess == 0) return null; // Native XP, rename, repair and conflict decisions stay native.
ItemStack output = left.clone();
output.setAmount(1);
var enchantments = new ArrayList<>(levels.keySet());
@@ -65,9 +69,6 @@ final class AdvancedAnvilMerge {
} catch (RuntimeException unsupported) {
return refused("Result cannot be represented exactly by this server.");
}
int excess = levels.entrySet().stream()
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
.max().orElse(0);
return new Proposal(output, policy.failurePercent(excess), null);
}
@@ -22,15 +22,28 @@ import org.bukkit.scheduler.BukkitTask;
/** Main-thread, one-use protocol-to-Bukkit admission for exact watched native anvil views. */
final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// SYNC invokes adapters on main, not native handling: ProtocolLib forwards via Netty and
// Purpur queues back to main. Bound lost intents without racing the next scheduler tick.
static final long ADMISSION_TICKS = 40L;
static final class Admission {
final PacketEvent event;
final ServerboundContainerClickPacket packet;
final Object preview;
final java.util.function.BooleanSupplier available;
BukkitTask expiry;
boolean sealed;
volatile boolean sealed;
volatile boolean revoked;
// Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement).
boolean wrapped;
boolean consumed;
final AnvilView view;
final net.minecraft.server.network.ServerGamePacketListenerImpl nativeListener;
java.util.function.BooleanSupplier dispatchable;
Runnable completed;
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
java.util.function.BooleanSupplier available) {
java.util.function.BooleanSupplier available, AnvilView view) {
this.view = view;
this.nativeListener = NativeAnvilGeneration.player(view).connection;
this.event = event;
this.packet = packet;
this.preview = preview;
@@ -38,7 +51,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
boolean valid() {
// Consumption removes the map ticket, but bridge loss must still revoke deferred payment.
return available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
return !revoked && available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
}
}
@@ -49,6 +62,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// Only this immutable actor membership is read on an unexpectedly off-thread library callback.
private final Set<Player> actors = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final Map<AnvilView, Admission> admissions = new IdentityHashMap<>();
private final Map<Player, NativeAnvilDispatch> dispatches = new IdentityHashMap<>();
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
@@ -97,6 +111,21 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
try {
NativeAnvilGeneration.menu(view);
if (!(view.getPlayer() instanceof Player player)) return false;
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null && !dispatch.attached()) {
dispatches.remove(player);
dispatch = null;
}
if (dispatch == null) {
var nativePlayer = NativeAnvilGeneration.player(view);
dispatch = new NativeAnvilDispatch(nativePlayer.connection.connection,
nativePlayer.level().getServer().packetProcessor(), () -> {
if (!closed && !plugin.getServer().isPrimaryThread()) plugin.getServer().getScheduler().runTask(plugin,
() -> { if (!closed && !watched.contains(view)) owner.protocolHookReady(view); });
}, () -> plugin.getServer().isPrimaryThread());
dispatches.put(player, dispatch);
}
if (!dispatch.ready()) return false;
watched.add(view);
actors.add(player);
invalidate(view);
@@ -108,7 +137,11 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
void invalidate(AnvilView view) {
Admission admission = admissions.remove(view);
if (admission != null && admission.expiry != null) admission.expiry.cancel();
if (admission != null) {
admission.revoked = true;
// Retain expiry for a cancelled/non-forwarded record too; cancelling it here
// would leak transport ownership when a newer intent replaces this admission.
}
}
private void send(PacketEvent event) {
@@ -160,13 +193,16 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
invalidate(view); // A new raw intent can never reuse an earlier ticket.
NativeAnvilDispatch acquiredDispatch = null;
Admission acquired = null;
try {
Object raw = event.getPacket().getHandle();
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|| !(raw instanceof ServerboundContainerClickPacket packet)
|| packet.containerInput() == null || !currentFrame(view, packet)
|| (NativeAnvilGeneration.take(packet) && !NativeAnvilGeneration.supportedTake(packet))
|| !owner.protocolReady(view)) {
|| !owner.protocolReady(view)
|| (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) {
refuse(event, view);
return;
}
@@ -175,14 +211,45 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
var translated = new ServerboundContainerClickPacket(packet.containerId(), frames.get(view).nativeState(),
packet.slotNum(), packet.buttonNum(), packet.containerInput(), packet.changedSlots(), packet.carriedItem());
event.setPacket(new PacketContainer(event.getPacketType(), translated));
if (!NativeAnvilGeneration.take(translated)) return;
// Formerly enhanced windows retain only frame translation/refusal for normal
// operations. No normal take depends on enhanced identity-budget/queue ownership.
if (!NativeAnvilGeneration.take(translated) || owner.protocolPreview(view) == null) return;
NativeAnvilDispatch dispatch = dispatches.get(event.getPlayer());
Admission admission = new Admission(event, translated, owner.protocolPreview(view),
() -> !closed && !manager.isClosed() && !versions.exhausted());
admissions.put(view, admission);
admission.expiry = plugin.getServer().getScheduler().runTask(plugin, () -> {
() -> !closed && !manager.isClosed() && !versions.exhausted() && dispatch.ready(), view);
admission.dispatchable = () -> admissions.get(view) == admission && watched.contains(view)
&& NativeAnvilGeneration.current(view, admission.packet)
&& owner.protocolPreview(view) == admission.preview && owner.protocolReady(view);
admission.completed = () -> {
if (admissions.get(view) == admission) admissions.remove(view);
});
} catch (RuntimeException | LinkageError unavailable) { refuse(event, view); }
if (admission.expiry != null) admission.expiry.cancel();
if (!admission.consumed && !closed && !admissions.containsKey(view)) owner.protocolRejected(view);
};
if (!dispatch.admit(admission)) { refuse(event, view); return; }
acquiredDispatch = dispatch;
acquired = admission;
admissions.put(view, admission);
// No owned post listener/marker: foreign markers remain entirely untouched.
admission.expiry = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
dispatch.revoke(admission);
if (admissions.get(view) == admission) admissions.remove(view);
// Unresolved raw ownership waits for the two-queue fence or exact raw arrival.
// Already-wrapped ownership is safe to release even if native skips handle.
}, ADMISSION_TICKS));
} catch (RuntimeException | LinkageError unavailable) {
if (acquired != null) {
acquiredDispatch.revoke(acquired);
if (admissions.get(view) == acquired) admissions.remove(view);
}
refuse(event, view);
} finally {
// Also fences a partially acquired, cancelled record after timer setup throws.
// Cancellation inside a callback is never upfront proof of no raw forward.
if (acquired != null && !acquiredDispatch.requestFence()) {
invalidate(view);
refuse(event, view);
}
}
}
private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) {
@@ -205,11 +272,14 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
Admission consume(AnvilView view, Object preview, InventoryClickEvent event) {
Admission admission = admissions.get(view);
invalidate(view);
if (closed || admission == null || !admission.valid() || admission.preview != preview
Admission admission = NativeAnvilDispatch.current();
if (closed || admission == null || admission.view != view || admissions.get(view) != admission
|| !admission.valid() || admission.preview != preview
|| !NativeAnvilGeneration.current(view, admission.packet)
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
admissions.remove(view);
admission.consumed = true;
if (admission.expiry != null) admission.expiry.cancel();
return admission;
}
@@ -223,7 +293,13 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
watched.remove(view);
frames.remove(view);
if (view.getPlayer() instanceof Player player
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) actors.remove(player);
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) {
actors.remove(player);
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null) dispatch.retire();
// Reuse a draining hook across window reuse, never install a second boundary.
if (dispatch != null && !dispatch.ready()) dispatches.remove(player);
}
}
void prune() {
@@ -241,6 +317,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// unrelated vanilla XP take after the protocol listeners have been removed.
if (view.getPlayer().getOpenInventory() == view) view.getPlayer().closeInventory();
}
dispatches.values().forEach(NativeAnvilDispatch::retire);
dispatches.clear();
actors.clear();
seen.clear();
manager.removePacketListener(outbound);
@@ -0,0 +1,241 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelPipeline;
import java.util.IdentityHashMap;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.PacketType;
import net.minecraft.network.protocol.PacketUtils;
import net.minecraft.network.protocol.game.ServerGamePacketListener;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Exact record ownership behind ProtocolLib, retained through the native thread hop.
* The synchronized ledger is transport-only; all Bukkit/policy validation runs on native main.
*/
final class NativeAnvilDispatch extends ChannelInboundHandlerAdapter {
static final int MAX_OUTSTANDING = 32;
private static final ThreadLocal<AdvancedAnvilProtocolGuard.Admission> CURRENT = new ThreadLocal<>();
private final Connection connection;
private final PacketProcessor processor;
private final Runnable readyCallback;
private final Map<ServerboundContainerClickPacket, AdvancedAnvilProtocolGuard.Admission> records =
new IdentityHashMap<>();
private final com.comphenix.protocol.scheduler.ProtocolScheduler scheduler;
private final java.util.function.BooleanSupplier mainThread;
private boolean mainFencePending;
private volatile boolean unavailable;
private volatile boolean ready;
private volatile boolean removing;
private boolean retiring;
NativeAnvilDispatch(Connection connection, PacketProcessor processor, Runnable readyCallback,
java.util.function.BooleanSupplier mainThread) {
// Link the public, library-owned main queue BEFORE enhanced output/hook installation.
// DefaultScheduler.runTask runs after the executing library filters/posts return;
// this is an ordering fence, not a promise to wait a strict server tick.
scheduler = java.util.Objects.requireNonNull(com.comphenix.protocol.ProtocolLibrary.getScheduler(),
"Missing ProtocolLib main scheduler");
this.mainThread = java.util.Objects.requireNonNull(mainThread);
NativeAnvilPacketLimits.link();
this.connection = java.util.Objects.requireNonNull(connection);
this.processor = java.util.Objects.requireNonNull(processor);
this.readyCallback = readyCallback;
ChannelPipeline pipeline = java.util.Objects.requireNonNull(connection.channel).pipeline();
ChannelHandlerContext nativeContext = pipeline.context(connection);
if (nativeContext == null) throw new IllegalStateException("Missing native connection boundary");
// The pinned Connection handler must be last; ProtocolLib resumes fireChannelRead ahead
// of it. Do not replace, reorder or remove any foreign handler.
if (pipeline.last() != connection) throw new IllegalStateException("Unsupported connection pipeline");
pipeline.addBefore(nativeContext.name(), null, this);
}
boolean attached() { return !removing && connection.channel.pipeline().context(this) != null; }
boolean ready() {
var pipeline = connection.channel.pipeline();
var context = pipeline.context(this);
return ready && !unavailable && !removing && connection.channel.isActive() && context != null
&& pipeline.context(connection) != null && pipeline.last() == connection
&& pipeline.names().indexOf(context.name()) + 1
== pipeline.names().indexOf(pipeline.context(connection).name());
}
@Override public void handlerAdded(ChannelHandlerContext context) {
ready = true;
readyCallback.run(); // Schedules owner recomputation; never accesses a view here.
}
@Override public void handlerRemoved(ChannelHandlerContext context) {
ready = false;
synchronized (this) { records.values().forEach(admission -> admission.revoked = true); }
}
synchronized boolean admit(AdvancedAnvilProtocolGuard.Admission admission) {
if (!ready() || records.size() >= MAX_OUTSTANDING || records.containsKey(admission.packet)) return false;
retiring = false;
records.put(admission.packet, admission);
return true;
}
/** One pending library-main callback per dispatch, never one job per admission. */
synchronized boolean requestFence() {
if (unavailable) return false;
if (mainFencePending) return true;
mainFencePending = true;
try {
if (scheduler.runTask(this::mainFence) == null) {
failBridge();
return false;
}
return true;
} catch (RuntimeException | LinkageError failure) {
failBridge();
return false;
}
}
private void mainFence() {
try {
if (!mainThread.getAsBoolean()) {
failBridge();
return;
}
final java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot;
synchronized (this) {
mainFencePending = false;
if (unavailable) return;
// Snapshot HERE, not on Netty: later unfinished receives cannot be declared
// NO_FORWARD by a barrier queued before their actual library raw forwards.
snapshot = java.util.List.copyOf(records.values()); // bounded by MAX_OUTSTANDING
}
connection.channel.eventLoop().execute(() -> terminalFence(snapshot));
} catch (RuntimeException | LinkageError failure) {
// Rejected submission is not evidence of NO_FORWARD.
failBridge();
}
}
private synchronized void terminalFence(java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot) {
for (var admission : snapshot) {
if (records.get(admission.packet) != admission) continue;
if (!admission.wrapped) {
// All actual raw forwards captured before mainFence are ahead of us on
// this same event loop. Never infer acknowledgement from mutable events/posts.
admission.revoked = true;
records.remove(admission.packet);
} else if (admission.revoked) records.remove(admission.packet);
}
// Transport-only completion: no Bukkit/map/policy callback on the socket loop.
removeIfDrained();
}
private synchronized void failBridge() {
unavailable = true;
mainFencePending = false;
revokeRecords(); // Keep unresolved exact-raw tombstones; no false cleanup claim.
removeIfDrained();
}
static AdvancedAnvilProtocolGuard.Admission current() { return CURRENT.get(); }
@Override public void channelRead(ChannelHandlerContext context, Object message) {
AdvancedAnvilProtocolGuard.Admission admission;
synchronized (this) {
admission = records.get(message);
if (admission != null) {
if (admission.wrapped) return; // Duplicate exact raw while still owned.
admission.wrapped = true; // Sticky, even after ledger retirement.
if (admission.revoked) complete(admission);
}
}
if (admission == null) {
context.fireChannelRead(message);
} else {
// Even a revoked owned record must traverse native connection admission/limits.
// Its exact wrapper denies gameplay on main; do not hide accepted traffic from
// aggregate or record-class native rate counters by dropping it ahead of Connection.
context.fireChannelRead(new BoundPacket(admission));
}
}
synchronized void complete(AdvancedAnvilProtocolGuard.Admission admission) {
if (records.get(admission.packet) == admission) records.remove(admission.packet);
removeIfDrained();
}
synchronized void revoke(AdvancedAnvilProtocolGuard.Admission admission) {
admission.revoked = true;
// The wrapper retains irreversible denial even if native never calls its handle.
if (admission.wrapped) complete(admission);
}
private void revokeRecords() {
records.values().removeIf(admission -> {
admission.revoked = true;
return admission.wrapped;
});
}
synchronized void retire() {
retiring = true;
revokeRecords();
removeIfDrained();
}
private void removeIfDrained() {
if (retiring && records.isEmpty()) {
var pipeline = connection.channel.pipeline();
if (!removing && pipeline.context(this) != null) {
removing = true; // Do not reactivate a hook whose event-loop removal is queued.
pipeline.remove(this);
}
}
}
@Override public void channelInactive(ChannelHandlerContext context) {
synchronized (this) {
records.values().forEach(admission -> admission.revoked = true);
records.clear();
retiring = true;
removeIfDrained();
}
context.fireChannelInactive();
}
final class BoundPacket implements Packet<ServerGamePacketListener> {
private final AdvancedAnvilProtocolGuard.Admission admission;
private boolean nativeLimitChecked;
BoundPacket(AdvancedAnvilProtocolGuard.Admission admission) { this.admission = admission; }
@Override public PacketType<? extends Packet<ServerGamePacketListener>> type() { return admission.packet.type(); }
@Override public void handle(ServerGamePacketListener listener) {
if (!nativeLimitChecked) {
nativeLimitChecked = true;
if (NativeAnvilPacketLimits.refuse(connection, admission.packet)) {
admission.revoked = true;
complete(admission);
// This refusal can occur on Netty: leave the main-thread view ledger to
// its bounded expiry/new intent, never invoke its completion callback here.
return;
}
}
// Queue THIS wrapper, not the original record, so identity survives native PacketProcessor.
PacketUtils.ensureRunningOnSameThread(this, listener, processor);
var previous = CURRENT.get();
try {
if (ready() && admission.valid() && admission.dispatchable.getAsBoolean()
&& listener == admission.nativeListener) {
CURRENT.set(admission);
admission.packet.handle(listener);
}
} finally {
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
complete(admission); // Includes native silent refusal before any Bukkit event.
admission.completed.run();
}
}
}
}
@@ -18,6 +18,13 @@ final class NativeAnvilGeneration {
return menu;
}
static net.minecraft.server.level.ServerPlayer player(AnvilView view) {
if (!(view.getPlayer() instanceof org.bukkit.craftbukkit.entity.CraftPlayer player)) {
throw new IllegalStateException("Unsupported native anvil actor");
}
return player.getHandle();
}
/** Ensure CraftEventFactory's post-event broadcast emits the final installed result.
* Quantity/risk-only changes may otherwise be skipped by native result diffing.
* Invalidate only the owned result's remote prediction, never an actual item or input.
@@ -0,0 +1,87 @@
package games.dmg.spigotinventoryhelper;
import io.papermc.paper.configuration.GlobalConfiguration;
import io.papermc.paper.util.IntervalledCounter;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Pinned Connection class-specific admission for a record carried by a dispatch wrapper.
* Connection still performs its aggregate limit first. Use its SAME lock/counters/config, once
* before the wrapper's native thread hop, not an independent plugin rate policy or counter.
*/
final class NativeAnvilPacketLimits {
private static final Field LOCK = field("PACKET_LIMIT_LOCK", Object.class);
private static final Field ALL = field("allPacketCounts", IntervalledCounter.class);
private static final Field SPECIFIC = field("packetSpecificLimits", Map.class);
private static final Field RECEIVED = field("receivedPackets", int.class);
private static final MethodHandle PUT;
private static final MethodHandle KICK;
static {
try {
PUT = MethodHandles.lookup().findVirtual(Map.class, "put",
MethodType.methodType(Object.class, Object.class, Object.class));
var method = Connection.class.getDeclaredMethod("killForPacketSpam");
method.setAccessible(true);
KICK = MethodHandles.lookup().unreflect(method);
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
private NativeAnvilPacketLimits() {}
static void link() { /* Initialize/verify pinned linkage before any enhanced output. */ }
static boolean refuse(Connection connection, ServerboundContainerClickPacket record) {
try {
var configuration = GlobalConfiguration.get().packetLimiter;
if (configuration == null) throw new IllegalStateException("Missing native packet limiter");
// Match native fast-path: superclass overrides are inspected only if an aggregate
// counter exists or the exact incoming record class has a configured entry.
if (ALL.get(connection) == null && !configuration.overrides.containsKey(record.getClass())) return false;
Object lock = LOCK.get(connection);
if (lock == null || !(SPECIFIC.get(connection) instanceof Map<?, ?> counters)) {
throw new IllegalStateException("Unsupported native packet counters");
}
long now = System.nanoTime();
synchronized (lock) {
for (Class<?> type = record.getClass(); type != Object.class; type = type.getSuperclass()) {
var limit = configuration.overrides.get(type);
if (limit == null || !limit.isEnabled()) continue;
Object stored = counters.get(type);
IntervalledCounter counter;
if (stored == null) {
counter = new IntervalledCounter((long) (limit.interval() * 1.0E9));
PUT.invoke(counters, type, counter);
} else if (stored instanceof IntervalledCounter existing) counter = existing;
else throw new IllegalStateException("Unsupported native packet counter type");
counter.updateAndAdd(1L, now);
if (counter.getRate() < limit.maxPacketRate()) continue;
if (limit.action() == GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK) {
KICK.invoke(connection); // Native disconnect/cancellation path, not a custom kick.
} else if (limit.action() != GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP) {
continue;
}
// Native class refusal returns before receivedPackets++. The native outer
// Connection will increment after this callback, so preserve that accounting.
RECEIVED.setInt(connection, RECEIVED.getInt(connection) - 1);
return true;
}
}
return false;
} catch (Throwable failure) {
if (failure instanceof Error error) throw error;
throw new IllegalStateException("Unavailable native anvil packet-limit bridge", failure);
}
}
private static Field field(String name, Class<?> type) {
try {
var field = Connection.class.getDeclaredField(name);
if (field.getType() != type) throw new IllegalStateException("Unsupported native field " + name);
field.setAccessible(true);
return field;
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
}
@@ -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(
@@ -80,11 +80,9 @@ class NativeAdvancedAnvilProtocolTest {
assertFalse(admitted.isCancelled());
if (policyOnly) {
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
// The admission expiry is deliberately pending: wait for the ACTUAL save
// acknowledgment, not accidentally treating ticket expiry as policy proof.
Runnable expiry = f.nextTick.remove();
// Wait for ACTUAL durable publication with the bounded admission expiry
// still pending; policy invalidation, not elapsed time, must refuse the take.
f.acknowledgeSave();
expiry.run();
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
@@ -111,7 +109,9 @@ class NativeAdvancedAnvilProtocolTest {
var input = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
assertFalse(f.receive(raw(f, mode.equals("slot") ? 3 : 2, 0,
mode.equals("slot") ? ContainerInput.PICKUP_ALL : ContainerInput.PICKUP)).isCancelled());
if (mode.equals("lost")) f.tick();
if (mode.equals("lost")) {
for (long tick = 0; tick < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; tick++) f.tick();
}
packet.handler.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) (mode.equals("button") ? 1 : 0),
mode.equals("input") ? ContainerInput.QUICK_MOVE : ContainerInput.PICKUP,
@@ -23,6 +23,33 @@ class NativeAdvancedAnvilRiskTest {
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
if (excess == 0) {
// US-010 supersedes enhanced E0 routing: keep the 100 controls, but native
// payment/output and absence of ANY enhanced roll/preview are authoritative.
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.player.setItemOnCursor(null);
var ordinary = NativeAnvilCombiningRegressionTest.sword(4);
ordinary.addUnsafeEnchantment(Enchantment.UNBREAKING, 2);
f.inputs(ordinary, ordinary.clone());
assertTrue(f.actionBars.isEmpty());
assertFalse(f.menu.bypassCost);
int cost = f.menu.getCost();
assertTrue(cost > 0);
var expected = f.output().clone();
assertEquals(5, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, expected.getEnchantmentLevel(Enchantment.UNBREAKING));
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(effects.explosions.isEmpty());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
return;
}
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
f.player.setItemOnCursor(null);
@@ -59,14 +59,14 @@ class NativeAdvancedAnvilTest {
fixture.inputs(sword(5), sword(7));
assertEquals(7, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.UNBREAKING, 3);
fixture.inputs(sword(5), right);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), right);
fixture.inputs(sword(6), right);
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), sword(6));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(0, fixture.menu.getCost());
fixture.inputs(right, new ItemStack(Material.DIAMOND_SWORD));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(sword(6), new ItemStack(Material.DIAMOND_SWORD));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
}
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
@@ -82,11 +82,11 @@ class NativeAdvancedAnvilTest {
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
fixture.inputs(sword(5), right);
fixture.inputs(sword(6), right);
assertNull(fixture.output());
assertTrue(lastAction().contains("conflict"));
assertInputs(sword(5), right);
var alreadyConflicting = sword(5);
assertInputs(sword(6), right);
var alreadyConflicting = sword(6);
alreadyConflicting.addUnsafeEnchantment(Enchantment.SMITE, 4);
fixture.inputs(alreadyConflicting, new ItemStack(Material.DIAMOND_SWORD));
assertNull(fixture.output(), "Conflicts already present on left cannot be silently retained");
@@ -94,12 +94,12 @@ class NativeAdvancedAnvilTest {
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
fixture.inputs(sword(5), invalid);
fixture.inputs(sword(6), invalid);
assertNull(fixture.output());
assertTrue(lastAction().contains("applicable"));
fixture.inputs(invalid, sword(5));
fixture.inputs(invalid, sword(6));
assertNull(fixture.output());
assertInputs(invalid, sword(5));
assertInputs(invalid, sword(6));
}
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
@@ -168,7 +168,7 @@ class NativeAdvancedAnvilTest {
int expected = Math.max(0, maximum - ((maximum - 1500) * 2 + maximum * 12 / 100));
assertEquals(expected, ((Damageable) fixture.output().getItemMeta()).getDamage());
assertInputs(left, right);
fixture.inputs(damaged(sword(5), 2), damaged(sword(4), maximum - 1));
fixture.inputs(damaged(sword(6), 2), damaged(sword(4), maximum - 1));
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@@ -0,0 +1,468 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Ordinary and over-limit combining through registered native result-taking paths. */
class NativeAnvilCombiningRegressionTest {
@TempDir Path directory;
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 4})
void withinLimitCombiningRetainsNativeExperienceAndResultTaking(int level) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = 20;
var left = sword(level); var right = sword(level);
f.inputs(left, right);
int cost = f.menu.getCost();
assertTrue(cost > 0, "Within-limit merging must retain its actual native XP cost");
assertFalse(f.menu.getBukkitView().canBypassCost(), "No advanced cost bypass for an ordinary merge");
assertTrue(f.actionBars.isEmpty(), "An ordinary merge must not acquire an advanced risk preview");
assertEquals(level + 1, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(20 - cost, f.nativePlayer.experienceLevel);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
f.tick();
assertEquals(0, rolls.get(), "Native merging performs no enhanced roll or deferred payment");
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void queuedDeclaredLibraryTakeSurvivesSchedulerBeforeNativeDispatch() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
assertEquals(6, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
assertEquals(1, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertTrue(packet.clicks.isEmpty(), "ProtocolLib queues forwarding rather than native inline dispatch");
f.tick(); // Scheduler can run before the Netty forward/native-main handoff.
assertEquals(0, rolls.get());
transport.forwardToNativeQueue();
assertTrue(packet.clicks.isEmpty(), "Native PacketUtils must queue off-thread dispatch");
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled(), "An unchanged admitted generation remains takeable across real queues");
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void earlierForwardedCancellationCannotBorrowALaterIdenticalAdmission() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
transport.forwardToNativeQueue(); // First exact wrapper is already in native PacketProcessor.
transport.receive(raw);
transport.forwardToNativeQueue();
assertEquals(2, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertFalse(transport.received.getLast().isCancelled());
// ProtocolLib has already scheduled both forwards. The first event is cancelled
// before its native event; the later admission must not authorize the first event.
transport.received.getFirst().setCancelled(true);
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertTrue(packet.clicks.isEmpty(), "Each native take must use its OWN final protocol cancellation: revoked wrapper is dropped before Bukkit");
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(1));
assertTrue(transport.processor.executeSinglePacket(), "Second exact wrapper still owns its admission");
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void realConnectionClickSpecificNativeRefusalMustStillApplyToOwnedDispatch() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previousLimiter = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP));
config.packetLimiter = limiter;
// Actual pinned Connection, not the ordinary fixture's controlled handler port.
var nativeConnection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
nativeConnection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, nativeConnection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", nativeConnection);
f.channel.pipeline().replace(f.connection, "packet_handler", nativeConnection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
var nativeRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(nativeRaw);
assertEquals(0, handled.get(), "Positive control: real native click-specific limiter refuses the original record");
var wireRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
var admitted = f.receive(wireRaw);
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(0, handled.get(), "Owned identity wrapper must not bypass native click-specific refusal");
} finally { config.packetLimiter = previousLimiter; }
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"shared-drop", "class-kick"})
void ownedDispatchUsesSharedNativeClassCountersAndNativeViolationAction(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previous = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
var action = mode.equals("class-kick")
? io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK
: io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP;
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, mode.equals("class-kick") ? 0.1 : 2.0, action));
config.packetLimiter = limiter;
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
connection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", connection);
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
if (mode.equals("shared-drop")) {
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(original);
assertEquals(1, handled.get(), "Native first class-rate count permits the original record");
}
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(mode.equals("shared-drop") ? 1 : 0, handled.get());
var received = net.minecraft.network.Connection.class.getDeclaredField("receivedPackets");
received.setAccessible(true);
assertEquals(mode.equals("shared-drop") ? 1 : 0, received.getInt(connection),
"Native class-refused packets must not increment successful receive accounting");
if (mode.equals("class-kick")) {
var stopped = net.minecraft.network.Connection.class.getDeclaredField("stopReadingPackets");
stopped.setAccessible(true);
assertTrue(stopped.getBoolean(connection), "Invoke the native kick/read-stop action");
}
} finally { config.packetLimiter = previous; }
}
}
@Test void removingAdvancedInputsAndPlacingNormalItemsViaNativePacketsImmediatelyRestoresVanilla() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
int oldFrame = f.clientState(f.menu);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(3, 0, ContainerInput.PICKUP); // Return left to native player slot9.
packet.click(1, 0, ContainerInput.PICKUP);
packet.click(4, 0, ContainerInput.PICKUP);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(f.menu.bypassCost);
f.realInventory.setItem(11, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.realInventory.setItem(12, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.menu.broadcastChanges();
packet.click(5, 0, ContainerInput.PICKUP);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(6, 0, ContainerInput.PICKUP);
packet.click(1, 0, ContainerInput.PICKUP);
assertSame(f.menu, f.nativePlayer.containerMenu, "No closing/reopening or advanced mode");
assertFalse(f.menu.bypassCost);
assertEquals(5, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(net.kyori.adventure.text.Component.empty(), f.actionBars.getLast());
int cost = f.menu.getCost();
assertTrue(cost > 0);
int eventsBefore = packet.clicks.size();
packet.click(f.menu.containerId, oldFrame, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertEquals(eventsBefore, packet.clicks.size());
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(5, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(sword(5), f.player.getInventory().getItem(9));
assertEquals(sword(5), f.player.getInventory().getItem(10));
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalRenameRepairPriorWorkAndRefusalRemainNative(boolean previouslyAdvanced) throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
packet.survival();
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
var left = sword(1);
var meta = (org.bukkit.inventory.meta.Damageable) left.getItemMeta();
meta.setDamage(1500);
((org.bukkit.inventory.meta.Repairable) meta).setRepairCost(8);
left.setItemMeta(meta);
var right = left.clone();
f.inputs(left, right);
f.menu.setItemName("ordinary rename");
f.prepareAgain();
assertFalse(f.menu.bypassCost);
assertTrue(f.menu.getCost() > 16, "Prior work plus native merge/repair/naming XP retained");
assertEquals(net.kyori.adventure.text.Component.text("ordinary rename"), f.output().getItemMeta().customName());
assertTrue(((org.bukkit.inventory.meta.Damageable) f.output().getItemMeta()).getDamage() < 1500);
assertEquals(17, ((org.bukkit.inventory.meta.Repairable) f.output().getItemMeta()).getRepairCost());
var expensive = sword(4);
var prior = (org.bukkit.inventory.meta.Repairable) expensive.getItemMeta();
prior.setRepairCost(80); expensive.setItemMeta(prior);
f.inputs(expensive, sword(4));
assertFalse(f.menu.bypassCost);
assertNull(f.output(), "Native Too Expensive refusal must not inherit enhanced bypass");
assertTrue(f.menu.getCost() >= 40);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalInsufficientExperienceAndFinalCancellationSpendNothing(boolean previouslyAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 0);
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
f.inputs(sword(1), sword(1));
assertTrue(f.menu.getCost() > 0);
assertFalse(f.menu.getSlot(2).mayPickup(f.nativePlayer));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
nativeExperienceAndWear(f, 100);
packet.listen(org.bukkit.event.EventPriority.MONITOR, event -> event.setCancelled(true));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"expiry", "inputs", "policy", "close", "shutdown", "native-refusal"})
void queuedRetiredOrRefusedIntentsNeverDebitAndCannotAuthorizeLaterTakes(String mode) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
transport.receive(raw(f));
transport.forwardToNativeQueue();
switch (mode) {
case "expiry" -> { for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick(); }
case "inputs" -> f.inputs(sword(6), sword(6));
case "policy" -> { f.admin(f.console, "advanced-anvil-base-failure-percent", 80); f.acknowledgeSave(); }
case "close" -> packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
case "shutdown" -> f.plugin.setEnabled(false);
case "native-refusal" -> org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(true);
default -> throw new AssertionError(mode);
}
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
if (mode.equals("close") || mode.equals("shutdown")) {
assertEquals(2, NativeAdvancedAnvilBoundaryTest.count(f, sword(5)));
assertEquals(1, f.channel.pipeline().names().stream().filter(n -> n.equals("packet_handler")).count());
} else {
org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(false);
var expected = f.output().clone();
transport.receive(raw(f)); transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(1, rolls.get(), "A refused old dispatch must not strand or lend a ticket to the fresh take");
assertEquals(expected, f.player.getItemOnCursor());
assertFalse(transport.processor.executeSinglePacket());
}
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void alreadyQueuedOldIdentityCannotTakeFromAReusedNativeWindow(boolean newAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
transport.receive(raw(f)); transport.forwardToNativeQueue();
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
var input = sword(newAdvanced ? 5 : 4);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
int cost = reopened.getCost();
assertEquals(newAdvanced, reopened.bypassCost);
transport.receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
reopened.containerId, f.clientState(reopened), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket()); // Retired exact old-window wrapper.
transport.assertNoErrors();
assertTrue(packet.clicks.isEmpty());
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(input, reopened.getBukkitView().getTopInventory().getItem(0));
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(newAdvanced ? 1 : 0, rolls.get());
assertEquals(newAdvanced ? 100 : 100 - cost, f.nativePlayer.experienceLevel);
assertTrue(reopened.getSlot(0).getItem().isEmpty());
assertTrue(reopened.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void outstandingIdentityBudgetRefusesOverflowAndRecoversOnlyAfterExactRetirement() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
f.inputs(sword(5), sword(5));
var held = new java.util.ArrayList<Object>();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
held.add(admitted.getPacket().getHandle());
}
assertTrue(f.receive(raw(f)).isCancelled(), "Bound outstanding exact records instead of replacing their identities");
for (var record : held) f.dispatchInbound(record);
f.tick();
assertTrue(packet.clicks.isEmpty());
assertEquals(0, rolls.get());
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void hookPassesUnrelatedTrafficAndRemovesOnlyItselfAfterShutdownDrain() throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
var forwarded = new java.util.ArrayList<Object>();
var probe = new io.netty.channel.ChannelInboundHandlerAdapter() {
@Override public void channelRead(io.netty.channel.ChannelHandlerContext context, Object message) {
forwarded.add(message); context.fireChannelRead(message);
}
};
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
var baseline = f.channel.pipeline().names();
var message = new Object();
// Replace only the fixture's controlled transport port behavior for non-packets.
org.mockito.Mockito.doAnswer(inv -> null).when(f.connection).channelRead(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.same(message));
f.channel.pipeline().fireChannelRead(message);
assertEquals(java.util.List.of(message), forwarded);
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.plugin.setEnabled(false);
f.dispatchInbound(admitted.getPacket().getHandle());
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(baseline.size() - 1, f.channel.pipeline().names().size(), "Remove only drained owned hook");
assertTrue(packet.clicks.isEmpty());
}
}
static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(NativeAnvilFixture f) {
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
}
static void nativeExperienceAndWear(NativeAnvilFixture f, int xp) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = xp;
}
static ItemStack sword(int sharpness) {
var item = new ItemStack(Material.DIAMOND_SWORD);
item.addUnsafeEnchantment(Enchantment.SHARPNESS, sharpness);
return item;
}
}
@@ -56,6 +56,15 @@ final class NativeAnvilFixture implements AutoCloseable {
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
final net.minecraft.server.MinecraftServer nativeServer = mock(net.minecraft.server.MinecraftServer.class);
final net.minecraft.network.PacketProcessor processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
final net.minecraft.network.Connection connection;
final net.minecraft.server.network.ServerGamePacketListenerImpl transport =
mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
io.netty.channel.Channel channel;
private java.util.function.Consumer<net.minecraft.network.protocol.game.ServerboundContainerClickPacket> legacyClick;
final List<Object> unrelatedInbound = new ArrayList<>();
private final io.papermc.paper.configuration.GlobalConfiguration previousConfiguration;
final CraftPlayer player = mock(CraftPlayer.class);
final ServerLevel level = mock(ServerLevel.class);
final CraftWorld world = mock(CraftWorld.class);
@@ -69,6 +78,15 @@ final class NativeAnvilFixture implements AutoCloseable {
final List<String> adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>();
final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class);
final java.util.concurrent.BlockingQueue<Runnable> nextTick = new java.util.concurrent.LinkedBlockingQueue<>();
private long tickCount;
RuntimeException admissionSchedulingFailure;
RuntimeException librarySchedulingFailure;
final java.util.Queue<Runnable> libraryTasks = new java.util.concurrent.ConcurrentLinkedQueue<>();
final org.bukkit.plugin.Plugin libraryPlugin = mock(org.bukkit.plugin.Plugin.class);
private final Object previousLibraryScheduler;
private final Object previousLibraryPlugin;
private int taskId;
private final Map<Long, java.util.Collection<Runnable>> delayed = new java.util.TreeMap<>();
final List<Runnable> repeating = new ArrayList<>();
final List<BukkitTask> tasks = new ArrayList<>();
final List<Throwable> eventErrors = new ArrayList<>();
@@ -88,9 +106,58 @@ final class NativeAnvilFixture implements AutoCloseable {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable");
}
NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception {
this(directory, () -> 99, () -> 1.0, "Purpur 26.2.build.2618-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version) throws Exception {
this(directory, risk, wear, version, false);
}
private NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version, boolean queuedTransport) throws Exception {
NativeRuntime.bootstrap();
previousConfiguration = io.papermc.paper.configuration.GlobalConfiguration.get();
if (previousConfiguration == null) {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.misc = config.new Misc();
config.packetLimiter = config.new PacketLimiter();
config.unsupportedSettings = config.new UnsupportedSettings();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", config);
}
connection = mock(net.minecraft.network.Connection.class);
when(level.getServer()).thenReturn(nativeServer);
when(nativeServer.packetProcessor()).thenReturn(processor);
when(transport.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; })
.when(transport).onPacketError(any(), any());
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "connection", connection);
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "server", nativeServer);
doAnswer(inv -> {
Object message = inv.getArgument(1);
try {
if (message instanceof net.minecraft.network.protocol.Packet<?> raw) dispatchPacket(raw);
else unrelatedInbound.add(message);
} catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
// Actual PacketUtils enqueued the wrapper or unrelated native packet.
}
return null;
}).when(connection).channelRead(any(), any());
channel = queuedTransport ? new NativeAnvilTestChannel() : new io.netty.channel.embedded.EmbeddedChannel();
channel.pipeline().addLast("packet_handler", connection);
connection.channel = channel;
nativePlayer.connection = transport;
doAnswer(inv -> {
if (legacyClick == null) throw new AssertionError("Unconfigured native click port");
legacyClick.accept(inv.getArgument(0));
return null;
}).when(transport).handleContainerClick(any());
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; })
.when(protocol).addPacketListener(any());
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; })
@@ -99,7 +166,8 @@ final class NativeAnvilFixture implements AutoCloseable {
setServer(server);
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.isPrimaryThread()).thenReturn(true);
var mainThread = Thread.currentThread();
when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread);
when(server.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)");
when(server.getMinecraftVersion()).thenReturn("26.2");
when(server.getBukkitVersion()).thenReturn("26.2.build.2618-stable");
@@ -120,7 +188,32 @@ final class NativeAnvilFixture implements AutoCloseable {
doReturn(List.of()).when(server).getOnlinePlayers();
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> schedule(inv.getArgument(1), nextTick));
when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> {
if (inv.getArgument(0) == libraryPlugin) {
if (!libraryPlugin.isEnabled()) throw new IllegalStateException("Required library disabled");
if (librarySchedulingFailure != null) throw librarySchedulingFailure;
return schedule(inv.getArgument(1), libraryTasks);
}
return schedule(inv.getArgument(1), nextTick);
});
when(libraryPlugin.getServer()).thenReturn(server);
when(libraryPlugin.isEnabled()).thenReturn(true);
var schedulerField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("scheduler");
schedulerField.setAccessible(true);
previousLibraryScheduler = schedulerField.get(null);
var pluginField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("plugin");
pluginField.setAccessible(true);
previousLibraryPlugin = pluginField.get(null);
schedulerField.set(null, new com.comphenix.protocol.scheduler.DefaultScheduler(libraryPlugin));
pluginField.set(null, libraryPlugin);
when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(inv -> {
if (admissionSchedulingFailure != null) throw admissionSchedulingFailure;
long delay = inv.getArgument(2);
if (delay < 1) throw new AssertionError("Unexpected non-positive delayed task");
return schedule(inv.getArgument(1), delayed.computeIfAbsent(tickCount + delay, ignored -> new ArrayList<>()));
});
doAnswer(ignored -> { List.copyOf(tasks).forEach(BukkitTask::cancel); return null; })
.when(scheduler).cancelTasks(any(org.bukkit.plugin.Plugin.class));
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
when(player.getHandle()).thenReturn(nativePlayer);
@@ -183,8 +276,6 @@ final class NativeAnvilFixture implements AutoCloseable {
plugin.setEnabled(true);
// Even event-level fixtures must echo a genuinely published client frame, never the
// server counter. Reuse the pinned native synchronizer with only its transport mocked.
var transport = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
nativePlayer.connection = transport;
doAnswer(inv -> { send(inv.getArgument(0)); return null; })
.when(transport).send(any(net.minecraft.network.protocol.Packet.class));
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
@@ -247,9 +338,16 @@ final class NativeAnvilFixture implements AutoCloseable {
var packet = receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
menu.containerId, clientState(menu), (short) rawSlot, (byte) button, input,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
if (packet.isCancelled()) event.setCancelled(true);
else events.callEvent(event);
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
event.setCancelled(true); // A dropped/revoked transport record never reaches Bukkit.
if (!packet.isCancelled()) {
legacyClick = translated -> {
event.setCancelled(false);
events.callEvent(event);
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
};
try { dispatchInbound(packet.getPacket().getHandle()); }
finally { legacyClick = null; }
}
assertNoEventErrors();
return event;
}
@@ -257,9 +355,31 @@ final class NativeAnvilFixture implements AutoCloseable {
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
var event = clientEvent(payload);
dispatchProtocol(event);
if (!event.isCancelled()) {
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event));
}
return event;
}
void dispatchInbound(Object record) {
channel.pipeline().fireChannelRead(record);
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.checkException();
}
private void dispatchPacket(net.minecraft.network.protocol.Packet<?> raw) {
if (raw instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(transport);
else if (raw instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(transport);
else throw new AssertionError("Unsupported native game packet port: " + raw.getClass());
}
static void field(Class<?> owner, Object target, String name, Object value) throws ReflectiveOperationException {
var field = owner.getDeclaredField(name);
if (value != null && !field.getType().isInstance(value)) throw new IllegalStateException("Unexpected native field: " + field);
field.setAccessible(true);
field.set(target, value);
}
com.comphenix.protocol.events.PacketEvent clientEvent(Object payload) {
var container = new com.comphenix.protocol.events.PacketContainer(
com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, payload);
@@ -311,7 +431,17 @@ final class NativeAnvilFixture implements AutoCloseable {
return menu.getBukkitView().getTopInventory().getItem(2);
}
void pumpLibraryTasks() {
if (!server.isPrimaryThread()) throw new AssertionError("Library callbacks must run on main");
Runnable task;
while ((task = libraryTasks.poll()) != null) task.run();
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks();
}
void tick() {
pumpLibraryTasks();
var due = delayed.remove(++tickCount);
if (due != null) List.copyOf(due).forEach(Runnable::run);
var ready = List.copyOf(nextTick);
nextTick.clear();
ready.forEach(Runnable::run);
@@ -319,6 +449,13 @@ final class NativeAnvilFixture implements AutoCloseable {
assertNoEventErrors();
}
// Paper disables the plugin before cancelling its scheduler tasks. Do not let a fixture
// silently supply the expiry callbacks that real disable removes.
void disableAndCancelTasks() {
plugin.setEnabled(false);
server.getScheduler().cancelTasks(plugin);
}
void assertNoEventErrors() {
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
}
@@ -327,7 +464,9 @@ final class NativeAnvilFixture implements AutoCloseable {
queue.add(runnable);
BukkitTask task = mock(BukkitTask.class);
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
tasks.add(task);
when(task.getTaskId()).thenReturn(++taskId);
// Only owning-plugin tasks belong to realistic owner-disable cancellation.
if (queue != libraryTasks) tasks.add(task);
return task;
}
@@ -335,6 +474,12 @@ final class NativeAnvilFixture implements AutoCloseable {
try {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.finishAndReleaseAll();
else ((NativeAnvilTestChannel) channel).finish();
field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", previousLibraryScheduler);
field(com.comphenix.protocol.ProtocolLibrary.class, null, "plugin", previousLibraryPlugin);
processor.close();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", previousConfiguration);
setServer(previous);
} catch (Exception failure) {
throw new AssertionError("Could not restore server", failure);
@@ -0,0 +1,342 @@
package games.dmg.spigotinventoryhelper;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.raw;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.sword;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import net.minecraft.network.HashedStack;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.world.inventory.ContainerInput;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Terminal ownership through actual local ProtocolLib forwarding/pipeline/native queue.
* No sockets, live server, or arbitrary gameplay-plugin compatibility claim.
*/
class NativeAnvilLifecycleRegressionTest {
@TempDir Path directory;
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void finalFilterNoForwardOfAdmittedHandleRestoresCapacityAtExpiry(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var foreign = filter(f, event -> {
if (mode.equals("cancel")) event.setCancelled(true);
else event.setPacket(new PacketContainer(event.getPacketType(), inert(f)));
});
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) transport.receive(raw(f));
for (var event : transport.received) assertEquals(mode.equals("cancel"), event.isCancelled());
expire(f);
transport.forwardToNativeQueue(); // Resolve queued fence before asserting capacity.
f.protocol.removePacketListener(foreign);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Final filter never forwarded admitted A: expiry must restore its capacity");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void cancelledPacketDisableDoesNotRelyOnPluginExpiryAndPreservesForeignHandler() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var probe = new io.netty.channel.ChannelInboundHandlerAdapter();
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> event.setCancelled(true));
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Cancelled means no native forward");
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Resolved no-forward shutdown removes only owned hook without a plugin timer");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void admissionTimerAcquisitionFailureRollsBackCapacity() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
f.admissionSchedulingFailure = new IllegalStateException("Controlled timer acquisition failure");
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertTrue(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
f.admissionSchedulingFailure = null;
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Failed acquisition must not fill the exact-identity budget");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void nativeQueueSkipWithoutWrapperHandleRestoresCapacityAtExpiry() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
assertTrue(transport.processor.executeSinglePacket());
}
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty(), "Native queue really skipped wrapper.handle");
expire(f);
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Revoked already-wrapped ownership can retire without handle/finally");
}
}
@Test void aggregateNativeRefusalWithoutWrapperHandleRetiresHookAtExpiryAndClose() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previous = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK);
limiter.overrides = new java.util.HashMap<>();
config.packetLimiter = limiter;
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
connection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", connection);
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
// Keep the inspected frame-synchronizer port: changing the listener's
// connection must not bypass the fixture's actual ProtocolLib frame publication.
doAnswer(inv -> { f.connection.send(inv.getArgument(0), inv.getArgument(1), true); return null; })
.when(f.transport).send(any(net.minecraft.network.protocol.Packet.class), any());
var disconnects = new java.util.concurrent.CopyOnWriteArrayList<Object>();
// Controlled outbound port: hold completion of the real native disconnect
// write so the channel stays active; do not silently let disconnect clear ownership.
var foreign = new io.netty.channel.ChannelOutboundHandlerAdapter() {
@Override public void write(io.netty.channel.ChannelHandlerContext context, Object message,
io.netty.channel.ChannelPromise promise) { disconnects.add(message); }
};
f.channel.pipeline().addFirst("held-disconnect-write", foreign);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertFalse(transport.received.getFirst().isCancelled());
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Aggregate refusal happens before wrapper.handle");
assertTrue(disconnects.stream().anyMatch(message -> message instanceof
net.minecraft.network.protocol.common.ClientboundDisconnectPacket), "Native kick path actually executed");
assertTrue(f.channel.isActive(), "Retirement proof must not rely on channelInactive");
expire(f);
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
assertSame(foreign, f.channel.pipeline().get("held-disconnect-write"));
assertSame(connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Already-wrapped aggregate refusal must not strand retired owned hook");
assertTrue(packet.clicks.isEmpty());
} finally { config.packetLimiter = previous; }
}
}
@ParameterizedTest @ValueSource(strings = {"filter", "post"})
void reentrantDisableKeepsUnresolvedOrScheduledExactRawSafeBeyondExpiryAndWindowReuse(String phase)
throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> {
if (phase.equals("filter")) {
// Cancellation is NOT terminal inside a filter: the same callback can
// still restore the captured exact raw before ProtocolLib schedules it.
event.setCancelled(true);
f.disableAndCancelTasks();
event.setCancelled(false);
} else {
post(f, event, forwarded -> {
// ProtocolLib has already captured/scheduled the exact raw. A
// post cancellation is not proof that this raw will never arrive.
forwarded.setReadOnly(false);
forwarded.setCancelled(true);
f.disableAndCancelTasks();
});
}
});
transport.receive(raw(f));
assertEquals(phase.equals("post"), transport.received.getFirst().isCancelled());
expire(f); f.tick(); // Raw exact forward is still held in the real library event loop.
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.tick();
assertTrue(packet.clicks.isEmpty(), "Late revoked exact raw must not fall through to vanilla");
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(sword(4), reopened.getBukkitView().getTopInventory().getItem(0));
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void postHandleMutationCannotEraseCapturedForwardAndRetiredWrapperDenial(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
filter(f, event -> post(f, event, forwarded -> {
forwarded.setReadOnly(false);
if (mode.equals("cancel")) forwarded.setCancelled(true);
else forwarded.setPacket(new PacketContainer(forwarded.getPacketType(), inert(f)));
f.disableAndCancelTasks();
}));
transport.receive(raw(f));
assertEquals(1, NetworkMarker.getNetworkMarker(transport.received.getFirst()).getPostListeners().size(),
"Only the foreign marker listener exists; ownership never adds an acknowledgement");
expire(f);
assertEquals(before, f.channel.pipeline().names().size(), "Captured exact raw is still unresolved");
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
transport.forwardToNativeQueue();
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Late raw is wrapped revoked and releases the retired hook without wrapper.handle");
assertTrue(transport.processor.executeSinglePacket());
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty());
assertTrue(f.player.getItemOnCursor().isEmpty(), "Skipped revoked wrapper delivers nothing");
}
}
@Test void mainFenceSnapshotExcludesSubsequentReceiveWhoseRawIsBehindBarrier() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var cancellation = filter(f, event -> event.setCancelled(true));
transport.receive(raw(f));
transport.pumpLibraryTasks(); // First snapshot/barrier precedes the next raw forward.
f.protocol.removePacketListener(cancellation);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
assertEquals(1, packet.clicks.size(), "Later admitted raw must still reach the exact native wrapper");
f.tick();
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
}
}
@Test void unavailablePublicLibrarySchedulerWithholdsEnhancedOutputBeforeHookInstallation() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var scheduler = com.comphenix.protocol.ProtocolLibrary.getScheduler();
int before = f.channel.pipeline().names().size();
try {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", null);
f.inputs(sword(5), sword(5));
transport.acknowledgeLoop();
assertNull(f.output(), "No library-main fence port means no enhanced bypass/output");
assertEquals(before, f.channel.pipeline().names().size());
} finally {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", scheduler);
}
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
assertNotNull(f.output(), "A failed constructor did not leave an owned boundary behind");
}
}
@Test void rejectedLibraryFenceKeepsUnresolvedTombstoneRatherThanClaimingNoForward() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
f.librarySchedulingFailure = new IllegalStateException("Controlled library fence rejection");
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket());
assertEquals(before, f.channel.pipeline().names().size(),
"Failed fence cannot prove NO_FORWARD; unresolved ownership stays until raw/disconnect");
assertTrue(packet.clicks.isEmpty());
}
}
private static void post(NativeAnvilFixture f, PacketEvent event, Consumer<PacketEvent> callback) {
// A foreign post listener owns its own marker; production no longer creates one.
var marker = NetworkMarker.getNetworkMarker(event);
if (marker == null) {
marker = new NetworkMarker(ConnectionSide.CLIENT_SIDE, event.getPacketType());
event.setNetworkMarker(marker);
}
marker.addPostListener(new PacketPostListener() {
@Override public org.bukkit.plugin.Plugin getPlugin() { return f.plugin; }
@Override public void onPostEvent(PacketEvent forwarded) { callback.accept(forwarded); }
});
}
private static PacketAdapter filter(NativeAnvilFixture f, Consumer<PacketEvent> callback) {
var listener = new PacketAdapter(f.plugin, ListenerPriority.MONITOR, PacketType.Play.Client.WINDOW_CLICK) {
@Override public void onPacketReceiving(PacketEvent event) { callback.accept(event); }
};
f.protocol.addPacketListener(listener);
return listener;
}
private static ServerboundContainerClickPacket inert(NativeAnvilFixture f) {
return new ServerboundContainerClickPacket(f.menu.containerId, f.menu.getStateId(), (short) -999,
(byte) 0, ContainerInput.PICKUP, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
}
private static void expire(NativeAnvilFixture f) {
for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick();
}
}
@@ -30,8 +30,8 @@ import org.bukkit.event.inventory.InventoryClickEvent;
/** Real packet classification/event dispatch/native clicked; explicit world/thread/network ports only. */
final class NativeAnvilPacketFixture implements AutoCloseable {
final NativeAnvilFixture anvil;
final MinecraftServer nativeServer = mock(MinecraftServer.class);
final PacketProcessor processor = mock(PacketProcessor.class);
final MinecraftServer nativeServer;
final PacketProcessor processor;
// Final external CraftServer port only; retain the suite's subclass maker everywhere else.
final CraftServer craftServer = mock(CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
@@ -58,8 +58,10 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
config.unsupportedSettings = config.new UnsupportedSettings();
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
}
connection = mock(Connection.class);
handler = mock(ServerGamePacketListenerImpl.class);
connection = anvil.connection;
handler = anvil.transport;
nativeServer = anvil.nativeServer;
processor = anvil.processor;
when(anvil.level.getServer()).thenReturn(nativeServer);
when(anvil.level.getCraftServer()).thenReturn(craftServer);
when(anvil.nativePlayer.isAlive()).thenReturn(true);
@@ -68,7 +70,6 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean());
when(nativeServer.packetProcessor()).thenReturn(processor);
when(processor.isSameThread()).thenReturn(true);
when(craftServer.getPluginManager()).thenReturn(anvil.events);
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
when(anvil.nativePlayer.getAbilities()).thenReturn(abilities);
@@ -158,7 +159,7 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
if (!event.isCancelled()) {
Object admitted = event.getPacket().getHandle();
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
handler.handleContainerClick(translated);
anvil.dispatchInbound(translated);
}
anvil.assertNoEventErrors();
}
@@ -0,0 +1,51 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** Real registered local pipeline/event loop, with no byte transport or sockets. */
final class NativeAnvilTestChannel extends AbstractChannel {
final DefaultEventLoop loop = new DefaultEventLoop();
private final ChannelConfig config = new DefaultChannelConfig(this);
final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private volatile boolean open = true;
NativeAnvilTestChannel() throws InterruptedException {
super(null);
loop.register(this).sync();
}
void acknowledge() throws Exception { loop.submit(() -> {}).get(10, TimeUnit.SECONDS); }
void finish() throws InterruptedException {
close().sync();
loop.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync();
}
@Override public ChannelConfig config() { return config; }
@Override public ChannelMetadata metadata() { return new ChannelMetadata(false); }
@Override public boolean isOpen() { return open; }
@Override public boolean isActive() { return open && isRegistered(); }
@Override protected boolean isCompatible(EventLoop candidate) { return candidate == loop; }
@Override protected java.net.SocketAddress localAddress0() { return null; }
@Override protected java.net.SocketAddress remoteAddress0() { return null; }
@Override protected void doBind(java.net.SocketAddress address) { throw new UnsupportedOperationException("No sockets"); }
@Override protected void doDisconnect() { doClose(); }
@Override protected void doClose() { open = false; }
@Override protected void doBeginRead() { }
@Override protected void doWrite(ChannelOutboundBuffer buffer) { while (buffer.current() != null) buffer.remove(); }
@Override protected AbstractUnsafe newUnsafe() {
return new AbstractUnsafe() {
@Override public void connect(java.net.SocketAddress remote, java.net.SocketAddress local,
ChannelPromise promise) { promise.setFailure(new UnsupportedOperationException("No sockets")); }
};
}
@Override protected DefaultChannelPipeline newChannelPipeline() {
return new DefaultChannelPipeline(this) {
@Override protected void onUnhandledInboundException(Throwable failure) {
if (!(failure instanceof net.minecraft.server.RunningOnDifferentThreadException)) failures.add(failure);
}
};
}
}
@@ -0,0 +1,115 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.injector.ListenerManager;
import com.comphenix.protocol.injector.netty.channel.NettyChannelInjector;
import io.netty.channel.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Actual library forwarding, one registered real event loop/pipeline, and real native queue.
* Only byte I/O, packet lookup/filter invocation and external player/world ports are controlled.
* No sockets/server startup. Handler readiness and library-main callbacks require explicit pumping.
*/
final class NativeAnvilTransportFixture implements AutoCloseable {
final NativeAnvilPacketFixture packet;
final PacketProcessor processor;
private final NativeAnvilFixture f;
private final DefaultEventLoop loop;
private final CountDownLatch forward = new CountDownLatch(1);
private boolean held;
private boolean receiving;
private final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private final NettyChannelInjector injector;
private final ChannelHandlerContext context;
final List<PacketEvent> received = new ArrayList<>();
NativeAnvilTransportFixture(NativeAnvilFixture f, NativeAnvilPacketFixture packet) throws Exception {
this.f = f;
this.packet = packet;
this.processor = f.processor;
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { failures.add(inv.getArgument(1)); return null; }).when(packet.handler).onPacketError(any(), any());
if (!(f.channel instanceof NativeAnvilTestChannel channel)) {
throw new AssertionError("Transport must start with its real channel; native handlers are not sharable");
}
loop = channel.loop;
var listeners = mock(ListenerManager.class);
when(listeners.hasMainThreadListener(PacketType.Play.Client.WINDOW_CLICK)).thenReturn(true);
doAnswer(inv -> {
PacketEvent event = inv.getArgument(0);
received.add(event);
f.dispatchProtocol(event);
return null;
}).when(listeners).invokeInboundPacketListeners(any());
injector = new NettyChannelInjector(f.player, packet.connection, channel, listeners,
mock(com.comphenix.protocol.injector.netty.channel.InjectionFactory.class),
mock(com.comphenix.protocol.error.ErrorReporter.class));
channel.pipeline().addFirst("protocollib-boundary", new ChannelInboundHandlerAdapter());
acknowledgeLoop();
context = channel.pipeline().context("protocollib-boundary");
if (context.channel() != packet.connection.channel || context.executor() != channel.eventLoop()
|| channel.eventLoop() != loop) throw new AssertionError("Library and native loop must be identical");
}
void acknowledgeHook() throws Exception {
acknowledgeLoop(); // handlerAdded on the actual event loop, not an inline readiness mock
f.tick(); // queued owner readiness/recompute callback on main
acknowledgeLoop();
if (f.output() == null) throw new AssertionError("Readiness callback did not publish enhanced output");
}
void receive(ServerboundContainerClickPacket raw) throws Exception {
if (!held) {
held = true;
loop.execute(() -> {
try { if (!forward.await(10, TimeUnit.SECONDS)) failures.add(new AssertionError("Forward gate timed out")); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); failures.add(failure); }
});
}
var method = NettyChannelInjector.class.getDeclaredMethod("processInboundInternal",
ChannelHandlerContext.class, PacketContainer.class);
method.setAccessible(true);
receiving = true;
try { method.invoke(injector, context, new PacketContainer(PacketType.Play.Client.WINDOW_CLICK, raw)); }
finally { receiving = false; }
}
void pumpLibraryTasks() {
if (receiving) throw new AssertionError("Fence main callback must run after library callback returns");
f.pumpLibraryTasks();
}
void forwardToNativeQueue() throws Exception {
pumpLibraryTasks(); // queues barriers behind captured raw tasks, never inline with filters/posts
forward.countDown();
acknowledgeLoop();
}
void acknowledgeLoop() throws Exception {
loop.submit(() -> {}).get(10, TimeUnit.SECONDS);
failures.addAll(((NativeAnvilTestChannel) f.channel).failures);
assertNoErrors();
}
void assertNoErrors() {
if (!failures.isEmpty()) throw new AssertionError("Transport/native dispatch failed", failures.getFirst());
}
@Override public void close() {
forward.countDown();
try { ((NativeAnvilTestChannel) f.channel).acknowledge(); }
catch (Exception failure) { throw new AssertionError("Transport shutdown acknowledgment failed", failure); }
assertNoErrors();
}
}
@@ -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"));