feat(anvil): combine items beyond enchantment limits
CI / build (push) Successful in 1m59s
CI / build (pull_request) Successful in 2m27s

This commit is contained in:
dmg committed 2026-10-11 23:51:17 -04:00
1 parent 24a220ae4a
commit de045fb458
29 files changed
+4519 -9

No files matched your search

+46 -1
View File
@@ -1,6 +1,6 @@
# Purpur Inventory Helper
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
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.
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).
@@ -10,6 +10,7 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
- Purpur 26.2 build 2618
- Java 25 or newer
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
## Build
@@ -25,6 +26,8 @@ verifies SHA-256
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
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
non-blocking live checks.
@@ -63,6 +66,48 @@ Player hotbar, armor, and off-hand slots remain unchanged during player sorting.
When a main-hand tool breaks, an item with the exact same material is moved into the broken tool's selected hotbar slot on the next server tick. Main-storage candidates are preferred before tools in other hotbar slots. The complete replacement item is moved unchanged, preserving durability, enchantments, custom names, and other metadata. Off-hand breaks are ignored, and no unrelated item is moved when a match is unavailable.
## 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.
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.
Enhanced merges charge **no XP** and have no "Too Expensive" restriction. The private action bar displays the result and exact success/failure chances; private chat explains that taking the result commits the attempt. Each accepted attempt consumes **one item from each input**. Success delivers one result; failure destroys both consumed items and causes an **explosion** that can hurt players and damage blocks. Native protections remain authoritative, but a protected/refused explosion does not refund the inputs.
Default risk uses the **largest resulting enchantment excess above its normal maximum**:
| Excess | Failure chance |
| --- | --- |
| 0 | 0% |
| 1 | 20% |
| 2 | 25% |
| 3 | 35% |
| 4 | 50% |
| 5 | 70% |
| 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.
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.
Administrators with `spigotinventoryhelper.admin` (default OP) and authorized console can read/set durable balance values:
```text
/inventoryhelperadmin config <key>
/inventoryhelperadmin config <key> <value>
```
| Key in `plugins/SpigotInventoryHelper/config.yml` | Default |
| --- | --- |
| `advanced-anvil-base-failure-percent` | 20 |
| `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.
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.
## Releases
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
+5
View File
@@ -27,6 +27,8 @@ tasks.withType<JavaCompile>().configureEach {
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
@@ -77,6 +79,7 @@ val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime"
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
@@ -87,6 +90,8 @@ dependencies {
val nativeRuntimeJars = files(fileTree(inventoryRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareInventoryRuntime)
// US-009's narrowly pinned generation adapter is compile-only native linkage, never bundled.
sourceSets.main.get().compileClasspath += nativeRuntimeJars
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeInventoryTest = tasks.register<Test>("nativeInventoryTest") {
@@ -0,0 +1,125 @@
package games.dmg.spigotinventoryhelper;
import java.util.Arrays;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
/** Main-thread destination reservation: no overflow drops, no overwrite of unrelated items. */
final class AdvancedAnvilDelivery {
private final Player player;
private final ItemStack cursorBefore;
private final ItemStack cursorAfter;
private final ItemStack[] storageBefore;
private final ItemStack[] storageAfter;
private final ItemStack offhandBefore;
private final ItemStack offhandAfter;
private final boolean cursorDelivery;
private final boolean offhandDelivery;
private AdvancedAnvilDelivery(Player player, ItemStack cursorBefore, ItemStack cursorAfter,
ItemStack[] storageBefore, ItemStack[] storageAfter, ItemStack offhandBefore,
ItemStack offhandAfter, boolean cursorDelivery, boolean offhandDelivery) {
this.player = player;
this.cursorBefore = cursorBefore;
this.cursorAfter = cursorAfter;
this.storageBefore = storageBefore;
this.storageAfter = storageAfter;
this.offhandBefore = offhandBefore;
this.offhandAfter = offhandAfter;
this.cursorDelivery = cursorDelivery;
this.offhandDelivery = offhandDelivery;
}
static AdvancedAnvilDelivery reserve(InventoryClickEvent event, ItemStack output) {
if (!(event.getWhoClicked() instanceof Player player) || event.getRawSlot() != 2) return null;
ItemStack cursor = copy(player.getItemOnCursor());
switch (event.getAction()) {
case PICKUP_ALL, PICKUP_HALF, PICKUP_ONE, PICKUP_SOME -> {
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) return null;
ItemStack next = copy(output);
if (cursor != null) {
if (!cursor.isSimilar(output) || cursor.getAmount() >= cursor.getMaxStackSize()) return null;
next = cursor.clone();
next.setAmount(next.getAmount() + 1);
}
return new AdvancedAnvilDelivery(player, cursor, next, null, null, null, null, true, false);
}
case MOVE_TO_OTHER_INVENTORY -> {
ItemStack[] before = copies(player.getInventory().getStorageContents());
ItemStack[] after = copies(before);
if (!allocate(after, output)) return null;
return new AdvancedAnvilDelivery(player, cursor, null, before, after, null, null, false, false);
}
case HOTBAR_SWAP, HOTBAR_MOVE_AND_READD -> {
int target = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
if ((target < 0 || target > 8) && target != 40) return null;
ItemStack[] before = copies(player.getInventory().getStorageContents());
ItemStack[] after = copies(before);
boolean offhand = target == 40;
ItemStack old = offhand ? copy(player.getInventory().getItemInOffHand()) : copy(before[target]);
if (!offhand) after[target] = output.clone();
if (old != null && !allocate(after, old)) return null;
return new AdvancedAnvilDelivery(player, cursor, null, before, after,
offhand ? old : null, offhand ? output.clone() : null, false, offhand);
}
default -> { return null; }
}
}
boolean stillValid() {
return Objects.equals(cursorBefore, copy(player.getItemOnCursor()))
&& (storageBefore == null || Arrays.equals(storageBefore,
copies(player.getInventory().getStorageContents())))
&& (!offhandDelivery || Objects.equals(offhandBefore,
copy(player.getInventory().getItemInOffHand())));
}
void deliver() {
if (cursorDelivery) player.setItemOnCursor(copy(cursorAfter));
if (storageAfter != null) {
for (int slot = 0; slot < storageAfter.length; slot++) {
if (!Objects.equals(storageBefore[slot], storageAfter[slot])) {
player.getInventory().setItem(slot, copy(storageAfter[slot]));
}
}
}
if (offhandDelivery) player.getInventory().setItemInOffHand(copy(offhandAfter));
}
/** Native anvil quick-move searches hotbar8..0 then storage35..9, merge pass before empty pass. */
private static boolean allocate(ItemStack[] storage, ItemStack source) {
ItemStack remaining = source.clone();
int[] order = new int[36];
for (int i = 0; i < 9; i++) order[i] = 8 - i;
for (int i = 9; i < 36; i++) order[i] = 44 - i;
for (int pass = 0; pass < 2; pass++) {
for (int slot : order) {
ItemStack present = storage[slot];
if (pass == 0 && present != null && present.isSimilar(remaining)) {
int moved = Math.min(remaining.getAmount(), present.getMaxStackSize() - present.getAmount());
if (moved > 0) {
present.setAmount(present.getAmount() + moved);
remaining.setAmount(remaining.getAmount() - moved);
}
} else if (pass == 1 && present == null) {
int moved = Math.min(remaining.getAmount(), remaining.getMaxStackSize());
storage[slot] = remaining.asQuantity(moved);
remaining.setAmount(remaining.getAmount() - moved);
}
if (remaining.getAmount() == 0) return true;
}
}
return false;
}
private static ItemStack copy(ItemStack item) {
return item == null || item.isEmpty() ? null : item.clone();
}
private static ItemStack[] copies(ItemStack[] items) {
return Arrays.stream(items).map(AdvancedAnvilDelivery::copy).toArray(ItemStack[]::new);
}
}
@@ -0,0 +1,54 @@
package games.dmg.spigotinventoryhelper;
import com.destroystokyo.paper.event.block.AnvilDamagedEvent;
import java.util.function.DoubleSupplier;
import org.bukkit.Effect;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.entity.Player;
import org.bukkit.inventory.view.AnvilView;
/** Native protection-aware side effects, invoked only after a committed inventory transaction. */
final class AdvancedAnvilEffects {
private AdvancedAnvilEffects() {}
static void explode(Player player, Location origin) {
// Purpur's final flag is essential: the combining player must remain damage-eligible.
origin.getWorld().createExplosion(player, origin.clone().add(.5, .5, .5),
6F, false, true, false);
}
static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear) {
var block = origin.getBlock();
Material type = block.getType();
if (player.getGameMode() != GameMode.CREATIVE && isAnvil(type) && wear.getAsDouble() < .12) {
Material next = switch (type) {
case ANVIL -> Material.CHIPPED_ANVIL;
case CHIPPED_ANVIL -> Material.DAMAGED_ANVIL;
default -> null;
};
BlockData proposal = next == null ? null : next.createBlockData();
var event = new AnvilDamagedEvent(view, proposal);
if (!event.callEvent()) return; // Native cancellation also suppresses the use effect.
if (event.getDamageState() == AnvilDamagedEvent.DamageState.BROKEN) {
block.setType(Material.AIR, false);
origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 0);
return;
}
BlockData chosen = event.getDamageState().getMaterial().createBlockData();
if (chosen instanceof Directional facing && block.getBlockData() instanceof Directional old) {
facing.setFacing(old.getFacing());
}
block.setBlockData(chosen, false);
}
origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0);
}
static boolean isAnvil(Material material) {
return material == Material.ANVIL || material == Material.CHIPPED_ANVIL
|| material == Material.DAMAGED_ANVIL;
}
}
@@ -0,0 +1,365 @@
package games.dmg.spigotinventoryhelper;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.PrepareAnvilEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.view.AnvilView;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Owns native preview and deferred, final-cancellation-aware one-item transactions. */
final class AdvancedAnvilListener implements Listener, AutoCloseable {
private static final Component WARNING = Component.text(
"Taking this result commits the attempt. Failure destroys both items and causes an explosion.");
private final Plugin plugin;
private final Supplier<AdvancedAnvilPolicy> policy;
private final java.util.function.IntSupplier risk;
private final java.util.function.DoubleSupplier wear;
private final Map<AnvilView, Preview> previews = new IdentityHashMap<>();
private final Map<AnvilView, Attempt> pending = new IdentityHashMap<>();
private final Map<org.bukkit.event.inventory.InventoryClickEvent, Boolean> seen = new java.util.WeakHashMap<>();
private static final class Attempt {
final Preview preview;
final org.bukkit.event.inventory.InventoryClickEvent event;
final AdvancedAnvilDelivery delivery;
final AdvancedAnvilProtocolGuard.Admission admission;
boolean invalid;
Attempt(Preview preview, org.bukkit.event.inventory.InventoryClickEvent event,
AdvancedAnvilDelivery delivery, AdvancedAnvilProtocolGuard.Admission admission, boolean invalid) {
this.preview = preview;
this.event = event;
this.delivery = delivery;
this.admission = admission;
this.invalid = invalid;
}
}
private final AdvancedAnvilProtocolGuard protocol;
private final BukkitTask refresh;
private boolean closed;
// Main-thread-only, exact snapshots for preparation/invalidation, not transaction authority.
private record Preview(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy,
AdvancedAnvilMerge.Proposal proposal, boolean originalBypass, Component message) {}
AdvancedAnvilListener(Plugin plugin, Supplier<AdvancedAnvilPolicy> policy,
java.util.function.IntSupplier risk, java.util.function.DoubleSupplier wear,
com.comphenix.protocol.ProtocolManager manager, String version) {
this.plugin = Objects.requireNonNull(plugin);
this.policy = Objects.requireNonNull(policy);
this.risk = Objects.requireNonNull(risk);
this.wear = Objects.requireNonNull(wear);
protocol = new AdvancedAnvilProtocolGuard(plugin, manager, version, this);
try {
refresh = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskTimer(
plugin, this::refreshActionBars, 40L, 40L));
} catch (RuntimeException | LinkageError unavailable) {
protocol.close();
throw unavailable;
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void prepare(PrepareAnvilEvent event) {
if (closed) {
return;
}
AnvilView view = event.getView();
Attempt attempt = pending.get(view);
if (attempt != null) {
// Any intervening preparation invalidates the captured generation, even an ABA edit.
attempt.invalid = true;
protocol.invalidate(view);
event.setResult(null);
return;
}
Preview previous = previews.get(view);
ItemStack left = view.getTopInventory().getItem(0);
ItemStack right = view.getTopInventory().getItem(1);
AdvancedAnvilPolicy effective = policy.get();
AdvancedAnvilMerge.Proposal proposal = AdvancedAnvilMerge.prepare(left, right, effective);
if (proposal == null) {
if (previous != null) {
release(view);
// Prior bypass can alter vanilla createResult's Too Expensive decision before this
// event runs. Recompute once with restored flags instead of leaking an exemption.
recompute(view);
event.setResult(view.getTopInventory().getItem(2));
}
return;
}
if (!protocol.watch(view)) {
release(view);
event.setResult(null); // Withhold enhanced output BEFORE any unsafe bypass/install.
return;
}
boolean originalBypass = previous == null ? view.canBypassCost() : previous.originalBypass();
Component message = message(proposal);
boolean generationChanged = previous == null || !matches(view, previous) || changed(previous, proposal);
if (generationChanged) {
NativeAnvilGeneration.menu(view).incrementStateId();
NativeAnvilGeneration.requireResultFrame(view, proposal.output() == null);
}
previews.put(view, generationChanged ? new Preview(left.clone(), right.clone(), effective, proposal,
originalBypass, message) : previous);
if (view.getPlayer() instanceof Player player) {
player.sendActionBar(message);
if (changed(previous, proposal)) {
player.sendMessage(proposal.output() == null ? message : WARNING);
}
}
// Exact private risk is published before CraftEventFactory installs/broadcasts the output.
event.setResult(proposal.output());
if (proposal.output() != null) {
view.setRepairCost(0);
view.setBypassCost(true);
} else view.setBypassCost(originalBypass);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void intercept(org.bukkit.event.inventory.InventoryClickEvent event) {
if (closed || !(event.getView() instanceof AnvilView view)) return;
Preview preview = previews.get(view);
boolean collection = event.getAction() == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR;
if (event.getRawSlot() != 2 && !collection) return;
if (pending.containsKey(view)) {
if (pending.get(view).event != event) event.setCancelled(true);
view.getTopInventory().setItem(2, null);
return;
}
if (preview == null && !protocol.watched(view)) return; // Never-owned vanilla stays native.
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
var admission = protocol.consume(view, preview, event);
if (duplicate || admission == null) {
event.setCancelled(true);
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
: AdvancedAnvilDelivery.reserve(event, preview.proposal().output());
Attempt attempt = new Attempt(preview, event, delivery, admission,
stale || event.isCancelled() || delivery == null);
pending.put(view, attempt);
// Do not self-cancel accepted intents: continuation can observe the final native denial.
// Empty output prevents native onTake, bulk loops and collection from producing/payment.
view.getTopInventory().setItem(2, null);
try {
plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt));
} catch (RuntimeException unavailable) {
pending.remove(view);
event.setCancelled(true);
rerender(view);
}
}
private void finish(AnvilView view, Attempt attempt) {
if (closed || pending.get(view) != attempt) return;
pending.remove(view);
if (attempt.invalid || attempt.event.isCancelled() || !attempt.admission.valid()
|| previews.get(view) != attempt.preview
|| !current(view) || !matches(view, attempt.preview)
|| !attempt.delivery.stillValid()) {
rerender(view);
return;
}
org.bukkit.Location origin = view.getTopInventory().getLocation();
if (origin == null || !isAnvil(origin.getBlock().getType())) {
rerender(view);
return;
}
int roll = risk.getAsInt();
if (roll < 0 || roll >= 100) throw new IllegalStateException("Invalid anvil risk roll");
boolean failed = roll < attempt.preview.proposal().failurePercent();
ItemStack left = view.getTopInventory().getItem(0);
ItemStack right = view.getTopInventory().getItem(1);
// Craft anvil getItem returns live native mirrors. Change both counts without invoking
// slotsChanged between debits, so close/death/prepare callbacks cannot return a paid item.
left.setAmount(left.getAmount() - 1);
right.setAmount(right.getAmount() - 1);
view.setBypassCost(attempt.preview.originalBypass());
try {
if (!failed) attempt.delivery.deliver();
try {
AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear);
} finally {
if (failed) AdvancedAnvilEffects.explode((Player) view.getPlayer(), origin);
}
} finally {
rerender(view);
}
}
private boolean matches(AnvilView view, Preview preview) {
return Objects.equals(preview.left(), view.getTopInventory().getItem(0))
&& Objects.equals(preview.right(), view.getTopInventory().getItem(1))
&& preview.policy().equals(policy.get());
}
private void rerender(AnvilView view) {
org.bukkit.Location origin = view.getTopInventory().getLocation();
if (current(view) && origin != null && isAnvil(origin.getBlock().getType())) {
recompute(view);
} else release(view);
}
private static boolean isAnvil(org.bukkit.Material material) {
return AdvancedAnvilEffects.isAnvil(material);
}
/** Durable settings acknowledgment invalidates snapshots through real input preparation. */
void policyChanged() {
if (closed) {
return;
}
for (AnvilView view : List.copyOf(previews.keySet())) {
if (current(view)) {
recompute(view);
} else {
release(view);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void closeView(InventoryCloseEvent event) {
if (event.getView() instanceof AnvilView view) {
pending.remove(view);
release(view);
protocol.forget(view);
}
}
private void refreshActionBars() {
if (closed) {
return;
}
protocol.prune();
for (AnvilView view : List.copyOf(previews.keySet())) {
if (!current(view)) {
release(view);
continue;
}
Preview preview = previews.get(view);
// A changed input/policy cannot keep displaying a stale risk between preparations.
if (!Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|| !Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|| !preview.policy().equals(policy.get())) {
recompute(view);
} else if (view.getPlayer() instanceof Player player) {
player.sendActionBar(preview.message());
}
}
}
Object protocolPreview(AnvilView view) { return previews.get(view); }
boolean protocolReady(AnvilView view) {
Preview preview = previews.get(view);
return !closed && current(view) && !pending.containsKey(view)
&& (preview == null || (matches(view, preview)
&& Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2))));
}
private static boolean current(AnvilView view) {
return view.getPlayer().getOpenInventory() == view
&& (!(view.getPlayer() instanceof Player player) || (player.isOnline() && !player.isDead()));
}
private static void recompute(AnvilView view) {
ItemStack left = view.getTopInventory().getItem(0);
view.getTopInventory().setItem(0, left == null ? null : left.clone());
// Preparation has now returned through CraftEventFactory's final result installation.
// Identical-output policy changes still need a real header/frame, not merely a counter.
NativeAnvilGeneration.publish(view);
}
private void release(AnvilView view) {
protocol.invalidate(view);
Preview preview = previews.remove(view);
if (preview != null) {
view.setBypassCost(preview.originalBypass());
if (view.getPlayer() instanceof Player player) {
player.sendActionBar(Component.empty());
}
}
}
private static boolean changed(Preview previous, AdvancedAnvilMerge.Proposal next) {
return previous == null || !Objects.equals(previous.proposal().output(), next.output())
|| previous.proposal().failurePercent() != next.failurePercent()
|| !Objects.equals(previous.proposal().refusal(), next.refusal());
}
private static Component message(AdvancedAnvilMerge.Proposal proposal) {
if (proposal.output() == null) {
return Component.text("Advanced Anvil: " + proposal.refusal());
}
ItemStack output = proposal.output();
Component result = Component.text(title(output.getType().name()));
Component customName = output.getItemMeta().customName();
if (customName != null) {
result = result.append(Component.text(" (" )).append(customName).append(Component.text(")"));
}
String enchantments = output.getEnchantments().entrySet().stream()
.sorted(Map.Entry.comparingByKey(java.util.Comparator.comparing(
enchantment -> enchantment.getKey().toString())))
.map(entry -> title(entry.getKey().getKey().getKey()) + " " + roman(entry.getValue()))
.collect(Collectors.joining(" · "));
return result.append(Component.text(" · " + enchantments + " · Success "
+ (100 - proposal.failurePercent()) + "% · Failure " + proposal.failurePercent()
+ "% — explosion"));
}
private static String title(String identifier) {
return java.util.Arrays.stream(identifier.toLowerCase(Locale.ROOT).split("_"))
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
.collect(Collectors.joining(" "));
}
private static String roman(int level) {
int[] values = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
String[] digits = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};
StringBuilder result = new StringBuilder();
for (int index = 0; index < values.length; index++) {
while (level >= values[index]) {
result.append(digits[index]);
level -= values[index];
}
}
return result.toString();
}
@Override public void close() {
if (closed) {
return;
}
closed = true;
pending.clear();
seen.clear();
refresh.cancel();
HandlerList.unregisterAll(this);
for (AnvilView view : List.copyOf(previews.keySet())) {
release(view);
if (current(view)) {
recompute(view);
}
}
protocol.close();
}
}
@@ -0,0 +1,111 @@
package games.dmg.spigotinventoryhelper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable;
/** Native item proposal, independent of payment and risk rolling. Never mutates either input. */
final class AdvancedAnvilMerge {
private AdvancedAnvilMerge() {}
/** Null prepare = unrelated vanilla operation; null output = explained enhanced refusal. */
record Proposal(ItemStack output, int failurePercent, String refusal) {}
static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy) {
Objects.requireNonNull(policy);
if (left == null || right == null || left.isEmpty() || right.isEmpty()
|| left.getType() != right.getType() || book(left.getType())) {
return null;
}
Map<Enchantment, Integer> levels = new LinkedHashMap<>(left.getEnchantments());
Map<Enchantment, Integer> rightLevels = right.getEnchantments();
if (levels.isEmpty() && rightLevels.isEmpty()) {
return null; // Repair/rename-only belongs exclusively to native handling.
}
try {
for (var entry : rightLevels.entrySet()) {
levels.merge(entry.getKey(), entry.getValue(), (first, second) ->
first.equals(second) ? Math.addExact(first, 1) : Math.max(first, second));
}
} catch (ArithmeticException overflow) {
return refused("Enchantment level cannot be represented by this server.");
}
ItemStack output = left.clone();
output.setAmount(1);
var enchantments = new ArrayList<>(levels.keySet());
for (int index = 0; index < enchantments.size(); index++) {
Enchantment enchantment = enchantments.get(index);
if (levels.get(enchantment) <= 0 || !enchantment.canEnchantItem(output)) {
return refused("An enchantment is not applicable to this item.");
}
for (int other = index + 1; other < enchantments.size(); other++) {
Enchantment candidate = enchantments.get(other);
if (enchantment.conflictsWith(candidate) || candidate.conflictsWith(enchantment)) {
return refused("The resulting enchantments conflict.");
}
}
}
try {
output.addUnsafeEnchantments(levels);
repair(output, right);
// Unsafe API setters may clamp silently; persistence can independently reject a result.
// Inspect the actual native representation instead of imposing a lower gameplay cap.
if (!output.getEnchantments().equals(levels)) {
return refused("Enchantment level cannot be represented by this server.");
}
ItemStack restored = ItemStack.deserializeBytes(output.serializeAsBytes());
if (!restored.equals(output) || !restored.getEnchantments().equals(levels)) {
return refused("Result cannot be represented exactly by this server.");
}
} 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);
}
private static Proposal refused(String reason) {
return new Proposal(null, 0, reason);
}
private static boolean book(Material material) {
return switch (material) {
case BOOK, ENCHANTED_BOOK, WRITABLE_BOOK, WRITTEN_BOOK, KNOWLEDGE_BOOK -> true;
default -> false;
};
}
private static void repair(ItemStack output, ItemStack right) {
if (!(output.getItemMeta() instanceof Damageable first) || first.isUnbreakable()) {
return;
}
int maximum = maxDamage(output, first);
if (maximum <= 0 || !first.hasDamageValue()) {
return;
}
if (!(right.getItemMeta() instanceof Damageable second)) {
return;
}
// Native same-item formula uses each input's effective MAX_DAMAGE component, not material
// durability for both. Use long intermediates so custom component values cannot overflow.
long remaining = (long) maximum - first.getDamage()
+ maxDamage(right, second) - second.getDamage() + (long) maximum * 12 / 100;
long damage = Math.max(0, maximum - remaining);
if (damage < first.getDamage()) {
first.setDamage((int) damage);
output.setItemMeta(first);
}
}
private static int maxDamage(ItemStack item, Damageable meta) {
Integer maximum = item.getData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
return maximum == null ? 0 : maximum;
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigotinventoryhelper;
/** Immutable balance captured by an attempt; excess is not an enchantment-level cap. */
record AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent) {
AdvancedAnvilPolicy {
validate(basePercent);
validate(stepPercent);
validate(maxFailurePercent);
}
static AdvancedAnvilPolicy defaults() {
return new AdvancedAnvilPolicy(20, 5, 99);
}
int failurePercent(int greatestExcess) {
if (greatestExcess < 0) {
throw new IllegalArgumentException("Excess must be nonnegative");
}
if (greatestExcess == 0) {
return 0;
}
if (basePercent >= maxFailurePercent) {
return maxFailurePercent;
}
if (stepPercent == 0) {
return basePercent;
}
// int-sized excess has a representable long triangular number. Compare before
// multiplying by step: that multiplication could overflow even a long.
long triangular = (long) greatestExcess * (greatestExcess - 1) / 2;
if (triangular > (maxFailurePercent - basePercent) / stepPercent) {
return maxFailurePercent;
}
return basePercent + (int) triangular * stepPercent;
}
private static void validate(int percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("Percent must be an integer from 0 through 100");
}
}
}
@@ -0,0 +1,250 @@
package games.dmg.spigotinventoryhelper;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolManager;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.events.PacketContainer;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.view.AnvilView;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Main-thread, one-use protocol-to-Bukkit admission for exact watched native anvil views. */
final class AdvancedAnvilProtocolGuard implements AutoCloseable {
static final class Admission {
final PacketEvent event;
final ServerboundContainerClickPacket packet;
final Object preview;
final java.util.function.BooleanSupplier available;
BukkitTask expiry;
boolean sealed;
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
java.util.function.BooleanSupplier available) {
this.event = event;
this.packet = packet;
this.preview = preview;
this.available = available;
}
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;
}
}
private final Plugin plugin;
private final ProtocolManager manager;
private final AdvancedAnvilListener owner;
private final Set<AnvilView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
// 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<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; }
}
private final Map<AnvilView, Frame> frames = new IdentityHashMap<>();
// Guard-wide, not reset on forget: even a reused window ID cannot reuse a displayed version.
private final AnvilFrameVersions versions = new AnvilFrameVersions();
private final PacketAdapter outbound;
private final PacketAdapter gate;
private final PacketAdapter seal;
private volatile boolean closed;
AdvancedAnvilProtocolGuard(Plugin plugin, ProtocolManager manager, String version, AdvancedAnvilListener owner) {
this.plugin = Objects.requireNonNull(plugin);
this.manager = Objects.requireNonNull(manager);
this.owner = Objects.requireNonNull(owner);
if (version == null || !version.contains("Purpur") || !version.contains("26.2.build.2618-stable")
|| manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge");
gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
.listenerPriority(ListenerPriority.LOWEST).optionSync()) {
@Override public void onPacketReceiving(PacketEvent event) { receive(event); }
};
seal = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
.listenerPriority(ListenerPriority.MONITOR).optionSync()) {
@Override public void onPacketReceiving(PacketEvent event) { seal(event); }
};
outbound = new PacketAdapter(PacketAdapter.params(plugin,
PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT)
.listenerPriority(ListenerPriority.HIGHEST).optionSync()) {
@Override public void onPacketSending(PacketEvent event) { send(event); }
};
try {
manager.addPacketListener(outbound);
manager.addPacketListener(gate);
manager.addPacketListener(seal);
} catch (RuntimeException | LinkageError failure) {
manager.removePacketListener(outbound);
manager.removePacketListener(gate);
manager.removePacketListener(seal);
throw failure;
}
}
boolean watch(AnvilView view) {
if (closed || manager.isClosed() || versions.exhausted() || !plugin.getServer().isPrimaryThread()) return false;
try {
NativeAnvilGeneration.menu(view);
if (!(view.getPlayer() instanceof Player player)) return false;
watched.add(view);
actors.add(player);
invalidate(view);
return true;
} catch (RuntimeException | LinkageError unavailable) { return false; }
}
boolean watched(AnvilView view) { return watched.contains(view); }
void invalidate(AnvilView view) {
Admission admission = admissions.remove(view);
if (admission != null && admission.expiry != null) admission.expiry.cancel();
}
private void send(PacketEvent event) {
if (closed) return;
if (!plugin.getServer().isPrimaryThread()) {
if (actors.contains(event.getPlayer())) event.setCancelled(true);
return;
}
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
try {
Object raw = event.getPacket().getHandle();
int containerId;
int nativeState;
if (raw instanceof ClientboundContainerSetContentPacket content) {
containerId = content.containerId();
nativeState = content.stateId();
} else if (raw instanceof ClientboundContainerSetSlotPacket slot) {
containerId = slot.getContainerId();
nativeState = slot.getStateId();
} else throw new IllegalStateException("Unsupported native anvil frame");
if (containerId != NativeAnvilGeneration.menu(view).containerId) return;
frames.remove(view);
if (event.isCancelled()) return;
int version = versions.next();
if (version < 0 || manager.isClosed()) {
event.setCancelled(true);
invalidate(view);
return;
}
Object translated = raw instanceof ClientboundContainerSetContentPacket content
? new ClientboundContainerSetContentPacket(containerId, version, content.items(), content.carriedItem())
: new ClientboundContainerSetSlotPacket(containerId, version,
((ClientboundContainerSetSlotPacket) raw).getSlot(),
((ClientboundContainerSetSlotPacket) raw).getItem());
event.setPacket(new PacketContainer(event.getPacketType(), translated));
frames.put(view, new Frame(event, translated, version, nativeState, owner.protocolPreview(view)));
} catch (RuntimeException | LinkageError unavailable) {
frames.remove(view);
invalidate(view);
event.setCancelled(true);
}
}
private void receive(PacketEvent event) {
if (closed) return;
if (!plugin.getServer().isPrimaryThread()) {
if (actors.contains(event.getPlayer())) event.setCancelled(true);
return; // Never inspect or mutate a Bukkit view off the tick thread.
}
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
invalidate(view); // A new raw intent can never reuse an earlier ticket.
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)) {
refuse(event, view);
return;
}
// Typed reconstruction changes ONLY stateId. Native classification and predictions
// receive every original action/input/hash field, with the menu's untouched state.
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;
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, () -> {
if (admissions.get(view) == admission) admissions.remove(view);
});
} catch (RuntimeException | LinkageError unavailable) { refuse(event, view); }
}
private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) {
Frame frame = frames.get(view);
var menu = NativeAnvilGeneration.menu(view);
return !manager.isClosed() && !versions.exhausted() && frame != null && frame.valid()
&& packet.containerId() == menu.containerId && packet.stateId() == frame.version()
&& frame.nativeState() == menu.getStateId() && frame.preview() == owner.protocolPreview(view);
}
private void seal(PacketEvent event) {
if (closed || !plugin.getServer().isPrimaryThread()) return;
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view)) return;
Admission admission = admissions.get(view);
if (admission == null || admission.event != event) return;
// MONITOR is observational. Retain the actual event for final cancellation checks both
// at Bukkit dispatch and deferred commit, including later MONITOR listeners.
admission.sealed = !event.isCancelled() && event.getPacket().getHandle() == admission.packet;
if (!admission.sealed) invalidate(view);
}
Admission consume(AnvilView view, Object preview, InventoryClickEvent event) {
Admission admission = admissions.get(view);
invalidate(view);
if (closed || admission == null || !admission.valid() || admission.preview != preview
|| !NativeAnvilGeneration.current(view, admission.packet)
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
return admission;
}
private static void refuse(PacketEvent event, AnvilView view) {
event.setCancelled(true);
if (view.getPlayer() instanceof Player player) player.updateInventory();
}
void forget(AnvilView view) {
invalidate(view);
watched.remove(view);
frames.remove(view);
if (view.getPlayer() instanceof Player player
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) actors.remove(player);
}
void prune() {
for (AnvilView view : List.copyOf(watched)) {
if (view.getPlayer().getOpenInventory() != view) forget(view);
}
}
@Override public void close() {
if (closed) return;
closed = true;
for (AnvilView view : List.copyOf(watched)) {
forget(view);
// Retiring the exact owned window prevents an old enhanced packet becoming an
// unrelated vanilla XP take after the protocol listeners have been removed.
if (view.getPlayer().getOpenInventory() == view) view.getPlayer().closeInventory();
}
actors.clear();
seen.clear();
manager.removePacketListener(outbound);
manager.removePacketListener(gate);
manager.removePacketListener(seal);
}
}
@@ -0,0 +1,319 @@
package games.dmg.spigotinventoryhelper;
import java.io.IOException;
import java.io.Reader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.Plugin;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.error.YAMLException;
/** Owns startup loading and durable admin updates, not plugin registration or gameplay. */
final class AdvancedAnvilSettings implements AutoCloseable {
private static final String PERMISSION = "spigotinventoryhelper.admin";
private static final List<String> KEYS = List.of(
"advanced-anvil-base-failure-percent",
"advanced-anvil-risk-step-percent",
"advanced-anvil-max-failure-percent");
private final Plugin plugin;
private final Path file;
private final Consumer<AdvancedAnvilPolicy> onChange;
private final ThreadPoolExecutor writer;
private final Object lifecycle = new Object();
private volatile AdvancedAnvilPolicy effective;
private volatile boolean closed;
// Writer-only state, independent of delayed scheduler acknowledgments.
private AdvancedAnvilPolicy persisted;
private long savedRevision;
// Accessed only under lifecycle, including main-thread acknowledgments.
private long publishedRevision;
static AdvancedAnvilSettings open(
Plugin plugin, Consumer<AdvancedAnvilPolicy> onAcknowledgedChange) throws IOException {
Objects.requireNonNull(plugin);
Objects.requireNonNull(onAcknowledgedChange);
Path file = plugin.getDataFolder().toPath().resolve("config.yml");
AdvancedAnvilPolicy initial = parsePolicy(read(file));
return new AdvancedAnvilSettings(plugin, file, onAcknowledgedChange, initial);
}
private AdvancedAnvilSettings(Plugin plugin, Path file,
Consumer<AdvancedAnvilPolicy> onChange, AdvancedAnvilPolicy initial) {
this.plugin = plugin;
this.file = file;
this.onChange = onChange;
effective = initial;
persisted = initial;
writer = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(128), runnable -> {
Thread thread = new Thread(runnable, "inventory-helper-config");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
}
AdvancedAnvilPolicy policy() {
return effective;
}
/** Call on enable with this plugin's actual inventoryhelperadmin command. */
void bind(PluginCommand command) {
Objects.requireNonNull(command);
if (command.getPlugin() != plugin || !command.getName().equals("inventoryhelperadmin")) {
throw new IllegalArgumentException("Expected this plugin's inventoryhelperadmin command");
}
synchronized (lifecycle) {
if (closed) {
throw new IllegalStateException("Settings closed");
}
command.setPermission(PERMISSION);
command.permissionMessage(Component.text("Permission denied."));
command.setExecutor((sender, ignored, label, args) -> execute(sender, args));
command.setTabCompleter((sender, ignored, label, args) -> complete(sender, args));
}
}
private boolean execute(CommandSender sender, String[] args) {
// Revalidate even if the caller bypasses Command.testPermission.
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("Permission denied.");
return true;
}
if (closed) {
sender.sendMessage("Configuration unavailable.");
return true;
}
if (args.length < 2 || args.length > 3 || !args[0].equals("config")
|| !KEYS.contains(args[1])) {
sender.sendMessage("Usage: /inventoryhelperadmin config <key> [value]");
return true;
}
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + " = " + value(effective, key));
return true;
}
int percent;
try {
// Parse integers rather than restricting users to suggested spellings.
// No trimming or floating-point/YAML coercion; range validation remains mandatory.
percent = Integer.parseInt(args[2]);
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException();
}
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Value must be an integer from 0 through 100.");
return true;
}
try {
writer.execute(() -> save(sender, key, percent));
} catch (RejectedExecutionException unavailable) {
sender.sendMessage("Configuration unavailable; no update accepted.");
}
return true;
}
private List<String> complete(CommandSender sender, String[] args) {
if (closed || !sender.hasPermission(PERMISSION) || args.length > 3) {
return List.of();
}
if (args.length <= 1) {
return prefix(List.of("config"), args.length == 0 ? "" : args[0]);
}
if (!args[0].equals("config")) {
return List.of();
}
if (args.length == 2) {
return prefix(KEYS, args[1]);
}
if (!KEYS.contains(args[1])) {
return List.of();
}
LinkedHashSet<String> candidates = new LinkedHashSet<>();
candidates.add(Integer.toString(value(effective, args[1])));
candidates.add(Integer.toString(value(AdvancedAnvilPolicy.defaults(), args[1])));
candidates.add("0");
candidates.add("100");
return prefix(List.copyOf(candidates), args[2]);
}
private static List<String> prefix(List<String> candidates, String prefix) {
String lower = prefix.toLowerCase(Locale.ROOT);
return candidates.stream().filter(value -> value.startsWith(lower)).toList();
}
private void save(CommandSender sender, String key, int percent) {
if (closed) {
return;
}
try {
Map<Object, Object> document = read(file);
parsePolicy(document); // Refuse a malformed intervening file, without clobbering it.
AdvancedAnvilPolicy next = replace(persisted, key, percent);
for (String setting : KEYS) {
document.put(setting, value(next, setting));
}
writeAtomically(document);
persisted = next;
long revision = ++savedRevision;
schedule(() -> acknowledge(sender, key, next, revision));
} catch (IOException | YAMLException failure) {
schedule(() -> {
synchronized (lifecycle) {
if (!closed && sender.hasPermission(PERMISSION)) {
sender.sendMessage("Configuration save failed; effective settings unchanged.");
}
}
});
}
}
private void schedule(Runnable acknowledgment) {
if (closed) {
return;
}
try {
plugin.getServer().getScheduler().runTask(plugin, acknowledgment);
} catch (RuntimeException unavailable) {
// A disabled/rejected scheduler cannot safely publish gameplay state off-thread.
plugin.getLogger().warning("Configuration acknowledgment unavailable; restart reloads disk settings.");
}
}
private void acknowledge(CommandSender sender, String key, AdvancedAnvilPolicy next, long revision) {
synchronized (lifecycle) {
if (closed || revision <= publishedRevision) {
return;
}
publishedRevision = revision;
effective = next;
try {
onChange.accept(next);
} catch (RuntimeException failure) {
plugin.getLogger().warning("Configuration saved but policy-change callback failed.");
}
if (!closed && sender.hasPermission(PERMISSION)) {
sender.sendMessage(key + " = " + value(next, key) + " (saved)");
}
}
}
private void writeAtomically(Map<Object, Object> document) throws IOException {
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), ".config-", ".tmp");
try {
DumperOptions options = new DumperOptions();
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
byte[] bytes = new Yaml(options).dump(document).getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
if (closed) {
throw new IOException("Settings closed");
}
// No non-atomic fallback: an unsupported atomic replacement is a failed save.
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
// Publication itself must be durable, not just the temporary file's contents.
try (FileChannel directory = FileChannel.open(file.getParent(), StandardOpenOption.READ)) {
directory.force(true);
}
} finally {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupFailure) {
plugin.getLogger().warning("Could not remove configuration temporary file.");
}
}
}
private static Map<Object, Object> read(Path file) throws IOException {
if (Files.notExists(file)) {
return new LinkedHashMap<>();
}
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
Object loaded = new Yaml(new SafeConstructor(options)).load(reader);
if (loaded == null) {
return new LinkedHashMap<>();
}
if (!(loaded instanceof Map<?, ?> values)) {
throw new IOException("Configuration must be a YAML mapping");
}
return new LinkedHashMap<>(values);
} catch (YAMLException invalid) {
throw new IOException("Malformed configuration", invalid);
}
}
private static AdvancedAnvilPolicy parsePolicy(Map<Object, Object> document) throws IOException {
AdvancedAnvilPolicy defaults = AdvancedAnvilPolicy.defaults();
int[] values = new int[3];
for (int index = 0; index < KEYS.size(); index++) {
String key = KEYS.get(index);
Object raw = document.get(key);
if (!document.containsKey(key)) {
values[index] = value(defaults, key);
} else if ((raw instanceof Integer || raw instanceof Long)
&& ((Number) raw).longValue() >= 0 && ((Number) raw).longValue() <= 100) {
values[index] = ((Number) raw).intValue();
} else {
throw new IOException("Invalid integer percent for " + key);
}
}
return new AdvancedAnvilPolicy(values[0], values[1], values[2]);
}
private static int value(AdvancedAnvilPolicy policy, String key) {
return switch (KEYS.indexOf(key)) {
case 0 -> policy.basePercent();
case 1 -> policy.stepPercent();
case 2 -> policy.maxFailurePercent();
default -> throw new IllegalArgumentException("Unsupported setting");
};
}
private static AdvancedAnvilPolicy replace(AdvancedAnvilPolicy policy, String key, int percent) {
return new AdvancedAnvilPolicy(
key.equals(KEYS.get(0)) ? percent : policy.basePercent(),
key.equals(KEYS.get(1)) ? percent : policy.stepPercent(),
key.equals(KEYS.get(2)) ? percent : policy.maxFailurePercent());
}
/** Nonblocking close: drop queued work, interrupt the daemon writer, suppress every late ack. */
@Override
public void close() {
synchronized (lifecycle) {
closed = true;
writer.shutdownNow();
}
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotinventoryhelper;
/** Positive VarInt frame IDs outside the native 15-bit range; never wrap or recycle. */
final class AnvilFrameVersions {
private long next = 32768;
private boolean exhausted;
int next() {
if (next > Integer.MAX_VALUE) {
exhausted = true;
return -1;
}
return (int) next++;
}
boolean exhausted() { return exhausted; }
}
@@ -0,0 +1,69 @@
package games.dmg.spigotinventoryhelper;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.world.inventory.AnvilMenu;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.craftbukkit.inventory.view.CraftAnvilView;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.view.AnvilView;
/** Compile-only linkage to the checksum-pinned Purpur 26.2 build 2618 menu/packet API. */
final class NativeAnvilGeneration {
private NativeAnvilGeneration() {}
static AnvilMenu menu(AnvilView view) {
if (!(view instanceof CraftAnvilView craft)) throw new IllegalStateException("Unsupported native anvil view");
if (!(craft.getHandle() instanceof AnvilMenu menu)) throw new IllegalStateException("Unsupported native anvil menu");
return menu;
}
/** 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.
*/
static void requireResultFrame(AnvilView view, boolean emptyOutput) {
var menu = menu(view);
menu.setRemoteSlot(2, emptyOutput ? menu.getSlot(0).getItem().copy()
: net.minecraft.world.item.ItemStack.EMPTY);
}
/** Publish after native preparation has installed the final result, even when diffing skips it. */
static void publish(AnvilView view) {
menu(view).sendAllDataToRemote();
}
static boolean current(AnvilView view, ServerboundContainerClickPacket packet) {
var menu = menu(view);
return packet.containerId() == menu.containerId && packet.stateId() == menu.getStateId();
}
static boolean take(ServerboundContainerClickPacket packet) {
return packet.slotNum() == 2 || packet.containerInput() == ContainerInput.PICKUP_ALL;
}
static boolean supportedTake(ServerboundContainerClickPacket packet) {
return click(packet) != ClickType.UNKNOWN;
}
private static ClickType click(ServerboundContainerClickPacket packet) {
int button = packet.buttonNum();
return switch (packet.containerInput()) {
case PICKUP -> button == 0 ? ClickType.LEFT : button == 1 ? ClickType.RIGHT : ClickType.UNKNOWN;
case QUICK_MOVE -> button == 0 ? ClickType.SHIFT_LEFT : button == 1 ? ClickType.SHIFT_RIGHT : ClickType.UNKNOWN;
case SWAP -> button == 40 ? ClickType.SWAP_OFFHAND
: button >= 0 && button <= 8 ? ClickType.NUMBER_KEY : ClickType.UNKNOWN;
case CLONE -> button == 2 ? ClickType.MIDDLE : ClickType.UNKNOWN;
case THROW -> button == 0 ? ClickType.DROP : button == 1 ? ClickType.CONTROL_DROP : ClickType.UNKNOWN;
case PICKUP_ALL -> button == 0 ? ClickType.DOUBLE_CLICK : ClickType.UNKNOWN;
case QUICK_CRAFT -> ClickType.UNKNOWN;
};
}
static boolean matches(ServerboundContainerClickPacket packet, InventoryClickEvent event) {
if (packet.slotNum() != event.getRawSlot()) return false;
ClickType click = click(packet);
return click != ClickType.UNKNOWN && event.getClick() == click
&& (click != ClickType.NUMBER_KEY || event.getHotbarButton() == packet.buttonNum());
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotinventoryhelper;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.PluginDescriptionFile;
@@ -10,6 +11,15 @@ import org.bukkit.plugin.java.JavaPluginLoader;
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
private SortingStickItemFactory sortingSticks;
private RecipeLifecycleGroup recipeLifecycles;
private AdvancedAnvilSettings advancedAnvilSettings;
private AdvancedAnvilListener advancedAnvil;
private java.util.function.IntSupplier anvilRisk = () -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100);
private java.util.function.DoubleSupplier anvilWear = () -> java.util.concurrent.ThreadLocalRandom.current().nextDouble();
private java.util.function.Supplier<com.comphenix.protocol.ProtocolManager> anvilProtocol =
com.comphenix.protocol.ProtocolLibrary::getProtocolManager;
private java.util.function.Supplier<String> anvilVersion =
() -> getServer().getName() + " " + getServer().getBukkitVersion();
public SpigotInventoryHelperPlugin() {}
@@ -20,6 +30,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
super(loader, description, dataFolder, file);
}
SpigotInventoryHelperPlugin(JavaPluginLoader loader, PluginDescriptionFile description,
File dataFolder, File file, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear) {
this(loader, description, dataFolder, file);
anvilRisk = java.util.Objects.requireNonNull(risk);
anvilWear = java.util.Objects.requireNonNull(wear);
}
SpigotInventoryHelperPlugin(JavaPluginLoader loader, PluginDescriptionFile description,
File dataFolder, File file, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, com.comphenix.protocol.ProtocolManager manager,
java.util.function.Supplier<String> version) {
this(loader, description, dataFolder, file, risk, wear);
anvilProtocol = () -> java.util.Objects.requireNonNull(manager);
anvilVersion = java.util.Objects.requireNonNull(version);
}
@Override
public void onEnable() {
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
@@ -54,17 +81,45 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
new BrokenToolListener(this, new ToolReplacementService()),
this
);
try {
advancedAnvilSettings = AdvancedAnvilSettings.open(this, ignored -> {
if (advancedAnvil != null) {
advancedAnvil.policyChanged();
}
});
advancedAnvilSettings.bind(java.util.Objects.requireNonNull(
getCommand("inventoryhelperadmin"), "Missing inventoryhelperadmin command"));
advancedAnvil = new AdvancedAnvilListener(this, advancedAnvilSettings::policy, anvilRisk, anvilWear,
anvilProtocol.get(), anvilVersion.get());
getServer().getPluginManager().registerEvents(advancedAnvil, this);
} catch (IOException | RuntimeException | LinkageError failure) {
closeAdvancedAnvil();
getLogger().log(java.util.logging.Level.SEVERE,
"Advanced Anvil unavailable; configuration or initialization failed.", failure);
}
getLogger().info("Spigot Inventory Helper enabled.");
}
@Override
public void onDisable() {
closeAdvancedAnvil();
if (recipeLifecycles != null) {
recipeLifecycles.disableAll();
recipeLifecycles = null;
}
}
private void closeAdvancedAnvil() {
if (advancedAnvil != null) {
advancedAnvil.close();
advancedAnvil = null;
}
if (advancedAnvilSettings != null) {
advancedAnvilSettings.close();
advancedAnvilSettings = null;
}
}
SortingStickItemFactory sortingSticks() {
if (sortingSticks == null) {
throw new IllegalStateException("Sorting Stick service is unavailable");
+6
View File
@@ -0,0 +1,6 @@
# Advanced Anvil: integer percentages, inclusive 0–100.
# E is the greatest resulting enchantment excess above its native maximum.
# E=0: no failure; otherwise min(cap, base + step * E * (E - 1) / 2).
advanced-anvil-base-failure-percent: 20
advanced-anvil-risk-step-percent: 5
advanced-anvil-max-failure-percent: 99
+10
View File
@@ -2,5 +2,15 @@ name: SpigotInventoryHelper
version: ${version}
main: games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin
api-version: "1.20"
depend: [ProtocolLib]
description: Inventory sorting and automatic tool replacement utilities.
author: dmg.games
commands:
inventoryhelperadmin:
description: Read and durably update Inventory Helper balance settings.
usage: /inventoryhelperadmin config <key> [value]
permission: spigotinventoryhelper.admin
permissions:
spigotinventoryhelper.admin:
description: Administer Inventory Helper balance settings.
default: op
@@ -0,0 +1,423 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.destroystokyo.paper.event.block.AnvilDamagedEvent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Effect;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Directional;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.ItemStack;
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.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
/** Final safety boundaries through registered commands, native packets and native menu removal. */
class NativeAdvancedAnvilBoundaryTest {
@TempDir Path directory;
private static final String BASE = "advanced-anvil-base-failure-percent";
private static final String STEP = "advanced-anvil-risk-step-percent";
private static final String CAP = "advanced-anvil-max-failure-percent";
@Test void identicalInputRemovalAndReinsertionInvalidatesPendingGeneration() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack oldResult = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
f.menu.getSlot(0).set(net.minecraft.world.item.ItemStack.EMPTY);
f.menu.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(5, 3)));
f.tick();
unpaid(f, rolls);
assertEquals(oldResult, f.output());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {BASE, STEP, CAP})
void acknowledgedCommandDuringPendingInvalidatesCapturedTake(String key) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
f.inputs(sword(6, 3), sword(6, 3)); // E2: base, step and ceiling all affect risk.
assertRisk(f, 25);
packet.click(2, 0, ContainerInput.PICKUP);
Runnable capturedTake = f.nextTick.remove();
when(f.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
f.admin(f.player, key, 0);
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(3, f.menu.getSlot(0).getItem().getCount());
f.acknowledgeSave();
capturedTake.run();
assertEquals(sword(6, 3), input(f, 0));
assertEquals(sword(6, 3), input(f, 1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertRisk(f, key.equals(BASE) ? 5 : key.equals(STEP) ? 20 : 0);
}
}
@ParameterizedTest @CsvSource({BASE + ",30,35", STEP + ",10,30", CAP + ",7,7"})
void allKeysConnectRegisteredDurableCommandToPreviewAttemptAndRestart(String key, int value, int failure)
throws Exception {
AtomicInteger rolls = new AtomicInteger();
AtomicInteger selectedRoll = new AtomicInteger(failure);
for (boolean restart : new boolean[] {false, true}) {
selectedRoll.set(failure);
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
f.inputs(sword(6, 3), sword(6, 3));
if (!restart) {
assertRisk(f, 25);
f.admin(f.console, key, value);
assertRisk(f, 25); // Writer completion alone cannot publish policy off-thread.
f.acknowledgeSave();
String disk = Files.readString(directory.resolve("data/config.yml"));
assertTrue(disk.contains(key + ": " + value), disk);
}
assertRisk(f, failure);
var command = f.plugin.getCommand("inventoryhelperadmin");
assertNotNull(command);
assertTrue(command.execute(f.console, "inventoryhelperadmin", new String[] {"config", key}));
assertTrue(f.adminMessages.contains(key + " = " + value));
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(expected, f.player.getItemOnCursor(), "Roll at the effective cutoff succeeds");
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertTrue(effects.explosions.isEmpty());
// The other side of THIS effective cutoff distinguishes runtime application
// from merely updating the preview while still rolling against old defaults.
f.player.setItemOnCursor(null);
selectedRoll.set(failure - 1);
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, input(f, 0).getAmount());
assertEquals(1, input(f, 1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, effects.explosions.size(), "Roll immediately below effective cutoff fails");
assertEquals(0, count(f, expected));
}
}
assertEquals(4, rolls.get());
}
@ParameterizedTest @ValueSource(strings = {"cursor-full", "storage-full", "storage-changed", "offhand-changed"})
void capacityAndDestinationChangesRefuseWithoutPaymentOrRoll(String scenario) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack result = f.output().clone();
if (scenario.equals("cursor-full")) f.player.setItemOnCursor(result.asQuantity(3));
if (scenario.equals("storage-full")) {
for (int slot = 0; slot < 36; slot++) f.player.getInventory().setItem(slot, new ItemStack(Material.EMERALD, 64));
}
if (scenario.equals("offhand-changed")) packet.click(2, 40, ContainerInput.SWAP);
else packet.click(2, 0, scenario.equals("cursor-full") ? ContainerInput.PICKUP : ContainerInput.QUICK_MOVE);
if (scenario.equals("storage-changed")) f.player.getInventory().setItem(8, new ItemStack(Material.EMERALD, 17));
if (scenario.equals("offhand-changed")) f.player.getInventory().setItemInOffHand(new ItemStack(Material.EMERALD, 17));
ItemStack[] storage = storage(f);
ItemStack cursor = f.player.getItemOnCursor().clone();
ItemStack offhand = f.player.getInventory().getItemInOffHand().clone();
f.tick();
unpaid(f, rolls);
assertArrayEquals(storage, storage(f));
assertEquals(cursor, f.player.getItemOnCursor());
assertEquals(offhand, f.player.getInventory().getItemInOffHand());
assertEquals(result, f.output());
}
}
@ParameterizedTest @ValueSource(strings = {"right", "offhand"})
void nativeEligibleRightClickAndEmptyOffhandDeliverOnePaidOutput(String destination) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(5, marker);
packet.click(2, destination.equals("right") ? 1 : 40,
destination.equals("right") ? ContainerInput.PICKUP : ContainerInput.SWAP);
assertEquals(destination.equals("right") ? ClickType.RIGHT : ClickType.SWAP_OFFHAND,
packet.clicks.getFirst().getClick());
assertEquals(destination.equals("right") ? InventoryAction.PICKUP_HALF : InventoryAction.HOTBAR_SWAP,
packet.clicks.getFirst().getAction());
f.tick();
paid(f, rolls);
assertEquals(expected, destination.equals("right") ? f.player.getItemOnCursor()
: f.player.getInventory().getItemInOffHand());
assertEquals(marker, f.player.getInventory().getItem(5));
assertEquals(1, count(f, expected));
}
}
@Test void exactSameEventReplayAndPendingPacketRepeatCannotSpendTwice() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
var sameEvent = packet.clicks.getFirst();
f.events.callEvent(sameEvent);
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, f.nextTick.size());
f.tick();
paid(f, rolls);
assertEquals(1, count(f, expected));
f.events.callEvent(sameEvent); // SAME object after the first attempt, not a lookalike.
f.tick();
paid(f, rolls);
assertEquals(1, count(f, expected));
assertEquals(expected, f.output());
}
}
@Test void prepareReenteredDuringPacketDispatchInvalidatesWithoutFreeOutputOrDoubleSpend() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
packet.listen(EventPriority.MONITOR, ignored -> { f.prepareAgain(); f.prepareAgain(); });
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
unpaid(f, rolls);
assertTrue(f.player.getItemOnCursor().isEmpty());
assertNotNull(f.output());
f.tick();
unpaid(f, rolls);
}
}
@Test void actualCloseAndRemovedReturnUnspentInputsAndInvalidatePendingTake() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack result = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
packet.closeMenu(InventoryCloseEvent.Reason.PLAYER);
f.tick();
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertEquals(6, count(f, sword(5, 1)), "Native removed/placeItemBackInInventory returns BOTH stacks");
assertEquals(0, count(f, result));
assertEquals(0, rolls.get());
assertTrue(packet.drops.isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"close", "death", "destroy"})
void synchronousFailureLifecycleRetainsExactlyOneDebitNoOutputAndNoRetry(String lifecycle) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
seed(f);
ItemStack result = f.output().clone();
effects.duringExplosion = () -> {
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
if (lifecycle.equals("destroy")) effects.block.setType(Material.AIR, false);
if (lifecycle.equals("death")) packet.dieAndClose();
else packet.closeMenu(lifecycle.equals("destroy") ? InventoryCloseEvent.Reason.CANT_USE
: InventoryCloseEvent.Reason.PLAYER);
};
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(0, count(f, result));
assertEquals(4, count(f, sword(5, 1)) + packet.drops.stream()
.filter(item -> org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(item).isSimilar(sword(5, 1)))
.mapToInt(net.minecraft.world.item.ItemStack::getCount).sum());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertNull(f.output());
packet.click(f.menu.containerId, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(0, count(f, result));
}
}
@ParameterizedTest @ValueSource(strings = {"cancel", "changed", "below", "at", "above", "creative"})
void nativeWearProtectionStateFacingAndTwelvePercentBoundary(String scenario) throws Exception {
AtomicInteger wearRolls = new AtomicInteger();
double wear = scenario.equals("at") ? .12 : scenario.equals("above") ? .1200001 : Math.nextDown(.12);
try (var f = new NativeAnvilFixture(directory, () -> 99, () -> { wearRolls.incrementAndGet(); return wear; });
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
if (scenario.equals("creative")) packet.creative();
var facing = (Directional) effects.data.get();
facing.setFacing(BlockFace.WEST);
effects.data.set(facing);
AtomicInteger damageEvents = new AtomicInteger();
f.events.registerEvent(AnvilDamagedEvent.class, new Listener() {}, EventPriority.HIGHEST,
(ignored, event) -> {
var damage = (AnvilDamagedEvent) event;
damageEvents.incrementAndGet();
if (scenario.equals("cancel")) damage.setCancelled(true);
if (scenario.equals("changed")) damage.setDamageState(AnvilDamagedEvent.DamageState.DAMAGED);
}, f.plugin, false);
seed(f);
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
boolean damages = !scenario.equals("at") && !scenario.equals("above") && !scenario.equals("creative");
assertEquals(damages ? 1 : 0, damageEvents.get());
assertEquals(scenario.equals("creative") ? 0 : 1, wearRolls.get());
Material expected = scenario.equals("changed") ? Material.DAMAGED_ANVIL
: damages && !scenario.equals("cancel") ? Material.CHIPPED_ANVIL : Material.ANVIL;
assertEquals(expected, effects.block.getType());
assertEquals(BlockFace.WEST, ((Directional) effects.data.get()).getFacing());
verify(f.world, times(scenario.equals("cancel") ? 0 : 1))
.playEffect(any(org.bukkit.Location.class), eq(Effect.ANVIL_USE), eq(0));
assertEquals(1, f.player.getItemOnCursor().getAmount());
assertEquals(2, input(f, 0).getAmount());
}
}
@ParameterizedTest @ValueSource(strings = {"refused", "uncertain-throw"})
void nativeExplosionRequestsCorrectFlagsAndNeverRefundsOrRetries(String behavior) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
effects.cancelExplosion = behavior.equals("refused");
var uncertain = new IllegalStateException("World may already have applied damage");
if (behavior.equals("uncertain-throw")) effects.duringExplosion = () -> { throw uncertain; };
seed(f);
ItemStack result = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
if (behavior.equals("uncertain-throw")) assertSame(uncertain, assertThrows(IllegalStateException.class, f::tick));
else f.tick();
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertEquals(1, rolls.get());
assertEquals(java.util.List.of(false), effects.requestedFire);
var explosion = effects.explosions.getFirst();
assertFalse(explosion.excludeSourceFromDamage);
assertSame(f.nativePlayer, explosion.getDirectSourceEntity());
assertSame(f.nativePlayer, explosion.getIndirectSourceEntity());
assertEquals(6F, explosion.radius());
assertEquals(behavior.equals("refused"), explosion.wasCanceled);
assertEquals(0, count(f, result));
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertEquals(0, count(f, result));
// Last so incorrect interaction does not mask no-refund/no-retry/attribution proof.
assertEquals(java.util.List.of(net.minecraft.world.level.Level.ExplosionInteraction.MOB), effects.requestedInteractions);
}
}
@ParameterizedTest @ValueSource(strings = {"inputs", "acknowledged-policy", "acknowledged-policy-resync"})
void oldClientStateAfterNewServerPreviewMustRefuseWithoutPaymentOrRiskRoll(String change) throws Exception {
AtomicInteger rolls = new AtomicInteger();
// Roll50 succeeds at the displayed OLD 20% risk, but fails at the unseen 80/95% risk.
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
seed(f);
f.menu.sendAllDataToRemote();
int oldNativeState = f.menu.getStateId();
int oldState = f.clientState(f.menu);
String oldRisk = latestRisk(f);
assertRisk(f, 20);
if (change.equals("inputs")) f.inputs(sword(10, 3), sword(10, 3)); // new Sharpness XI: 95% failure
else {
f.admin(f.console, BASE, 80);
f.acknowledgeSave();
}
int reprepareState = f.menu.getStateId();
// Approved A requires a new native epoch even for identical-output risk changes.
assertNotEquals(oldNativeState, reprepareState, "Every changed risk must version the native menu");
// Also retain the full-resync variant sent but not yet observed by the client.
if (change.equals("acknowledged-policy-resync")) f.player.updateInventory();
int currentState = f.clientState(f.menu);
assertNotEquals(oldState, currentState, "A real newer server menu generation must exist");
assertRisk(f, change.equals("inputs") ? 95 : 80);
ItemStack left = input(f, 0).clone();
ItemStack right = input(f, 1).clone();
String currentRisk = latestRisk(f);
// Outbound packets/action bars are captured, NOT delivered to a client. This packet
// was created from the older rendered preview, not from the latest server preview.
packet.packets.clear();
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
assertEquals(0, packet.clicks.size(), "Protocol rejection must precede Bukkit dispatch");
assertTrue(packet.received.getLast().isCancelled());
if (oldState != currentState) {
assertTrue(packet.packets.stream().anyMatch(
net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
}
f.tick();
String facts = "change=" + change + ", oldState=" + oldState + ", stateAfterReprepare=" + reprepareState
+ ", currentStateBeforePacket=" + currentState
+ ", oldRisk=" + oldRisk + ", currentRisk=" + currentRisk + ", selectedRoll=50, rolls=" + rolls.get()
+ ", explosions=" + effects.explosions.size() + ", leftAfter=" + input(f, 0).getAmount();
System.out.println("STALE_CLIENT_FACTS " + facts);
assertAll(facts,
() -> assertEquals(left, input(f, 0), "Old client take must not debit the NEW preview"),
() -> assertEquals(right, input(f, 1)),
() -> assertEquals(0, rolls.get(), "Resync is not refusal: outdated displayed risk must not commit"),
() -> assertTrue(effects.explosions.isEmpty()),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()));
}
}
private NativeAnvilFixture successFixture(AtomicInteger rolls) throws Exception {
return new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
}
private static ItemStack sword(int level, int count) {
return NativeAdvancedAnvilTransactionTest.stackedSword(level, count);
}
private static void seed(NativeAnvilFixture f) { f.inputs(sword(5, 3), sword(5, 3)); }
private static ItemStack input(NativeAnvilFixture f, int slot) { return f.menu.getBukkitView().getTopInventory().getItem(slot); }
private static void unpaid(NativeAnvilFixture f, AtomicInteger rolls) {
assertEquals(sword(5, 3), input(f, 0));
assertEquals(sword(5, 3), input(f, 1));
assertEquals(0, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static void paid(NativeAnvilFixture f, AtomicInteger rolls) {
assertEquals(sword(5, 2), input(f, 0));
assertEquals(sword(5, 2), input(f, 1));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static ItemStack[] storage(NativeAnvilFixture f) {
return Arrays.stream(f.player.getInventory().getStorageContents())
.map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
}
static int count(NativeAnvilFixture f, ItemStack expected) {
return Arrays.stream(f.player.getInventory().getContents()).filter(item -> item != null && item.isSimilar(expected))
.mapToInt(ItemStack::getAmount).sum() + (f.player.getItemOnCursor().isSimilar(expected)
? f.player.getItemOnCursor().getAmount() : 0);
}
static String latestRisk(NativeAnvilFixture f) {
return PlainTextComponentSerializer.plainText().serialize(f.actionBars.getLast());
}
static void assertRisk(NativeAnvilFixture f, int failure) {
String preview = latestRisk(f);
assertTrue(preview.contains("Success " + (100 - failure) + "% · Failure " + failure + "%"), preview);
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.Effect;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAdvancedAnvilEffectsTest {
@TempDir Path directory;
@Test void failureConsumesOneOfEachBeforeRealProtectedSourceDamagingExplosionAndNeverRetries() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
effects.cancelExplosion = true;
f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(5, 3));
effects.duringExplosion = () -> {
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertNull(f.output());
};
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, effects.explosions.size(), "A committed failed attempt causes one native explosion");
var explosion = effects.explosions.getFirst();
assertEquals(6F, explosion.radius());
assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center());
assertSame(f.nativePlayer, explosion.getDirectSourceEntity());
assertSame(f.nativePlayer, explosion.getIndirectSourceEntity());
assertFalse(explosion.excludeSourceFromDamage);
assertTrue(explosion.wasCanceled);
assertEquals(1, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
f.tick();
assertEquals(1, effects.explosions.size());
verify(f.level).explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
eq(6F), eq(false), any(), any(), any(), any(), any(), any());
}
}
@Test void successfulSurvivalUsePreservesNativeWearFacingAndUseEffect() throws Exception {
try (var f = new NativeAnvilFixture(directory, () -> 99, () -> 0);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
var facing = ((org.bukkit.block.data.Directional) effects.data.get()).getFacing();
f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(5, 3));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(Material.CHIPPED_ANVIL, effects.block.getType());
assertEquals(facing, ((org.bukkit.block.data.Directional) effects.data.get()).getFacing());
assertEquals(1, f.player.getItemOnCursor().getAmount());
verify(f.world).playEffect(any(org.bukkit.Location.class), eq(Effect.ANVIL_USE), eq(0));
assertTrue(effects.explosions.isEmpty());
}
}
}
@@ -0,0 +1,336 @@
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.event.EventPriority;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Packet -> native classification -> registered plugin -> untouched native phase -> deferred commit. */
class NativeAdvancedAnvilPacketTest {
@TempDir Path directory;
@Test void zeroXpSurvivalPickupReachesNativeEventAndPaysExactlyOnceOnTick() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(left, left.clone());
assertEquals(0, f.menu.getCost());
assertTrue(f.menu.getSlot(2).mayPickup(f.nativePlayer), "Real zero-XP survival result must be takeable");
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size(), "Handler must dispatch a registered Bukkit event");
assertEquals(InventoryAction.PICKUP_ALL, packet.clicks.getFirst().getAction());
assertEquals(ClickType.LEFT, packet.clicks.getFirst().getClick());
assertFalse(packet.clicks.getFirst().isCancelled(), "Gate empties output, not fabricated cancellation");
assertTrue(f.menu.getSlot(2).getItem().isEmpty());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get(), "No native-phase roll/payment");
assertEquals(1, f.nextTick.size());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
assertEquals(6, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void shiftQuickMoveAndRepeatBeforeTickCommitOnlyOneOutput() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(4, marker);
ItemStack[] before = storage(f);
packet.click(2, 0, ContainerInput.QUICK_MOVE);
packet.click(2, 0, ContainerInput.QUICK_MOVE);
assertEquals(1, packet.clicks.size(), "Pending repeat is refused before native Bukkit dispatch");
assertTrue(packet.received.getLast().isCancelled());
assertEquals(InventoryAction.MOVE_TO_OTHER_INVENTORY, packet.clicks.getFirst().getAction());
assertEquals(ClickType.SHIFT_LEFT, packet.clicks.getFirst().getClick());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size(), "Repeat packet cannot reserve a second payment");
f.tick();
assertPaid(f, rolls);
assertEquals(expected, f.player.getInventory().getItem(8), "Native reverse hotbar destination");
assertEquals(marker, f.player.getInventory().getItem(4));
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, countOutput(f, expected));
f.tick();
assertEquals(1, countOutput(f, expected));
assertEquals(1, rolls.get());
}
}
@Test void numberKeyDeliversOnceToEmptyHotbarSlotWithoutChangingUnrelatedItem() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(5, marker);
ItemStack[] before = storage(f);
packet.click(2, 4, ContainerInput.SWAP);
assertEquals(1, packet.clicks.size());
assertEquals(InventoryAction.HOTBAR_SWAP, packet.clicks.getFirst().getAction());
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
assertEquals(4, packet.clicks.getFirst().getHotbarButton());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
f.tick();
assertPaid(f, rolls);
assertEquals(expected, f.player.getInventory().getItem(4));
assertEquals(marker, f.player.getInventory().getItem(5));
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, countOutput(f, expected));
f.tick();
assertEquals(1, rolls.get());
}
}
@Test void occupiedHotbarNumberKeyRetainsNativeNothingClassificationAndConsumesNothing() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(4, marker);
ItemStack[] before = storage(f);
packet.click(2, 4, ContainerInput.SWAP);
assertEquals(1, packet.clicks.size());
assertEquals(InventoryAction.NOTHING, packet.clicks.getFirst().getAction(),
"Native result slot may not accept the existing hotbar item");
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
f.tick();
assertUnpaid(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
}
}
@Test void finalHighestNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
finalCancellation(EventPriority.HIGHEST);
}
@Test void finalMonitorNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
finalCancellation(EventPriority.MONITOR);
}
private void finalCancellation(EventPriority priority) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.listen(priority, event -> {
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Cancellation runs after plugin's gate");
assertEquals(InventoryAction.PICKUP_ALL, event.getAction());
event.setCancelled(true);
});
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertTrue(packet.clicks.getFirst().isCancelled(), "Final native event, not substituted event");
assertGated(f, rolls, null);
assertEquals(1, f.nextTick.size());
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(expected, f.output(), "Denied native intent restores preview without payment");
assertEquals(0, countOutput(f, expected));
}
}
@Test void creativeCloneCannotCopyUnpaidEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.CLONE, 2, ClickType.MIDDLE, InventoryAction.CLONE_STACK, true);
}
@Test void dropOneCannotEmitOrPayForEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.THROW, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT, false);
}
@Test void dropStackCannotEmitOrPayForEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.THROW, 1, ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT, false);
}
private void unsupportedResult(ContainerInput input, int button, ClickType click,
InventoryAction action, boolean creative) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
if (creative) packet.creative();
seed(f);
ItemStack expected = f.output().clone();
ItemStack[] before = storage(f);
packet.click(2, button, input);
assertEquals(1, packet.clicks.size());
assertEquals(click, packet.clicks.getFirst().getClick());
assertEquals(action, packet.clicks.getFirst().getAction());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size());
f.tick();
assertUnpaid(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never())
.drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class),
org.mockito.ArgumentMatchers.anyBoolean());
}
}
@Test void doubleClickOutsideResultCannotCollectUnpaidPreviewIntoMatchingCursor() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack carried = f.output().clone();
f.player.setItemOnCursor(carried.clone());
ItemStack[] before = storage(f);
packet.click(3, 0, ContainerInput.PICKUP_ALL); // empty native inventory slot, not result
assertEquals(1, packet.clicks.size());
assertEquals(3, packet.clicks.getFirst().getRawSlot());
assertEquals(InventoryAction.COLLECT_TO_CURSOR, packet.clicks.getFirst().getAction());
assertEquals(ClickType.DOUBLE_CLICK, packet.clicks.getFirst().getClick());
assertGated(f, rolls, carried);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size(), "Outside-result collection still passes through enhanced gate");
f.tick();
assertUnpaid(f, rolls, carried);
assertArrayEquals(before, storage(f));
assertEquals(carried, f.output());
assertEquals(1, countOutput(f, carried), "Only the pre-existing cursor item remains");
}
}
@Test void mismatchedContainerIdIsRefusedByNativeHandlerBeforeEventAndGate() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack preview = f.output().clone();
packet.click(f.menu.containerId + 1, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
assertTrue(packet.clicks.isEmpty());
assertTrue(f.nextTick.isEmpty());
assertEquals(preview, f.output(), "Native container-ID guard precedes the registered gate");
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(preview, f.output());
}
}
@Test void stateIdMismatchRefusesBeforeDispatchOrPayment() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.packets.clear();
packet.click(f.menu.containerId, -1, 2, 0, ContainerInput.PICKUP);
assertEquals(0, packet.clicks.size(), "Approved A refuses the old native resync-but-accept path");
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.packets.stream().anyMatch(
net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance),
"Real native synchronizer emits a full resync through the connection sink");
assertEquals(expected, f.output());
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(expected, f.output());
}
}
@Test void inactiveViewBeforeDeferredCommitCannotPayOldMenu() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertGated(f, rolls, null);
packet.inactiveView();
assertSame(packet.inventoryMenu.getBukkitView(), f.player.getOpenInventory());
packet.click(f.menu.containerId, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size(), "Old container packet cannot reach anvil after native menu replacement");
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(0, countOutput(f, expected));
assertFalse(f.menu.bypassCost, "Inactive view releases owned exemption");
}
}
@Test void destinationChangeAfterNativeGateInvalidatesPaymentWithoutOverwritingCursor() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertGated(f, rolls, null);
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.setItemOnCursor(marker);
f.tick();
assertUnpaid(f, rolls, marker);
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
}
}
private NativeAnvilFixture fixture(AtomicInteger rolls) throws Exception {
return new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
}
private static void seed(NativeAnvilFixture f) {
ItemStack input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(input, input.clone());
}
private static void assertGated(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Real native result is gated");
assertUnpaid(f, rolls, cursor);
}
private static void assertUnpaid(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
if (cursor == null) assertTrue(f.player.getItemOnCursor().isEmpty());
else assertEquals(cursor, f.player.getItemOnCursor());
assertEquals(0, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static void assertPaid(NativeAnvilFixture f, AtomicInteger rolls) {
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 2);
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static ItemStack[] storage(NativeAnvilFixture f) {
return java.util.Arrays.stream(f.player.getInventory().getStorageContents())
.map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
}
private static int countOutput(NativeAnvilFixture f, ItemStack output) {
int count = java.util.Arrays.stream(f.player.getInventory().getContents())
.filter(item -> item != null && item.isSimilar(output)).mapToInt(ItemStack::getAmount).sum();
ItemStack cursor = f.player.getItemOnCursor();
return count + (cursor.isSimilar(output) ? cursor.getAmount() : 0);
}
}
@@ -0,0 +1,347 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.ListenerOptions;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAdvancedAnvilProtocolTest {
@TempDir Path directory;
@Test void enableRegistersSynchronousDeclaredLibraryListenerAndRemovesOnlyOwnedListeners() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
assertFalse(f.protocolListeners.isEmpty(), "Real enable must register ProtocolLib adapter");
for (var listener : f.protocolListeners) {
assertSame(f.plugin, listener.getPlugin());
if (!listener.getReceivingWhitelist().getTypes().isEmpty()) {
assertEquals(java.util.Set.of(PacketType.Play.Client.WINDOW_CLICK), listener.getReceivingWhitelist().getTypes());
assertTrue(listener.getReceivingWhitelist().getOptions().contains(ListenerOptions.SYNC));
} else {
assertEquals(java.util.Set.of(PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT),
listener.getSendingWhitelist().getTypes());
assertTrue(listener.getSendingWhitelist().getOptions().contains(ListenerOptions.SYNC));
}
}
var foreign = org.mockito.Mockito.mock(com.comphenix.protocol.events.PacketListener.class);
f.protocolListeners.add(foreign);
f.plugin.setEnabled(false);
assertEquals(java.util.List.of(foreign), f.protocolListeners);
org.mockito.Mockito.verify(f.protocol, org.mockito.Mockito.never()).removePacketListeners(org.mockito.ArgumentMatchers.any());
f.assertNoEventErrors();
}
}
@Test void unsupportedBukkitTakeWithoutRawAdmissionRefusesAndConserves() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
seed(f);
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var event = new InventoryClickEvent(f.menu.getBukkitView(), InventoryType.SlotType.RESULT,
2, ClickType.LEFT, InventoryAction.PICKUP_ALL);
f.events.callEvent(event);
assertTrue(event.isCancelled(), "Missing client generation must fail closed");
f.tick();
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@Test void unknownPayloadCancelsAndNativeResynchronizesBeforeAnyBukkitClick() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
packet.packets.clear();
var event = f.receive(new Object());
assertTrue(event.isCancelled());
assertTrue(packet.clicks.isEmpty());
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
}
}
@Test void policyAndInputsChangedBetweenLibraryAdmissionAndNativeBukkitDispatchCannotRepurposeTicket() throws Exception {
for (boolean policyOnly : new boolean[] {true, false}) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(Boolean.toString(policyOnly)),
() -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
var admitted = f.receive(raw);
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();
f.acknowledgeSave();
expiry.run();
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var right = f.menu.getBukkitView().getTopInventory().getItem(1).clone();
packet.handler.handleContainerClick((net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
admitted.getPacket().getHandle());
assertEquals(1, packet.clicks.size());
assertTrue(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(right, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
}
@Test void ticketCannotAuthorizeDifferentSlotButtonOrInputAndExpiresIfNativeDispatchIsLost() throws Exception {
for (String mode : java.util.List.of("slot", "button", "input", "lost")) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
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();
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,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
assertTrue(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
}
}
}
@Test void earlierAndFinalProtocolCancellationNeverAuthorizesPaymentEvenIfLaterListenerUncancels() throws Exception {
for (String mode : java.util.List.of("earlier", "late", "uncancel", "after-bukkit")) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
var event = f.clientEvent(raw);
if (mode.equals("earlier") || mode.equals("uncancel")) event.setCancelled(true);
if (mode.equals("late") || mode.equals("uncancel")) f.protocolListeners.add(
new com.comphenix.protocol.events.PacketAdapter(f.plugin,
com.comphenix.protocol.events.ListenerPriority.HIGHEST, PacketType.Play.Client.WINDOW_CLICK) {
@Override public void onPacketReceiving(com.comphenix.protocol.events.PacketEvent callback) {
callback.setCancelled(!mode.equals("uncancel"));
}
});
f.dispatchProtocol(event);
if (!event.isCancelled()) packet.handler.handleContainerClick(
(net.minecraft.network.protocol.game.ServerboundContainerClickPacket) event.getPacket().getHandle());
if (mode.equals("after-bukkit")) event.setCancelled(true);
f.tick();
assertEquals(0, rolls.get());
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
}
@Test void offThreadCallbackCancelsOnlyWatchedActorWithoutMutatingBukkit() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var event = f.clientEvent(raw(f, 2, 0, ContainerInput.PICKUP));
org.mockito.Mockito.clearInvocations(f.player);
org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(false);
try {
var thread = new Thread(() -> f.dispatchProtocol(event));
thread.start();
thread.join();
assertTrue(event.isCancelled());
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).getOpenInventory();
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).updateInventory();
assertTrue(f.nextTick.isEmpty());
assertTrue(packet.clicks.isEmpty());
} finally { org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(true); }
}
}
@Test void unavailableManagerWithholdsNewEnhancedOutputAndRestoresOwnedBypass() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
org.mockito.Mockito.when(f.protocol.isClosed()).thenReturn(true);
f.prepareAgain();
assertNull(f.output());
assertFalse(f.menu.bypassCost);
assertTrue(packet.clicks.isEmpty());
f.assertNoEventErrors();
}
}
@Test void watchedEnhancedToVanillaRejectsOldTakeButFreshNormalBookOperationStaysNative() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
int oldState = f.clientState(f.menu);
var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 1);
var book = new org.bukkit.inventory.ItemStack(org.bukkit.Material.ENCHANTED_BOOK);
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
meta.addStoredEnchant(org.bukkit.enchantments.Enchantment.UNBREAKING, 3, false);
book.setItemMeta(meta);
f.inputs(left, book);
var expected = f.output().clone();
int cost = f.menu.getCost();
assertTrue(cost > 0);
assertFalse(f.menu.bypassCost);
assertNotEquals(oldState, f.clientState(f.menu));
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(book, f.menu.getBukkitView().getTopInventory().getItem(1));
f.nativePlayer.experienceLevel = 100;
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(1F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
assertFalse(packet.clicks.getFirst().isCancelled());
assertEquals(expected, f.player.getItemOnCursor());
org.mockito.Mockito.verify(f.nativePlayer).giveExperienceLevels(-cost);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
f.tick();
assertEquals(0, rolls.get());
}
}
@Test void disableRetiresOwnedNativeWindowReturnsInputsAndOldPacketCannotBecomeVanillaTake() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
assertFalse(f.receive(raw).isCancelled()); // A held, admitted raw packet has not reached native.
f.plugin.setEnabled(false);
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
assertTrue(f.protocolListeners.isEmpty());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(f.menu.bypassCost);
packet.handler.handleContainerClick(raw);
f.tick();
assertTrue(packet.clicks.isEmpty());
assertEquals(0, rolls.get());
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f,
NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1,
"Purpur 26.2.build.2619-stable")) {
assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure");
assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst());
assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage());
f.eventErrors.clear(); // The exact expected enable refusal is asserted above, never suppressed.
// The quantity fixture removes MAX_DAMAGE to make custom swords stackable; vanilla
// same-material combining requires damageable items. Use genuinely ordinary swords.
var input = new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND_SWORD);
input.addUnsafeEnchantment(org.bukkit.enchantments.Enchantment.SHARPNESS, 5);
f.inputs(input, input.clone());
assertTrue(f.protocolListeners.isEmpty());
assertFalse(f.menu.bypassCost);
assertEquals(5, f.output().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
assertTrue(f.menu.getCost() > 0);
assertTrue(f.actionBars.isEmpty());
assertEquals(0, rolls.get());
f.assertNoEventErrors();
}
}
@Test void initialAndIdenticalOutputRiskOnlyFramesPublishExactPrivateRiskBeforeDelivery() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
int initial = f.clientState(f.menu);
packet.packets.clear();
seed(f);
assertNotEquals(initial, f.clientState(f.menu));
assertRiskFrames(packet, f.output(), initial, 20);
int oldState = f.clientState(f.menu);
var oldOutput = f.output().clone();
packet.packets.clear();
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
f.acknowledgeSave();
assertEquals(oldOutput, f.output());
assertNotEquals(oldState, f.clientState(f.menu));
assertRiskFrames(packet, oldOutput, oldState, 80);
}
}
@Test void malformedTakingHeadersCancelAndResyncBeforeNativeEventsAndOtherExactViewsStayUnwatched() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
for (ContainerInput input : java.util.List.of(ContainerInput.PICKUP, ContainerInput.SWAP, ContainerInput.QUICK_CRAFT)) {
packet.packets.clear();
packet.click(2, -1, input);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
}
var unrelated = new net.minecraft.world.inventory.AnvilMenu(19, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
unrelated.setTitle(net.minecraft.network.chat.Component.literal("Other ordinary anvil"));
f.nativePlayer.containerMenu = unrelated;
assertFalse(f.receive(new Object()).isCancelled(), "Same actor/menu kind is not exact owned-view scope");
assertTrue(f.nextTick.isEmpty());
}
}
private static void assertRiskFrames(NativeAnvilPacketFixture packet, org.bukkit.inventory.ItemStack expected,
int oldState, int failure) {
int frames = 0;
for (var frame : packet.packets) {
net.minecraft.world.item.ItemStack output = null;
int state = -1;
if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId) {
output = content.items().get(2);
state = content.stateId();
} else if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId && slot.getSlot() == 2) {
output = slot.getItem();
state = slot.getStateId();
}
if (output != null && org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(output).equals(expected)) {
frames++;
assertNotEquals(oldState, state);
assertTrue(packet.privateRiskAtSend.get(frame).contains("Failure " + failure + "%"));
}
}
assertTrue(frames > 0, "Must prove actual native output-frame delivery, not only server-side state");
}
private static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(
NativeAnvilFixture f, int slot, int button, ContainerInput input) {
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
f.clientState(f.menu), (short) slot, (byte) button, input,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
}
static void seed(NativeAnvilFixture f) {
var input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(input, input.clone());
}
}
@@ -0,0 +1,62 @@
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.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/** Native displayed risk and committed outcomes for every controlled roll, not domain-only math. */
class NativeAdvancedAnvilRiskTest {
@TempDir Path directory;
@ParameterizedTest @CsvSource({"0,0", "1,20", "2,25", "3,35", "4,50", "5,70", "6,95", "7,99", "20,99"})
void largestResultExcessDefaultTableControlsEveryRollFromZeroThroughNinetyNine(int excess, int failure)
throws Exception {
AtomicInteger selectedRoll = new AtomicInteger();
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
f.player.setItemOnCursor(null);
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(4 + excess, 3);
// A second compatible enchantment has a smaller excess: SUM would give a
// different risk at E>=2. Native maxima differ (Sharpness5, Unbreaking3).
left.addUnsafeEnchantment(Enchantment.UNBREAKING, 2 + Math.max(0, excess - 1));
f.inputs(left, left.clone());
NativeAdvancedAnvilBoundaryTest.assertRisk(f, failure);
ItemStack expected = f.output().clone();
assertEquals(5 + excess, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3 + Math.max(0, excess - 1), expected.getEnchantmentLevel(Enchantment.UNBREAKING));
assertEquals(roll, rolls.get(), "Preparation never rolls");
int explosionsBefore = effects.explosions.size();
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
String context = "E=" + excess + ", cutoff=" + failure + ", roll=" + roll;
assertEquals(roll + 1, rolls.get(), context);
assertEquals(2, f.menu.getSlot(0).getItem().getCount(), context);
assertEquals(2, f.menu.getSlot(1).getItem().getCount(), context);
assertEquals(0, f.nativePlayer.experienceLevel, context);
if (roll < failure) {
assertTrue(f.player.getItemOnCursor().isEmpty(), context);
assertEquals(0, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
assertEquals(explosionsBefore + 1, effects.explosions.size(), context);
} else {
assertEquals(expected, f.player.getItemOnCursor(), context);
assertEquals(1, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
assertEquals(explosionsBefore, effects.explosions.size(), context);
}
}
assertEquals(failure, effects.explosions.size(), "100 rolls: precisely the displayed failure count");
assertEquals(100, rolls.get());
assertEquals(1, f.player.getItemOnCursor().getAmount(), "Roll99 succeeds even at the 99% ceiling");
}
}
}
@@ -0,0 +1,295 @@
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 net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.EventPriority;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.PrepareAnvilEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable;
import org.bukkit.inventory.meta.Repairable;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered plugin -> native slotsChanged/result event -> Craft result and private preview. */
class NativeAdvancedAnvilTest {
@TempDir Path directory;
NativeAnvilFixture fixture;
@BeforeEach void enable() throws Exception { fixture = new NativeAnvilFixture(directory); }
@AfterEach void disable() { if (fixture != null) fixture.close(); }
@Test void registeredHighestListenerMergesEqualAboveNormalMaximumAtZeroXpAndTooExpensive() {
assertTrue(java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin
&& listener.getPriority() == EventPriority.HIGHEST));
var left = sword(5);
var meta = left.getItemMeta();
((Repairable) meta).setRepairCost(80);
left.setItemMeta(meta);
fixture.nativePlayer.experienceLevel = 0;
fixture.inputs(left, sword(5));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(0, fixture.menu.getCost());
assertTrue(fixture.menu.getBukkitView().canBypassCost());
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
assertFalse(fixture.menu.canDoUnsafeEnchants);
assertFalse(fixture.menu.bypassEnchantmentLevelRestriction);
assertEquals(80, ((Repairable) fixture.output().getItemMeta()).getRepairCost());
assertInputs(left, sword(5));
}
@Test void equalUnequalAndSingleSourceUnionUseEntireEnchantments() {
fixture.inputs(sword(6), sword(5));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
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);
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
assertEquals(0, fixture.menu.getCost());
fixture.inputs(right, new ItemStack(Material.DIAMOND_SWORD));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
}
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
var left = sword(5);
var meta = (Damageable) left.getItemMeta();
meta.setDamage(1200);
left.setItemMeta(meta);
var right = sword(5);
right.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
fixture.inputs(left, right);
assertEquals(1013, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
fixture.inputs(sword(5), right);
assertNull(fixture.output());
assertTrue(lastAction().contains("conflict"));
assertInputs(sword(5), right);
var alreadyConflicting = sword(5);
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");
}
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
fixture.inputs(sword(5), invalid);
assertNull(fixture.output());
assertTrue(lastAction().contains("applicable"));
fixture.inputs(invalid, sword(5));
assertNull(fixture.output());
assertInputs(invalid, sword(5));
}
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
fixture.inputs(sword(254), sword(254));
assertNotNull(fixture.output());
assertEquals(255, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(fixture.output(), ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
fixture.inputs(sword(255), sword(255));
assertNull(fixture.output());
assertTrue(lastAction().contains("represent"));
assertInputs(sword(255), sword(255));
assertNotNull(AdvancedAnvilMerge.prepare(sword(255), sword(255), AdvancedAnvilPolicy.defaults()).refusal());
}
@Test void exactStyledUnicodeNameLorePdcAndLeftOnlyMetadataSurviveCraftRoundTrip() {
var left = sword(5);
left.setAmount(3);
var meta = left.getItemMeta();
meta.customName(Component.text("Épée 雪 🗡", NamedTextColor.AQUA).decorate(TextDecoration.BOLD));
meta.lore(List.of(Component.text("Lore λ", NamedTextColor.GOLD), Component.empty()));
meta.itemName(Component.text("Separate native item-name"));
meta.setUnbreakable(true);
meta.setMaxStackSize(3);
meta.getPersistentDataContainer().set(key("left"), PersistentDataType.STRING, "雪");
left.setItemMeta(meta);
// Native slots clamp ordinary swords to one; a real stackable sword must also remove
// MAX_DAMAGE, since the native codec rejects damageable + stackable components.
left.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
assertEquals(left, ItemStack.deserializeBytes(left.serializeAsBytes()));
var right = sword(5);
var rightMeta = right.getItemMeta();
rightMeta.customName(Component.text("DO NOT IMPORT"));
rightMeta.lore(List.of(Component.text("foreign")));
rightMeta.getPersistentDataContainer().set(key("right"), PersistentDataType.STRING, "foreign");
right.setItemMeta(rightMeta);
fixture.menu.itemName = "rename field ignored";
fixture.inputs(left, right);
var expected = left.clone();
expected.setAmount(1);
expected.addUnsafeEnchantment(Enchantment.SHARPNESS, 6);
assertEquals(expected, fixture.output());
assertEquals(expected, ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
assertFalse(fixture.output().getItemMeta().getPersistentDataContainer().has(key("right")));
assertEquals(0, fixture.menu.getBukkitView().getRepairCost());
assertTrue(fixture.menu.getBukkitView().canBypassCost());
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
assertInputs(left, right);
}
@Test void absentNameAndLoreStayAbsentAndEnhancedRenameDoesNotChangeThem() {
fixture.menu.itemName = "Injected rename";
fixture.inputs(sword(5), sword(5));
assertFalse(fixture.output().getItemMeta().hasCustomName());
assertFalse(fixture.output().getItemMeta().hasLore());
fixture.menu.setItemName("second rename");
fixture.prepareAgain();
assertFalse(fixture.output().getItemMeta().hasCustomName());
assertFalse(fixture.output().getItemMeta().hasLore());
}
@Test void nativeNormalDurabilityRepairUsesTwelvePercentAndOnlyImprovesDamage() {
var left = damaged(sword(5), 1500);
var right = damaged(sword(5), 1500);
fixture.inputs(left, right);
int maximum = left.getType().getMaxDurability();
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));
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@Test void customNativeMaximumDurabilityAndNonDamageableMaterialsRemainRepresentable() {
var left = sword(5);
var meta = (Damageable) left.getItemMeta();
meta.setMaxDamage(1000);
meta.setDamage(900);
left.setItemMeta(meta);
var right = damaged(sword(5), 1500);
fixture.inputs(left, right);
assertEquals(719, ((Damageable) fixture.output().getItemMeta()).getDamage());
assertEquals(1000, ((Damageable) fixture.output().getItemMeta()).getMaxDamage());
var helmet = enchanted(Material.DIAMOND_HELMET, Enchantment.PROTECTION, 4);
fixture.inputs(helmet, helmet);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.PROTECTION));
}
@Test void booksAndMismatchedMaterialsRemainNativeAndDoNotSendEnhancedWarnings() {
var book = new ItemStack(Material.ENCHANTED_BOOK);
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
meta.addStoredEnchant(Enchantment.SHARPNESS, 5, true);
book.setItemMeta(meta);
fixture.inputs(sword(5), book);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
assertTrue(fixture.chats.isEmpty());
assertTrue(fixture.actionBars.isEmpty());
fixture.inputs(book, book);
assertNotNull(fixture.output());
assertEquals(5, ((org.bukkit.inventory.meta.EnchantmentStorageMeta) fixture.output().getItemMeta())
.getStoredEnchantLevel(Enchantment.SHARPNESS));
fixture.inputs(sword(5), enchanted(Material.NETHERITE_SWORD, Enchantment.SHARPNESS, 5));
assertNull(fixture.output());
assertTrue(fixture.chats.isEmpty());
assertNull(AdvancedAnvilMerge.prepare(book, book, AdvancedAnvilPolicy.defaults()));
}
@Test void zeroEnchantmentRepairAndRenameRemainNativeAfterEnhancedPreview() {
fixture.inputs(sword(5), sword(5));
int notices = fixture.chats.size();
// Remove the enchanted right input before replacing the left, as a player can do.
// Left-first replacement creates a genuine intermediate enhanced merge with the old right.
fixture.inputs(sword(5), null);
fixture.inputs(damaged(new ItemStack(Material.DIAMOND_SWORD), 1200),
damaged(new ItemStack(Material.DIAMOND_SWORD), 1200));
assertNotNull(fixture.output());
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
assertEquals(notices, fixture.chats.size());
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), null);
fixture.menu.setItemName("native rename");
fixture.prepareAgain();
assertEquals(Component.text("native rename"), fixture.output().getItemMeta().customName());
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
}
@Test void exactPrivateRomanPreviewAndChangedOnlyWarningDoNotAlterItemsOrSpam() {
fixture.inputs(sword(5), sword(5));
assertEquals("Diamond Sword · Sharpness VI · Success 80% · Failure 20% — explosion", lastAction());
assertEquals(1, fixture.chats.size());
assertTrue(plain(fixture.chats.getFirst()).contains("Failure destroys both items and causes an explosion"));
for (int i = 0; i < 5; i++) { fixture.prepareAgain(); fixture.tick(); }
assertEquals(1, fixture.chats.size());
fixture.inputs(sword(6), sword(6));
assertEquals(2, fixture.chats.size());
assertTrue(lastAction().contains("Sharpness VII · Success 75% · Failure 25%"));
fixture.inputs(sword(10), sword(10));
assertTrue(lastAction().contains("Sharpness XI · Success 5% · Failure 95%"));
verify(fixture.server, never()).broadcast(any(Component.class));
}
@Test void policyChangeRerendersCurrentViewAndCloseDisableReleasePreviewLifecycle() throws Exception {
fixture.inputs(sword(5), sword(5));
var admin = mock(org.bukkit.command.CommandSender.class);
when(admin.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
assertTrue(fixture.commands.dispatch(admin,
"inventoryhelperadmin config advanced-anvil-base-failure-percent 45"));
var acknowledgment = fixture.nextTick.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(acknowledgment, "Real durable save must schedule acknowledgment");
assertTrue(lastAction().contains("Success 80% · Failure 20%"), "No off-thread publication");
acknowledgment.run();
assertTrue(lastAction().contains("Success 55% · Failure 45%"));
var preview = java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
.map(org.bukkit.plugin.RegisteredListener::getListener)
.filter(AdvancedAnvilListener.class::isInstance).map(AdvancedAnvilListener.class::cast)
.findFirst().orElseThrow();
int chats = fixture.chats.size();
preview.policyChanged();
assertEquals(chats, fixture.chats.size());
fixture.events.callEvent(new InventoryCloseEvent(fixture.menu.getBukkitView()));
int bars = fixture.actionBars.size();
fixture.tick();
assertEquals(bars, fixture.actionBars.size());
fixture.plugin.setEnabled(false);
assertTrue(fixture.repeating.isEmpty());
assertFalse(fixture.menu.bypassCost);
}
private void assertInputs(ItemStack left, ItemStack right) {
assertEquals(left, fixture.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(right, fixture.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, fixture.nativePlayer.experienceLevel);
fixture.assertNoEventErrors();
}
private String lastAction() { assertFalse(fixture.actionBars.isEmpty()); return plain(fixture.actionBars.getLast()); }
private static String plain(Component message) { return PlainTextComponentSerializer.plainText().serialize(message); }
private static NamespacedKey key(String key) { return new NamespacedKey("test", key); }
private static ItemStack sword(int level) { return enchanted(Material.DIAMOND_SWORD, Enchantment.SHARPNESS, level); }
private static ItemStack enchanted(Material material, Enchantment enchantment, int level) {
var item = new ItemStack(material);
item.addUnsafeEnchantment(enchantment, level);
return item;
}
private static ItemStack damaged(ItemStack item, int damage) {
var meta = (Damageable) item.getItemMeta();
meta.setDamage(damage);
item.setItemMeta(meta);
return item;
}
}
@@ -0,0 +1,45 @@
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.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered click gate plus native clicked/slots/cursor and deterministic next-tick commit. */
class NativeAdvancedAnvilTransactionTest {
@TempDir Path directory;
@Test void nativeTakeConsumesOneOfEachStackAndRollsOnceWithoutXpOrFreeRepeat() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
f.inputs(stackedSword(5, 3), stackedSword(5, 3));
f.click(2, ClickType.LEFT, InventoryAction.PICKUP_ALL, 0, ContainerInput.PICKUP);
f.tick();
assertNotNull(f.menu.getBukkitView().getTopInventory().getItem(0), "Native payment must leave the two unpaid items");
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertEquals(1, f.player.getItemOnCursor().getAmount());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
}
static ItemStack stackedSword(int level, int count) {
var item = new ItemStack(Material.DIAMOND_SWORD);
item.addUnsafeEnchantment(Enchantment.SHARPNESS, level);
var meta = item.getItemMeta();
meta.setMaxStackSize(3);
item.setItemMeta(meta);
item.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
item.setAmount(count);
return item;
}
}
@@ -0,0 +1,77 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.world.level.ServerExplosion;
import net.minecraft.world.phys.Vec3;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.craftbukkit.block.data.CraftBlockData;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.ExplosionResult;
/** Real Craft explosion overload and ServerExplosion; world/protection execution is the external port. */
final class NativeAnvilEffectsFixture {
final NativeAnvilFixture anvil;
final Block block;
final AtomicReference<BlockData> data = new AtomicReference<>(
CraftBlockData.createData(net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState()));
final List<ServerExplosion> explosions = new ArrayList<>();
final List<EntityExplodeEvent> protectionEvents = new ArrayList<>();
final List<net.minecraft.world.level.Level.ExplosionInteraction> requestedInteractions = new ArrayList<>();
final List<Boolean> requestedFire = new ArrayList<>();
Runnable duringExplosion = () -> {};
boolean cancelExplosion;
NativeAnvilEffectsFixture(NativeAnvilFixture anvil) throws Exception {
this.anvil = anvil;
block = anvil.world.getBlockAt(10, 65, -9);
when(block.getType()).thenAnswer(ignored -> data.get().getMaterial());
when(block.getBlockData()).thenAnswer(ignored -> data.get().clone());
when(block.getLocation()).thenAnswer(ignored -> anvil.menu.getBukkitView().getTopInventory().getLocation());
when(anvil.server.createBlockData(any(Material.class)))
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null));
when(anvil.server.createBlockData(any(Material.class), nullable(String.class)))
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), inv.getArgument(1)));
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class));
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class), anyBoolean());
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
.when(block).setType(any(Material.class));
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
.when(block).setType(any(Material.class), anyBoolean());
when(anvil.level.getBlockState(anvil.position)).thenAnswer(ignored -> ((CraftBlockData) data.get()).getState());
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
worldField.setAccessible(true);
worldField.set(anvil.world, anvil.level);
var sources = mock(net.minecraft.world.damagesource.DamageSources.class);
var damage = mock(net.minecraft.world.damagesource.DamageSource.class);
when(anvil.level.damageSources()).thenReturn(sources);
when(sources.explosion(anvil.nativePlayer, anvil.nativePlayer)).thenReturn(damage);
when(anvil.level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
// Capture requested native flags before the external world adapter maps block behavior.
requestedInteractions.add(inv.getArgument(8));
requestedFire.add(inv.getArgument(7));
var explosion = new ServerExplosion(anvil.level, inv.getArgument(0), inv.getArgument(1), null,
new Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
java.util.function.Consumer<ServerExplosion> configure = inv.getArgument(13);
if (configure != null) configure.accept(explosion);
var event = new EntityExplodeEvent(anvil.player, anvil.menu.getBukkitView().getTopInventory()
.getLocation().clone().add(.5, .5, .5), new ArrayList<>(), 1F, ExplosionResult.DESTROY);
event.setCancelled(cancelExplosion);
anvil.events.callEvent(event);
explosion.wasCanceled = event.isCancelled();
protectionEvents.add(event);
explosions.add(explosion);
duringExplosion.run();
return explosion;
});
doCallRealMethod().when(anvil.world).createExplosion(any(org.bukkit.entity.Entity.class),
any(org.bukkit.Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
}
}
@@ -0,0 +1,367 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityEquipment;
import net.minecraft.world.inventory.AnvilMenu;
import net.minecraft.world.inventory.ContainerLevelAccess;
import net.minecraft.world.level.block.Blocks;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.PluginCommandYamlParser;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.event.HandlerList;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.java.JavaPluginLoader;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.purpurmc.purpur.PurpurWorldConfig;
/** Real menu/inventory/events/items; only server, world, network and scheduler boundaries are ports. */
final class NativeAnvilFixture implements AutoCloseable {
static {
try {
NativeRuntime.bootstrap();
} catch (Exception failure) {
throw new ExceptionInInitializerError(failure);
}
}
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
final CraftPlayer player = mock(CraftPlayer.class);
final ServerLevel level = mock(ServerLevel.class);
final CraftWorld world = mock(CraftWorld.class);
final BlockPos position = new BlockPos(10, 65, -9);
final net.minecraft.world.entity.player.Inventory realInventory =
new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment());
final SimpleCommandMap commands = new SimpleCommandMap(server, new HashMap<>());
final PaperPluginManagerImpl events = new PaperPluginManagerImpl(server, commands, null);
final List<Component> actionBars = new ArrayList<>();
final List<Component> chats = new ArrayList<>();
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<>();
final List<Runnable> repeating = new ArrayList<>();
final List<BukkitTask> tasks = new ArrayList<>();
final List<Throwable> eventErrors = new ArrayList<>();
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
final List<com.comphenix.protocol.events.PacketListener> protocolListeners = new ArrayList<>();
private final Map<Integer, Integer> displayedStates = new HashMap<>();
final SpigotInventoryHelperPlugin plugin;
final AnvilMenu menu;
private final Server previous;
NativeAnvilFixture(Path directory) throws Exception {
this(directory, () -> 99, () -> 1.0);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear) throws Exception {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable");
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version) throws Exception {
NativeRuntime.bootstrap();
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());
previous = Bukkit.getServer();
setServer(server);
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.isPrimaryThread()).thenReturn(true);
when(server.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)");
when(server.getMinecraftVersion()).thenReturn("26.2");
when(server.getBukkitVersion()).thenReturn("26.2.build.2618-stable");
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
when(server.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(server.getPluginManager()).thenReturn(events);
when(server.getPluginCommand(any(String.class))).thenAnswer(invocation -> {
var command = commands.getCommand(invocation.getArgument(0));
return command instanceof PluginCommand owned ? owned : null;
});
Map<NamespacedKey, Recipe> recipes = new HashMap<>();
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.get(inv.getArgument(0)));
when(server.addRecipe(any(Recipe.class))).thenAnswer(inv -> {
Recipe recipe = inv.getArgument(0);
return recipes.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.remove(inv.getArgument(0)) != null);
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.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
when(player.getHandle()).thenReturn(nativePlayer);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn("AnvilPlayer");
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
when(nativePlayer.hasInfiniteMaterials()).thenReturn(true);
doCallRealMethod().when(player).getItemOnCursor();
doCallRealMethod().when(player).setItemOnCursor(any());
when(player.getInventory()).thenReturn(new CraftInventoryPlayer(realInventory));
when(player.hasPermission(any(String.class))).thenReturn(false);
when(console.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
.when(console).sendMessage(any(String.class));
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
.when(player).sendMessage(any(String.class));
doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; }).when(player).sendActionBar(any(Component.class));
doAnswer(inv -> { chats.add(inv.getArgument(0)); return null; }).when(player).sendMessage(any(Component.class));
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(nativePlayer.getInventory()).thenReturn(realInventory);
when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
when(nativePlayer.level()).thenReturn(level);
when(level.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
when(level.getWorld()).thenReturn(world);
when(level.getBlockState(position)).thenReturn(Blocks.ANVIL.defaultBlockState());
var block = mock(org.bukkit.block.Block.class);
when(block.getType()).thenReturn(org.bukkit.Material.ANVIL);
when(world.getBlockAt(any(org.bukkit.Location.class))).thenReturn(block);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
when(world.getHandle()).thenReturn(level);
when(world.getUID()).thenReturn(UUID.randomUUID());
var configField = net.minecraft.world.level.Level.class.getDeclaredField("purpurConfig");
configField.setAccessible(true);
configField.set(level, mock(PurpurWorldConfig.class));
menu = new AnvilMenu(7, realInventory, ContainerLevelAccess.create(level, position));
menu.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
nativePlayer.containerMenu = menu;
when(player.getOpenInventory()).thenAnswer(ignored -> nativePlayer.containerMenu.getBukkitView());
plugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(server),
description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), risk, wear, protocol,
() -> version);
commands.registerAll(plugin.getName(), PluginCommandYamlParser.parse(plugin));
plugin.getLogger().addHandler(new java.util.logging.Handler() {
@Override public void publish(java.util.logging.LogRecord record) {
if (record.getThrown() != null) eventErrors.add(record.getThrown());
}
@Override public void flush() {}
@Override public void close() {}
});
if (plugin.getServer() != server || plugin.getCommand("inventoryhelperadmin") == null) {
throw new AssertionError("Explicit native command wiring failed: server=" + (plugin.getServer() == server)
+ ", command=" + commands.getCommand("inventoryhelperadmin")
+ ", owner=" + (commands.getCommand("inventoryhelperadmin") instanceof PluginCommand command
&& command.getPlugin() == plugin));
}
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")
.getDeclaredConstructor(ServerPlayer.class);
constructor.setAccessible(true);
Object created = constructor.newInstance(nativePlayer);
if (!(created instanceof net.minecraft.world.inventory.ContainerSynchronizer synchronizer)) {
throw new IllegalStateException("Unexpected native synchronizer");
}
var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(realInventory, true, nativePlayer);
var inventoryField = net.minecraft.world.entity.player.Player.class.getDeclaredField("inventoryMenu");
if (inventoryField.getType() != net.minecraft.world.inventory.InventoryMenu.class) {
throw new IllegalStateException("Unexpected inventory-menu port");
}
inventoryField.setAccessible(true);
inventoryField.set(nativePlayer, inventoryMenu);
inventoryMenu.setSynchronizer(synchronizer);
menu.setSynchronizer(synchronizer);
}
void inputs(ItemStack left, ItemStack right) {
// setItem triggers SimpleContainer.setChanged -> slotsChanged -> actual result event dispatch.
menu.getSlot(0).set(CraftItemStack.asNMSCopy(left));
menu.getSlot(1).set(CraftItemStack.asNMSCopy(right));
assertNoEventErrors();
}
void admin(org.bukkit.command.CommandSender sender, String key, int value) {
var command = plugin.getCommand("inventoryhelperadmin");
if (command == null || command != commands.getCommand("inventoryhelperadmin")) {
throw new AssertionError("Missing actual registered administrator command");
}
if (!command.execute(sender, "inventoryhelperadmin", new String[] {"config", key, Integer.toString(value)})) {
throw new AssertionError("Registered administrator command refused execution");
}
}
void acknowledgeSave() throws InterruptedException {
Runnable acknowledgment = nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (acknowledgment == null) throw new AssertionError("Durable save did not schedule acknowledgment");
acknowledgment.run();
assertNoEventErrors();
if (adminMessages.stream().noneMatch(message -> message.endsWith("(saved)"))) {
throw new AssertionError("No durable success acknowledgment: " + adminMessages);
}
}
void prepareAgain() {
menu.slotsChanged(menu.getSlot(0).container);
assertNoEventErrors();
}
org.bukkit.event.inventory.InventoryClickEvent click(int rawSlot,
org.bukkit.event.inventory.ClickType click, org.bukkit.event.inventory.InventoryAction action,
int button, net.minecraft.world.inventory.ContainerInput input) {
var event = new org.bukkit.event.inventory.InventoryClickEvent(menu.getBukkitView(),
rawSlot == 2 ? org.bukkit.event.inventory.InventoryType.SlotType.RESULT
: org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
rawSlot, click, action, button);
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);
assertNoEventErrors();
return event;
}
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
var event = clientEvent(payload);
dispatchProtocol(event);
return event;
}
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);
return com.comphenix.protocol.events.PacketEvent.fromClient(protocol, container, player);
}
void dispatchProtocol(com.comphenix.protocol.events.PacketEvent event) {
List.copyOf(protocolListeners).stream()
.filter(listener -> listener.getReceivingWhitelist().getTypes().contains(event.getPacketType()))
.sorted(java.util.Comparator.comparingInt(listener -> listener.getReceivingWhitelist().getPriority().getSlot()))
.forEach(listener -> listener.onPacketReceiving(event));
}
int clientState(net.minecraft.world.inventory.AbstractContainerMenu active) {
Integer state = displayedStates.get(active.containerId);
if (state == null) throw new AssertionError("Menu has no displayed frame: " + active.containerId);
return state;
}
com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw) {
com.comphenix.protocol.PacketType type;
if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket) {
type = com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS;
} else if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket) {
type = com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
} else return null; // Non-state packets are outside this fixture's client header port.
var event = com.comphenix.protocol.events.PacketEvent.fromServer(protocol,
new com.comphenix.protocol.events.PacketContainer(type, raw), player);
dispatchSending(event);
if (!event.isCancelled()) {
Object wire = event.getPacket().getHandle();
if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content) {
displayedStates.put(content.containerId(), content.stateId());
} else if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot) {
displayedStates.put(slot.getContainerId(), slot.getStateId());
} else throw new AssertionError("Unsupported wire frame");
}
return event;
}
void dispatchSending(com.comphenix.protocol.events.PacketEvent event) {
List.copyOf(protocolListeners).stream()
.filter(listener -> listener.getSendingWhitelist().getTypes().contains(event.getPacketType()))
.sorted(java.util.Comparator.comparingInt(listener -> listener.getSendingWhitelist().getPriority().getSlot()))
.forEach(listener -> listener.onPacketSending(event));
}
ItemStack output() {
return menu.getBukkitView().getTopInventory().getItem(2);
}
void tick() {
var ready = List.copyOf(nextTick);
nextTick.clear();
ready.forEach(Runnable::run);
List.copyOf(repeating).forEach(Runnable::run);
assertNoEventErrors();
}
void assertNoEventErrors() {
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
}
private BukkitTask schedule(Runnable runnable, java.util.Collection<Runnable> queue) {
queue.add(runnable);
BukkitTask task = mock(BukkitTask.class);
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
tasks.add(task);
return task;
}
@Override public void close() {
try {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
setServer(previous);
} catch (Exception failure) {
throw new AssertionError("Could not restore server", failure);
}
}
static PluginDescriptionFile description() throws java.io.IOException, org.bukkit.plugin.InvalidDescriptionException {
// Declared ProtocolLib also ships plugin.yml. Select the actual generated owning metadata,
// not whichever dependency happens to be first on the native-test classpath.
PluginDescriptionFile selected = null;
var resources = SpigotInventoryHelperPlugin.class.getClassLoader().getResources("plugin.yml");
while (resources.hasMoreElements()) {
try (var stream = resources.nextElement().openStream()) {
var description = new PluginDescriptionFile(stream);
if (description.getMain().equals(SpigotInventoryHelperPlugin.class.getName())) {
if (selected != null) throw new AssertionError("Duplicate owning plugin metadata");
selected = description;
}
}
}
if (selected == null) throw new AssertionError("Missing generated owning plugin metadata");
return selected;
}
static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,329 @@
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.network.protocol.game.ClientboundContainerSetContentPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
import net.minecraft.world.inventory.ContainerInput;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAnvilFrameGenerationTest {
@TempDir Path directory;
@Test void oldTwentyPercentFrameCannotPayNewNinetyFivePercentPreviewAfterNativeCounterReuse() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
NativeAdvancedAnvilProtocolTest.seed(f);
f.player.updateInventory();
int oldWire = lastFrame(packet);
int oldNative = f.menu.getStateId();
assertTrue(risk(f).contains("Failure 20%"));
var newInput = NativeAdvancedAnvilTransactionTest.stackedSword(10, 3);
f.inputs(newInput, newInput.clone());
assertTrue(risk(f).contains("Failure 95%"));
int synchronizations = 0;
// Ordinary full native syncs only: no synthetic field writes or increment shortcut.
do {
f.player.updateInventory();
synchronizations++;
} while (f.menu.getStateId() != oldNative && synchronizations < 32768);
assertEquals(oldNative, f.menu.getStateId());
assertTrue(synchronizations > 32000 && synchronizations <= 32768);
int newWire = lastFrame(packet);
packet.click(f.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
f.tick();
System.out.println("WRAP_FACTS oldWire=" + oldWire + ", newWire=" + newWire
+ ", reusedNative=" + oldNative + ", fullSyncs=" + synchronizations
+ ", rolls=" + rolls.get() + ", explosions=" + effects.explosions.size());
assertAll("Old displayed 20% risk must not commit the new 95% attempt",
() -> assertTrue(packet.received.getLast().isCancelled()),
() -> assertTrue(packet.clicks.isEmpty()),
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(0)),
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(1)),
() -> assertEquals(0, rolls.get()),
() -> assertTrue(effects.explosions.isEmpty()),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()));
assertNotEquals(oldWire, newWire);
}
}
@Test void supportedNativeCodecsRoundTripFullPositiveIntegerVersionsAndEveryField() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var item = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
changes.put(0, hash);
changes.put(2, net.minecraft.network.HashedStack.EMPTY);
for (int version : new int[] {32767, 32768, 65536, Integer.MAX_VALUE}) {
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, version, java.util.List.of(item), item);
var decodedContent = roundTrip(ClientboundContainerSetContentPacket.STREAM_CODEC, content);
assertEquals(content.containerId(), decodedContent.containerId());
assertEquals(version, decodedContent.stateId());
assertEquals(content.items().size(), decodedContent.items().size());
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.items().getFirst()));
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.carriedItem()));
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, version, 2, item);
var decodedSlot = roundTrip(ClientboundContainerSetSlotPacket.STREAM_CODEC, slot);
assertEquals(slot.getContainerId(), decodedSlot.getContainerId());
assertEquals(version, decodedSlot.getStateId());
assertEquals(slot.getSlot(), decodedSlot.getSlot());
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedSlot.getItem()));
for (ContainerInput input : ContainerInput.values()) {
var click = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
version, (short) -999, (byte) 40, input, changes, hash);
assertEquals(click, roundTrip(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.STREAM_CODEC, click));
}
}
}
}
@Test void slotAndContentFramesOnlyReplaceHeaderAndCurrentEchoReachesNativeHandler() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
int nativeState = f.menu.getStateId();
var unrelated = new ClientboundContainerSetContentPacket(19, nativeState, f.menu.getItems(), f.menu.getCarried());
var unrelatedEvent = f.send(unrelated);
assertFalse(unrelatedEvent.isCancelled());
assertSame(unrelated, unrelatedEvent.getPacket().getHandle(), "Never rewrite another window's frame");
var carried = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.EMERALD, 17);
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, nativeState, f.menu.getItems(), carried);
var contentEvent = f.send(content);
var translatedContent = (ClientboundContainerSetContentPacket) contentEvent.getPacket().getHandle();
assertNotEquals(nativeState, translatedContent.stateId());
assertEquals(content.containerId(), translatedContent.containerId());
assertSame(content.items(), translatedContent.items());
assertSame(carried, translatedContent.carriedItem());
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, nativeState, 2, f.menu.getSlot(2).getItem());
var slotEvent = f.send(slot);
var translatedSlot = (ClientboundContainerSetSlotPacket) slotEvent.getPacket().getHandle();
assertTrue(translatedSlot.getStateId() > translatedContent.stateId());
assertEquals(slot.getContainerId(), translatedSlot.getContainerId());
assertEquals(slot.getSlot(), translatedSlot.getSlot());
assertTrue(net.minecraft.world.item.ItemStack.matches(slot.getItem(), translatedSlot.getItem()));
assertEquals(nativeState, f.menu.getStateId(), "Translation must not touch native state");
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
changes.put(3, hash);
for (ContainerInput input : ContainerInput.values()) {
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
translatedSlot.getStateId(), (short) -999,
(byte) (input == ContainerInput.PICKUP_ALL ? 0 : 40), input, changes, hash);
var received = f.receive(original);
assertFalse(received.isCancelled()); // Non-take actions must also be translated.
var translated = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket) received.getPacket().getHandle();
assertEquals(nativeState, translated.stateId());
assertEquals(original.containerId(), translated.containerId());
assertEquals(original.slotNum(), translated.slotNum());
assertEquals(original.buttonNum(), translated.buttonNum());
assertSame(original.containerInput(), translated.containerInput());
assertEquals(original.changedSlots(), translated.changedSlots());
assertSame(original.carriedItem(), translated.carriedItem());
assertEquals(nativeState, f.menu.getStateId());
}
var expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
var admitted = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
packet.received.getLast().getPacket().getHandle();
assertEquals(nativeState, admitted.stateId());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
}
}
@Test void exhaustionSuppressesFramesAndRevokesAlreadyQueuedPaymentWithoutClosingView() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled());
nearExhaustion(f);
f.player.updateInventory();
assertEquals(Integer.MAX_VALUE, lastFrame(packet));
long count = ownedFrameCount(packet);
f.player.updateInventory();
assertEquals(count, ownedFrameCount(packet), "No repeated/wrapped owned frame may escape");
packet.click(f.menu.containerId, Integer.MAX_VALUE, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
f.tick();
assertAll("Exhaustion must also revoke an already consumed admission",
() -> assertEquals(0, rolls.get()),
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0)),
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1)),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()),
() -> assertNull(f.output()),
() -> assertSame(f.menu.getBukkitView(), f.player.getOpenInventory()));
f.prepareAgain();
assertNull(f.output());
assertFalse(f.menu.bypassCost);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"content,cancel", "content,replace", "slot,cancel", "slot,replace"})
void finallyCancelledOrReplacedOutboundFrameCannotAuthorizePaymentAndFreshFrameRecovers(String kind, String mode)
throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var tamperedVersion = new AtomicInteger();
var tamperOnce = new java.util.concurrent.atomic.AtomicBoolean(true);
var type = kind.equals("content") ? com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS
: com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
var foreign = new com.comphenix.protocol.events.PacketAdapter(f.plugin,
com.comphenix.protocol.events.ListenerPriority.MONITOR, type) {
@Override public void onPacketSending(com.comphenix.protocol.events.PacketEvent event) {
if (!tamperOnce.getAndSet(false)) return;
Object raw = event.getPacket().getHandle();
tamperedVersion.set(raw instanceof ClientboundContainerSetContentPacket content
? content.stateId() : ((ClientboundContainerSetSlotPacket) raw).getStateId());
if (mode.equals("cancel")) event.setCancelled(true);
else event.setPacket(new com.comphenix.protocol.events.PacketContainer(type,
new ClientboundContainerSetContentPacket(f.menu.containerId, tamperedVersion.get(),
f.menu.getItems(), f.menu.getCarried())));
}
};
f.protocol.addPacketListener(foreign);
try {
var outgoing = f.send(kind.equals("content")
? new ClientboundContainerSetContentPacket(f.menu.containerId, f.menu.getStateId(), f.menu.getItems(), f.menu.getCarried())
: new ClientboundContainerSetSlotPacket(f.menu.containerId, f.menu.getStateId(), 2, f.menu.getSlot(2).getItem()));
assertEquals(mode.equals("cancel"), outgoing.isCancelled());
assertTrue(tamperedVersion.get() >= 32768);
packet.click(f.menu.containerId, tamperedVersion.get(), 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
f.tick();
assertEquals(0, rolls.get());
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
assertTrue(f.player.getItemOnCursor().isEmpty());
var expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
} finally { f.protocol.removePacketListener(foreign); }
}
}
@Test void nativeCloseRevokesQueuedAdmissionAndReusedWindowKeepsVersionsUnique() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
int oldWire = f.clientState(f.menu);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.clicks.getLast().isCancelled());
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
f.tick();
assertEquals(0, rolls.get());
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f, NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
assertFalse(f.menu.bypassCost);
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(before));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(before));
int newWire = f.clientState(reopened);
assertTrue(newWire > oldWire);
packet.click(reopened.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertEquals(1, packet.clicks.size(), "Reopened stale intent never reaches Bukkit");
f.tick();
assertEquals(0, rolls.get());
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(1));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
}
}
// Test-only boundary arrangement avoids billions of packets; production has no reset hook.
private static void nearExhaustion(NativeAnvilFixture f) throws ReflectiveOperationException {
var listenerField = SpigotInventoryHelperPlugin.class.getDeclaredField("advancedAnvil");
listenerField.setAccessible(true);
var listener = listenerField.get(f.plugin);
var protocolField = AdvancedAnvilListener.class.getDeclaredField("protocol");
protocolField.setAccessible(true);
var guard = protocolField.get(listener);
var versionsField = AdvancedAnvilProtocolGuard.class.getDeclaredField("versions");
versionsField.setAccessible(true);
var nextField = AnvilFrameVersions.class.getDeclaredField("next");
nextField.setAccessible(true);
nextField.setLong(versionsField.get(guard), Integer.MAX_VALUE);
}
private static long ownedFrameCount(NativeAnvilPacketFixture packet) {
return packet.packets.stream().filter(raw ->
raw instanceof ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId
|| raw instanceof ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId).count();
}
private static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
T packet) throws ReflectiveOperationException {
// Paper's real item sanitizer consults the server registry during class initialization.
// Supply only that external registry port, and restore the singleton even on failure.
var serverField = net.minecraft.server.MinecraftServer.class.getDeclaredField("SERVER");
assertEquals(net.minecraft.server.MinecraftServer.class, serverField.getType());
serverField.setAccessible(true);
Object previous = serverField.get(null);
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
org.mockito.Mockito.when(server.registryAccess()).thenReturn(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry().freeze());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
serverField.set(null, server);
codec.encode(buffer, packet);
T decoded = codec.decode(buffer);
assertEquals(0, buffer.readableBytes());
return decoded;
} finally {
buffer.release();
serverField.set(null, previous);
}
}
static int lastFrame(NativeAnvilPacketFixture packet) {
for (int index = packet.packets.size() - 1; index >= 0; index--) {
var raw = packet.packets.get(index);
if (raw instanceof ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId) return content.stateId();
if (raw instanceof ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId) return slot.getStateId();
}
throw new AssertionError("No real displayed menu frame");
}
private static String risk(NativeAnvilFixture f) {
return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(f.actionBars.getLast());
}
}
@@ -0,0 +1,198 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import io.papermc.paper.configuration.GlobalConfiguration;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import net.minecraft.network.Connection;
import net.minecraft.network.HashedStack;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerCommonPacketListenerImpl;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.entity.player.Abilities;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.ContainerSynchronizer;
import net.minecraft.world.inventory.InventoryMenu;
import net.minecraft.world.level.GameType;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
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 external CraftServer port only; retain the suite's subclass maker everywhere else.
final CraftServer craftServer = mock(CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final Connection connection;
final ServerGamePacketListenerImpl handler;
private final GlobalConfiguration previousConfiguration;
final Abilities abilities = new Abilities();
final InventoryMenu inventoryMenu;
final ContainerSynchronizer synchronizer;
final List<Packet<?>> packets = new ArrayList<>();
final java.util.Map<Packet<?>, String> privateRiskAtSend = new java.util.IdentityHashMap<>();
final List<InventoryClickEvent> clicks = new ArrayList<>();
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
final List<net.minecraft.world.item.ItemStack> drops = new ArrayList<>();
NativeAnvilPacketFixture(NativeAnvilFixture anvil) throws Exception {
this.anvil = anvil;
previousConfiguration = GlobalConfiguration.get();
if (previousConfiguration == null) {
// Connection static initialization and the packet equipment guard need default config.
// No file/configuration I/O; restore the singleton at this fixture's boundary.
var config = new GlobalConfiguration();
config.misc = config.new Misc();
config.unsupportedSettings = config.new UnsupportedSettings();
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
}
connection = mock(Connection.class);
handler = mock(ServerGamePacketListenerImpl.class);
when(anvil.level.getServer()).thenReturn(nativeServer);
when(anvil.level.getCraftServer()).thenReturn(craftServer);
when(anvil.nativePlayer.isAlive()).thenReturn(true);
doCallRealMethod().when(anvil.nativePlayer).doCloseContainer();
doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
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);
when(anvil.nativePlayer.registryAccess()).thenReturn(
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
survival();
inventoryMenu = new InventoryMenu(anvil.realInventory, true, anvil.nativePlayer);
field(Player.class, anvil.nativePlayer, "inventoryMenu", InventoryMenu.class, inventoryMenu);
field(ServerCommonPacketListenerImpl.class, handler, "server", MinecraftServer.class, nativeServer);
field(ServerCommonPacketListenerImpl.class, handler, "cserver", CraftServer.class, craftServer);
field(ServerCommonPacketListenerImpl.class, handler, "connection", Connection.class, connection);
field(ServerGamePacketListenerImpl.class, handler, "player", net.minecraft.server.level.ServerPlayer.class,
anvil.nativePlayer);
anvil.nativePlayer.connection = handler;
doAnswer(inv -> {
Packet<?> sent = inv.getArgument(0);
var outgoing = anvil.send(sent);
if (outgoing != null) {
if (outgoing.isCancelled()) return null;
Object wire = outgoing.getPacket().getHandle();
if (!(wire instanceof Packet<?> translated)) throw new AssertionError("Unsupported wire payload");
sent = translated;
}
packets.add(sent);
privateRiskAtSend.put(sent, anvil.actionBars.isEmpty() ? ""
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(anvil.actionBars.getLast()));
return null;
}).when(connection).send(any(Packet.class), any(), anyBoolean());
doCallRealMethod().when(handler).send(any(Packet.class));
doCallRealMethod().when(handler).send(any(Packet.class), any());
doCallRealMethod().when(handler).handleContainerClick(any(ServerboundContainerClickPacket.class));
// Reuse the runtime's cheap native synchronizer, including actual hashed remote slots and packets.
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
.getDeclaredConstructor(net.minecraft.server.level.ServerPlayer.class);
constructor.setAccessible(true);
Object created = constructor.newInstance(anvil.nativePlayer);
if (!(created instanceof ContainerSynchronizer nativeSynchronizer)) {
throw new IllegalStateException("Unexpected native player synchronizer: " + created.getClass());
}
synchronizer = nativeSynchronizer;
field(net.minecraft.server.level.ServerPlayer.class, anvil.nativePlayer, "containerSynchronizer",
ContainerSynchronizer.class, synchronizer);
inventoryMenu.setSynchronizer(synchronizer);
anvil.menu.setSynchronizer(synchronizer);
doCallRealMethod().when(anvil.player).updateInventory();
doCallRealMethod().when(anvil.player).closeInventory();
doCallRealMethod().when(anvil.player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
// An observer of the registered event, never a replacement event supplied to production.
listen(EventPriority.MONITOR, clicks::add);
}
void survival() {
abilities.instabuild = false;
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(false);
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.SURVIVAL);
}
void creative() {
abilities.instabuild = true;
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(true);
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.CREATIVE);
}
void listen(EventPriority priority, Consumer<InventoryClickEvent> observer) {
anvil.events.registerEvent(InventoryClickEvent.class, new Listener() {}, priority,
(ignored, event) -> {
if (!(event instanceof InventoryClickEvent click)) {
throw new IllegalStateException("Unexpected native click event: " + event.getClass());
}
observer.accept(click);
}, anvil.plugin, false);
}
void click(int slot, int button, ContainerInput input) {
click(anvil.nativePlayer.containerMenu.containerId, anvil.clientState(anvil.nativePlayer.containerMenu),
slot, button, input);
}
void click(int containerId, int stateId, int slot, int button, ContainerInput input) {
var raw = new ServerboundContainerClickPacket(containerId, stateId,
(short) slot, (byte) button, input, new Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
var event = anvil.receive(raw);
received.add(event);
if (!event.isCancelled()) {
Object admitted = event.getPacket().getHandle();
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
handler.handleContainerClick(translated);
}
anvil.assertNoEventErrors();
}
void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason reason) {
anvil.nativePlayer.closeContainer(reason);
anvil.assertNoEventErrors();
}
void dieAndClose() {
// The world/damage boundary reports death; native menu removal owns remainder drops.
when(anvil.nativePlayer.isAlive()).thenReturn(false);
when(anvil.nativePlayer.isDeadOrDying()).thenReturn(true);
when(anvil.player.isDead()).thenReturn(true);
closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DEATH);
}
void inactiveView() {
anvil.nativePlayer.containerMenu = inventoryMenu;
}
@Override public void close() {
try {
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, previousConfiguration);
} catch (ReflectiveOperationException failure) {
throw new AssertionError("Could not restore native config port", failure);
}
}
private static <T> void field(Class<?> owner, Object target, String name, Class<T> type, T value)
throws ReflectiveOperationException {
var field = owner.getDeclaredField(name);
if (field.getType() != type) throw new IllegalStateException("Unexpected native field type: " + field);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -60,10 +60,13 @@ class NativeCobwebPluginWiringTest {
@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 {
@@ -76,9 +79,17 @@ class NativeCobwebPluginWiringTest {
@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() {
@@ -98,8 +109,15 @@ class NativeCobwebPluginWiringTest {
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
events = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), 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.scheduler.BukkitScheduler.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();
}
@@ -254,10 +272,13 @@ class NativeCobwebPluginWiringTest {
}
private SpigotInventoryHelperPlugin plugin() {
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
try {
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile());
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) {
@@ -0,0 +1,61 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigInteger;
import org.junit.jupiter.api.Test;
final class AdvancedAnvilPolicyTest {
@Test
void defaultCurveMatchesApprovedTableAndHigherLevels() {
AdvancedAnvilPolicy policy = AdvancedAnvilPolicy.defaults();
assertEquals(new AdvancedAnvilPolicy(20, 5, 99), policy);
int[] expected = {0, 20, 25, 35, 50, 70, 95, 99, 99, 99};
for (int excess = 0; excess < expected.length; excess++) {
assertEquals(expected[excess], policy.failurePercent(excess), "E=" + excess);
}
}
@Test
void settingsIndependentlyControlBaseStepAndCapIncludingEndpoints() {
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 100).failurePercent(0));
assertEquals(0, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(1));
assertEquals(5, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(2));
assertEquals(20, new AdvancedAnvilPolicy(20, 0, 99).failurePercent(Integer.MAX_VALUE));
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 0).failurePercent(Integer.MAX_VALUE));
assertEquals(100, new AdvancedAnvilPolicy(20, 5, 100).failurePercent(7));
assertEquals(12, new AdvancedAnvilPolicy(20, 5, 12).failurePercent(1));
assertEquals(99, AdvancedAnvilPolicy.defaults().failurePercent(Integer.MAX_VALUE));
}
@Test
void triangularGrowthMatchesExactArithmeticWithoutOverflowOrArbitraryCap() {
int[] excesses = {0, 1, 2, 3, 6, 7, 10, 100, 65536, 1000000, Integer.MAX_VALUE};
for (int base : new int[] {0, 1, 20, 100}) {
for (int step : new int[] {0, 1, 5, 100}) {
for (int cap : new int[] {0, 1, 99, 100}) {
AdvancedAnvilPolicy policy = new AdvancedAnvilPolicy(base, step, cap);
for (int excess : excesses) {
BigInteger e = BigInteger.valueOf(excess);
int expected = excess == 0 ? 0 : e.multiply(e.subtract(BigInteger.ONE))
.divide(BigInteger.TWO).multiply(BigInteger.valueOf(step))
.add(BigInteger.valueOf(base)).min(BigInteger.valueOf(cap)).intValueExact();
assertEquals(expected, policy.failurePercent(excess), policy + " E=" + excess);
}
}
}
}
}
@Test
void rejectsInvalidPolicyFieldsAndNegativeExcess() {
for (int invalid : new int[] {-1, 101, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(invalid, 5, 99));
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, invalid, 99));
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, 5, invalid));
}
assertThrows(IllegalArgumentException.class, () -> AdvancedAnvilPolicy.defaults().failurePercent(-1));
}
}
@@ -0,0 +1,396 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Server;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
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;
final class AdvancedAnvilSettingsTest {
private static final String BASE = "advanced-anvil-base-failure-percent";
private static final String STEP = "advanced-anvil-risk-step-percent";
private static final String CAP = "advanced-anvil-max-failure-percent";
private static final String PERMISSION = "spigotinventoryhelper.admin";
@TempDir Path directory;
@Test
void boundConsoleCommandPublishesOnlyAfterDurableMainThreadAcknowledgmentAndRestarts()
throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE);
assertEquals(List.of(BASE + " = 20"), fixture.messages);
assertFalse(Files.exists(directory.resolve("config.yml")));
fixture.messages.clear();
fixture.execute("config", BASE, "37");
Runnable acknowledgment = fixture.nextAcknowledgment();
assertEquals(20, settings.policy().basePercent());
assertTrue(changes.isEmpty());
assertTrue(fixture.messages.isEmpty(), "No success before acknowledgment");
Fixture restart = new Fixture(directory);
try (AdvancedAnvilSettings loaded = restart.open(ignored -> {})) {
assertEquals(37, loaded.policy().basePercent(), "Acknowledgment follows durable save");
}
acknowledgment.run();
assertEquals(37, settings.policy().basePercent());
assertEquals(List.of(settings.policy()), changes);
assertEquals(List.of(BASE + " = 37 (saved)"), fixture.messages);
}
}
@Test
void absentAndPartialConfigurationUseDefaultsWithoutStartupWrites() throws Exception {
Fixture fixture = new Fixture(directory.resolve("absent"));
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Startup callback"))) {
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("absent")));
}
Files.writeString(directory.resolve("config.yml"), STEP + ": 0\nunrelated: keep\n");
try (AdvancedAnvilSettings settings = new Fixture(directory).open(ignored -> {})) {
assertEquals(new AdvancedAnvilPolicy(20, 0, 99), settings.policy());
}
assertEquals(STEP + ": 0\nunrelated: keep\n", Files.readString(directory.resolve("config.yml")));
}
@ParameterizedTest
@ValueSource(strings = {"-1", "101", "20.0", "'20'", "true", "null", "999999999999999999999999"})
void presentInvalidSettingsFailClosedForEveryKey(String invalid) throws Exception {
for (String key : List.of(BASE, STEP, CAP)) {
Files.writeString(directory.resolve("config.yml"), key + ": " + invalid + "\n");
Fixture fixture = new Fixture(directory);
assertThrows(IOException.class, () -> fixture.open(ignored -> fail("Partial installation")));
assertNull(fixture.command.getTabCompleter());
assertTrue(fixture.acknowledgments.isEmpty());
assertEquals(key + ": " + invalid + "\n", Files.readString(directory.resolve("config.yml")));
}
}
@ParameterizedTest
@ValueSource(strings = {"[broken", "- list", "!!java/object:java.lang.Runtime {}",
"advanced-anvil-base-failure-percent: 20\nadvanced-anvil-base-failure-percent: 30\n"})
void malformedYamlAndDuplicateSettingsFailBeforeOpening(String document) throws Exception {
Files.writeString(directory.resolve("config.yml"), document);
assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> {}));
}
@Test
void serializedQueuedWritesComposeBeforeAnyAcknowledgmentAndPreserveUnknownKeys() throws Exception {
Files.writeString(directory.resolve("config.yml"), "unrelated:\n list: [one, two]\nother: true\n");
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE, "31");
fixture.execute("config", STEP, "7");
fixture.execute("config", CAP, "100");
fixture.execute("config", BASE, "42");
List<Runnable> callbacks = new ArrayList<>();
for (int count = 0; count < 4; count++) {
callbacks.add(fixture.nextAcknowledgment());
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
fixture.execute("config", BASE);
assertEquals(List.of(BASE + " = 20"), fixture.messages);
fixture.messages.clear();
callbacks.getLast().run();
AdvancedAnvilPolicy expected = new AdvancedAnvilPolicy(42, 7, 100);
assertEquals(expected, settings.policy());
for (Runnable callback : callbacks) {
callback.run(); // Older and repeated acknowledgments cannot regress or duplicate publication.
}
assertEquals(List.of(expected), changes);
assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages);
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
assertEquals(expected, restarted.policy());
}
Object document = new org.yaml.snakeyaml.Yaml().load(Files.readString(directory.resolve("config.yml")));
assertInstanceOf(java.util.Map.class, document);
java.util.Map<?, ?> values = (java.util.Map<?, ?>) document;
assertEquals(java.util.Map.of("list", List.of("one", "two")), values.get("unrelated"));
assertEquals(true, values.get("other"));
try (var files = Files.list(directory)) {
assertEquals(List.of("config.yml"), files.map(path -> path.getFileName().toString()).toList());
}
}
}
@Test
void failedSaveRetainsEffectivePolicyAndDoesNotPoisonSubsequentQueuedUpdates() throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE, "31");
fixture.nextAcknowledgment().run();
Path file = directory.resolve("config.yml");
String prior = Files.readString(file);
Files.delete(file);
Files.createDirectory(file); // Real unwritable target, not a mocked save failure.
fixture.messages.clear();
fixture.execute("config", STEP, "17");
fixture.nextAcknowledgment().run();
assertEquals(new AdvancedAnvilPolicy(31, 5, 99), settings.policy());
assertEquals(1, changes.size());
assertEquals(List.of("Configuration save failed; effective settings unchanged."), fixture.messages);
Files.delete(file);
Files.writeString(file, prior);
fixture.messages.clear();
fixture.execute("config", CAP, "0");
fixture.nextAcknowledgment().run();
assertEquals(new AdvancedAnvilPolicy(31, 5, 0), settings.policy());
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
assertEquals(settings.policy(), restarted.policy());
}
}
}
@ParameterizedTest
@ValueSource(strings = {"-1", "101", "2147483648", "1.5", "true", "", " 20", "20 ", "garbage"})
void invalidCommandValuesNeverWriteOrPublish(String invalid) throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Invalid update"))) {
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, invalid);
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertEquals(3, fixture.messages.size());
assertTrue(fixture.messages.stream().allMatch(message -> message.contains("0 through 100")));
}
}
@ParameterizedTest
@ValueSource(strings = {"37", "+37", "00037", "00000000000000000037", "-0"})
void validManuallyEnteredIntegersAreNotLimitedToCompletionSuggestions(String raw) throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
fixture.execute("config", BASE, raw);
assertTrue(fixture.messages.isEmpty(), "Valid integer should be accepted, not rejected");
fixture.nextAcknowledgment().run();
assertEquals(Integer.parseInt(raw), settings.policy().basePercent());
}
}
@Test
void unsupportedRoutesAndExtraArgumentsAreRejectedWithoutMutation() throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Unsupported update"))) {
for (String[] args : List.of(new String[0], new String[] {"config"},
new String[] {"other", BASE, "10"}, new String[] {"config", "unknown", "10"},
new String[] {"config", BASE, "10", "extra"})) {
fixture.execute(args);
if (args.length >= 2) {
assertEquals(List.of(), fixture.complete(args));
}
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.stream().allMatch(message -> message.startsWith("Usage:")));
}
}
@Test
void authorizationDeniesReadsWritesCompletionAndExecutorBypassForPlayersAndConsole() throws Exception {
for (CommandSender sender : List.of(mock(ConsoleCommandSender.class), mock(Player.class))) {
Fixture fixture = new Fixture(directory, sender);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Denied update"))) {
when(sender.hasPermission(PERMISSION)).thenReturn(false);
fixture.execute("config", BASE);
fixture.execute("config", BASE, "10");
assertTrue(fixture.command.getExecutor().onCommand(sender, fixture.command,
"inventoryhelperadmin", new String[] {"config", BASE, "10"}));
assertEquals(List.of(), fixture.complete());
assertEquals(List.of(), fixture.complete("config", BASE, ""));
assertEquals(List.of("Permission denied."), fixture.messages);
verify(sender, times(2)).sendMessage(Component.text("Permission denied."));
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
}
}
}
@Test
void authorizedPlayerCanSetEndpointsAndReadAllThreeEffectiveSettings() throws Exception {
Fixture fixture = new Fixture(directory, mock(Player.class));
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, "100");
fixture.nextAcknowledgment().run();
fixture.messages.clear();
fixture.execute("config", key);
assertEquals(List.of(key + " = 100"), fixture.messages);
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
assertEquals(List.of("100", defaultValue, "0"), fixture.complete("config", key, ""));
}
assertEquals(new AdvancedAnvilPolicy(100, 100, 100), settings.policy());
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, "0");
fixture.nextAcknowledgment().run();
}
assertEquals(new AdvancedAnvilPolicy(0, 0, 0), settings.policy());
assertEquals(List.of("0", "20", "100"), fixture.complete("config", BASE, ""));
}
}
@Test
void boundCompletionIsStablePrefixFilteredDeduplicatedAndSideEffectFree() throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
assertNotNull(fixture.command.getExecutor());
assertNotNull(fixture.command.getTabCompleter());
assertEquals(List.of("config"), fixture.complete());
assertEquals(List.of("config"), fixture.complete(""));
assertEquals(List.of("config"), fixture.complete("con"));
assertEquals(List.of(), fixture.complete("bad"));
assertEquals(List.of(BASE, STEP, CAP), fixture.complete("config", ""));
assertEquals(List.of(STEP), fixture.complete("config", "advanced-anvil-risk"));
for (String key : List.of(BASE, STEP, CAP)) {
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
assertEquals(List.of(defaultValue, "0", "100"), fixture.complete("config", key, ""));
assertEquals(List.of("100"), fixture.complete("config", key, "1"));
assertEquals(List.of(), fixture.complete("config", key, "garbage"));
}
assertEquals(List.of(), fixture.complete("other", BASE, ""));
assertEquals(List.of(), fixture.complete("config", "unknown", ""));
assertEquals(List.of(), fixture.complete("config", BASE, "", "extra"));
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.isEmpty());
fixture.execute("config", BASE, "37");
fixture.nextAcknowledgment().run();
Files.delete(directory.resolve("config.yml"));
Files.createDirectory(directory.resolve("config.yml")); // Completion must never read disk.
fixture.messages.clear();
assertEquals(List.of("37", "20", "0", "100"), fixture.complete("config", BASE, ""));
assertEquals(List.of("37"), fixture.complete("config", BASE, "3"));
assertEquals(37, settings.policy().basePercent());
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.isEmpty());
}
}
@Test
void permissionRevokedBeforeAcknowledgmentDoesNotLeakSavedValue() throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE, "37");
Runnable acknowledgment = fixture.nextAcknowledgment();
when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false);
acknowledgment.run();
assertEquals(37, settings.policy().basePercent());
assertEquals(1, changes.size());
assertTrue(fixture.messages.isEmpty());
}
}
@Test
void closeIsNonblockingIdempotentAndSuppressesLateCallbacksAndCompletion() throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
AdvancedAnvilSettings settings = fixture.open(changes::add);
try {
fixture.execute("config", BASE, "37");
Runnable acknowledgment = fixture.nextAcknowledgment();
assertTimeout(java.time.Duration.ofMillis(250), settings::close);
settings.close();
acknowledgment.run();
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertTrue(changes.isEmpty());
assertTrue(fixture.messages.isEmpty());
assertEquals(List.of(), fixture.complete());
fixture.execute("config", BASE, "50");
assertEquals(List.of("Configuration unavailable."), fixture.messages);
assertThrows(IllegalStateException.class, () -> settings.bind(fixture.command));
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
assertEquals(37, restarted.policy().basePercent());
}
} finally {
settings.close();
}
}
private static final class Fixture {
private final Plugin plugin = mock(Plugin.class);
private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
private final CommandSender sender;
private final BlockingQueue<Runnable> acknowledgments = new LinkedBlockingQueue<>();
private final List<String> messages = new ArrayList<>();
private final PluginCommand command;
private final Thread mainThread = Thread.currentThread();
Fixture(Path dataFolder) throws Exception {
this(dataFolder, mock(ConsoleCommandSender.class));
}
Fixture(Path dataFolder, CommandSender sender) throws Exception {
this.sender = sender;
Server server = mock(Server.class);
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(sender.hasPermission(PERMISSION)).thenReturn(true);
doAnswer(call -> {
assertSame(mainThread, Thread.currentThread(), "Feedback must stay on main thread");
messages.add(call.getArgument(0, String.class));
return null;
}).when(sender).sendMessage(anyString());
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
assertNotSame(mainThread, Thread.currentThread(), "Persistence must be asynchronous");
acknowledgments.add(call.getArgument(1, Runnable.class));
return mock(BukkitTask.class);
});
Constructor<PluginCommand> constructor =
PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
constructor.setAccessible(true);
command = constructor.newInstance("inventoryhelperadmin", plugin);
}
AdvancedAnvilSettings open(Consumer<AdvancedAnvilPolicy> changes) throws Exception {
AdvancedAnvilSettings settings = AdvancedAnvilSettings.open(plugin, changes);
settings.bind(command);
return settings;
}
void execute(String... args) {
assertTrue(command.execute(sender, "inventoryhelperadmin", args));
}
List<String> complete(String... args) {
return command.tabComplete(sender, "inventoryhelperadmin", args);
}
Runnable nextAcknowledgment() throws InterruptedException {
Runnable acknowledgment = acknowledgments.poll(5, TimeUnit.SECONDS);
assertNotNull(acknowledgment, "Expected scheduled durable acknowledgment");
return acknowledgment;
}
}
}
@@ -20,9 +20,21 @@ class PurpurDistributionTest {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
assertFalse(metadata.containsKey("permissions"));
assertFalse(metadata.containsKey("commands"));
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
assertEquals(java.util.Set.of("spigotinventoryhelper.admin"), permissions.keySet());
assertEquals("op", ((Map<?, ?>) permissions.get("spigotinventoryhelper.admin")).get("default"));
assertEquals(java.util.Set.of("inventoryhelperadmin"), commands.keySet());
assertEquals("spigotinventoryhelper.admin", ((Map<?, ?>) commands.get("inventoryhelperadmin")).get("permission"));
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/AdvancedAnvilListener.class"));
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class"));
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/NativeAnvilGeneration.class"));
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.class"));
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
assertNotNull(jar.getJarEntry(
@@ -34,7 +46,8 @@ class PurpurDistributionTest {
assertNotNull(jar.getJarEntry(
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/comphenix/") || entry.getName().endsWith("Test.class")
|| entry.getName().contains("NativeRuntime")));
}
}
@@ -67,6 +80,7 @@ class PurpurDistributionTest {
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
assertTrue(build.contains("nativeInventoryTest"));
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));