diff --git a/README.md b/README.md index 9b83cdb..95ffe3f 100644 --- a/README.md +++ b/README.md @@ -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 +/inventoryhelperadmin config +``` + +| 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. diff --git a/build.gradle.kts b/build.gradle.kts index 6024b22..9e4b962 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,8 @@ tasks.withType().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("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("nativeInventoryTest") { diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilDelivery.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilDelivery.java new file mode 100644 index 0000000..79aabf9 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilDelivery.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java new file mode 100644 index 0000000..f21c928 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilListener.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilListener.java new file mode 100644 index 0000000..21ee1ed --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilListener.java @@ -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 policy; + private final java.util.function.IntSupplier risk; + private final java.util.function.DoubleSupplier wear; + private final Map previews = new IdentityHashMap<>(); + private final Map pending = new IdentityHashMap<>(); + private final Map 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 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(); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java new file mode 100644 index 0000000..7cd6835 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java @@ -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 levels = new LinkedHashMap<>(left.getEnchantments()); + Map 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; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java new file mode 100644 index 0000000..62808ae --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java @@ -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"); + } + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java new file mode 100644 index 0000000..92b4c51 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java @@ -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 watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + // Only this immutable actor membership is read on an unexpectedly off-thread library callback. + private final Set actors = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final Map admissions = new IdentityHashMap<>(); + private final Map 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 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); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java new file mode 100644 index 0000000..e1ed73a --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java @@ -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 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 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 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 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 [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 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 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 prefix(List 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 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 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 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 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(); + } + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AnvilFrameVersions.java b/src/main/java/games/dmg/spigotinventoryhelper/AnvilFrameVersions.java new file mode 100644 index 0000000..d431296 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/AnvilFrameVersions.java @@ -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; } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java b/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java new file mode 100644 index 0000000..be3a7ee --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java @@ -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()); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index 800b2d4..9f6dcd4 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -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 anvilProtocol = + com.comphenix.protocol.ProtocolLibrary::getProtocolManager; + private java.util.function.Supplier 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 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"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..fcbdc71 --- /dev/null +++ b/src/main/resources/config.yml @@ -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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 224f52b..5f1d9b1 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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 [value] + permission: spigotinventoryhelper.admin +permissions: + spigotinventoryhelper.admin: + description: Administer Inventory Helper balance settings. + default: op diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilBoundaryTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilBoundaryTest.java new file mode 100644 index 0000000..5103466 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilBoundaryTest.java @@ -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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilEffectsTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilEffectsTest.java new file mode 100644 index 0000000..152b6e3 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilEffectsTest.java @@ -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()); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilPacketTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilPacketTest.java new file mode 100644 index 0000000..7f69194 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilPacketTest.java @@ -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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilProtocolTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilProtocolTest.java new file mode 100644 index 0000000..73912bf --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilProtocolTest.java @@ -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()); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java new file mode 100644 index 0000000..cafebba --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java @@ -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"); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTest.java new file mode 100644 index 0000000..a59a20d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTest.java @@ -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; + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTransactionTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTransactionTest.java new file mode 100644 index 0000000..bbde027 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilTransactionTest.java @@ -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; + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilEffectsFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilEffectsFixture.java new file mode 100644 index 0000000..93d8e7d --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilEffectsFixture.java @@ -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 data = new AtomicReference<>( + CraftBlockData.createData(net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState())); + final List explosions = new ArrayList<>(); + final List protectionEvents = new ArrayList<>(); + final List requestedInteractions = new ArrayList<>(); + final List 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.getArgument(0).asBlockType(), null)); + when(anvil.server.createBlockData(any(Material.class), nullable(String.class))) + .thenAnswer(inv -> CraftBlockData.fromString(inv.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.getArgument(0).asBlockType(), null)); return null; }) + .when(block).setType(any(Material.class)); + doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.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 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()); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java new file mode 100644 index 0000000..d430091 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java @@ -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 actionBars = new ArrayList<>(); + final List chats = new ArrayList<>(); + final List adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>(); + final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class); + final java.util.concurrent.BlockingQueue nextTick = new java.util.concurrent.LinkedBlockingQueue<>(); + final List repeating = new ArrayList<>(); + final List tasks = new ArrayList<>(); + final List eventErrors = new ArrayList<>(); + final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class); + final List protocolListeners = new ArrayList<>(); + private final Map 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 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 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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java new file mode 100644 index 0000000..4760b5c --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java @@ -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(); + 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(); + 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 roundTrip(net.minecraft.network.codec.StreamCodec 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()); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilPacketFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilPacketFixture.java new file mode 100644 index 0000000..eea2f79 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilPacketFixture.java @@ -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> packets = new ArrayList<>(); + final java.util.Map, String> privateRiskAtSend = new java.util.IdentityHashMap<>(); + final List clicks = new ArrayList<>(); + final List received = new ArrayList<>(); + final List 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.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 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 void field(Class owner, Object target, String name, Class 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); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java index 9dbfccb..f3bd983 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java @@ -60,10 +60,13 @@ class NativeCobwebPluginWiringTest { @TempDir Path directory; private Map registry; private PaperPluginManagerImpl events; + private SimpleCommandMap commands; private Logger logger; private List logs; private Handler logHandler; private SpigotInventoryHelperPlugin createdPlugin; + private com.comphenix.protocol.ProtocolManager protocol; + private List protocolListeners; @BeforeAll static void bootstrap() throws Exception { @@ -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) { diff --git a/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicyTest.java b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicyTest.java new file mode 100644 index 0000000..c6db8da --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicyTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java new file mode 100644 index 0000000..a7f830a --- /dev/null +++ b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java @@ -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 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 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 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 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 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 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 acknowledgments = new LinkedBlockingQueue<>(); + private final List 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 constructor = + PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class); + constructor.setAccessible(true); + command = constructor.newInstance("inventoryhelperadmin", plugin); + } + + AdvancedAnvilSettings open(Consumer 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 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; + } + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java index c7bff2f..8060ad4 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java @@ -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) }"));