diff --git a/README.md b/README.md index cf4dd61..b2f41ce 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,24 @@ 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. +## Reinforcing anvils + +Craft one Anvil, Chipped Anvil or Damaged Anvil with **one Netherite Block**, shapeless in either crafting grid. The universally discovered `MISC` book entry is searchable as **Anvil Reinforcement**. The live preview/private messages teach both effective risk tables; book previews never guarantee success or mint usable protection. + +| Next reinforcement level | Default failure | +| --- | --- | +| 1 | 20% | +| 2 | 40% | +| 3 | 60% | +| 4 | 80% | +| 5 | 99% | + +Each accepted single attempt pays one source anvil and one block. Success returns the next level; failure destroys both without output or a crafting explosion. Damage, names/lore and unrelated metadata are preserved. Bulk/shift, unavailable destinations and sixth-level attempts are refused before payment or a roll. The five-step default survival is intentionally **0.0384%**, approximately one in2,604 chains. + +A placed anvil's reinforcement lowers **risk excess** to `max(0, actual resulting enchantment excess − reinforcement level)` before applying the combine curve/cap below. Output eligibility/levels do not change. Actual over-limit combining remains advanced at0%: zero XP, one accepted roll/outcome, and **all reinforcement spent once**. Success resets protection without repairing the anvil; failure destroys committed items and the owned used anvil with one existing explosion, subject to final native protections. Cancellations never force block removal or refund already-committed inputs. Ordinary combining/repair/naming preserves protection and native costs/refusals/wear. + +Native item, loaded-chunk and falling-entity PDC carry ownership; supported placement, break/drop, wear, falling, destruction and reload handling do not spawn duplicate anvils or inherit a replaced owner's charge. Unknown/refused persistence remains withheld rather than authorizing protection. No forced chunk loads or plugin tick-thread disk writes. Tests use native codecs/events/menus with explicit world/loaded-context ports, not a live disk-restart/crash-atomic transaction or arbitrary event-bypassing API-mutation guarantee. + ## Advanced anvil combining All players can combine two **same-material, non-book items** in an ordinary anvil. Enhanced combining applies **only when the combined enchantments exceed a normal maximum**: equal levels become one higher; unequal levels keep the higher. Compatible applicable enchantments carry over. Enhanced conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded. @@ -89,7 +107,7 @@ Within-limit combinations (for example Sharpness I + I → II or IV + IV → V) 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. +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 the owned used anvil, subject to native protection, 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**: @@ -120,8 +138,15 @@ Administrators with `spigotinventoryhelper.admin` (default OP) and authorized co | `advanced-anvil-base-failure-percent` | 20 | | `advanced-anvil-risk-step-percent` | 5 | | `advanced-anvil-max-failure-percent` | 99 | +| `advanced-anvil-reinforcement-failure-1-percent` | 20 | +| `advanced-anvil-reinforcement-failure-2-percent` | 40 | +| `advanced-anvil-reinforcement-failure-3-percent` | 60 | +| `advanced-anvil-reinforcement-failure-4-percent` | 80 | +| `advanced-anvil-reinforcement-failure-5-percent` | 99 | -Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 stays native and never performs an enhanced roll. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available. +All reinforcement percentages are integers0–100 and the complete five-value table must be nondecreasing. The max5/one-excess-tier reduction/all-level spend rules are fixed. At each durable acknowledgment, only that requested key publishes after current permission/plugin/player lifetime checks; saved neighboring keys are not publication authority. Failed saves keep prior live values; read/completion shows effective settings. A0% reinforcement attempt always succeeds,100% always fails. + +Values are integers 0–100. For positive risk excess `X`, failure is `min(cap, base + step × X × (X − 1) / 2)`; `X = max(0, E − R)` for actual excessE and reinforcementR. Actual E0 stays native and never performs an enhanced roll; actual E>0 with X0 remains advanced, rolls at0% and spends all protection. 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, real declared-library forwarding/local Netty pipeline/native queue dispatch, item serialization, result-taking, native packet limits, stale client generations and protected explosion boundaries. Pipeline installation on a live server, real-client transport/rendering and installed-stack behavior are not established by these controlled fixtures. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence. diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java index f21c928..4df27e4 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.java @@ -22,6 +22,12 @@ final class AdvancedAnvilEffects { } static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear) { + use(view, player, origin, wear, () -> true); + } + + static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear, + java.util.function.BooleanSupplier owned) { + if (!owned.getAsBoolean()) return; var block = origin.getBlock(); Material type = block.getType(); if (player.getGameMode() != GameMode.CREATIVE && isAnvil(type) && wear.getAsDouble() < .12) { @@ -32,7 +38,7 @@ final class AdvancedAnvilEffects { }; 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.callEvent() || !owned.getAsBoolean()) return; // Final cancellation/replacement wins over old wear. if (event.getDamageState() == AnvilDamagedEvent.DamageState.BROKEN) { block.setType(Material.AIR, false); origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 0); @@ -47,6 +53,33 @@ final class AdvancedAnvilEffects { origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0); } + /** A failed combine loses its owned anvil even when native wear/blast resistance would spare it. */ + static void destroy(AnvilView view, Player player, Location origin, + java.util.function.BooleanSupplier owned, + java.util.function.Predicate ownedBreak) { + var block = origin.getBlock(); + if (!isAnvil(block.getType()) || !owned.getAsBoolean()) return; + var damage = new AnvilDamagedEvent(view, null); + if (!damage.callEvent() || !owned.getAsBoolean()) return; + if (damage.getDamageState() != AnvilDamagedEvent.DamageState.BROKEN) { + // A native protection decision can select a surviving damage state instead. + BlockData chosen = damage.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); + return; + } + var breaking = new org.bukkit.event.block.BlockBreakEvent(block, player); + breaking.setDropItems(false); + breaking.setExpToDrop(0); + if (!breaking.callEvent() || !ownedBreak.test(breaking)) return; + // Do not mint native mining drops/XP for an anvil destroyed by an already-paid gamble. + // The exact own break fence is permitted; reentrant player replacements are not. + block.setType(Material.AIR, false); + origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 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 index c974d8c..21b9979 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilListener.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilListener.java @@ -23,7 +23,7 @@ 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."); + "Taking this result commits the attempt and spends all reinforcement. Failure destroys both items and causes an explosion; it also destroys the used anvil, subject to native protection."); private final Plugin plugin; private final Supplier policy; private final java.util.function.IntSupplier risk; @@ -48,11 +48,20 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { } } private final AdvancedAnvilProtocolGuard protocol; + private final ReinforcementState reinforcement; private final BukkitTask refresh; private boolean closed; + private ReinforcementCrafting crafting; + + void enableReinforcement(ReinforcementRecipeRegistrar recipes) { + if (crafting != null || closed) throw new IllegalStateException("Reinforcement already initialized or closed"); + crafting = new ReinforcementCrafting(plugin, policy, risk, recipes, protocol); + plugin.getServer().getPluginManager().registerEvents(crafting, plugin); + } // Main-thread-only, exact snapshots for preparation/invalidation, not transaction authority. private record Preview(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy, + ReinforcementState.Snapshot reinforcement, AdvancedAnvilMerge.Proposal proposal, boolean originalBypass, Component message) {} AdvancedAnvilListener(Plugin plugin, Supplier policy, @@ -63,10 +72,13 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { this.risk = Objects.requireNonNull(risk); this.wear = Objects.requireNonNull(wear); protocol = new AdvancedAnvilProtocolGuard(plugin, manager, version, this); + reinforcement = new ReinforcementState(plugin, this::policyChanged); try { + plugin.getServer().getPluginManager().registerEvents(reinforcement, plugin); refresh = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskTimer( plugin, this::refreshActionBars, 40L, 40L)); } catch (RuntimeException | LinkageError unavailable) { + reinforcement.close(); protocol.close(); throw unavailable; } @@ -101,6 +113,13 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { } return; } + var ownedAnvil = reinforcement.capture(view.getTopInventory().getLocation()); + if (ownedAnvil == null) { + release(view); + event.setResult(null); + return; + } + proposal = AdvancedAnvilMerge.prepare(left, right, effective, ownedAnvil.level()); if (!protocol.watch(view)) { release(view); event.setResult(null); // Withhold enhanced output BEFORE any unsafe bypass/install. @@ -108,12 +127,14 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { } boolean originalBypass = previous == null ? view.canBypassCost() : previous.originalBypass(); Component message = message(proposal); + if (ownedAnvil.level() > 0) message = message.append(Component.text( + " · Reinforcement " + ownedAnvil.level() + "/5 (all spent on take)")); 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, + previews.put(view, generationChanged ? new Preview(left.clone(), right.clone(), effective, ownedAnvil, proposal, originalBypass, message) : previous); if (view.getPlayer() instanceof Player player) { player.sendActionBar(message); @@ -182,6 +203,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { rerender(view); return; } + var spent = reinforcement.spend(origin, attempt.preview.reinforcement()); + if (spent == null) { 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(); @@ -195,11 +218,16 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { try { if (!failed) attempt.delivery.deliver(); try { - AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear); + if (failed) AdvancedAnvilEffects.destroy(view, (Player) view.getPlayer(), origin, + () -> reinforcement.current(origin, spent), + event -> reinforcement.removalCurrent(origin, spent, event)); + else AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear, + () -> reinforcement.current(origin, spent)); } finally { if (failed) AdvancedAnvilEffects.explode((Player) view.getPlayer(), origin); } } finally { + policyChanged(); rerender(view); } } @@ -207,7 +235,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { 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()); + && preview.policy().equals(policy.get()) + && reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement()); } private void rerender(AnvilView view) { @@ -226,6 +255,7 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { if (closed) { return; } + if (crafting != null) crafting.policyChanged(); for (AnvilView view : List.copyOf(previews.keySet())) { if (current(view)) { recompute(view); @@ -258,7 +288,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { // 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())) { + || !preview.policy().equals(policy.get()) + || !reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement())) { recompute(view); } else if (view.getPlayer() instanceof Player player) { player.sendActionBar(preview.message()); @@ -276,6 +307,27 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory(); } + Object protocolPreview(org.bukkit.inventory.InventoryView view) { + if (view instanceof AnvilView anvil) return protocolPreview(anvil); + return crafting == null ? null : crafting.preview(view); + } + + boolean protocolReady(org.bukkit.inventory.InventoryView view) { + if (view instanceof AnvilView anvil) return protocolReady(anvil); + return crafting != null && crafting.ready(view); + } + + void protocolHookReady(org.bukkit.inventory.InventoryView view) { + if (view instanceof AnvilView anvil) protocolHookReady(anvil); + else if (crafting != null) crafting.rerender(view); + } + + void protocolRejected(org.bukkit.inventory.InventoryView view) { + if (view instanceof AnvilView anvil) protocolRejected(anvil); + else if (!closed && view.getPlayer().getOpenInventory() == view && view.getPlayer() instanceof Player player) + player.updateInventory(); + } + Object protocolPreview(AnvilView view) { return previews.get(view); } boolean protocolReady(AnvilView view) { @@ -359,6 +411,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable { return; } closed = true; + if (crafting != null) crafting.close(); + reinforcement.close(); pending.clear(); seen.clear(); refresh.cancel(); diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java index 020b7e0..bcfeac0 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilMerge.java @@ -17,7 +17,12 @@ final class AdvancedAnvilMerge { record Proposal(ItemStack output, int failurePercent, String refusal) {} static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy) { + return prepare(left, right, policy, 0); + } + + static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy, int reinforcement) { Objects.requireNonNull(policy); + if (reinforcement < 0 || reinforcement > 5) throw new IllegalArgumentException("Invalid protection level"); if (left == null || right == null || left.isEmpty() || right.isEmpty() || left.getType() != right.getType() || book(left.getType())) { return null; @@ -69,7 +74,7 @@ final class AdvancedAnvilMerge { } catch (RuntimeException unsupported) { return refused("Result cannot be represented exactly by this server."); } - return new Proposal(output, policy.failurePercent(excess), null); + return new Proposal(output, policy.failurePercent(Math.max(0, excess - reinforcement)), null); } private static Proposal refused(String reason) { diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java index 62808ae..983bd91 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilPolicy.java @@ -1,11 +1,29 @@ 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) { +record AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent, + java.util.List reinforcementFailures) { + AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent) { + this(basePercent, stepPercent, maxFailurePercent, java.util.List.of(20, 40, 60, 80, 99)); + } + AdvancedAnvilPolicy { validate(basePercent); validate(stepPercent); validate(maxFailurePercent); + reinforcementFailures = java.util.List.copyOf(reinforcementFailures); + if (reinforcementFailures.size() != 5) throw new IllegalArgumentException("Expected five reinforcement tiers"); + int previous = 0; + for (int percent : reinforcementFailures) { + validate(percent); + if (percent < previous) throw new IllegalArgumentException("Reinforcement failures must be nondecreasing"); + previous = percent; + } + } + + int reinforcementFailure(int currentLevel) { + if (currentLevel < 0 || currentLevel >= 5) throw new IllegalArgumentException("No next reinforcement tier"); + return reinforcementFailures.get(currentLevel); } static AdvancedAnvilPolicy defaults() { diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java index c133aa7..f694a1d 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java @@ -16,7 +16,7 @@ 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.inventory.InventoryView; import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitTask; @@ -36,12 +36,12 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { // Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement). boolean wrapped; boolean consumed; - final AnvilView view; + final InventoryView view; final net.minecraft.server.network.ServerGamePacketListenerImpl nativeListener; java.util.function.BooleanSupplier dispatchable; Runnable completed; Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview, - java.util.function.BooleanSupplier available, AnvilView view) { + java.util.function.BooleanSupplier available, InventoryView view) { this.view = view; this.nativeListener = NativeAnvilGeneration.player(view).connection; this.event = event; @@ -58,16 +58,16 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { private final Plugin plugin; private final ProtocolManager manager; private final AdvancedAnvilListener owner; - private final Set watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + 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 admissions = new IdentityHashMap<>(); private final Map dispatches = 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<>(); + 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; @@ -106,7 +106,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { } } - boolean watch(AnvilView view) { + boolean watch(InventoryView view) { if (closed || manager.isClosed() || versions.exhausted() || !plugin.getServer().isPrimaryThread()) return false; try { NativeAnvilGeneration.menu(view); @@ -133,9 +133,9 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { } catch (RuntimeException | LinkageError unavailable) { return false; } } - boolean watched(AnvilView view) { return watched.contains(view); } + boolean watched(InventoryView view) { return watched.contains(view); } - void invalidate(AnvilView view) { + void invalidate(InventoryView view) { Admission admission = admissions.remove(view); if (admission != null) { admission.revoked = true; @@ -150,7 +150,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { if (actors.contains(event.getPlayer())) event.setCancelled(true); return; } - if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return; + InventoryView view = event.getPlayer().getOpenInventory(); + if (!watched.contains(view)) return; try { Object raw = event.getPacket().getHandle(); int containerId; @@ -191,7 +192,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { 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; + InventoryView view = event.getPlayer().getOpenInventory(); + if (!watched.contains(view)) return; invalidate(view); // A new raw intent can never reuse an earlier ticket. NativeAnvilDispatch acquiredDispatch = null; Admission acquired = null; @@ -200,7 +202,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { 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)) + || (NativeAnvilGeneration.take(view, packet) && !NativeAnvilGeneration.supportedTake(packet)) || !owner.protocolReady(view) || (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) { refuse(event, view); @@ -213,7 +215,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { event.setPacket(new PacketContainer(event.getPacketType(), translated)); // Formerly enhanced windows retain only frame translation/refusal for normal // operations. No normal take depends on enhanced identity-budget/queue ownership. - if (!NativeAnvilGeneration.take(translated) || owner.protocolPreview(view) == null) return; + if (!NativeAnvilGeneration.take(view, translated) || owner.protocolPreview(view) == null) return; NativeAnvilDispatch dispatch = dispatches.get(event.getPlayer()); Admission admission = new Admission(event, translated, owner.protocolPreview(view), () -> !closed && !manager.isClosed() && !versions.exhausted() && dispatch.ready(), view); @@ -252,7 +254,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { } } - private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) { + private boolean currentFrame(InventoryView view, ServerboundContainerClickPacket packet) { Frame frame = frames.get(view); var menu = NativeAnvilGeneration.menu(view); return !manager.isClosed() && !versions.exhausted() && frame != null && frame.valid() @@ -262,7 +264,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { private void seal(PacketEvent event) { if (closed || !plugin.getServer().isPrimaryThread()) return; - if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view)) return; + InventoryView view = event.getPlayer().getOpenInventory(); Admission admission = admissions.get(view); if (admission == null || admission.event != event) return; // MONITOR is observational. Retain the actual event for final cancellation checks both @@ -271,7 +273,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { if (!admission.sealed) invalidate(view); } - Admission consume(AnvilView view, Object preview, InventoryClickEvent event) { + Admission consume(InventoryView view, Object preview, InventoryClickEvent event) { Admission admission = NativeAnvilDispatch.current(); if (closed || admission == null || admission.view != view || admissions.get(view) != admission || !admission.valid() || admission.preview != preview @@ -283,12 +285,12 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { return admission; } - private static void refuse(PacketEvent event, AnvilView view) { + private static void refuse(PacketEvent event, InventoryView view) { event.setCancelled(true); if (view.getPlayer() instanceof Player player) player.updateInventory(); } - void forget(AnvilView view) { + void forget(InventoryView view) { invalidate(view); watched.remove(view); frames.remove(view); @@ -303,7 +305,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { } void prune() { - for (AnvilView view : List.copyOf(watched)) { + for (InventoryView view : List.copyOf(watched)) { if (view.getPlayer().getOpenInventory() != view) forget(view); } } @@ -311,7 +313,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable { @Override public void close() { if (closed) return; closed = true; - for (AnvilView view : List.copyOf(watched)) { + for (InventoryView 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. diff --git a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java index e1ed73a..03ce112 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettings.java @@ -36,7 +36,12 @@ final class AdvancedAnvilSettings implements AutoCloseable { private static final List KEYS = List.of( "advanced-anvil-base-failure-percent", "advanced-anvil-risk-step-percent", - "advanced-anvil-max-failure-percent"); + "advanced-anvil-max-failure-percent", + "advanced-anvil-reinforcement-failure-1-percent", + "advanced-anvil-reinforcement-failure-2-percent", + "advanced-anvil-reinforcement-failure-3-percent", + "advanced-anvil-reinforcement-failure-4-percent", + "advanced-anvil-reinforcement-failure-5-percent"); private final Plugin plugin; private final Path file; private final Consumer onChange; @@ -48,7 +53,7 @@ final class AdvancedAnvilSettings implements AutoCloseable { private AdvancedAnvilPolicy persisted; private long savedRevision; // Accessed only under lifecycle, including main-thread acknowledgments. - private long publishedRevision; + private final long[] publishedRevisions = new long[KEYS.size()]; static AdvancedAnvilSettings open( Plugin plugin, Consumer onAcknowledgedChange) throws IOException { @@ -179,7 +184,7 @@ final class AdvancedAnvilSettings implements AutoCloseable { persisted = next; long revision = ++savedRevision; schedule(() -> acknowledge(sender, key, next, revision)); - } catch (IOException | YAMLException failure) { + } catch (IOException | YAMLException | IllegalArgumentException failure) { schedule(() -> { synchronized (lifecycle) { if (!closed && sender.hasPermission(PERMISSION)) { @@ -204,10 +209,20 @@ final class AdvancedAnvilSettings implements AutoCloseable { private void acknowledge(CommandSender sender, String key, AdvancedAnvilPolicy next, long revision) { synchronized (lifecycle) { - if (closed || revision <= publishedRevision) { + if (closed) return; + int index = KEYS.indexOf(key); + if (index < 0 || revision <= publishedRevisions[index]) return; + publishedRevisions[index] = revision; + if (!plugin.isEnabled() || !sender.hasPermission(PERMISSION) + || (sender instanceof org.bukkit.entity.Player player && !player.isOnline())) return; + try { + // Every key requires its own current authorized callback. Saved neighboring + // values (including combine keys) are not runtime publication authority. + next = replace(effective, key, value(next, key)); + } catch (IllegalArgumentException staleTable) { + sender.sendMessage("Configuration acknowledgment refused; effective settings unchanged."); return; } - publishedRevision = revision; effective = next; try { onChange.accept(next); @@ -276,7 +291,7 @@ final class AdvancedAnvilSettings implements AutoCloseable { private static AdvancedAnvilPolicy parsePolicy(Map document) throws IOException { AdvancedAnvilPolicy defaults = AdvancedAnvilPolicy.defaults(); - int[] values = new int[3]; + int[] values = new int[KEYS.size()]; for (int index = 0; index < KEYS.size(); index++) { String key = KEYS.get(index); Object raw = document.get(key); @@ -289,7 +304,12 @@ final class AdvancedAnvilSettings implements AutoCloseable { throw new IOException("Invalid integer percent for " + key); } } - return new AdvancedAnvilPolicy(values[0], values[1], values[2]); + try { + return new AdvancedAnvilPolicy(values[0], values[1], values[2], + List.of(values[3], values[4], values[5], values[6], values[7])); + } catch (IllegalArgumentException invalid) { + throw new IOException("Invalid reinforcement failure table", invalid); + } } private static int value(AdvancedAnvilPolicy policy, String key) { @@ -297,15 +317,19 @@ final class AdvancedAnvilSettings implements AutoCloseable { case 0 -> policy.basePercent(); case 1 -> policy.stepPercent(); case 2 -> policy.maxFailurePercent(); + case 3, 4, 5, 6, 7 -> policy.reinforcementFailures().get(KEYS.indexOf(key) - 3); default -> throw new IllegalArgumentException("Unsupported setting"); }; } private static AdvancedAnvilPolicy replace(AdvancedAnvilPolicy policy, String key, int percent) { + var failures = new java.util.ArrayList<>(policy.reinforcementFailures()); + int index = KEYS.indexOf(key); + if (index >= 3) failures.set(index - 3, 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()); + key.equals(KEYS.get(2)) ? percent : policy.maxFailurePercent(), failures); } /** Nonblocking close: drop queued work, interrupt the daemon writer, suppress every late ack. */ diff --git a/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java b/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java index 274aada..93d0505 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/NativeAnvilGeneration.java @@ -18,7 +18,17 @@ final class NativeAnvilGeneration { return menu; } - static net.minecraft.server.level.ServerPlayer player(AnvilView view) { + static net.minecraft.world.inventory.AbstractContainerMenu menu(org.bukkit.inventory.InventoryView view) { + if (!(view instanceof org.bukkit.craftbukkit.inventory.CraftInventoryView craft)) + throw new IllegalStateException("Unsupported native inventory view"); + var menu = craft.getHandle(); + if (!(menu instanceof AnvilMenu || menu instanceof net.minecraft.world.inventory.InventoryMenu + || menu instanceof net.minecraft.world.inventory.CraftingMenu)) + throw new IllegalStateException("Unsupported native transaction menu"); + return menu; + } + + static net.minecraft.server.level.ServerPlayer player(org.bukkit.inventory.InventoryView view) { if (!(view.getPlayer() instanceof org.bukkit.craftbukkit.entity.CraftPlayer player)) { throw new IllegalStateException("Unsupported native anvil actor"); } @@ -40,13 +50,14 @@ final class NativeAnvilGeneration { menu(view).sendAllDataToRemote(); } - static boolean current(AnvilView view, ServerboundContainerClickPacket packet) { + static boolean current(org.bukkit.inventory.InventoryView 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 take(org.bukkit.inventory.InventoryView view, ServerboundContainerClickPacket packet) { + return packet.slotNum() == (view instanceof AnvilView ? 2 : 0) + || packet.containerInput() == ContainerInput.PICKUP_ALL; } static boolean supportedTake(ServerboundContainerClickPacket packet) { diff --git a/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementCrafting.java b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementCrafting.java new file mode 100644 index 0000000..f55ab60 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementCrafting.java @@ -0,0 +1,221 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.IntSupplier; +import java.util.function.Supplier; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +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.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.inventory.CraftingInventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; + +/** Accepted single gambling crafts share the exact bounded packet ownership of enhanced takes. */ +final class ReinforcementCrafting implements Listener, AutoCloseable { + private final Plugin plugin; + private final Supplier policy; + private final IntSupplier risk; + private final ReinforcementRecipeRegistrar recipe; + private final AdvancedAnvilProtocolGuard protocol; + private final ReinforcementItems items; + private final Map previews = new IdentityHashMap<>(); + private final Map pending = new IdentityHashMap<>(); + private final Map seen = new java.util.WeakHashMap<>(); + private boolean closed; + private record Preview(ItemStack[] matrix, int anvil, int fuel, int level, + AdvancedAnvilPolicy policy, ItemStack output, String feedback) {} + private static final class Attempt { + private final Preview preview; + private final InventoryClickEvent event; + private final AdvancedAnvilProtocolGuard.Admission admission; + private boolean invalid; + Attempt(Preview preview, InventoryClickEvent event, AdvancedAnvilProtocolGuard.Admission admission) { + this.preview = preview; + this.event = event; + this.admission = admission; + } + Preview preview() { return preview; } + InventoryClickEvent event() { return event; } + AdvancedAnvilProtocolGuard.Admission admission() { return admission; } + } + + ReinforcementCrafting(Plugin plugin, Supplier policy, IntSupplier risk, + ReinforcementRecipeRegistrar recipe, AdvancedAnvilProtocolGuard protocol) { + this.plugin = plugin; + this.policy = policy; + this.risk = risk; + this.recipe = recipe; + this.protocol = protocol; + items = new ReinforcementItems(plugin); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void prepare(PrepareItemCraftEvent event) { + if (closed) return; + InventoryView view = event.getView(); + var inventory = event.getInventory(); + Attempt attempt = pending.get(view); + if (attempt != null) { + attempt.invalid = true; // Consumed admissions are no longer in the guard map; invalidation must be sticky. + protocol.invalidate(view); + inventory.setResult(null); + return; + } + ItemStack[] matrix = copies(inventory.getMatrix()); + int anvil = -1; + int fuel = -1; + for (int i = 0; i < matrix.length; i++) { + ItemStack item = matrix[i]; + if (item == null) continue; + if (AdvancedAnvilEffects.isAnvil(item.getType()) && anvil == -1) anvil = i; + else if (item.getType() == Material.NETHERITE_BLOCK && fuel == -1) fuel = i; + else { release(view); return; } + } + if (anvil < 0 || fuel < 0) { release(view); return; } + if (!recipe.active()) { inventory.setResult(null); release(view); return; } + if (!(inventory.getRecipe() instanceof org.bukkit.Keyed keyed) + || !recipe.recipeKeys().contains(keyed.getKey())) { release(view); return; } + int level = items.level(matrix[anvil]); + AdvancedAnvilPolicy effective = policy.get(); + String feedback = level < 0 ? "Invalid reinforcement ownership; no attempt accepted." + : level == 5 ? "Maximum reinforcement level 5; no payment or roll." + : "Reinforcement level " + level + "/5 → " + (level + 1) + "/5 · Failure " + + effective.reinforcementFailure(level) + "% · One anvil + one Netherite Block; failure loses both."; + ItemStack output = level < 0 || level == 5 ? null + : items.preview(matrix[anvil], level + 1, effective.reinforcementFailure(level)); + Preview previous = previews.get(view); + if (!protocol.watch(view)) { inventory.setResult(null); release(view); return; } + boolean changed = previous == null || !Arrays.equals(matrix, previous.matrix()) + || !effective.equals(previous.policy()); + if (changed) { + NativeAnvilGeneration.menu(view).incrementStateId(); + previews.put(view, new Preview(matrix, anvil, fuel, level, effective, output, feedback)); + } + var nativeMenu = NativeAnvilGeneration.menu(view); + nativeMenu.setRemoteSlot(0, output == null ? nativeMenu.getSlot(1).getItem().copy() + : net.minecraft.world.item.ItemStack.EMPTY); + inventory.setResult(output); + if (view.getPlayer() instanceof Player player) { + player.sendActionBar(Component.text(feedback)); + if (changed) { + player.sendMessage(Component.text(feedback + + " Each reinforcement shifts advanced risk by one excess tier. An advanced attempt spends all levels; failure destroys inputs/anvil with an explosion.")); + player.sendMessage(Component.text("Reinforcement levels 1–5 failure: " + + effective.reinforcementFailures().stream().map(percent -> percent + "%") + .collect(java.util.stream.Collectors.joining(", ")) + ".")); + player.sendMessage(Component.text("Combine risk excess 1–7 failure: " + + java.util.stream.IntStream.rangeClosed(1, 7).mapToObj(excess -> effective.failurePercent(excess) + "%") + .collect(java.util.stream.Collectors.joining(", ")) + + ". Risk excess = max(0, actual enchantment excess − reinforcement); zero risk excess means 0%. " + + "An over-limit take remains advanced and spends all reinforcement even at 0%; ordinary combining keeps it and native XP costs.")); + } + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void take(InventoryClickEvent event) { + InventoryView view = event.getView(); + Preview preview = previews.get(view); + if (closed || preview == null || (event.getRawSlot() != 0 + && event.getAction() != org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR)) return; + if (pending.containsKey(view) || seen.put(event, Boolean.TRUE) != null) { event.setCancelled(true); return; } + var admission = protocol.consume(view, preview, event); + if (event.isCancelled() || admission == null || preview.output() == null + || (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) + || event.getRawSlot() != 0 || !event.getWhoClicked().getItemOnCursor().isEmpty() + || !matches(view, preview)) { + event.setCancelled(true); + return; + } + Attempt attempt = new Attempt(preview, event, admission); + pending.put(view, attempt); + ((CraftingInventory) view.getTopInventory()).setResult(null); + try { + plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt)); + } catch (RuntimeException unavailable) { + pending.remove(view); + event.setCancelled(true); + rerender(view); + } + } + + private void finish(InventoryView view, Attempt attempt) { + if (closed || pending.get(view) != attempt) return; + pending.remove(view); + Preview preview = attempt.preview(); + if (attempt.invalid || attempt.event().isCancelled() || !attempt.admission().valid() || previews.get(view) != preview + || !current(view) || !matches(view, preview) || !view.getPlayer().getItemOnCursor().isEmpty()) { + rerender(view); + return; + } + int roll = risk.getAsInt(); + if (roll < 0 || roll >= 100) throw new IllegalStateException("Invalid reinforcement risk roll"); + var menu = (net.minecraft.world.inventory.AbstractCraftingMenu) NativeAnvilGeneration.menu(view); + // Live native counts: debit both before any prepare/feedback/player callback can reenter. + menu.craftSlots.getItem(preview.anvil()).shrink(1); + menu.craftSlots.getItem(preview.fuel()).shrink(1); + if (roll >= preview.policy().reinforcementFailure(preview.level())) + view.getPlayer().setItemOnCursor(items.reinforce(preview.matrix()[preview.anvil()], preview.level() + 1)); + rerender(view); + } + + Object preview(InventoryView view) { return previews.get(view); } + boolean ready(InventoryView view) { + Preview preview = previews.get(view); + return !closed && current(view) && !pending.containsKey(view) + && (preview == null || (matches(view, preview) && Objects.equals(preview.output(), + ((CraftingInventory) view.getTopInventory()).getResult()))); + } + private boolean matches(InventoryView view, Preview preview) { + return recipe.active() && view.getTopInventory() instanceof CraftingInventory inventory + && Arrays.equals(preview.matrix(), copies(inventory.getMatrix())) && preview.policy().equals(policy.get()); + } + private static boolean current(InventoryView view) { + return view.getPlayer().getOpenInventory() == view && view.getPlayer() instanceof Player player + && player.isOnline() && !player.isDead(); + } + void rerender(InventoryView view) { + if (!closed && current(view) && view.getTopInventory() instanceof CraftingInventory inventory) { + inventory.setMatrix(inventory.getMatrix()); + NativeAnvilGeneration.menu(view).sendAllDataToRemote(); + } else release(view); + } + void policyChanged() { for (InventoryView view : List.copyOf(previews.keySet())) rerender(view); } + private void release(InventoryView view) { + if (previews.remove(view) != null && view.getPlayer() instanceof Player player) + player.sendActionBar(Component.empty()); // Withdraw our obsolete gambling warning, not native ordinary feedback. + protocol.invalidate(view); + } + @EventHandler(priority = EventPriority.MONITOR) + public void closeView(InventoryCloseEvent event) { + pending.remove(event.getView()); + release(event.getView()); + protocol.forget(event.getView()); + } + private static ItemStack[] copies(ItemStack[] matrix) { + return Arrays.stream(matrix).map(item -> item == null || item.isEmpty() ? null : item.clone()).toArray(ItemStack[]::new); + } + @Override public void close() { + closed = true; + pending.clear(); + seen.clear(); + HandlerList.unregisterAll(this); + for (InventoryView view : List.copyOf(previews.keySet())) { + release(view); + if (current(view)) ((CraftingInventory) view.getTopInventory()).setResult(null); + } + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementItems.java b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementItems.java new file mode 100644 index 0000000..2f15d6c --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementItems.java @@ -0,0 +1,74 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.kyori.adventure.text.Component; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +/** Only plugin-owned typed metadata establishes a tier; presentation never establishes authority. */ +final class ReinforcementItems { + private final NamespacedKey tierKey; + private final NamespacedKey identityKey; + ReinforcementItems(Plugin plugin) { + tierKey = new NamespacedKey(plugin, "reinforcement"); + identityKey = new NamespacedKey(plugin, "reinforcement_identity"); + } + + /** -1 means malformed owned state, 0 ordinary, 1..5 authentic. */ + int level(ItemStack item) { + if (item == null || item.isEmpty() || !AdvancedAnvilEffects.isAnvil(item.getType())) return -1; + var data = item.getItemMeta().getPersistentDataContainer(); + if (!data.has(tierKey) && !data.has(identityKey)) return 0; + try { + Integer tier = data.get(tierKey, PersistentDataType.INTEGER); + String id = data.get(identityKey, PersistentDataType.STRING); + if (tier == null || tier < 1 || tier > 5 || id == null || !UUID.fromString(id).toString().equals(id)) return -1; + return tier; + } catch (IllegalArgumentException malformed) { return -1; } + } + + ItemStack preview(ItemStack source, int next, int failure) { + var output = ordinary(source); + var meta = output.getItemMeta(); + var lore = meta.lore() == null ? new ArrayList() : new ArrayList<>(meta.lore()); + lore.add(Component.text("Attempt level " + next + "/5 · Failure " + failure + "% (not guaranteed)")); + meta.lore(lore); + output.setItemMeta(meta); + return output; + } + + ItemStack reinforce(ItemStack source, int level) { + if (level < 1 || level > 5) throw new IllegalArgumentException("Invalid reinforcement level"); + var output = ordinary(source); + var meta = output.getItemMeta(); + meta.getPersistentDataContainer().set(tierKey, PersistentDataType.INTEGER, level); + meta.getPersistentDataContainer().set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString()); + var lore = meta.lore() == null ? new ArrayList() : new ArrayList<>(meta.lore()); + lore.add(label(level)); + meta.lore(lore); + output.setItemMeta(meta); + return output; + } + + ItemStack ordinary(ItemStack source) { + var output = source.asQuantity(1); + int previous = level(source); + var meta = output.getItemMeta(); + List lore = meta.lore(); + if (previous > 0 && lore != null && !lore.isEmpty() && lore.getLast().equals(label(previous))) { + var retained = new ArrayList<>(lore); + retained.removeLast(); + meta.lore(retained.isEmpty() ? null : retained); + } + meta.getPersistentDataContainer().remove(tierKey); + meta.getPersistentDataContainer().remove(identityKey); + output.setItemMeta(meta); + return output; + } + + private static Component label(int level) { return Component.text("Reinforcement level " + level + "/5"); } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementRecipeRegistrar.java b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementRecipeRegistrar.java new file mode 100644 index 0000000..0d44ed8 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementRecipeRegistrar.java @@ -0,0 +1,69 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; + +/** A discoverable native recipe; its static result is never reinforcement authority. */ +final class ReinforcementRecipeRegistrar implements RecipeRegistrar { + private final NamespacedKey key; + private final String group = "reinforcement_" + UUID.randomUUID(); + private Server server; + private boolean owned; + + ReinforcementRecipeRegistrar(NamespacedKey key) { this.key = Objects.requireNonNull(key); } + + @Override public boolean register(Server target) { + if (owned) return true; + server = Objects.requireNonNull(target); + if (server.getRecipe(key) != null) return false; + var result = new ItemStack(Material.ANVIL); + var meta = result.getItemMeta(); + meta.displayName(Component.text("Anvil Reinforcement")); // Native book hover-name/search identity; no usable tier authority. + meta.lore(List.of(Component.text("Gamble one Netherite Block: up to five reinforcement levels."), + Component.text("See the current tier and failure chance in crafting."), + Component.text("Each level shifts advanced risk by one excess tier."), + Component.text("One advanced attempt spends all protection; failure causes an explosion."))); + result.setItemMeta(meta); + var recipe = new ShapelessRecipe(key, result); + recipe.addIngredient(new RecipeChoice.MaterialChoice(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL)); + recipe.addIngredient(Material.NETHERITE_BLOCK); + recipe.setCategory(CraftingBookCategory.MISC); + recipe.setGroup(group); + try { + owned = server.addRecipe(recipe); + return owned; + } catch (RuntimeException failure) { + owned = matches(server.getRecipe(key)); + try { unregister(); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); } + throw failure; + } + } + + boolean active() { return owned && server != null && matches(server.getRecipe(key)); } + + private boolean matches(Recipe recipe) { + return recipe instanceof ShapelessRecipe shapeless && shapeless.getKey().equals(key) + && shapeless.getGroup().equals(group); + } + + @Override public List recipeKeys() { return List.of(key); } + @Override public String description() { return "Anvil reinforcement recipe"; } + @Override public void unregister() { + if (!owned || server == null) return; + Recipe current = server.getRecipe(key); + if (current != null && !matches(current)) { owned = false; return; } + if (current != null && !server.removeRecipe(key) && server.getRecipe(key) != null) + throw new IllegalStateException("Could not remove owned reinforcement recipe"); + owned = false; + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementState.java b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementState.java new file mode 100644 index 0000000..c47f584 --- /dev/null +++ b/src/main/java/games/dmg/spigotinventoryhelper/ReinforcementState.java @@ -0,0 +1,530 @@ +package games.dmg.spigotinventoryhelper; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +/** Native loaded-chunk PDC owns a physical anvil. No plugin disk store, chunk load or replay. */ +final class ReinforcementState implements Listener, AutoCloseable { + record Snapshot(String identity, ItemStack item, int level) {} + private record Position(UUID world, int x, int y, int z) {} + private final Plugin plugin; + private final ReinforcementItems items; + private final Runnable changed; + private final NamespacedKey identity; + private final NamespacedKey payload; + private final NamespacedKey withheld; + private final NamespacedKey carrierPayload; + private final NamespacedKey carrierPhase; + private final Map landings = new HashMap<>(); + private final Map dropping = new HashMap<>(); + private final Map transitions = new HashMap<>(); + private final Map breaks = new HashMap<>(); + private record Drop(org.bukkit.entity.Item entity, ItemStack before) {} + private static final class Breakage { + final Block block; + final Snapshot snapshot; + final org.bukkit.event.block.BlockBreakEvent event; + org.bukkit.event.block.BlockDropItemEvent dropEvent; + List drops = List.of(); + Breakage(Block block, Snapshot snapshot, org.bukkit.event.block.BlockBreakEvent event) { + this.block = block; + this.snapshot = snapshot; + this.event = event; + } + } + private boolean closed; + + ReinforcementState(Plugin plugin, Runnable changed) { + this.plugin = plugin; + this.changed = changed; + items = new ReinforcementItems(plugin); + identity = new NamespacedKey(plugin, "identity"); + payload = new NamespacedKey(plugin, "item"); + withheld = new NamespacedKey(plugin, "withheld"); + carrierPayload = new NamespacedKey(plugin, "falling_reinforcement"); + carrierPhase = new NamespacedKey(plugin, "transport_phase"); + } + + Snapshot capture(Location origin) { + if (closed || origin == null || !loaded(origin) || transitions.containsKey(position(origin))) return null; + Block block = origin.getBlock(); + if (!AdvancedAnvilEffects.isAnvil(block.getType())) return null; + try { + var data = data(block); + if (data == null) return null; + Snapshot snapshot = read(block); + if (snapshot == null && !data.has(key(block))) { + snapshot = new Snapshot(UUID.randomUUID().toString(), new ItemStack(block.getType()), 0); + write(block, snapshot); + } + return snapshot; + } catch (RuntimeException malformed) { return null; } + } + + boolean current(Location origin, Snapshot snapshot) { + return snapshot != null && snapshot.equals(capture(origin)); + } + + /** A destruction decision may cross ONLY its own registered break fence, never a replacement. */ + boolean removalCurrent(Location origin, Snapshot snapshot, org.bukkit.event.block.BlockBreakEvent event) { + if (closed || origin == null || snapshot == null || !loaded(origin)) return false; + Object transition = transitions.get(position(origin)); + if (transition != null && (!(transition instanceof Breakage breakage) || breakage.event != event)) return false; + try { return snapshot.equals(read(origin.getBlock(), transition instanceof Breakage)); } + catch (RuntimeException malformed) { return false; } + } + + Snapshot spend(Location origin, Snapshot snapshot) { + if (!current(origin, snapshot)) return null; + Snapshot ordinary = new Snapshot(UUID.randomUUID().toString(), items.ordinary(snapshot.item()), 0); + try { + write(origin.getBlock(), ordinary); + return ordinary.equals(read(origin.getBlock())) ? ordinary : null; + } catch (RuntimeException uncertain) { return null; } + } + + private boolean loaded(Location location) { + return location.getWorld() != null && location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4); + } + private PersistentDataContainer data(Block block) { + if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) return null; + var chunk = block.getChunk(); + return chunk != null && chunk.isLoaded() ? chunk.getPersistentDataContainer() : null; + } + private NamespacedKey key(Block block) { + return new NamespacedKey(plugin, "anvil_" + (block.getX() & 15) + "_" + block.getY() + "_" + (block.getZ() & 15)); + } + private static Position position(Location location) { + return new Position(location.getWorld().getUID(), location.getBlockX(), location.getBlockY(), location.getBlockZ()); + } + private Snapshot read(Block block) { return read(block, false); } + private Snapshot read(Block block, boolean allowWithheld) { + var chunk = data(block); + if (chunk == null || !chunk.has(key(block))) return null; + var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + if (record == null) throw new IllegalArgumentException("Malformed anvil record"); + if (!allowWithheld && record.has(withheld)) return null; // Durable unknown custody is not a fresh ordinary owner. + String id = record.get(identity, PersistentDataType.STRING); + byte[] bytes = record.get(payload, PersistentDataType.BYTE_ARRAY); + if (id == null || !UUID.fromString(id).toString().equals(id) || bytes == null) + throw new IllegalArgumentException("Malformed anvil identity"); + ItemStack item = ItemStack.deserializeBytes(bytes); + int level = items.level(item); + if (level < 0 || item.getAmount() != 1) throw new IllegalArgumentException("Malformed anvil item"); + // Native wear/falling can change damage. Never restore the serialized old variant. + item.setType(block.getType()); + return new Snapshot(id, item, level); + } + private void write(Block block, Snapshot snapshot) { + var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable"); + var record = chunk.getAdapterContext().newPersistentDataContainer(); + record.set(identity, PersistentDataType.STRING, snapshot.identity()); + record.set(payload, PersistentDataType.BYTE_ARRAY, snapshot.item().serializeAsBytes()); + chunk.set(key(block), PersistentDataType.TAG_CONTAINER, record); + } + private void quarantine(Block block) { + var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable"); + var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + if (record == null) return; + var reserved = chunk.getAdapterContext().newPersistentDataContainer(); + record.copyTo(reserved, true); + reserved.set(withheld, PersistentDataType.BYTE, (byte) 1); + chunk.set(key(block), PersistentDataType.TAG_CONTAINER, reserved); + var installed = chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + if (installed == null || !Objects.equals(installed.get(withheld, PersistentDataType.BYTE), (byte) 1)) + throw new IllegalStateException("Anvil ownership quarantine refused"); + } + + private void remove(Block block) { + var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable"); + quarantine(block); // A failed/ignored removal cannot reactivate the previous charge on reload. + chunk.remove(key(block)); + if (chunk.has(key(block))) throw new IllegalStateException("Anvil ownership removal refused"); + } + + /** Native falling has distinct source-removal and destination-placement outcomes. */ + @EventHandler(priority = EventPriority.MONITOR) + public void falling(org.bukkit.event.entity.EntityChangeBlockEvent event) { + if (closed || event.isCancelled() || !(event.getEntity() instanceof org.bukkit.entity.FallingBlock carrier)) return; + if (event.getTo().isAir() && AdvancedAnvilEffects.isAnvil(event.getBlock().getType())) depart(event, carrier); + else if (AdvancedAnvilEffects.isAnvil(event.getTo())) land(event, carrier); + } + + private void depart(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) { + Block block = event.getBlock(); + Snapshot snapshot = capture(block.getLocation()); + if (snapshot == null || carrier.getPersistentDataContainer().has(carrierPayload)) return; + Position position = position(block.getLocation()); + Object transition = new Object(); + transitions.put(position, transition); + changed.run(); + try { + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return; + if (event.isCancelled()) { + transitions.remove(position, transition); + changed.run(); + return; // No persistent authority was written to the unspawned candidate. + } + if (AdvancedAnvilEffects.isAnvil(block.getType())) return; // Source removal unobserved. + try { + if (carrier.isValid() && !carrier.isDead()) { + writeCarrier(carrier, snapshot, (byte) 0); // Provisional, not usable before source retirement. + retire(block, snapshot); + writeCarrier(carrier, snapshot, (byte) 1); + } else { + retire(block, snapshot); // Native spawn refusal/destruction cannot mint a live charge. + } + } catch (RuntimeException uncertain) { return; } + transitions.remove(position, transition); + changed.run(); + }); + } catch (RuntimeException unavailable) { + plugin.getLogger().warning("Anvil departure ownership unavailable; reinforcement withheld."); + } + } + + private Snapshot carrierSnapshot(org.bukkit.entity.FallingBlock carrier, byte phase) { + var record = carrier.getPersistentDataContainer().get(carrierPayload, PersistentDataType.TAG_CONTAINER); + if (record == null || !Objects.equals(record.get(carrierPhase, PersistentDataType.BYTE), phase)) return null; + String id = record.get(identity, PersistentDataType.STRING); + byte[] bytes = record.get(payload, PersistentDataType.BYTE_ARRAY); + if (id == null || !UUID.fromString(id).toString().equals(id) || bytes == null) return null; + ItemStack item = ItemStack.deserializeBytes(bytes); + int level = items.level(item); + if (level < 0 || item.getAmount() != 1 || !AdvancedAnvilEffects.isAnvil(carrier.getBlockData().getMaterial())) return null; + item.setType(carrier.getBlockData().getMaterial()); + return new Snapshot(id, item, level); + } + + private void writeCarrier(org.bukkit.entity.FallingBlock carrier, Snapshot snapshot, byte phase) { + var data = carrier.getPersistentDataContainer(); + var record = data.getAdapterContext().newPersistentDataContainer(); + record.set(identity, PersistentDataType.STRING, snapshot.identity()); + record.set(payload, PersistentDataType.BYTE_ARRAY, snapshot.item().serializeAsBytes()); + record.set(carrierPhase, PersistentDataType.BYTE, phase); + data.set(carrierPayload, PersistentDataType.TAG_CONTAINER, record); + if (!snapshot.equals(carrierSnapshot(carrier, phase))) throw new IllegalStateException("Anvil transport write refused"); + } + + private void land(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) { + Snapshot snapshot; + try { snapshot = carrierSnapshot(carrier, (byte) 1); } + catch (RuntimeException malformed) { event.setCancelled(true); return; } + if (snapshot == null) { + if (carrier.getPersistentDataContainer().has(carrierPayload)) event.setCancelled(true); + else ordinaryLanding(event, carrier); + return; + } + Block block = event.getBlock(); + if (!loaded(block.getLocation()) || landings.containsKey(carrier.getUniqueId())) { + event.setCancelled(true); + return; + } + Object transition = new Object(); + Position position = position(block.getLocation()); + landings.put(carrier.getUniqueId(), transition); + transitions.put(position, transition); + try { + writeCarrier(carrier, snapshot, (byte) 2); // Persisted one-use reservation survives reload conservatively. + changed.run(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || landings.get(carrier.getUniqueId()) != transition) return; + if (!loaded(block.getLocation())) return; // Unknown outcome stays reserved, never reusable. + try { + if (transitions.get(position) == transition && !event.isCancelled() && carrier.isDead() + && block.getType() == event.getTo() && snapshot.equals(carrierSnapshot(carrier, (byte) 2))) { + // Native final cancellation + removal + unchanged destination reservation + // distinguish accepted landing from a refused write/player replacement. + Snapshot landed = new Snapshot(UUID.randomUUID().toString(), snapshot.item(), snapshot.level()); + write(block, landed); + if (!landed.equals(read(block))) throw new IllegalStateException("Anvil landing write refused"); + carrier.getPersistentDataContainer().remove(carrierPayload); + } else if (carrier.isDead() && !dropping.containsKey(carrier.getUniqueId())) { + carrier.getPersistentDataContainer().remove(carrierPayload); // Refused landing with no native drop claimant. + } else if (snapshot.equals(carrierSnapshot(carrier, (byte) 2))) { + writeCarrier(carrier, snapshot, (byte) 1); // Observed failed-write/live carrier may retry, not the block. + } + } catch (RuntimeException uncertain) { return; } + landings.remove(carrier.getUniqueId(), transition); + transitions.remove(position, transition); + changed.run(); + }); + } catch (RuntimeException unavailable) { + event.setCancelled(true); + plugin.getLogger().warning("Anvil landing ownership unavailable; reinforcement withheld."); + } + } + + /** Untagged native reload/ordinary carriers must not inherit a prior position's durable charge. */ + private void ordinaryLanding(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) { + Block block = event.getBlock(); + if (!loaded(block.getLocation())) { event.setCancelled(true); return; } + Position position = position(block.getLocation()); + Object transition = new Object(); + var before = block.getBlockData().clone(); + var chunk = data(block); + var previous = chunk == null ? null : chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + transitions.put(position, transition); + try { + remove(block); + changed.run(); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return; + try { + if (event.isCancelled() && previous != null && AdvancedAnvilEffects.isAnvil(before.getMaterial()) + && before.equals(block.getBlockData())) + Objects.requireNonNull(data(block)).set(key(block), PersistentDataType.TAG_CONTAINER, previous); + else if (!event.isCancelled() && carrier.isDead() && block.getType() == event.getTo()) { + var ordinary = new Snapshot(UUID.randomUUID().toString(), new ItemStack(event.getTo()), 0); + write(block, ordinary); + if (!ordinary.equals(read(block))) return; + } + } catch (RuntimeException uncertain) { return; } + transitions.remove(position, transition); + changed.run(); + }); + } catch (RuntimeException unavailable) { + event.setCancelled(true); + changed.run(); + plugin.getLogger().warning("Ordinary anvil landing ownership unavailable; reinforcement withheld."); + } + } + + /** Native ordinary onTake wear does not necessarily emit a mining/explosion event. */ + @EventHandler(priority = EventPriority.MONITOR) + public void wear(com.destroystokyo.paper.event.block.AnvilDamagedEvent event) { + if (closed || event.isCancelled()) return; + Location origin = event.getView().getTopInventory().getLocation(); + Snapshot snapshot = capture(origin); + if (snapshot == null) return; + try { + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || !loaded(origin) || event.isCancelled() + || event.getDamageState() != com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.BROKEN + || AdvancedAnvilEffects.isAnvil(origin.getBlock().getType())) return; + try { retire(origin.getBlock(), snapshot); } catch (RuntimeException uncertain) { return; } + changed.run(); + }); + } catch (RuntimeException unavailable) { + plugin.getLogger().warning("Anvil wear ownership unavailable; reinforcement withheld."); + } + } + + /** Rewrite one accepted vanilla supplied item only AFTER final drop cancellation and registration. */ + @EventHandler(priority = EventPriority.MONITOR) + public void fallingDrop(org.bukkit.event.entity.EntityDropItemEvent event) { + if (closed || !(event.getEntity() instanceof org.bukkit.entity.FallingBlock carrier) + || !carrier.getPersistentDataContainer().has(carrierPayload)) return; + if (dropping.containsKey(carrier.getUniqueId())) { + if (dropping.get(carrier.getUniqueId()) != event) event.setCancelled(true); + return; + } + Snapshot snapshot; + try { + Snapshot active = carrierSnapshot(carrier, (byte) 1); + snapshot = active == null ? carrierSnapshot(carrier, (byte) 2) : active; + } catch (RuntimeException malformed) { event.setCancelled(true); return; } + if (snapshot == null) { event.setCancelled(true); return; } + var supplied = new Drop(event.getItemDrop(), event.getItemDrop().getItemStack().clone()); + try { + writeCarrier(carrier, snapshot, (byte) 3); // One terminal claimant; no retry from a second callback. + dropping.put(carrier.getUniqueId(), event); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || dropping.get(carrier.getUniqueId()) != event) return; + try { + if (!snapshot.equals(carrierSnapshot(carrier, (byte) 3))) return; + var data = carrier.getPersistentDataContainer(); + data.remove(carrierPayload); + if (data.has(carrierPayload)) return; // Failed retirement may not publish a second charge. + if (!event.isCancelled() && supplied.entity().isValid() && !supplied.entity().isDead() + && supplied.entity().getWorld().equals(carrier.getWorld()) + && supplied.before().getAmount() == 1 + && supplied.before().getType() == snapshot.item().getType() + && supplied.entity().getItemStack().equals(supplied.before())) + supplied.entity().setItemStack(snapshot.item().clone()); + } catch (RuntimeException uncertain) { return; } + dropping.remove(carrier.getUniqueId(), event); + changed.run(); + }); + } catch (RuntimeException unavailable) { + event.setCancelled(true); + plugin.getLogger().warning("Anvil drop ownership unavailable; reinforcement withheld."); + } + } + + /** Fence the old owner during native execution; final cancellation is read after return. */ + @EventHandler(priority = EventPriority.MONITOR) + public void breaking(org.bukkit.event.block.BlockBreakEvent event) { + if (closed || event.isCancelled()) return; + Block block = event.getBlock(); + Snapshot snapshot = capture(block.getLocation()); + if (snapshot == null) return; + Position position = position(block.getLocation()); + Breakage breakage = new Breakage(block, snapshot, event); + breaks.put(position, breakage); + transitions.put(position, breakage); + changed.run(); + try { + quarantine(block); // Persist the fence BEFORE native removal; unload/close may cancel reconciliation. + plugin.getServer().getScheduler().runTask(plugin, () -> finishBreak(position, breakage)); + } catch (RuntimeException unavailable) { + // Rejected durable withdrawal/native outcome cannot authorize destructive gameplay. + event.setCancelled(true); + plugin.getLogger().warning("Anvil break ownership unavailable; reinforcement withheld."); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void drops(org.bukkit.event.block.BlockDropItemEvent event) { + if (closed) return; + Breakage breakage = breaks.get(position(event.getBlock().getLocation())); + if (breakage == null || breakage.dropEvent != null || event.getPlayer() != breakage.event.getPlayer() + || event.getBlockState().getType() != breakage.snapshot.item().getType()) return; + breakage.dropEvent = event; + breakage.drops = event.getItems().stream().map(item -> new Drop(item, item.getItemStack().clone())).toList(); + } + + private void finishBreak(Position position, Breakage breakage) { + if (closed || breaks.get(position) != breakage) return; + Block block = breakage.block; + if (!loaded(block.getLocation())) return; // Unobserved native outcome stays fenced. + if (breakage.event.isCancelled()) { + if (transitions.get(position) != breakage) return; + try { + var record = Objects.requireNonNull(data(block)).get(key(block), PersistentDataType.TAG_CONTAINER); + if (record != null && Objects.equals(record.get(identity, PersistentDataType.STRING), breakage.snapshot.identity()) + && block.getType() == breakage.snapshot.item().getType()) write(block, breakage.snapshot); + } catch (RuntimeException uncertain) { return; } + breaks.remove(position, breakage); + transitions.remove(position, breakage); + changed.run(); + return; // Native refusal leaves the original persistent record untouched. + } + if (AdvancedAnvilEffects.isAnvil(block.getType()) && transitions.get(position) == breakage) + return; // No observed terminal removal: do not copy an unresolved charge into a drop. + try { + retire(block, breakage.snapshot); + } catch (RuntimeException uncertain) { return; } + breaks.remove(position, breakage); + transitions.remove(position, breakage); + if (breakage.event.isDropItems() && breakage.dropEvent != null && !breakage.dropEvent.isCancelled()) { + for (Drop drop : breakage.drops) { + if (breakage.dropEvent.getItems().contains(drop.entity()) && !drop.entity().isDead() + && drop.before().getType() == breakage.snapshot.item().getType() + && drop.before().getAmount() == 1 && drop.entity().getItemStack().equals(drop.before())) { + drop.entity().setItemStack(breakage.snapshot.item().clone()); + break; // Rewrite one native supplied drop; never spawn/duplicate a second one. + } + } + } + changed.run(); + } + + /** Never retire a subsequently placed owner at the same coordinates. */ + private void retire(Block block, Snapshot snapshot) { + var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable"); + var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + if (record != null && Objects.equals(record.get(identity, PersistentDataType.STRING), snapshot.identity())) { + chunk.remove(key(block)); + if (chunk.has(key(block))) throw new IllegalStateException("Anvil ownership retirement refused"); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void explode(org.bukkit.event.entity.EntityExplodeEvent event) { + if (!closed && !event.isCancelled()) destruction(List.copyOf(event.blockList()), + () -> !event.isCancelled(), event.blockList()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void explode(org.bukkit.event.block.BlockExplodeEvent event) { + if (!closed && !event.isCancelled()) destruction(List.copyOf(event.blockList()), + () -> !event.isCancelled(), event.blockList()); + } + + private void destruction(List candidates, java.util.function.BooleanSupplier accepted, List finalBlocks) { + for (Block block : candidates) { + Snapshot snapshot = capture(block.getLocation()); + if (snapshot == null) continue; + Position position = position(block.getLocation()); + Object transition = new Object(); + transitions.put(position, transition); + changed.run(); + try { + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return; + if (accepted.getAsBoolean() && finalBlocks.contains(block) + && !AdvancedAnvilEffects.isAnvil(block.getType())) { + try { retire(block, snapshot); } catch (RuntimeException uncertain) { return; } + } + transitions.remove(position, transition); + changed.run(); + }); + } catch (RuntimeException unavailable) { + plugin.getLogger().warning("Anvil destruction ownership unavailable; reinforcement withheld."); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void place(BlockPlaceEvent event) { + if (closed) return; + Block block = event.getBlockPlaced(); + Location location = block.getLocation(); + if (location == null || !loaded(location)) return; + Position position = position(location); + Object transition = new Object(); + var chunk = data(block); + PersistentDataContainer previous = chunk == null || !chunk.has(key(block), PersistentDataType.TAG_CONTAINER) + ? null : chunk.get(key(block), PersistentDataType.TAG_CONTAINER); + transitions.put(position, transition); + // Withdraw old authority immediately; uncertain/cancelled task delivery cannot leave + // an old physical identity attached to a newly placed same-material block on restart. + changed.run(); + ItemStack placed = event.getItemInHand().asQuantity(1); + try { + remove(block); + plugin.getServer().getScheduler().runTask(plugin, () -> { + if (closed || transitions.get(position) != transition) return; + transitions.remove(position); + if ((event.isCancelled() || !event.canBuild()) && previous != null && loaded(location) + && Objects.equals(block.getBlockData(), event.getBlockReplacedState().getBlockData())) { + Objects.requireNonNull(data(block)).set(key(block), PersistentDataType.TAG_CONTAINER, previous); + } else if (!event.isCancelled() && event.canBuild() && loaded(location) + && block.getType() == placed.getType() && items.level(placed) >= 0) { + try { write(block, new Snapshot(UUID.randomUUID().toString(), placed, items.level(placed))); } + catch (RuntimeException uncertain) { remove(block); } + } + changed.run(); + }); + } catch (RuntimeException unavailable) { + // No speculative acknowledgment/restore after a rejected scheduler. + plugin.getLogger().warning("Anvil placement ownership unavailable; reinforcement withheld."); + } + } + + @Override public void close() { + closed = true; + transitions.clear(); + breaks.clear(); + landings.clear(); + dropping.clear(); + HandlerList.unregisterAll(this); + } +} diff --git a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java index d028cb2..bd2a0f0 100644 --- a/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java +++ b/src/main/java/games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.java @@ -13,6 +13,7 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { private RecipeLifecycleGroup recipeLifecycles; private AdvancedAnvilSettings advancedAnvilSettings; private AdvancedAnvilListener advancedAnvil; + private RecipeLifecycle reinforcementRecipe; 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(); @@ -98,6 +99,10 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { advancedAnvil = new AdvancedAnvilListener(this, advancedAnvilSettings::policy, anvilRisk, anvilWear, anvilProtocol.get(), anvilVersion.get()); getServer().getPluginManager().registerEvents(advancedAnvil, this); + var reinforcementRecipes = new ReinforcementRecipeRegistrar(new NamespacedKey(this, "reinforce_anvil")); + advancedAnvil.enableReinforcement(reinforcementRecipes); + reinforcementRecipe = new RecipeLifecycle(getServer(), this, reinforcementRecipes, getLogger()); + reinforcementRecipe.enable(); } catch (IOException | RuntimeException | LinkageError failure) { closeAdvancedAnvil(); getLogger().log(java.util.logging.Level.SEVERE, @@ -108,22 +113,47 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin { @Override public void onDisable() { - closeAdvancedAnvil(); + RuntimeException failure = null; + try { closeAdvancedAnvil(); } + catch (RuntimeException unavailable) { failure = unavailable; } if (recipeLifecycles != null) { - recipeLifecycles.disableAll(); - recipeLifecycles = null; + try { + recipeLifecycles.disableAll(); + recipeLifecycles = null; + } catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); } } + if (failure != null) throw failure; } private void closeAdvancedAnvil() { + RuntimeException failure = null; + // A failed owner remains reachable for explicit cleanup retry, but must not + // prevent independent listener/protocol/settings owners from closing now. + if (reinforcementRecipe != null) { + try { + reinforcementRecipe.disable(); + reinforcementRecipe = null; + } catch (RuntimeException unavailable) { failure = unavailable; } + } if (advancedAnvil != null) { - advancedAnvil.close(); - advancedAnvil = null; + try { + advancedAnvil.close(); + advancedAnvil = null; + } catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); } } if (advancedAnvilSettings != null) { - advancedAnvilSettings.close(); - advancedAnvilSettings = null; + try { + advancedAnvilSettings.close(); + advancedAnvilSettings = null; + } catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); } } + if (failure != null) throw failure; + } + + private static RuntimeException cleanupFailure(RuntimeException prior, RuntimeException next) { + if (prior == null) return next; + if (prior != next) prior.addSuppressed(next); + return prior; } SortingStickItemFactory sortingSticks() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index fcbdc71..21db113 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -4,3 +4,9 @@ advanced-anvil-base-failure-percent: 20 advanced-anvil-risk-step-percent: 5 advanced-anvil-max-failure-percent: 99 +# Complete reinforcement table must be nondecreasing. Five successful levels maximum. +advanced-anvil-reinforcement-failure-1-percent: 20 +advanced-anvil-reinforcement-failure-2-percent: 40 +advanced-anvil-reinforcement-failure-3-percent: 60 +advanced-anvil-reinforcement-failure-4-percent: 80 +advanced-anvil-reinforcement-failure-5-percent: 99 diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java index ad113eb..fba9735 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAdvancedAnvilRiskTest.java @@ -51,6 +51,24 @@ class NativeAdvancedAnvilRiskTest { return; } for (int roll = 0; roll < 100; roll++) { + // US-011 explicitly destroys the used anvil on failure. Each independent + // trial needs a new physical anvil through registered placement, not an + // assertion that later trials can reuse AIR or inherit its old ownership. + if (effects.block.getType() == org.bukkit.Material.AIR) { + var replaced = org.bukkit.craftbukkit.block.CraftBlockStates.getBlockState( + f.world, f.position, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), null); + var anvil = new ItemStack(org.bukkit.Material.ANVIL); + f.realInventory.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(anvil)); + effects.data.set(org.bukkit.craftbukkit.block.data.CraftBlockData.createData( + net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState())); + var placement = new org.bukkit.event.block.BlockPlaceEvent(effects.block, replaced, + effects.block, anvil, f.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + f.events.callEvent(placement); + assertFalse(placement.isCancelled()); + assertTrue(placement.canBuild()); + f.realInventory.getItem(0).shrink(1); // Qualified world/hand execution port. + f.tick(); + } selectedRoll.set(roll); f.player.setItemOnCursor(null); ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(4 + excess, 3); @@ -72,10 +90,12 @@ class NativeAdvancedAnvilRiskTest { assertEquals(2, f.menu.getSlot(1).getItem().getCount(), context); assertEquals(0, f.nativePlayer.experienceLevel, context); if (roll < failure) { + assertEquals(org.bukkit.Material.AIR, effects.block.getType(), context); assertTrue(f.player.getItemOnCursor().isEmpty(), context); assertEquals(0, NativeAdvancedAnvilBoundaryTest.count(f, expected), context); assertEquals(explosionsBefore + 1, effects.explosions.size(), context); } else { + assertEquals(org.bukkit.Material.ANVIL, effects.block.getType(), context); assertEquals(expected, f.player.getItemOnCursor(), context); assertEquals(1, NativeAdvancedAnvilBoundaryTest.count(f, expected), context); assertEquals(explosionsBefore, effects.explosions.size(), context); diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java index 61b29bc..2069ac6 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFixture.java @@ -93,6 +93,7 @@ final class NativeAnvilFixture implements AutoCloseable { final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class); final List protocolListeners = new ArrayList<>(); private final Map displayedStates = new HashMap<>(); + private final List additionalActors = new ArrayList<>(); final SpigotInventoryHelperPlugin plugin; final AnvilMenu menu; private final Server previous; @@ -244,6 +245,22 @@ final class NativeAnvilFixture implements AutoCloseable { 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(block.getWorld()).thenReturn(world); + when(block.getX()).thenReturn(position.getX()); + when(block.getY()).thenReturn(position.getY()); + when(block.getZ()).thenReturn(position.getZ()); + when(block.getLocation()).thenAnswer(ignored -> new org.bukkit.Location(world, + position.getX(), position.getY(), position.getZ())); + when(block.getBlockData()).thenAnswer(ignored -> org.bukkit.craftbukkit.block.data.CraftBlockData.createData( + Blocks.ANVIL.defaultBlockState())); + // A typed loaded-chunk/native-PDC port. No permissive global mock or forced loading. + var chunk = mock(org.bukkit.Chunk.class); + var chunkData = new org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer( + new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()); + when(chunk.isLoaded()).thenReturn(true); + when(chunk.getPersistentDataContainer()).thenReturn(chunkData); + when(block.getChunk()).thenReturn(chunk); + when(world.isChunkLoaded(position.getX() >> 4, position.getZ() >> 4)).thenReturn(true); when(world.getBlockAt(any(org.bukkit.Location.class))).thenReturn(block); when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); when(world.getHandle()).thenReturn(level); @@ -296,6 +313,139 @@ final class NativeAnvilFixture implements AutoCloseable { menu.setSynchronizer(synchronizer); } + /** A second native user, not a second fixture/plugin or a swapped main-player pointer. + * Reuses the existing exact world/chunk/PDC, scheduler and registered protocol listeners. + * Only per-user server-player/network boundaries are doubled, as for the primary actor. + */ + SharedAnvilActor secondActor(NativeAnvilPacketFixture primary) throws Exception { + if (primary.anvil != this) throw new IllegalArgumentException("Different owning fixture"); + var actor = new SharedAnvilActor(primary); + additionalActors.add(actor); + return actor; + } + + final class SharedAnvilActor { + final ServerPlayer nativePlayer = mock(ServerPlayer.class); + final CraftPlayer player = mock(CraftPlayer.class); + final net.minecraft.world.entity.player.Inventory inventory = + new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment()); + final net.minecraft.network.Connection connection = mock(net.minecraft.network.Connection.class); + final net.minecraft.server.network.ServerGamePacketListenerImpl handler = + mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class); + final io.netty.channel.embedded.EmbeddedChannel channel = new io.netty.channel.embedded.EmbeddedChannel(); + final AnvilMenu menu; + final List actionBars = new ArrayList<>(); + final List clicks = new ArrayList<>(); + final List received = new ArrayList<>(); + private final Map states = new HashMap<>(); + + private SharedAnvilActor(NativeAnvilPacketFixture primary) throws Exception { + when(player.getHandle()).thenReturn(nativePlayer); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn("SecondAnvilPlayer"); + when(player.isOnline()).thenReturn(true); + when(player.getWorld()).thenReturn(world); + when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL); + when(player.getInventory()).thenReturn(new CraftInventoryPlayer(inventory)); + doCallRealMethod().when(player).getItemOnCursor(); + doCallRealMethod().when(player).setItemOnCursor(any()); + doCallRealMethod().when(player).updateInventory(); + doCallRealMethod().when(player).closeInventory(); + doCallRealMethod().when(player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class)); + doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; }) + .when(player).sendActionBar(any(Component.class)); + when(nativePlayer.getBukkitEntity()).thenReturn(player); + when(nativePlayer.getInventory()).thenReturn(inventory); + when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry()); + when(nativePlayer.level()).thenReturn(level); + when(nativePlayer.isAlive()).thenReturn(true); + when(nativePlayer.isWithinBlockInteractionRange(position, 4.0)).thenReturn(true); + when(nativePlayer.getAbilities()).thenReturn(new net.minecraft.world.entity.player.Abilities()); + when(nativePlayer.hasInfiniteMaterials()).thenReturn(false); + when(nativePlayer.gameMode()).thenReturn(net.minecraft.world.level.GameType.SURVIVAL); + doCallRealMethod().when(nativePlayer).doCloseContainer(); + doCallRealMethod().when(nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class)); + doAnswer(inv -> { throw new AssertionError("Unexpected second-user drop"); }) + .when(nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean()); + field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "server", nativeServer); + field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "cserver", primary.craftServer); + field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "connection", connection); + field(net.minecraft.server.network.ServerGamePacketListenerImpl.class, handler, "player", nativePlayer); + nativePlayer.connection = handler; + connection.channel = channel; + channel.pipeline().addLast("packet_handler", connection); + when(handler.shouldHandleMessage(any())).thenReturn(true); + doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; }) + .when(handler).onPacketError(any(), any()); + doCallRealMethod().when(handler).handleContainerClick(any()); + doCallRealMethod().when(handler).send(any(net.minecraft.network.protocol.Packet.class)); + doCallRealMethod().when(handler).send(any(net.minecraft.network.protocol.Packet.class), any()); + doAnswer(inv -> { + send(inv.getArgument(0), player, states); + return null; + }).when(connection).send(any(net.minecraft.network.protocol.Packet.class), any(), anyBoolean()); + doAnswer(inv -> { + Object message = inv.getArgument(1); + try { + if (message instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(handler); + else if (message instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(handler); + else throw new AssertionError("Unsupported second-user native packet: " + message); + } catch (net.minecraft.server.RunningOnDifferentThreadException expected) { + // The existing shared PacketProcessor owns native thread dispatch. + } + return null; + }).when(connection).channelRead(any(), any()); + var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(inventory, true, nativePlayer); + field(net.minecraft.world.entity.player.Player.class, nativePlayer, "inventoryMenu", inventoryMenu); + menu = new AnvilMenu(9, inventory, 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()); + var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1") + .getDeclaredConstructor(ServerPlayer.class); + constructor.setAccessible(true); + var synchronizer = (net.minecraft.world.inventory.ContainerSynchronizer) constructor.newInstance(nativePlayer); + field(ServerPlayer.class, nativePlayer, "containerSynchronizer", synchronizer); + inventoryMenu.setSynchronizer(synchronizer); + menu.setSynchronizer(synchronizer); + events.registerEvent(org.bukkit.event.inventory.InventoryClickEvent.class, new org.bukkit.event.Listener() {}, + org.bukkit.event.EventPriority.MONITOR, (ignored, raw) -> { + var click = (org.bukkit.event.inventory.InventoryClickEvent) raw; + if (click.getWhoClicked() == player) clicks.add(click); + }, plugin, false); + } + + void inputs(ItemStack left, ItemStack right) { + menu.getSlot(0).set(CraftItemStack.asNMSCopy(left)); + menu.getSlot(1).set(CraftItemStack.asNMSCopy(right)); + assertNoEventErrors(); + } + + int clientState() { + Integer state = states.get(menu.containerId); + if (state == null) throw new AssertionError("Second user has no published frame"); + return state; + } + + void click() { + var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket( + menu.containerId, clientState(), (short) 2, (byte) 0, + net.minecraft.world.inventory.ContainerInput.PICKUP, + new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY); + var event = com.comphenix.protocol.events.PacketEvent.fromClient(protocol, + new com.comphenix.protocol.events.PacketContainer(com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, raw), player); + dispatchProtocol(event); + if (!event.isCancelled()) { + var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event); + if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event)); + channel.pipeline().fireChannelRead(event.getPacket().getHandle()); + channel.checkException(); + } + received.add(event); + assertNoEventErrors(); + } + } + void inputs(ItemStack left, ItemStack right) { // setItem triggers SimpleContainer.setChanged -> slotsChanged -> actual result event dispatch. menu.getSlot(0).set(CraftItemStack.asNMSCopy(left)); @@ -400,6 +550,11 @@ final class NativeAnvilFixture implements AutoCloseable { } com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet raw) { + return send(raw, player, displayedStates); + } + + private com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet raw, + CraftPlayer recipient, Map states) { com.comphenix.protocol.PacketType type; if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket) { type = com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS; @@ -407,14 +562,14 @@ final class NativeAnvilFixture implements AutoCloseable { 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); + new com.comphenix.protocol.events.PacketContainer(type, raw), recipient); 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()); + states.put(content.containerId(), content.stateId()); } else if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot) { - displayedStates.put(slot.getContainerId(), slot.getStateId()); + states.put(slot.getContainerId(), slot.getStateId()); } else throw new AssertionError("Unsupported wire frame"); } return event; @@ -436,6 +591,7 @@ final class NativeAnvilFixture implements AutoCloseable { Runnable task; while ((task = libraryTasks.poll()) != null) task.run(); if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks(); + additionalActors.forEach(actor -> actor.channel.runPendingTasks()); } void tick() { @@ -474,6 +630,7 @@ final class NativeAnvilFixture implements AutoCloseable { try { plugin.setEnabled(false); HandlerList.unregisterAll(plugin); + additionalActors.forEach(actor -> actor.channel.finishAndReleaseAll()); if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.finishAndReleaseAll(); else ((NativeAnvilTestChannel) channel).finish(); field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", previousLibraryScheduler); diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java index 4760b5c..7a1c6b4 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeAnvilFrameGenerationTest.java @@ -287,7 +287,7 @@ class NativeAnvilFrameGenerationTest { && slot.getContainerId() == packet.anvil.menu.containerId).count(); } - private static T roundTrip(net.minecraft.network.codec.StreamCodec codec, + 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. diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java index 2269a2a..f0656bb 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeCobwebPluginWiringTest.java @@ -316,6 +316,7 @@ class NativeCobwebPluginWiringTest { private static Set expectedKnown() { Set expected = new LinkedHashSet<>(EXISTING); expected.add(COBWEB); + expected.add(NativeReinforcementCraftTest.KEY); expected.add(VANILLA); return expected; } diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java index 08e0374..56bf11e 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowBookTest.java @@ -38,6 +38,188 @@ class NativePowderSnowBookTest { && result.stack().create().is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET); } + @Test + void reinforcementBookDisplayHasExactSearchIdentityNativeIngredientsAndOneWireClonedMiscEntry() throws Exception { + try (var f = new NativePowderSnowFixture(directory, 2)) { + var key = NativeReinforcementCraftTest.KEY; + var holder = f.holders.get(key); + assertEquals(RecipeBookCategories.CRAFTING_MISC, holder.value().recipeBookCategory()); + var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, holder.value().display().getFirst()); + var context = new net.minecraft.util.context.ContextMap.Builder() + .withParameter(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES, + org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()) + .create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT); + var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList(); + assertEquals(List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL), ingredients.getFirst() + .stream().map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList()); + assertEquals(List.of(Material.NETHERITE_BLOCK), ingredients.getLast().stream() + .map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList()); + var result = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, display.result()).stack().create(); + var shown = CraftItemStack.asBukkitCopy(result); + assertEquals(f.registered.get(key).getResult(), shown, "Display is the exact owning registration, not any anvil count"); + assertEquals(1, shown.getAmount()); + assertFalse(shown.getItemMeta().getPersistentDataContainer().has( + new NamespacedKey("spigotinventoryhelper", "reinforcement"))); + // These native hover-name components are the actual client search/display identity. + // A plain 'Anvil' plus tooltip-only lore cannot identify the mechanic in book search. + String searchName = result.getHoverName().getString().toLowerCase(java.util.Locale.ROOT); + assertTrue(searchName.contains("reinforc"), "Reinforcement-specific searchable output name: " + searchName); + var displays = new java.util.ArrayList(); + f.manager.listDisplaysForRecipe(holder.id(), displays::add); + assertEquals(1, displays.size()); + var target = displays.getFirst(); + var entries = new java.util.ArrayList(); + for (var packet : f.packets.packets) { + if (!(packet instanceof ClientboundRecipeBookAddPacket book)) continue; + var wire = NativeAnvilFrameGenerationTest.roundTrip(ClientboundRecipeBookAddPacket.STREAM_CODEC, book); + for (var entry : wire.entries()) { + if (entry.contents().display().result() instanceof SlotDisplay.ItemStackSlotDisplay item + && shown.equals(CraftItemStack.asBukkitCopy(item.stack().create()))) entries.add(entry); + } + } + assertFalse(entries.isEmpty()); + assertEquals(1, entries.stream().filter(ClientboundRecipeBookAddPacket.Entry::notification).count()); + assertEquals(target.id(), entries.getLast().contents().id(), "Current display ID identifies this exact recipe"); + assertTrue(entries.getLast().highlight()); + for (var entry : entries) { + assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category()); + assertEquals(target.display(), entry.contents().display()); + var wireResult = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, + entry.contents().display().result()).stack().create(); + assertEquals(shown, CraftItemStack.asBukkitCopy(wireResult)); + assertEquals(searchName, wireResult.getHoverName().getString().toLowerCase(java.util.Locale.ROOT)); + } + assertEquals(1, f.discoveries.stream().filter(event -> event.getRecipe().equals(key)).count()); + assertTrue(f.menu.craftSlots.isEmpty()); + assertTrue(f.menu.getCarried().isEmpty()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void actualInGameTeachingExplainsBothEffectiveRiskTablesAndAllSpentDestructiveExplosion(boolean configured) + throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + if (configured) { + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true); + for (var setting : List.of("advanced-anvil-base-failure-percent", "advanced-anvil-risk-step-percent", + "advanced-anvil-max-failure-percent")) { + int value = setting.contains("base") ? 30 : setting.contains("step") ? 10 : 90; + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.player, setting, value); + f.actor.acknowledgeSave(); + } + for (int tier = 1; tier <= 5; tier++) { + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.player, "advanced-anvil-reinforcement-failure-" + tier + "-percent", tier * 20 - 10); + f.actor.acknowledgeSave(); + } + } + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.ANVIL))); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK))); + var lessons = new java.util.ArrayList(); + var bookLore = f.registered.get(NativeReinforcementCraftTest.KEY).getResult().getItemMeta().lore(); + if (bookLore != null) bookLore.forEach(line -> lessons.add(plain(line))); + var previewLore = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()).getItemMeta().lore(); + if (previewLore != null) previewLore.forEach(line -> lessons.add(plain(line))); + // Exercise actual private preparation too: accessible in-game teaching may live in + // the tooltip, crafting feedback or private anvil feedback, not just one static format. + f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView())); + f.actor.nativePlayer.containerMenu = f.actor.menu; + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(7), NativeAnvilCombiningRegressionTest.sword(7)); + f.actor.actionBars.forEach(line -> lessons.add(plain(line))); + f.actor.chats.forEach(line -> lessons.add(plain(line))); + // Do NOT include admin output or README: percentages must be explained to a player. + String teaching = String.join("\n", lessons).toLowerCase(java.util.Locale.ROOT); + String reinforcement = configured ? "10%, 30%, 50%, 70%, 90%" : "20%, 40%, 60%, 80%, 99%"; + String combine = configured ? "30%, 40%, 60%, 90%, 90%, 90%, 90%" : "20%, 25%, 35%, 50%, 70%, 95%, 99%"; + assertTrue(hasOrderedTable(lessons, "reinforc", reinforcement), "Missing explained level 1–5 effective failure table:\n" + teaching); + assertTrue(hasOrderedTable(lessons, "excess", combine), "Missing explained excess 1–7 effective combine table:\n" + teaching); + assertTrue(teaching.contains("one excess tier"), teaching); + assertTrue(teaching.contains("0%"), "Explain the zero-excess/zero-risk floor: " + teaching); + assertTrue(teaching.contains("all") && (teaching.contains("spent") || teaching.contains("spends")), teaching); + assertTrue(teaching.contains("explosion"), teaching); + assertTrue(teaching.contains("destroy") && teaching.contains("input") && teaching.contains("anvil"), teaching); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertEquals(0, rolls.get(), "Teaching/registration/discovery/preview is never an attempt"); + f.actor.assertNoEventErrors(); + } + } + + private static boolean hasOrderedTable(List lessons, String subject, String values) { + // Semantic subject plus ordered percentages; tolerate punctuation/whitespace and + // either one compact line or adjacent multiline rows, but not unrelated aggregate counts. + for (int start = 0; start < lessons.size(); start++) { + String text = String.join(" ", lessons.subList(start, Math.min(start + 8, lessons.size()))) + .toLowerCase(java.util.Locale.ROOT); + if (!lessons.get(start).toLowerCase(java.util.Locale.ROOT).contains(subject)) continue; + int position = 0; + boolean found = true; + for (String value : values.split(", ")) { + int next = text.indexOf(value, position); + if (next < 0) { found = false; break; } + position = next + value.length(); + } + if (found) return true; + } + return false; + } + + private static String plain(net.kyori.adventure.text.Component component) { + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(component); + } + + @Test + void reinforcementSpecificPerPlayerDiscoveryFailureAndCancellationRetryWithoutLosingOtherNativeRecords() + throws Exception { + try (var f = new NativePowderSnowFixture(directory, 2)) { + var key = NativeReinforcementCraftTest.KEY; + var nativeKey = NativePowderSnowFixture.nativeKey(key); + var cancelled = f.joiningPlayer(null); + var failed = f.joiningPlayer(null); + var healthy = f.joiningPlayer(null); + var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA); + for (var actor : List.of(cancelled, failed, healthy)) actor.book().add(vanillaKey); + var cancel = new AtomicBoolean(true); + f.actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.HIGHEST, + (ignored, raw) -> { + var event = (PlayerRecipeDiscoverEvent) raw; + if (event.getPlayer() == cancelled.player() && event.getRecipe().equals(key)) event.setCancelled(cancel.get()); + }, f.actor.plugin, false); + var injected = new IllegalStateException("injected reinforcement-only discovery failure"); + doThrow(injected).when(failed.player()).discoverRecipes(List.of(key)); + f.actor.plugin.setEnabled(false); + doReturn(List.of(cancelled.player(), failed.player(), healthy.player())).when(f.actor.server).getOnlinePlayers(); + f.actor.plugin.setEnabled(true); // Actual ONLINE registration failure must not block healthy players. + assertFalse(cancelled.book().contains(nativeKey)); + assertFalse(failed.book().contains(nativeKey)); + assertTrue(healthy.book().contains(nativeKey)); + for (var actor : List.of(cancelled, failed, healthy)) { + assertTrue(actor.book().contains(vanillaKey)); + assertTrue(actor.book().contains(NativePowderSnowFixture.nativeKey(KEY)), "Unrelated utility discovery stays independent"); + } + var failedKnown = failed.book().pack().known(); + var cancelledKnown = cancelled.book().pack().known(); + cancel.set(false); + doCallRealMethod().when(failed.player()).discoverRecipes(any()); + // Healthy existing native book already knows all registrations; retry only missing reinforcement. + f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry")); + f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry")); + assertTrue(failed.book().pack().known().containsAll(failedKnown)); + assertTrue(cancelled.book().pack().known().containsAll(cancelledKnown)); + assertTrue(failed.book().contains(nativeKey)); + assertTrue(cancelled.book().contains(nativeKey)); + int sent = failed.sent().size(); + f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "known")); + assertEquals(sent, failed.sent().size()); + assertEquals(1, f.discoveries.stream().filter(event -> event.getRecipe().equals(key) + && event.getPlayer() == failed.player()).count()); + assertEquals(List.of(injected), f.actor.eventErrors, "Qualify only the exact intentionally injected per-player fault"); + } + } + private static void assertPackedEquals(ServerRecipeBook.Packed expected, ServerRecipeBook.Packed actual) { assertEquals(ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, expected).getOrThrow(), ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, actual).getOrThrow()); @@ -70,25 +252,20 @@ class NativePowderSnowBookTest { var powderPackets = f.packets.packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance) .map(ClientboundRecipeBookAddPacket.class::cast) .filter(packet -> packet.entries().stream().anyMatch(NativePowderSnowBookTest::powderEntry)).toList(); - assertEquals(1, powderPackets.size()); - var packet = powderPackets.getFirst(); + assertEquals(1, powderPackets.stream().flatMap(p -> p.entries().stream()) + .filter(NativePowderSnowBookTest::powderEntry).filter(ClientboundRecipeBookAddPacket.Entry::notification).count(), + "Exactly one new-discovery notification; recipe reloads also send native resets"); + var packet = powderPackets.getLast(); var entry = packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).findFirst().orElseThrow(); assertEquals(id, entry.contents().id()); assertEquals(1, packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).count()); assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category()); - assertTrue(entry.notification()); assertTrue(entry.highlight()); + assertTrue(powderPackets.getFirst().entries().stream().filter(NativePowderSnowBookTest::powderEntry) + .findFirst().orElseThrow().notification()); assertEquals(2, entry.contents().craftingRequirements().orElseThrow().size()); - var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), - org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()); - try { - ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, packet); - var decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer); - assertEquals(packet, decoded); - assertFalse(buffer.isReadable()); - } finally { - buffer.release(); - } + assertEquals(packet, NativeAnvilFrameGenerationTest.roundTrip( + ClientboundRecipeBookAddPacket.STREAM_CODEC, packet)); var powderEvents = f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).toList(); assertEquals(1, powderEvents.size()); assertTrue(powderEvents.getFirst().shouldShowNotification()); @@ -103,6 +280,11 @@ class NativePowderSnowBookTest { void nativeKnownHighlightCodecJoinReconnectDisableAndActualReenableRetainUnrelatedState() throws Exception { try (var f = new NativePowderSnowFixture(directory, 3)) { var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA); + var reinforcement = NativeReinforcementCraftTest.KEY; + var reinforcedKey = NativePowderSnowFixture.nativeKey(reinforcement); + assertTrue(f.book.contains(reinforcedKey)); + assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement) + && e.getPlayer() == f.actor.player).count()); f.book.add(vanillaKey); f.book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true); var joined = f.joiningPlayer(null); @@ -110,11 +292,15 @@ class NativePowderSnowBookTest { f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "join")); assertTrue(joined.book().contains(NativePowderSnowFixture.nativeKey(KEY))); assertTrue(joined.book().contains(vanillaKey)); - assertEquals(5, addedEntries(joined.sent())); + assertTrue(joined.book().contains(reinforcedKey)); + assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement) + && e.getPlayer() == joined.player()).count()); + assertEquals(6, addedEntries(joined.sent())); f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "idempotent")); - assertEquals(5, addedEntries(joined.sent())); + assertEquals(6, addedEntries(joined.sent())); var packed = f.book.pack(); assertTrue(packed.highlight().contains(NativePowderSnowFixture.nativeKey(KEY))); + assertTrue(packed.highlight().contains(reinforcedKey)); var nbt = ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, packed).getOrThrow(); var decoded = ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, nbt).getOrThrow(); var reconnected = f.joiningPlayer(decoded); @@ -123,6 +309,8 @@ class NativePowderSnowBookTest { assertTrue(reconnected.book().isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING)); f.actor.events.callEvent(new PlayerJoinEvent(reconnected.player(), "reconnected")); assertTrue(reconnected.sent().isEmpty(), "No second discovery/notification for known recipe"); + assertTrue(reconnected.book().contains(reinforcedKey)); + assertTrue(reconnected.book().contains(vanillaKey)); f.actor.plugin.setEnabled(false); assertTrue(f.registered.isEmpty()); assertPackedEquals(packed, f.book.pack()); @@ -132,12 +320,19 @@ class NativePowderSnowBookTest { assertTrue(disabledJoiner.book().pack().known().isEmpty()); doReturn(List.of(f.actor.player, reconnected.player())).when(f.actor.server).getOnlinePlayers(); f.actor.plugin.setEnabled(true); - assertEquals(5, f.registered.size()); - assertTrue(reconnected.sent().isEmpty()); + assertEquals(6, f.registered.size()); + assertTrue(reconnected.sent().stream().filter(ClientboundRecipeBookAddPacket.class::isInstance) + .map(ClientboundRecipeBookAddPacket.class::cast).allMatch(ClientboundRecipeBookAddPacket::replace), + "Known recipes are resynchronized, not rediscovered, during native recipe reload"); assertPackedEquals(packed, f.book.pack()); assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY) && e.getPlayer() == f.actor.player).count()); + assertTrue(f.registered.containsKey(reinforcement)); + assertTrue(reconnected.book().contains(reinforcedKey)); + assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement) + && e.getPlayer() == f.actor.player).count()); f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "enabled")); assertTrue(disabledJoiner.book().contains(NativePowderSnowFixture.nativeKey(KEY))); + assertTrue(disabledJoiner.book().contains(reinforcedKey)); f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK))); f.actor.realInventory.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.BUCKET))); f.autofill(false); @@ -167,19 +362,19 @@ class NativePowderSnowBookTest { assertFalse(cancelled.book().contains(key)); assertFalse(failed.book().contains(key)); assertTrue(healthy.book().contains(key)); - assertEquals(4, cancelled.book().pack().known().size()); - assertEquals(4, failed.book().pack().known().size()); - assertEquals(5, healthy.book().pack().known().size()); - assertEquals(4, addedEntries(cancelled.sent())); + assertEquals(5, cancelled.book().pack().known().size()); + assertEquals(5, failed.book().pack().known().size()); + assertEquals(6, healthy.book().pack().known().size()); + assertEquals(5, addedEntries(cancelled.sent())); cancel.set(false); doCallRealMethod().when(failed.player()).discoverRecipes(any()); f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry")); f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry")); assertTrue(cancelled.book().contains(key)); assertTrue(failed.book().contains(key)); - assertEquals(5, addedEntries(cancelled.sent())); + assertEquals(6, addedEntries(cancelled.sent())); f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "known")); - assertEquals(5, addedEntries(cancelled.sent())); + assertEquals(6, addedEntries(cancelled.sent())); assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)); assertEquals(List.of(injected), f.actor.eventErrors, "Only the deliberately injected discovery failure is logged"); } diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java index 167dd1a..4518007 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowFixture.java @@ -47,9 +47,20 @@ final class NativePowderSnowFixture implements AutoCloseable { final Map> holders = new HashMap<>(); final List discoveries = new ArrayList<>(); final AbstractCraftingMenu menu; + NamespacedKey removalFailureKey; + RuntimeException removalFailure; NativePowderSnowFixture(Path directory, int width) throws Exception { - actor = new NativeAnvilFixture(directory); + this(directory, width, () -> 99); + } + + NativePowderSnowFixture(Path directory, int width, java.util.function.IntSupplier risk) throws Exception { + this(directory, width, risk, () -> 1.0); + } + + NativePowderSnowFixture(Path directory, int width, java.util.function.IntSupplier risk, + java.util.function.DoubleSupplier wear) throws Exception { + actor = new NativeAnvilFixture(directory, risk, wear); packets = new NativeAnvilPacketFixture(actor); actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR, (ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false); @@ -80,6 +91,7 @@ final class NativePowderSnowFixture implements AutoCloseable { }); when(actor.server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> { NamespacedKey key = inv.getArgument(0); + if (key.equals(removalFailureKey) && removalFailure != null) throw removalFailure; holders.remove(key); refreshManager(); return registered.remove(key) != null; @@ -87,8 +99,8 @@ final class NativePowderSnowFixture implements AutoCloseable { doReturn(List.of(actor.player)).when(actor.server).getOnlinePlayers(); actor.plugin.setEnabled(true); assertTrue(book.contains(nativeKey(NativePowderSnowRecipeTest.KEY))); - assertEquals(5, registered.size()); - assertEquals(5, discoveries.size()); + assertEquals(6, registered.size()); + assertEquals(6, discoveries.size()); assertEquals(0, count(actor.realInventory, org.bukkit.Material.POWDER_SNOW_BUCKET)); if (width == 2) { menu = packets.inventoryMenu; @@ -160,6 +172,17 @@ final class NativePowderSnowFixture implements AutoCloseable { private void refreshManager() { manager.recipes = RecipeMap.create(new ArrayList>(holders.values())); manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET); + // Pinned CraftServer.add/removeRecipe calls PlayerList.reloadRecipes after finalizing. + // Run that real native book synchronization at the actor/network boundary too: later + // registrations can renumber displays without changing discovered resource keys. + for (var online : actor.server.getOnlinePlayers()) { + if (!(online instanceof org.bukkit.craftbukkit.entity.CraftPlayer craft)) + throw new AssertionError("Expected typed native recipe-book player port"); + var nativePlayer = craft.getHandle(); + nativePlayer.connection.send(new net.minecraft.network.protocol.game.ClientboundUpdateRecipesPacket( + manager.getSynchronizedItemProperties(), manager.getSynchronizedStonecutterRecipes())); + nativePlayer.getRecipeBook().sendInitialRecipeBook(nativePlayer); + } } private static CraftingRecipe convert(Recipe recipe) { diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java index b7ee127..e0f680b 100644 --- a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativePowderSnowPluginWiringTest.java @@ -3,6 +3,7 @@ package games.dmg.spigotinventoryhelper; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -12,6 +13,8 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.papermc.paper.plugin.manager.PaperPluginManagerImpl; @@ -35,6 +38,7 @@ import org.bukkit.NamespacedKey; import org.bukkit.Server; import org.bukkit.command.SimpleCommandMap; import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.craftbukkit.util.CraftMagicNumbers; import org.bukkit.entity.Player; import org.bukkit.event.HandlerList; @@ -156,12 +160,17 @@ class NativePowderSnowPluginWiringTest { assertTrue(registry.keySet().containsAll(EXISTING)); assertTrue(registry.containsKey(POWDER)); assertEquals(expectedKnown(), onlineKnown); + assertInstanceOf(org.bukkit.inventory.ShapelessRecipe.class, registry.get(NativeReinforcementCraftTest.KEY)); + assertTrue(onlineKnown.contains(NativeReinforcementCraftTest.KEY)); + verify(online, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY)); plugin.setEnabled(true); events.callEvent(new PlayerJoinEvent(joining, "joined")); events.callEvent(new PlayerJoinEvent(joining, "joined again")); assertEquals(expectedKnown(), joiningKnown); assertEquals(1, onlineGrants.get()); assertEquals(1, joiningGrants.get()); + assertTrue(joiningKnown.contains(NativeReinforcementCraftTest.KEY)); + verify(joining, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY)); plugin.setEnabled(false); assertEquals(Set.of(VANILLA), registry.keySet()); @@ -177,6 +186,10 @@ class NativePowderSnowPluginWiringTest { assertTrue(registry.keySet().containsAll(EXISTING)); assertTrue(registry.containsKey(POWDER)); assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe"); + assertTrue(registry.containsKey(NativeReinforcementCraftTest.KEY)); + assertTrue(onlineKnown.contains(NativeReinforcementCraftTest.KEY)); + assertTrue(onlineKnown.contains(VANILLA)); + verify(online, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY)); assertNoSevereLogs(); } finally { plugin.setEnabled(false); @@ -454,6 +467,177 @@ class NativePowderSnowPluginWiringTest { } } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"craft", "combine"}) + void actualDisableClosesAdvancedOwnersDespiteThrowingOwnedRecipeRemovalAndCanExplicitlyRetry(String pendingKind) + throws Exception { + var rolls = new AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var foreignKey = new NamespacedKey("acceptance", "foreign_recipe"); + var foreignRecipe = new org.bukkit.inventory.ShapelessRecipe(foreignKey, + new org.bukkit.inventory.ItemStack(org.bukkit.Material.EMERALD)); + foreignRecipe.addIngredient(org.bukkit.Material.DIRT); + assertTrue(f.actor.server.addRecipe(foreignRecipe)); + Listener foreignListener = new Listener() {}; + var foreignJoins = new AtomicInteger(); + f.actor.events.registerEvent(PlayerJoinEvent.class, foreignListener, org.bukkit.event.EventPriority.MONITOR, + (ignored, event) -> foreignJoins.incrementAndGet(), f.actor.plugin, false); + var foreignProtocol = new com.comphenix.protocol.events.PacketAdapter(f.actor.plugin, + com.comphenix.protocol.events.ListenerPriority.NORMAL, + com.comphenix.protocol.PacketType.Play.Client.CHAT) {}; + f.actor.protocol.addPacketListener(foreignProtocol); + f.actor.tick(); + var settingsField = SpigotInventoryHelperPlugin.class.getDeclaredField("advancedAnvilSettings"); + assertEquals(AdvancedAnvilSettings.class, settingsField.getType(), "Inspected typed settings owner"); + settingsField.setAccessible(true); + var settings = org.junit.jupiter.api.Assertions.assertInstanceOf(AdvancedAnvilSettings.class, + settingsField.get(f.actor.plugin)); + var initialPolicy = settings.policy(); + String base = "advanced-anvil-base-failure-percent"; + f.actor.admin(f.actor.console, base, 37); + Runnable acknowledgment = f.actor.nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS); + assertNotNull(acknowledgment, "Real save must enqueue its main-thread acknowledgment"); + assertTrue(java.nio.file.Files.readString(f.actor.plugin.getDataFolder().toPath().resolve("config.yml")) + .contains(base + ": 37"), "Retain real durable-save proof before disable"); + assertEquals(initialPolicy, settings.policy()); + assertTrue(f.actor.adminMessages.isEmpty()); + if (pendingKind.equals("craft")) { + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(org.bukkit.Material.CHIPPED_ANVIL, 3))); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(org.bukkit.Material.NETHERITE_BLOCK, 3))); + } else { + f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView())); + f.actor.nativePlayer.containerMenu = f.actor.menu; + f.actor.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3), + NativeAdvancedAnvilTransactionTest.stackedSword(5, 3)); + } + var unrelated = new org.bukkit.inventory.ItemStack(org.bukkit.Material.EMERALD, 7); + unrelated.editMeta(meta -> meta.displayName(net.kyori.adventure.text.Component.text("Unrelated inventory"))); + f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(unrelated)); + var possessionsBeforeClose = shutdownPossessions(f); + f.packets.click(pendingKind.equals("craft") ? 0 : 2, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + assertFalse(f.packets.received.getLast().isCancelled()); + assertFalse(f.packets.clicks.getLast().isCancelled(), "Actual native intent admitted before disable"); + if (pendingKind.equals("craft")) + org.junit.jupiter.api.Assertions.assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertFalse(f.actor.nextTick.isEmpty(), "Native admitted intent has queued payment"); + assertEquals(0, rolls.get()); + var ownedRecipe = f.registered.get(NativeReinforcementCraftTest.KEY); + assertNotNull(ownedRecipe); + var injected = new IllegalStateException("injected owned reinforcement removal failure"); + f.removalFailureKey = NativeReinforcementCraftTest.KEY; + f.removalFailure = injected; + long removalsBefore = org.mockito.Mockito.mockingDetails(f.actor.server).getInvocations().stream() + .filter(inv -> inv.getMethod().getName().equals("removeRecipe") + && NativeReinforcementCraftTest.KEY.equals(inv.getArgument(0))).count(); + try { + // Actual production callback, not individual owners' close methods. Calling the + // callback directly also prevents a false GREEN from isEnabled=false alone; + // listener/protocol/settings closure must suppress late work in its own right. + try { + f.actor.plugin.onDisable(); + } catch (IllegalStateException failure) { + assertSame(injected, failure); // Propagating or reporting this port error is not the tested contract. + } + assertEquals(removalsBefore + 1, org.mockito.Mockito.mockingDetails(f.actor.server).getInvocations().stream() + .filter(inv -> inv.getMethod().getName().equals("removeRecipe") + && NativeReinforcementCraftTest.KEY.equals(inv.getArgument(0))).count(), + "Actual callback reaches the throwing owned-removal port"); + // US-010 closes the exact owned native window: removed() returns unconsumed + // inputs to real inventory. Unchanged source-slot positions are not a no-debit oracle. + assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu); + assertTrue(f.menu.craftSlots.getItems().stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty), + "Actual native close clears the old crafting matrix"); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(possessionsBeforeClose, shutdownPossessions(f), + "Native close conserves every input quantity and its metadata"); + // A production callback may log the deliberate fault instead of propagating it. + // Qualify ONLY that exact injected object; all other listener/setup errors still fail. + f.actor.eventErrors.removeIf(failure -> failure == injected); + var remaining = HandlerList.getRegisteredListeners(f.actor.plugin).stream() + .map(org.bukkit.plugin.RegisteredListener::getListener).toList(); + f.actor.tick(); // Run actual queued payment only via the existing scheduler port. + f.actor.tick(); + acknowledgment.run(); + org.junit.jupiter.api.Assertions.assertAll("Independent shutdown remains fail-closed after recipe removal failure", + () -> assertFalse(remaining.stream().anyMatch(listener -> listener instanceof AdvancedAnvilListener + || listener instanceof ReinforcementCrafting || listener instanceof ReinforcementState), + "RED: throwing recipe cleanup must not strand advanced native listeners"), + () -> assertEquals(List.of(foreignProtocol), f.actor.protocolListeners, + "Owned protocol listener is closed; unrelated registration survives"), + () -> assertTrue(remaining.contains(foreignListener)), + () -> assertSame(foreignRecipe, f.registered.get(foreignKey)), + () -> assertSame(ownedRecipe, f.registered.get(NativeReinforcementCraftTest.KEY), + "Unremoved recipe ownership is retained honestly for explicit retry"), + () -> assertEquals(initialPolicy, settings.policy(), "No late saved-policy publication"), + () -> assertTrue(f.actor.adminMessages.isEmpty(), "No late saved-success feedback"), + () -> assertEquals(0, rolls.get(), "No late admitted payment/roll after actual onDisable"), + () -> assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu), + () -> assertTrue(f.menu.craftSlots.getItems().stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty)), + () -> assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()), + () -> assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()), + () -> assertEquals(possessionsBeforeClose, shutdownPossessions(f), + "Late work cannot debit, duplicate or alter returned input metadata"), + () -> assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))), + () -> assertTrue(f.menu.getCarried().isEmpty()), + () -> assertTrue(f.actor.menu.getCarried().isEmpty()), + () -> assertTrue(f.packets.drops.isEmpty()), + () -> assertTrue(effects.explosions.isEmpty())); + var command = f.actor.plugin.getCommand("inventoryhelperadmin"); + assertNotNull(command); + assertEquals(List.of(), command.tabComplete(f.actor.console, "inventoryhelperadmin", new String[] {"config", ""}), + "Closed settings cannot offer live commands"); + assertTrue(command.execute(f.actor.console, "inventoryhelperadmin", new String[] {"config", base})); + assertEquals(List.of("Configuration unavailable."), f.actor.adminMessages); + f.actor.events.callEvent(new PlayerJoinEvent(f.actor.player, "unrelated listener probe")); + assertEquals(1, foreignJoins.get()); + + // RecipeLifecycle has disable(), not close(), and retains owned registrar state + // on a throwing removal. Require only an EXPLICIT same-callback cleanup retry, + // never an automatic setEnabled(false) retry or an invented retry scheduler. + f.removalFailure = null; + assertDoesNotThrow(f.actor.plugin::onDisable); + assertEquals(Set.of(foreignKey), f.registered.keySet()); + assertSame(foreignRecipe, f.registered.get(foreignKey)); + assertEquals(List.of(foreignProtocol), f.actor.protocolListeners); + assertDoesNotThrow(f.actor.plugin::onDisable, "Successful cleanup is idempotent"); + assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu); + assertEquals(possessionsBeforeClose, shutdownPossessions(f), "Retry cannot return inputs twice"); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))); + assertEquals(0, rolls.get()); + assertEquals(initialPolicy, settings.policy()); + f.actor.assertNoEventErrors(); + } finally { + f.removalFailure = null; + f.actor.plugin.onDisable(); // Recover fixture ownership without discarding the behavioral RED. + } + } + } + + private static Map shutdownPossessions(NativePowderSnowFixture f) { + var stacks = new ArrayList(); + stacks.addAll(f.actor.realInventory.getContents()); + stacks.addAll(f.menu.craftSlots.getItems()); + // Only authoritative inputs: crafting/anvil preview result slots are not possessions. + stacks.add(f.actor.menu.getSlot(0).getItem()); + stacks.add(f.actor.menu.getSlot(1).getItem()); + stacks.add(f.menu.getCarried()); + stacks.add(f.actor.menu.getCarried()); + var active = f.actor.nativePlayer.containerMenu; + if (active != f.menu && active != f.actor.menu) stacks.add(active.getCarried()); + stacks.addAll(f.packets.drops); // The fixture's actual native drop port, not Bukkit previews. + Map counts = new HashMap<>(); + for (var stack : stacks) { + if (stack.isEmpty()) continue; + var normalized = CraftItemStack.asBukkitCopy(stack); + int quantity = normalized.getAmount(); + normalized.setAmount(1); // Detached keys retain all metadata; amounts accumulate once per source. + counts.merge(normalized, quantity, Integer::sum); + } + return Map.copyOf(counts); + } + private SpigotInventoryHelperPlugin plugin() { try { createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest), @@ -497,6 +681,7 @@ class NativePowderSnowPluginWiringTest { private static Set expectedKnown() { Set expected = new LinkedHashSet<>(EXISTING); expected.add(POWDER); + expected.add(NativeReinforcementCraftTest.KEY); // Approved additional independently discoverable recipe. expected.add(VANILLA); return expected; } diff --git a/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeReinforcementCraftTest.java b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeReinforcementCraftTest.java new file mode 100644 index 0000000..9430f20 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotinventoryhelper/NativeReinforcementCraftTest.java @@ -0,0 +1,2636 @@ +package games.dmg.spigotinventoryhelper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.item.FallingBlockEntity; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.level.block.Blocks; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapelessRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.craftbukkit.block.CraftBlockStates; +import org.bukkit.craftbukkit.block.data.CraftBlockData; +import org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer; +import org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry; +import org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockDropItemEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class NativeReinforcementCraftTest { + static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "reinforce_anvil"); + @TempDir Path directory; + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void actualSingleCraftsPayOnceRetainDamagedVariantAndMetadataThroughFiveTiersAndRefuseSixth(int width) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, width, () -> { rolls.incrementAndGet(); return 99; })) { + var source = new org.bukkit.inventory.ItemStack(Material.CHIPPED_ANVIL); + var meta = source.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("My native anvil")); + meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "foreign"), + org.bukkit.persistence.PersistentDataType.STRING, "keep"); + source.setItemMeta(meta); + for (int tier = 1; tier <= 5; tier++) { + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK, 3))); + assertEquals(tier - 1, rolls.get(), "No preview/autofill roll"); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + f.actor.tick(); + source = CraftItemStack.asBukkitCopy(f.menu.getCarried()); + assertEquals(Material.CHIPPED_ANVIL, source.getType()); + assertEquals(1, source.getAmount()); + assertEquals(tier, source.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), org.bukkit.persistence.PersistentDataType.INTEGER)); + assertEquals(meta.displayName(), source.getItemMeta().displayName()); + assertEquals("keep", source.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("acceptance", "foreign"), org.bukkit.persistence.PersistentDataType.STRING)); + assertEquals(0, NativePowderSnowFixture.count(f.menu.craftSlots, Material.CHIPPED_ANVIL)); + assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + assertEquals(tier, rolls.get()); + } + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK))); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + f.actor.tick(); + assertTrue(f.menu.getCarried().isEmpty()); + assertEquals(5, rolls.get()); + assertEquals(source, CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0))); + assertEquals(1, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"acknowledged-policy", "prepared-matrix"}) + void consumedNativeCraftAdmissionCannotReviveAfterAbaBeforeQueuedCompletion(String change) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var source = markedAnvil().asQuantity(3); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK, 3))); + f.actor.tick(); // Drain setup/hook work before capturing an actual admitted native event. + var grid = f.gridSnapshot(); + var inventory = f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList(); + int eventsBefore = f.packets.clicks.size(); + f.packets.click(0, 0, ContainerInput.PICKUP); + assertEquals(eventsBefore + 1, f.packets.clicks.size()); + var admitted = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertFalse(admitted.isCancelled(), "Registered owner consumed actual native admission"); + assertFalse(f.packets.received.getLast().isCancelled()); + assertNull(f.menu.getBukkitView().getTopInventory().getItem(0), "Admitted output is withheld until completion"); + assertEquals(grid, f.gridSnapshot()); + assertTrue(f.menu.getCarried().isEmpty()); + assertEquals(0, rolls.get()); + + // This existing scheduler port controls ordering, not source finish/consume calls. + // Park ALL already queued continuations; run real durable acknowledgments while + // the admitted attempt is pending, then return the unchanged tasks to tick(). + var queued = new ArrayList(); + f.actor.nextTick.drainTo(queued); + assertFalse(queued.isEmpty(), "Actual admission must queue deferred completion"); + if (change.equals("acknowledged-policy")) { + String key = "advanced-anvil-reinforcement-failure-1-percent"; + for (int percent : new int[] {21, 20}) { + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.console, key, percent); + f.actor.acknowledgeSave(); + assertEquals(List.of(key + " = " + percent + " (saved)"), f.actor.adminMessages); + assertTrue(java.nio.file.Files.readString(f.actor.plugin.getDataFolder().toPath().resolve("config.yml")) + .contains(key + ": " + percent), "Policy change is durably saved, not supplied by a mock"); + } + } else { + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source.asQuantity(2))); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + } + assertEquals(grid, f.gridSnapshot(), "A restored matrix is not a restored admission"); + assertNull(f.menu.getBukkitView().getTopInventory().getItem(0), "Intervening real preparation withholds pending output"); + f.actor.nextTick.addAll(queued); + f.actor.tick(); + f.actor.tick(); + assertEquals(0, rolls.get(), "RED: consumed pending ownership must stay invalid after A→B→A"); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList()); + assertTrue(f.menu.getCarried().isEmpty()); + assertNoMint(f); + + // A genuinely fresh displayed frame still delivers exactly one paid outcome. + f.packets.click(0, 0, ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertFalse(f.packets.clicks.getLast().isCancelled()); + f.actor.tick(); + var expected = source.asQuantity(2); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0))); + assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + var output = CraftItemStack.asBukkitCopy(f.menu.getCarried()); + assertEquals(1, output.getAmount()); + assertEquals(Material.CHIPPED_ANVIL, output.getType()); + assertEquals(1, output.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), PersistentDataType.INTEGER)); + assertEquals(1, rolls.get()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"damage", "BROKEN"}) + void successfulAdvancedNativeWearCannotMutateRegisteredReentrantPlayerReplacement(String outcome) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + var wears = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, + () -> { rolls.incrementAndGet(); return 99; }, () -> { wears.incrementAndGet(); return 0.0; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + openAnvil(f); + f.packets.survival(); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(6, 3); + var right = left.clone(); + f.actor.inputs(left, right); + assertRisk(f, 0); // E=2, R=2; an accepted zero-risk attempt still spends ALL charge once. + var expected = f.actor.output().clone(); + assertEquals(1, expected.getAmount()); + var unrelated = new ItemStack(Material.EMERALD, 7); + f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(unrelated)); + var replacement = markedAnvil(); // Exact same physical material as the old owner. + var replacementMeta = replacement.getItemMeta(); + replacementMeta.displayName(net.kyori.adventure.text.Component.text("Player replacement")); + replacementMeta.lore(List.of(net.kyori.adventure.text.Component.text("Replacement lore"))); + replacementMeta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "foreign"), + PersistentDataType.STRING, "replacement"); + replacement.setItemMeta(replacementMeta); + var damages = new ArrayList(); + var placements = new ArrayList(); + var replacementData = new AtomicReference(); + f.actor.events.registerEvent(com.destroystokyo.paper.event.block.AnvilDamagedEvent.class, + new Listener() {}, EventPriority.HIGHEST, (ignored, raw) -> { + var event = (com.destroystokyo.paper.event.block.AnvilDamagedEvent) raw; + assertSame(f.actor.menu.getBukkitView(), event.getView()); + assertFalse(event.isCancelled()); + assertEquals(com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.DAMAGED, event.getDamageState()); + damages.add(event); + assertEquals(1, damages.size()); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(markedAnvil(), persistedItem(effects.block), "Old owner is already fully spent before native effects"); + assertEquals(3, rolls.get()); + assertEquals(1, wears.get()); + if (outcome.equals("BROKEN")) + event.setDamageState(com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.BROKEN); + // Supported registered PLAYER lifecycle, not silent imperative interoperability. + // Tentative world placement and actual one-item hand debit remain qualified ports. + var replaced = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) effects.data.get()).getState(), null); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(replacement)); + effects.data.set(CraftBlockData.createData(Blocks.CHIPPED_ANVIL.defaultBlockState())); + replacementData.set(effects.data.get().clone()); + var placement = new BlockPlaceEvent(effects.block, replaced, effects.block, + replacement, f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + f.actor.events.callEvent(placement); + assertFalse(placement.isCancelled()); + assertTrue(placement.canBuild()); + placements.add(placement); + f.actor.realInventory.getItem(0).shrink(1); + }, f.actor.plugin, false); + f.packets.click(2, 0, ContainerInput.PICKUP); + assertFalse(f.packets.clicks.getLast().isCancelled()); + f.actor.tick(); + f.actor.tick(); // Settle registered placement ownership after the damage callback. + assertEquals(1, damages.size()); + assertEquals(1, placements.size()); + assertEquals(outcome.equals("BROKEN") + ? com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.BROKEN + : com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.DAMAGED, + damages.getFirst().getDamageState()); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertTrue(f.actor.realInventory.getItem(0).isEmpty(), "Replacement itself pays exactly one native hand item"); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIAMOND_SWORD)); + assertEquals(3, rolls.get(), "Two prerequisite crafts and exactly one successful combine"); + assertEquals(1, wears.get()); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + assertEquals(replacementData.get(), effects.block.getBlockData(), + "RED: old successful wear must not damage/remove the registered same-material replacement"); + reloadChunk(effects); + assertEquals(replacement, persistedItem(effects.block), "Replacement retains its exact physical item/name/lore/foreign PDC"); + assertEquals("keep", chunkData(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(left, right); + assertRisk(f, 25); // No original charge can leak back onto the replacement. + f.actor.tick(); + assertEquals(3, rolls.get()); + assertEquals(1, wears.get()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void nativeCraftThenRegisteredPlacementReducesAdvancedRiskToZeroAndSpendsProtection(boolean playerReplacement) throws Exception { + var nextRoll = new java.util.concurrent.atomic.AtomicInteger(99); + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return nextRoll.get(); })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var data = assertInstanceOf(DirtyCraftPersistentDataContainer.class, + effects.block.getChunk().getPersistentDataContainer()); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.ANVIL))); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK))); + f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + f.actor.tick(); + var reinforced = CraftItemStack.asBukkitCopy(f.menu.getCarried()); + assertEquals(1, reinforced.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), org.bukkit.persistence.PersistentDataType.INTEGER)); + var replaced = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + Blocks.AIR.defaultBlockState(), null); + // World placement/hand payment is the native-world boundary port. Actual registered + // placement event, native PDC, crafting, ProtocolLib admission and taking stay real. + var placement = new org.bukkit.event.block.BlockPlaceEvent(effects.block, replaced, effects.block, + reinforced, f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + f.actor.events.callEvent(placement); + f.actor.tick(); + assertFalse(placement.isCancelled()); + assertTrue(data.dirty(), "Accepted placement must publish native persistent chunk state"); + f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView())); + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.nativePlayer.containerMenu = f.actor.menu; + nextRoll.set(0); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + String risk = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(f.actor.actionBars.getLast()); + assertTrue(risk.contains("Failure 0%"), risk); + assertEquals(0, f.actor.menu.getBukkitView().getRepairCost()); + if (playerReplacement) { + // Capture the exact published wire frame BEFORE the supported gameplay lifecycle. + int oldWire = f.actor.clientState(f.actor.menu); + var breaking = new BlockBreakEvent(effects.block, f.actor.player); + breaking.setDropItems(false); + f.actor.events.callEvent(breaking); + assertFalse(breaking.isCancelled()); + // Native accepted break/world execution is the existing typed boundary, not an + // imperative setType compatibility test. No events are fabricated for mutators. + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + f.actor.tick(); + assertEquals(Material.AIR, effects.block.getType()); + assertFalse(data.has(ownedKey()), "A successfully broken anvil has no old authority, even before replacement"); + place(f, effects, new ItemStack(Material.ANVIL)); + assertEquals(Material.ANVIL, effects.block.getType()); + int nativeClicks = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertTrue(f.packets.received.getLast().isCancelled(), "The old physical owner's frame is stale"); + assertEquals(nativeClicks, f.packets.clicks.size()); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertEquals(1, rolls.get(), "Replacement invalidates admission before roll/payment"); + assertEquals(NativeAnvilCombiningRegressionTest.sword(5), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(NativeAnvilCombiningRegressionTest.sword(5), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertTrue(effects.explosions.isEmpty()); + f.actor.prepareAgain(); + assertRisk(f, 20); + nextRoll.set(99); // A genuinely fresh unreinforced attempt can still succeed. + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(2, rolls.get()); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertTrue(effects.explosions.isEmpty()); + f.actor.assertNoEventErrors(); + return; + } + f.packets.click(2, 0, net.minecraft.world.inventory.ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()).getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(effects.explosions.isEmpty()); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertTrue(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(f.actor.actionBars.getLast()).contains("Failure 20%"), "All protection is spent"); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void cancelledBreakIncludingLaterMonitorCancellationPreservesReloadedProtection(boolean laterCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 1); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var before = chunkData(effects).toTagCompound().copy(); + if (laterCancellation) { + // Registered after the owning listener: final cancellation must win even when + // the owner first observes an uncancelled MONITOR event. + f.actor.events.registerEvent(BlockBreakEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, event) -> ((BlockBreakEvent) event).setCancelled(true), f.actor.plugin, false); + } + var breaking = new BlockBreakEvent(effects.block, f.actor.player); + breaking.setCancelled(!laterCancellation); + f.actor.events.callEvent(breaking); + assertTrue(breaking.isCancelled()); + // Native cancellation leaves the physical block and drop boundary untouched. + f.actor.tick(); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType()); + assertEquals(before, chunkData(effects).toTagCompound()); + assertEquals(1, rolls.get()); + assertNoMint(f); + assertEquals(before, reloadChunk(effects).toTagCompound()); + openAnvil(f); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 0); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(2, rolls.get()); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + reloadChunk(effects); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 20); // Reload cannot revive the subsequently spent protection. + f.actor.assertNoEventErrors(); + } + } + + @Test + void acceptedBreakDropRecoversOneTieredCurrentlyDamagedItemAndForeignMetadataThenReplacesAndSpendsIt() throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + reloadChunk(effects); + // Existing native world port reports wear of THIS anvil, not a new placement. + effects.data.set(CraftBlockData.createData(Blocks.DAMAGED_ANVIL.defaultBlockState())); + var brokenState = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) effects.data.get()).getState(), null); + var breaking = new BlockBreakEvent(effects.block, f.actor.player); + f.actor.events.callEvent(breaking); + assertFalse(breaking.isCancelled()); + assertTrue(breaking.isDropItems()); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + // Native world/entity execution supplies the ordinary drop. CraftItem's real + // mirror/setItemStack and real serialized ItemStacks remain the item boundary. + var dropped = dropPort(f, new ItemStack(Material.DAMAGED_ANVIL)); + var unrelated = dropPort(f, new ItemStack(Material.EMERALD, 2)); + var drops = new BlockDropItemEvent(effects.block, brokenState, f.actor.player, + new ArrayList<>(List.of(dropped, unrelated))); + f.actor.events.callEvent(drops); + f.actor.tick(); + assertFalse(drops.isCancelled()); + assertEquals(List.of(dropped, unrelated), drops.getItems(), "No duplicate anvil may be minted"); + var expected = reinforced.clone(); + expected.setType(Material.DAMAGED_ANVIL); + var recovered = ItemStack.deserializeBytes(dropped.getItemStack().serializeAsBytes()); + assertEquals(expected, recovered, "Tier, identity, name/lore and foreign PDC survive without repairing damage"); + assertEquals(new ItemStack(Material.EMERALD, 2), unrelated.getItemStack()); + assertFalse(chunkData(effects).has(ownedKey()), "Recovery must retire block ownership"); + assertFalse(reloadChunk(effects).has(ownedKey()), "Native reload must not resurrect a recovered owner"); + assertEquals(2, rolls.get()); + assertNoMint(f); + place(f, effects, recovered); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + openAnvil(f); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 0); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(3, rolls.get()); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + reloadChunk(effects); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 20); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"no-drop-break", "empty-drop-break", "explosion"}) + void acceptedNoDropOrDestructionRetiresOwnershipWithoutMintingAndReplacementHasOrdinaryRisk(String lifecycle) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 1); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var brokenState = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) effects.data.get()).getState(), null); + if (lifecycle.equals("explosion")) { + var destruction = new org.bukkit.event.entity.EntityExplodeEvent(f.actor.player, + effects.block.getLocation(), new ArrayList<>(List.of(effects.block)), 0F, + org.bukkit.ExplosionResult.DESTROY); + f.actor.events.callEvent(destruction); + assertFalse(destruction.isCancelled()); + assertTrue(destruction.blockList().contains(effects.block)); + } else { + var breaking = new BlockBreakEvent(effects.block, f.actor.player); + breaking.setDropItems(!lifecycle.equals("no-drop-break")); + f.actor.events.callEvent(breaking); + assertFalse(breaking.isCancelled()); + assertEquals(lifecycle.equals("empty-drop-break"), breaking.isDropItems()); + } + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + if (lifecycle.equals("empty-drop-break")) { + var empty = new BlockDropItemEvent(effects.block, brokenState, f.actor.player, new ArrayList<>()); + f.actor.events.callEvent(empty); + assertTrue(empty.getItems().isEmpty(), "An absent native drop must not mint a recovered anvil"); + } + f.actor.tick(); + assertEquals(Material.AIR, effects.block.getType()); + assertFalse(chunkData(effects).has(ownedKey())); + assertFalse(reloadChunk(effects).has(ownedKey())); + assertEquals(1, rolls.get()); + assertNoMint(f); + place(f, effects, new ItemStack(Material.ANVIL)); + openAnvil(f); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 20); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(2, rolls.get()); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(effects.explosions.isEmpty()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void cancelledPlacementRestoresExactOldNativePdcOwnershipAcrossSerializationReload(boolean laterCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 1); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + var before = chunkData(effects).toTagCompound().copy(); + var oldData = effects.data.get().clone(); + var replaced = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) oldData).getState(), null); + var held = new ItemStack(Material.ANVIL, 2); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(held)); + if (laterCancellation) { + f.actor.events.registerEvent(BlockPlaceEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, event) -> ((BlockPlaceEvent) event).setCancelled(true), f.actor.plugin, false); + } + // Native tentative placement + final cancelled rollback are world/hand boundary + // outcomes; registered events, deferred ownership and chunk/item codecs are real. + effects.data.set(CraftBlockData.createData(Blocks.ANVIL.defaultBlockState())); + var placement = new BlockPlaceEvent(effects.block, replaced, effects.block, + held, f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + placement.setCancelled(!laterCancellation); + f.actor.events.callEvent(placement); + assertTrue(placement.isCancelled()); + effects.data.set(oldData); + f.actor.tick(); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType()); + assertEquals(held, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(0))); + assertEquals(before, chunkData(effects).toTagCompound(), "Final cancellation restores exact old owner, not a fresh ordinary identity"); + assertEquals(before, reloadChunk(effects).toTagCompound()); + assertEquals(1, rolls.get()); + assertNoMint(f); + openAnvil(f); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 0); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(2, rolls.get()); + assertEquals(6, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + f.actor.assertNoEventErrors(); + } + } + + @Test + void nativeFallingDepartureAndPdcReloadLandCurrentDamageWithTwoTiersThenSpendOnce() throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + var reinforced = craft(f, markedAnvil(), 2); + place(f, falling.sourceBlock, reinforced, data -> falling.sourceState.set(((CraftBlockData) data).getState())); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + reloadChunk(effects); + + // REAL pinned fall constructs the carrier, dispatches CraftEventFactory, removes + // the source through setBlock(..., AIR, 3), then calls addFreshEntity exactly once. + var carrier = falling.depart(); + assertEquals(Material.AIR, falling.sourceBlock.getType()); + assertEquals(List.of(carrier), falling.spawned); + f.actor.tick(); + assertFalse(chunkData(effects).has(ownedKey(falling.sourceBlock)), "Departure retires the physical source owner"); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + assertTrue(hasPluginData(carrier), "Protection must travel on persistent entity data, not only an in-memory map"); + falling.assertOnlyNativeCarrierSpawn(); + assertEquals(2, rolls.get(), "Falling is not another reinforcement attempt"); + + carrier = falling.reloadCarrier(carrier); + // Current native wear/arrival state is a qualified entity port, not a claim that + // an arbitrary Bukkit data mutator emits lifecycle events or simulates gravity. + carrier.blockState = Blocks.DAMAGED_ANVIL.defaultBlockState(); + assertTrue(falling.land(carrier)); + assertTrue(carrier.isRemoved(), "Accepted native landing discards its carrier"); + f.actor.tick(); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + var expected = reinforced.clone(); + expected.setType(Material.DAMAGED_ANVIL); + assertEquals(expected, persistedItem(effects.block), "Same tier/identity/name/lore/foreign item PDC, with current damage"); + assertEquals("keep", chunkData(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + reloadChunk(effects); + assertEquals(expected, persistedItem(effects.block)); + assertFalse(chunkData(effects).has(ownedKey(falling.sourceBlock)), "Landing cannot restore the departed source"); + + openAnvil(f); + // Excess two distinguishes the transported TWO tiers from a fresh one-tier owner. + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(6), NativeAnvilCombiningRegressionTest.sword(6)); + assertRisk(f, 0); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(3, rolls.get()); + assertEquals(7, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried()) + .getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + assertTrue(effects.explosions.isEmpty()); + reloadChunk(effects); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(6), NativeAnvilCombiningRegressionTest.sword(6)); + assertRisk(f, 25); // All transported protection was spent, even on a zero-risk take. + assertEquals(3, rolls.get(), "Reload/preview cannot replay a paid attempt"); + falling.assertOnlyNativeCarrierSpawn(); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void nativeFallingSourceCancellationIncludingLaterMonitorRetainsChargeAndMintsNoCarrier(boolean laterCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + var reinforced = craft(f, markedAnvil(), 1); + place(f, falling.sourceBlock, reinforced, data -> falling.sourceState.set(((CraftBlockData) data).getState())); + var before = chunkData(effects).toTagCompound().copy(); + var observedUncancelled = new java.util.concurrent.atomic.AtomicBoolean(); + f.actor.events.registerEvent(EntityChangeBlockEvent.class, new Listener() {}, + laterCancellation ? EventPriority.MONITOR : EventPriority.LOWEST, (ignored, raw) -> { + var event = (EntityChangeBlockEvent) raw; + if (event.getTo() != Material.AIR) return; + // MONITOR registration is AFTER the owner's onEnable registration: + // the owner first sees uncancelled departure; this later decision wins. + observedUncancelled.set(!event.isCancelled()); + event.setCancelled(true); + }, f.actor.plugin, false); + var candidate = falling.depart(); + assertTrue(falling.changes.getLast().isCancelled()); + assertTrue(observedUncancelled.get()); + f.actor.tick(); + assertEquals(Material.CHIPPED_ANVIL, falling.sourceBlock.getType()); + assertEquals(before, chunkData(effects).toTagCompound()); + assertEquals(before, reloadChunk(effects).toTagCompound()); + assertEquals(reinforced, persistedItem(falling.sourceBlock), "Final cancellation retains the actual source charge"); + assertTrue(falling.spawned.isEmpty(), "Native fall returns an unspawned candidate after refusal"); + verify(f.actor.level, never()).setBlock(any(BlockPos.class), any(net.minecraft.world.level.block.state.BlockState.class), anyInt()); + assertFalse(hasPluginData(candidate), "An unspawned cancelled candidate must not retain a second persistent charge"); + assertEquals(1, rolls.get()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"cancelled", "failed-set-block"}) + void refusedNativeLandingDoesNotLendTransportedChargeToAPlayerReplacement(String outcome) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + var reinforced = craft(f, markedAnvil(), 1); + place(f, falling.sourceBlock, reinforced, data -> falling.sourceState.set(((CraftBlockData) data).getState())); + var carrier = falling.depart(); + f.actor.tick(); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + carrier = falling.reloadCarrier(carrier); + carrier.blockState = Blocks.DAMAGED_ANVIL.defaultBlockState(); + if (outcome.equals("cancelled")) { + f.actor.events.registerEvent(EntityChangeBlockEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> { + var event = (EntityChangeBlockEvent) raw; + if (event.getTo() == Material.DAMAGED_ANVIL) { + assertFalse(event.isCancelled(), "The owner saw an accepted-looking landing before final refusal"); + event.setCancelled(true); + } + }, f.actor.plugin, false); + } else { + falling.failLandingWrite = true; + carrier.dropItem = false; // Pinned failed-write/no-drop branch leaves the carrier alive. + } + assertFalse(falling.land(carrier)); + assertEquals(outcome.equals("cancelled"), falling.changes.getLast().isCancelled()); + assertEquals(outcome.equals("cancelled"), carrier.isRemoved()); + assertEquals(Material.AIR, effects.block.getType(), "Refusal must not install the arriving block"); + // Same material at the destination BEFORE deferred reconciliation is not evidence + // of successful landing. This is a supported player placement, not silent mutation. + place(f, effects, new ItemStack(Material.DAMAGED_ANVIL)); + assertEquals(new ItemStack(Material.DAMAGED_ANVIL), persistedItem(effects.block)); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + openAnvil(f); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 20); + assertEquals(1, rolls.get(), "Refused landing/player replacement cannot roll or spend a transported charge"); + assertTrue(effects.explosions.isEmpty()); + falling.assertOnlyNativeCarrierSpawn(); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "2,0,20", "2,1,40", "2,2,60", "2,3,80", "2,4,99", + "3,0,20", "3,1,40", "3,2,60", "3,3,80", "3,4,99"}) + void authenticPriorCraftsSelectEachDefaultBoundaryWithoutPreviewRolls(int width, int tier, int percent) throws Exception { + // Each row proves BOTH sides of the strict less-than boundary with a fresh actor. + for (boolean success : new boolean[] {false, true}) { + var nextRoll = new java.util.concurrent.atomic.AtomicInteger(99); + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, width, + () -> { rolls.incrementAndGet(); return nextRoll.get(); })) { + var source = craft(f, markedAnvil(), tier); // Never forge higher-tier PDC. + assertEquals(tier, rolls.get()); + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + var anvils = source.asQuantity(3); + var fuel = new ItemStack(Material.NETHERITE_BLOCK, 3); + var fuelMeta = fuel.getItemMeta(); + fuelMeta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "foreign_fuel"), + PersistentDataType.STRING, "keep"); + fuel.setItemMeta(fuelMeta); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(anvils)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(fuel)); + var unrelated = new ItemStack(Material.EMERALD, 7); + f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(unrelated)); + nextRoll.set(success ? percent : percent - 1); + assertEquals(tier, rolls.get(), "Recipe preparation must not gamble"); + assertEquals(anvils, CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0))); + assertEquals(fuel, CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(1))); + var preview = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()); + assertEquals(Material.CHIPPED_ANVIL, preview.getType()); + assertFalse(preview.getItemMeta().getPersistentDataContainer().has( + new NamespacedKey("spigotinventoryhelper", "reinforcement")), "Preview is not usable protection"); + assertFalse(preview.getItemMeta().getPersistentDataContainer().has( + new NamespacedKey("spigotinventoryhelper", "reinforcement_identity"))); + assertRisk(f, percent); + var inventory = f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList(); + f.packets.click(0, 0, ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + assertEquals(tier, rolls.get(), "Packet/native intent is not yet committed"); + f.actor.tick(); + assertEquals(tier + 1, rolls.get(), "One final accepted craft, one roll"); + assertEquals(source.asQuantity(2), CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0)), + "Exactly one source anvil is paid; remaining tier/damage/foreign metadata stay exact"); + assertEquals(fuel.asQuantity(2), CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(1))); + assertEquals(inventory, f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList()); + assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty)); + if (success) { + var returned = ItemStack.deserializeBytes(CraftItemStack.asBukkitCopy(f.menu.getCarried()).serializeAsBytes()); + assertEquals(Material.CHIPPED_ANVIL, returned.getType(), "Reinforcement never repairs native damage"); + assertEquals(1, returned.getAmount()); + var meta = returned.getItemMeta(); + assertEquals(tier + 1, meta.getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), PersistentDataType.INTEGER)); + assertNotNull(meta.getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement_identity"), PersistentDataType.STRING)); + assertEquals(source.getItemMeta().displayName(), meta.displayName()); + assertEquals("keep", meta.getPersistentDataContainer().get( + new NamespacedKey("acceptance", "foreign"), PersistentDataType.STRING)); + assertNotNull(meta.lore()); + assertEquals(markedAnvil().getItemMeta().lore().getFirst(), meta.lore().getFirst(), "Foreign lore survives"); + } else { + assertTrue(f.menu.getCarried().isEmpty(), "Roll percent-1 loses one anvil and one block, with no returned item"); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.CHIPPED_ANVIL)); + } + f.actor.tick(); + assertEquals(tier + 1, rolls.get(), "Rerender cannot repeat the committed gamble"); + f.actor.assertNoEventErrors(); + } + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = { + "bukkit-final", "protocol-final", "wrong-container", "bulk", "creative-clone", "drop", "hotbar", "cursor-full"}) + void nativeRefusedReinforcementTakesPreserveEveryStackDestinationAndRoll(String control) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var source = craft(f, markedAnvil(), 2); + f.menu.setCarried(control.equals("cursor-full") + ? CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)) : net.minecraft.world.item.ItemStack.EMPTY); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.EMERALD, 7))); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source.asQuantity(3))); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK, 3))); + var grid = f.gridSnapshot(); + var inventory = f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList(); + var cursor = CraftItemStack.asBukkitCopy(f.menu.getCarried()); + var sawFinalDecision = new java.util.concurrent.atomic.AtomicBoolean(); + if (control.equals("bukkit-final")) { + f.packets.listen(EventPriority.MONITOR, event -> { + if (event instanceof org.bukkit.event.inventory.CraftItemEvent) { + assertFalse(event.isCancelled(), "Owner admitted the intent before later native cancellation"); + sawFinalDecision.set(true); + event.setCancelled(true); + } + }); + } else if (control.equals("protocol-final")) { + f.actor.protocol.addPacketListener(new com.comphenix.protocol.events.PacketAdapter(f.actor.plugin, + com.comphenix.protocol.events.ListenerPriority.MONITOR, + com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK) { + @Override public void onPacketReceiving(com.comphenix.protocol.events.PacketEvent event) { + assertFalse(event.isCancelled(), "Final protocol filter runs after plugin admission"); + sawFinalDecision.set(true); + event.setCancelled(true); + } + }); + } + if (control.equals("creative-clone")) f.packets.creative(); + int eventsBefore = f.packets.clicks.size(); + if (control.equals("wrong-container")) { + f.packets.click(f.menu.containerId + 99, 0, 0, 0, ContainerInput.PICKUP); + } else { + ContainerInput input = switch (control) { + case "bulk" -> ContainerInput.QUICK_MOVE; + case "creative-clone" -> ContainerInput.CLONE; + case "drop" -> ContainerInput.THROW; + case "hotbar" -> ContainerInput.SWAP; + default -> ContainerInput.PICKUP; + }; + f.packets.click(0, control.equals("creative-clone") ? 2 : 0, input); + } + if (control.equals("bukkit-final")) { + assertTrue(sawFinalDecision.get()); + assertTrue(f.packets.clicks.getLast().isCancelled()); + } else if (control.equals("protocol-final")) { + assertTrue(sawFinalDecision.get()); + assertTrue(f.packets.received.getLast().isCancelled()); + assertEquals(eventsBefore, f.packets.clicks.size(), "Revoked protocol intent never reaches Bukkit"); + } else if (control.equals("wrong-container")) { + assertEquals(eventsBefore, f.packets.clicks.size(), "Native wrong-window refusal never reaches crafting"); + } + f.actor.tick(); + f.actor.tick(); + assertEquals(2, rolls.get(), "Only the two genuine prerequisite crafts rolled"); + assertEquals(grid, f.gridSnapshot()); + assertEquals(inventory, f.actor.realInventory.getContents().stream().map(CraftItemStack::asBukkitCopy).toList()); + assertEquals(cursor, CraftItemStack.asBukkitCopy(f.menu.getCarried())); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "0,unprotected", "1,unprotected", "1,damage-final-cancel", "1,break-final-cancel", "1,player-replacement"}) + void committedAdvancedFailureExplicitlyLosesOnlyUsedAnvilUnlessNativeProtectionOrPlayerReplacementWins( + int tier, String outcome) throws Exception { + var nextRoll = new java.util.concurrent.atomic.AtomicInteger(99); + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, + () -> { rolls.incrementAndGet(); return nextRoll.get(); })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var source = craft(f, markedAnvil(), tier); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, source); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(6, 3); + var meta = left.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("Original sword")); + meta.lore(List.of(net.kyori.adventure.text.Component.text("Original lore"))); + meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "foreign"), PersistentDataType.STRING, "keep"); + left.setItemMeta(meta); + var right = left.clone(); + var unrelated = new ItemStack(Material.EMERALD, 7); + f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(unrelated)); + f.actor.inputs(left, right); // Sharpness VII: actual E=2 exceeds R=0 or R=1. + assertRisk(f, tier == 0 ? 25 : 20); + assertEquals(tier, rolls.get(), "Combine preview has no roll"); + nextRoll.set(0); + var paidLeft = left.asQuantity(2); + var paidRight = right.asQuantity(2); + Runnable assertPaidBeforeEffects = () -> { + assertEquals(paidLeft, f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(paidRight, f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertNull(f.actor.output(), "Failed attempt has no merged result before a reentrant effect"); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))); + assertEquals(tier + 1, rolls.get()); + assertNoMint(f); + }; + var damages = new ArrayList(); + var breaks = new ArrayList(); + var placements = new ArrayList(); + var replacement = markedAnvil(); // Same native damage/material; registered ownership, not type, distinguishes it. + var replacementMeta = replacement.getItemMeta(); + replacementMeta.displayName(net.kyori.adventure.text.Component.text("Player replacement")); + replacement.setItemMeta(replacementMeta); + f.actor.events.registerEvent(com.destroystokyo.paper.event.block.AnvilDamagedEvent.class, + new Listener() {}, EventPriority.HIGHEST, (ignored, raw) -> { + var event = (com.destroystokyo.paper.event.block.AnvilDamagedEvent) raw; + assertPaidBeforeEffects.run(); + damages.add(event); + if (outcome.equals("player-replacement")) { + assertEquals(1, damages.size(), "One supported reentrant replacement, not repeated gameplay"); + // Registered PLAYER placement during the damage decision, with native + // tentative world/hand execution supplied at the existing boundary. + // No arbitrary silent API-mutation compatibility promise is made. + var replaced = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) effects.data.get()).getState(), null); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(replacement)); + effects.data.set(CraftBlockData.createData(Blocks.CHIPPED_ANVIL.defaultBlockState())); + var placement = new BlockPlaceEvent(effects.block, replaced, effects.block, + replacement, f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + f.actor.events.callEvent(placement); + assertFalse(placement.isCancelled()); + assertTrue(placement.canBuild()); + placements.add(placement); + f.actor.realInventory.getItem(0).shrink(1); + } + }, f.actor.plugin, false); + f.actor.events.registerEvent(com.destroystokyo.paper.event.block.AnvilDamagedEvent.class, + new Listener() {}, EventPriority.MONITOR, (ignored, raw) -> { + if (outcome.equals("damage-final-cancel")) { + var event = (com.destroystokyo.paper.event.block.AnvilDamagedEvent) raw; + assertFalse(event.isCancelled()); + event.setCancelled(true); + } + }, f.actor.plugin, false); + f.actor.events.registerEvent(BlockBreakEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> { + var event = (BlockBreakEvent) raw; + assertEquals(effects.block, event.getBlock()); + assertPaidBeforeEffects.run(); + breaks.add(event); + if (outcome.equals("break-final-cancel")) { + assertFalse(event.isCancelled(), "Final protection wins after owning MONITOR listener"); + event.setCancelled(true); + } + }, f.actor.plugin, false); + if (outcome.equals("break-final-cancel")) { + f.actor.events.registerEvent(org.bukkit.event.entity.EntityExplodeEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> ((org.bukkit.event.entity.EntityExplodeEvent) raw).setCancelled(true), f.actor.plugin, false); + } + // Fixture wear is 1.0: probabilistic native wear DOES NOT destroy this chipped + // anvil. Its explosion port calls real CraftWorld/ServerExplosion and registered + // protection but supplies an EMPTY affected-block list, not automatic destruction. + // Native anvil blast resistance means an explosion alone is not loss proof. + effects.duringExplosion = assertPaidBeforeEffects; + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + f.actor.tick(); // Allow registered placement/terminal-ownership reconciliation. + assertEquals(paidLeft, f.actor.menu.getBukkitView().getTopInventory().getItem(0), "Committed inputs are never refunded"); + assertEquals(paidRight, f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))); + assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIAMOND_SWORD)); + assertEquals(tier + 1, rolls.get()); + assertNoMint(f); + assertEquals(1, effects.explosions.size(), "Exactly one existing native explosion, never a destructive replay"); + var explosion = effects.explosions.getFirst(); + assertEquals(6F, explosion.radius()); + assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center()); + assertEquals(List.of(false), effects.requestedFire); + assertEquals(List.of(net.minecraft.world.level.Level.ExplosionInteraction.MOB), effects.requestedInteractions); + assertSame(f.actor.nativePlayer, explosion.getDirectSourceEntity()); + assertSame(f.actor.nativePlayer, explosion.getIndirectSourceEntity()); + assertFalse(explosion.excludeSourceFromDamage); + assertEquals(outcome.equals("break-final-cancel"), explosion.wasCanceled); + assertEquals("keep", chunkData(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + if (outcome.equals("unprotected")) { + assertEquals(Material.AIR, effects.block.getType(), + "Genuine RED: accepted advanced failure must explicitly destroy the used unprotected anvil even without native wear/blast removal"); + assertFalse(chunkData(effects).has(ownedKey()), "Destroyed anvil cannot retain stale physical PDC"); + assertFalse(reloadChunk(effects).has(ownedKey())); + } else { + if (outcome.equals("damage-final-cancel")) { + assertEquals(1, damages.size(), "Explicit destruction must consult native damage protection despite wear=1"); + assertTrue(damages.getFirst().isCancelled()); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType(), "No forced removal after final damage cancellation"); + } else if (outcome.equals("break-final-cancel")) { + assertEquals(1, breaks.size(), "Explicit removal must consult native block protection"); + assertTrue(breaks.getFirst().isCancelled()); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType(), "No forced removal after final break cancellation"); + } else { + assertEquals(1, placements.size(), "Replacement must actually pass the registered player lifecycle"); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType(), "Explicit plugin damage/removal must not mutate a same-material player replacement"); + } + reloadChunk(effects); + assertEquals(outcome.equals("player-replacement") ? replacement : markedAnvil(), persistedItem(effects.block), + "Protection does not restore spent charges; replacement retains its own exact item metadata"); + f.actor.inputs(left, right); + assertRisk(f, 25); // Original charge spent, or ordinary replacement owns the position. + assertEquals(tier + 1, rolls.get()); + } + f.actor.tick(); + assertEquals(1, effects.explosions.size()); + assertEquals(tier + 1, rolls.get()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {1, 2, 3, 4, 5}) + void authenticEveryTierSurvivesWithinLimitNativeTakeWithExactXpAndPresentation(int tier) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), tier); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var before = chunkData(effects).toTagCompound().copy(); + openAnvil(f); + f.packets.survival(); + NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f.actor, 100); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(1), NativeAnvilCombiningRegressionTest.sword(1)); + assertFalse(f.actor.menu.bypassCost); + assertEquals(2, f.actor.output().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertEquals(net.kyori.adventure.text.Component.empty(), f.actor.actionBars.getLast()); + int cost = f.actor.menu.getCost(); + assertTrue(cost > 0 && cost < 40, "Within-limit native XP, not an advanced free take"); + var expected = f.actor.output().clone(); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(100 - cost, f.actor.nativePlayer.experienceLevel); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(tier, rolls.get(), "Only authentic prerequisite crafts roll"); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType()); + assertEquals(before, chunkData(effects).toTagCompound(), "Ordinary take cannot reset identity/tier/presentation"); + assertEquals(before, reloadChunk(effects).toTagCompound()); + assertEquals(reinforced, persistedItem(effects.block)); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @Test + void authenticTierThreeSurvivesNativeNamingRepairAndPriorWorkTake() throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 3); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var before = chunkData(effects).toTagCompound().copy(); + openAnvil(f); + f.packets.survival(); + NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f.actor, 100); + // Pinned onTake sends the accepted name to this player service. The actual menu, + // repair, XP and text remain native; only the existing actor's filter port is supplied. + when(f.actor.nativePlayer.getTextFilter()).thenReturn(net.minecraft.server.network.TextFilter.DUMMY); + var left = NativeAnvilCombiningRegressionTest.sword(1); + var meta = (org.bukkit.inventory.meta.Damageable) left.getItemMeta(); + meta.setDamage(1500); + ((org.bukkit.inventory.meta.Repairable) meta).setRepairCost(8); + left.setItemMeta(meta); + f.actor.inputs(left, left.clone()); + f.actor.menu.setItemName("ordinary reinforced repair"); + f.actor.prepareAgain(); + var expected = f.actor.output().clone(); + assertFalse(f.actor.menu.bypassCost); + int cost = f.actor.menu.getCost(); + assertTrue(cost > 16 && cost < 40); + assertEquals(net.kyori.adventure.text.Component.text("ordinary reinforced repair"), expected.getItemMeta().customName()); + assertEquals(2, expected.getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(((org.bukkit.inventory.meta.Damageable) expected.getItemMeta()).getDamage() < 1500); + assertEquals(17, ((org.bukkit.inventory.meta.Repairable) expected.getItemMeta()).getRepairCost()); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(100 - cost, f.actor.nativePlayer.experienceLevel); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(3, rolls.get()); + assertEquals(before, reloadChunk(effects).toTagCompound()); + assertEquals(reinforced, persistedItem(effects.block)); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"damage", "late-cancel", "break-player", "break-falling"}) + void realOrdinaryOnTakeWearPreservesSurvivorOrRetiresBrokenOwnerBeforeReplacement(String outcome) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + boolean broken = outcome.startsWith("break-"); + var source = markedAnvil(); + if (broken) source.setType(Material.DAMAGED_ANVIL); + var reinforced = craft(f, source, 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + var before = chunkData(effects).toTagCompound().copy(); + openAnvil(f); + f.packets.survival(); + NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f.actor, 100); + when(f.actor.nativePlayer.getRandom().nextFloat()).thenReturn(0F); + var nativeEffects = new ArrayList(); + // Exact inspected onTake world writes, not fabricated events for Bukkit setType. + when(f.actor.level.setBlock(any(BlockPos.class), any(net.minecraft.world.level.block.state.BlockState.class), anyInt())) + .thenAnswer(inv -> { + assertEquals(f.actor.position, inv.getArgument(0)); + assertEquals(2, inv.getArgument(2)); + assertEquals(Blocks.DAMAGED_ANVIL.defaultBlockState(), inv.getArgument(1)); + effects.data.set(CraftBlockData.createData(inv.getArgument(1))); + return true; + }); + when(f.actor.level.removeBlock(any(BlockPos.class), anyBoolean())).thenAnswer(inv -> { + assertEquals(f.actor.position, inv.getArgument(0)); + assertFalse(inv.getArgument(1)); + assertTrue(broken); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + return true; + }); + doAnswer(inv -> { + assertEquals(f.actor.position, inv.getArgument(1)); + assertEquals(0, inv.getArgument(2)); + int effect = inv.getArgument(0); + assertTrue(effect == 1029 || effect == 1030); + nativeEffects.add(effect); + return null; + }).when(f.actor.level).levelEvent(anyInt(), any(BlockPos.class), anyInt()); + var damages = new ArrayList(); + f.actor.events.registerEvent(com.destroystokyo.paper.event.block.AnvilDamagedEvent.class, + new Listener() {}, EventPriority.MONITOR, (ignored, raw) -> { + var event = (com.destroystokyo.paper.event.block.AnvilDamagedEvent) raw; + assertSame(f.actor.menu.getBukkitView(), event.getView()); + assertFalse(event.isCancelled(), "Final native damage decision follows owning listener"); + assertEquals(broken ? com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.BROKEN + : com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.DAMAGED, event.getDamageState()); + damages.add(event); + if (outcome.equals("late-cancel")) event.setCancelled(true); + }, f.actor.plugin, false); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(1), NativeAnvilCombiningRegressionTest.sword(1)); + int cost = f.actor.menu.getCost(); + assertTrue(cost > 0 && cost < 40); + var expected = f.actor.output().clone(); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + f.actor.tick(); + f.actor.assertNoEventErrors(); + assertEquals(1, damages.size(), "Event must originate from real native ordinary onTake"); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(100 - cost, f.actor.nativePlayer.experienceLevel, "Damage cancellation does not refund accepted native XP"); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(2, rolls.get()); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + if (!broken) { + boolean cancelled = outcome.equals("late-cancel"); + assertEquals(cancelled, damages.getFirst().isCancelled()); + assertEquals(cancelled ? List.of() : List.of(1030), nativeEffects); + assertEquals(cancelled ? Material.CHIPPED_ANVIL : Material.DAMAGED_ANVIL, effects.block.getType()); + assertEquals(before, reloadChunk(effects).toTagCompound(), "Wear changes live damage, not tier or serialized presentation"); + var retained = reinforced.clone(); + retained.setType(effects.block.getType()); + assertEquals(retained, persistedItem(effects.block)); + } else { + assertEquals(List.of(1029), nativeEffects); + assertEquals(Material.AIR, effects.block.getType()); + assertFalse(chunkData(effects).has(ownedKey()), "RED: ordinary native break must retire old persistent protection"); + assertFalse(reloadChunk(effects).has(ownedKey())); + assertEquals("keep", chunkData(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + if (outcome.equals("break-player")) { + place(f, effects, new ItemStack(Material.DAMAGED_ANVIL)); + } else { + var falling = new FallingWorldPort(f, effects); + place(f, falling.sourceBlock, new ItemStack(Material.DAMAGED_ANVIL), + data -> falling.sourceState.set(((CraftBlockData) data).getState())); + var carrier = falling.depart(); + f.actor.tick(); + assertTrue(falling.land(carrier)); + f.actor.tick(); + falling.assertOnlyNativeCarrierSpawn(); + assertFalse(chunkData(effects).has(ownedKey(falling.sourceBlock))); + } + assertEquals(new ItemStack(Material.DAMAGED_ANVIL), persistedItem(effects.block)); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(5), NativeAnvilCombiningRegressionTest.sword(5)); + assertRisk(f, 20); + assertEquals(2, rolls.get(), "Native ordinary/falling replacement cannot inherit broken protection"); + f.actor.assertNoEventErrors(); + } + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void nativeFallingDropTransfersOnlyOneSuppliedLiveItemAndLateCancellationCannotMintProtection(boolean lateCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + falling.allowNativeItemDrop(); + var reinforced = craft(f, markedAnvil(), 2); + place(f, falling.sourceBlock, reinforced, data -> falling.sourceState.set(((CraftBlockData) data).getState())); + var carrier = falling.depart(); + f.actor.tick(); + assertTrue(hasPluginData(carrier)); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + carrier.blockState = Blocks.DAMAGED_ANVIL.defaultBlockState(); + carrier.setPos(f.actor.position.getX() + .5, f.actor.position.getY(), f.actor.position.getZ() + .5); + var drops = new ArrayList(); + f.actor.events.registerEvent(org.bukkit.event.entity.EntityDropItemEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> { + var event = (org.bukkit.event.entity.EntityDropItemEvent) raw; + drops.add(event); + if (lateCancellation && drops.size() == 1) { + assertFalse(event.isCancelled(), "Owning listener observes candidate before this final cancellation"); + event.setCancelled(true); + } + }, f.actor.plugin, false); + // Pinned failed-place branch: discard(DROP), then spawnAtLocation. We supply the + // failed-placement position/current damage, NOT full falling physics or tick(). + // Both removal factory and Entity's actual native drop construction/event/spawn run. + carrier.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DROP); + assertTrue(carrier.isRemoved()); + var result = carrier.spawnAtLocation(f.actor.level, carrier.blockState.getBlock()); + assertEquals(1, drops.size()); + var event = drops.getFirst(); + assertSame(carrier.getBukkitEntity(), event.getEntity()); + var supplied = assertInstanceOf(org.bukkit.craftbukkit.entity.CraftItem.class, event.getItemDrop()); + assertFalse(mockingDetails(supplied.getHandle()).isMock()); + assertEquals(lateCancellation, event.isCancelled()); + if (lateCancellation) { + assertNull(result, "Native final refusal must not spawn a drop"); + assertFalse(supplied.isValid()); + } else { + assertSame(supplied.getHandle(), result); + assertTrue(supplied.isValid()); + assertFalse(supplied.isDead()); + } + f.actor.tick(); + f.actor.tick(); + f.actor.assertNoEventErrors(); + var expected = reinforced.clone(); + expected.setType(Material.DAMAGED_ANVIL); + assertEquals(lateCancellation ? new ItemStack(Material.DAMAGED_ANVIL) : expected, + ItemStack.deserializeBytes(supplied.getItemStack().serializeAsBytes()), + "RED: accepted native DROP carries tier/identity/current damage/name/lore/foreign PDC; cancelled candidate carries none"); + assertFalse(hasPluginData(carrier), "Retired native carrier cannot retain reusable protection"); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + assertFalse(chunkData(effects).has(ownedKey()), "Drop/reload does not create destination block ownership"); + assertEquals(Material.AIR, effects.block.getType()); + assertEquals(2, rolls.get()); + falling.assertNativeDropSpawns(lateCancellation ? 0 : 1); + // Repeated registered callbacks are not extra vanilla spawns. The duplicate is a + // real ItemEntity/CraftItem with explicitly supplied LIVE world-registration port, + // not another addFreshEntity call or a second claimed native spawn. Even a live + // replay candidate must remain ordinary after the carrier's charge was retired. + f.actor.events.callEvent(event); + var duplicate = new net.minecraft.world.entity.item.ItemEntity(f.actor.level, + carrier.getX(), carrier.getY(), carrier.getZ(), CraftItemStack.asNMSCopy(new ItemStack(Material.DAMAGED_ANVIL))); + duplicate.valid = true; // Narrow replay/liveness boundary only; no tracking engine. + var replayItem = assertInstanceOf(org.bukkit.entity.Item.class, duplicate.getBukkitEntity()); + assertTrue(replayItem.isValid()); + assertFalse(replayItem.isDead()); + var replay = new org.bukkit.event.entity.EntityDropItemEvent(carrier.getBukkitEntity(), replayItem); + f.actor.events.callEvent(replay); + f.actor.tick(); + assertTrue(replayItem.isValid()); + assertEquals(new ItemStack(Material.DAMAGED_ANVIL), CraftItemStack.asBukkitCopy(duplicate.getItem()), + "A repeated callback cannot mint a second charged LIVE drop"); + assertEquals(lateCancellation ? new ItemStack(Material.DAMAGED_ANVIL) : expected, supplied.getItemStack()); + assertFalse(hasPluginData(carrier)); + falling.assertNativeDropSpawns(lateCancellation ? 0 : 1); + assertEquals(2, rolls.get()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void acceptedLandingWithFailedNativeWriteDropsPhaseTwoChargeOnceUnlessFinalDropCancellationWins( + boolean lateCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + falling.allowNativeItemDrop(); + var reinforced = craft(f, markedAnvil(), 2); + place(f, falling.sourceBlock, reinforced, data -> falling.sourceState.set(((CraftBlockData) data).getState())); + var carrier = falling.depart(); + f.actor.tick(); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + carrier.blockState = Blocks.DAMAGED_ANVIL.defaultBlockState(); + var drops = new ArrayList(); + f.actor.events.registerEvent(org.bukkit.event.entity.EntityDropItemEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> { + var event = (org.bukkit.event.entity.EntityDropItemEvent) raw; + drops.add(event); + if (lateCancellation && drops.size() == 1) { + assertFalse(event.isCancelled()); + event.setCancelled(true); + } + }, f.actor.plugin, false); + falling.failLandingWrite = true; + var result = falling.failedLandingThenNativeDrop(carrier); + assertFalse(falling.changes.getLast().isCancelled(), "The landing EVENT was accepted; the native WRITE failed"); + assertEquals(1, drops.size()); + var event = drops.getFirst(); + var supplied = assertInstanceOf(org.bukkit.craftbukkit.entity.CraftItem.class, event.getItemDrop()); + assertFalse(mockingDetails(supplied.getHandle()).isMock()); + assertEquals(lateCancellation, event.isCancelled()); + if (lateCancellation) assertNull(result); + else assertSame(supplied.getHandle(), result); + assertEquals(!lateCancellation, supplied.isValid()); + var transport = carrier.getBukkitEntity().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "falling_reinforcement"), PersistentDataType.TAG_CONTAINER); + assertNotNull(transport); + assertEquals((byte) 3, transport.get(new NamespacedKey("spigotinventoryhelper", "transport_phase"), PersistentDataType.BYTE), + "Native drop owns the terminal reservation before either queued callback runs"); + // Landing reconciliation was queued FIRST. It must not steal phase 3 from the + // actual DROP claimant merely because failed setBlock left an AIR destination. + f.actor.tick(); + f.actor.tick(); + var expected = reinforced.clone(); + expected.setType(Material.DAMAGED_ANVIL); + assertEquals(lateCancellation ? new ItemStack(Material.DAMAGED_ANVIL) : expected, + ItemStack.deserializeBytes(supplied.getItemStack().serializeAsBytes())); + assertFalse(hasPluginData(carrier)); + assertEquals(Material.AIR, effects.block.getType()); + assertFalse(chunkData(effects).has(ownedKey())); + assertFalse(reloadChunk(effects).has(ownedKey(falling.sourceBlock))); + assertEquals(2, rolls.get()); + falling.assertNativeDropSpawns(lateCancellation ? 0 : 1); + + f.actor.events.callEvent(event); // Repeating the SAME callback is not another native spawn. + var duplicate = new net.minecraft.world.entity.item.ItemEntity(f.actor.level, + carrier.getX(), carrier.getY(), carrier.getZ(), CraftItemStack.asNMSCopy(new ItemStack(Material.DAMAGED_ANVIL))); + duplicate.valid = true; // Only a qualified live replay candidate, not a second world spawn. + var replayItem = assertInstanceOf(org.bukkit.entity.Item.class, duplicate.getBukkitEntity()); + f.actor.events.callEvent(new org.bukkit.event.entity.EntityDropItemEvent(carrier.getBukkitEntity(), replayItem)); + f.actor.tick(); + assertTrue(replayItem.isValid()); + assertEquals(new ItemStack(Material.DAMAGED_ANVIL), replayItem.getItemStack()); + assertEquals(lateCancellation ? new ItemStack(Material.DAMAGED_ANVIL) : expected, supplied.getItemStack()); + assertFalse(hasPluginData(carrier)); + assertFalse(chunkData(effects).has(ownedKey())); + falling.assertNativeDropSpawns(lateCancellation ? 0 : 1); + if (!lateCancellation) { + // Transfer the ONE native supplied item into the player's hand at the existing + // pickup/world port; real placement, PDC and native advanced payment follow. + var recovered = ItemStack.deserializeBytes(supplied.getItemStack().serializeAsBytes()); + supplied.getHandle().setItem(net.minecraft.world.item.ItemStack.EMPTY); + assertTrue(supplied.getHandle().getItem().isEmpty(), "Pickup transfer leaves no second physical stack"); + place(f, effects, recovered); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(6, 3); + f.actor.inputs(left, left.clone()); + assertRisk(f, 0); + var output = f.actor.output().clone(); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(output, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(3, rolls.get(), "Two genuine reinforcement crafts and only ONE paid advanced take"); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + reloadChunk(effects); + f.actor.realInventory.setItem(10, f.actor.menu.getCarried().copy()); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.prepareAgain(); + assertRisk(f, 25); + f.actor.tick(); + assertEquals(3, rolls.get()); + } else { + assertEquals(2, rolls.get(), "Cancelled native drop/replay cannot charge or roll another attempt"); + } + assertTrue(effects.explosions.isEmpty()); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void settledChargedAndSpentNativeCodecsSurviveActualDisableReenableWithFreshOwnerAndConservedClose( + boolean spent) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var damaged = markedAnvil(); + damaged.setType(Material.DAMAGED_ANVIL); + var reinforced = craft(f, damaged, 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var foreign = new NamespacedKey("acceptance", "foreign_chunk"); + chunkData(effects).set(foreign, PersistentDataType.STRING, "keep"); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(6, 3); + f.actor.inputs(left, left.clone()); + assertRisk(f, 0); + if (spent) { + var output = f.actor.output().clone(); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(output, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + // Qualified native cursor -> inventory movement, retaining the real paid output. + f.actor.realInventory.setItem(10, f.actor.menu.getCarried().copy()); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.prepareAgain(); + assertRisk(f, 25); + } + int beforeRolls = spent ? 3 : 2; + assertEquals(beforeRolls, rolls.get()); + var expectedItem = spent ? damaged : reinforced; + assertEquals(expectedItem, ItemStack.deserializeBytes(persistedItem(effects.block).serializeAsBytes())); + var beforeCodec = chunkData(effects).toTagCompound().copy(); + var previous = registeredStateListener(f); + var possessions = nativePossessions(f); + f.actor.disableAndCancelTasks(); // REAL onDisable + native close/removed(), not helper-owner close(). + assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + assertEquals(possessions, nativePossessions(f), "Native close returns every input, cursor item and foreign metadata exactly once"); + assertEquals(beforeCodec, chunkData(effects).toTagCompound()); + reenableWithNativeChunkCodec(f, effects, previous); + assertEquals(beforeCodec, chunkData(effects).toTagCompound()); + assertEquals(expectedItem, persistedItem(effects.block)); + assertEquals("keep", chunkData(effects).get(foreign, PersistentDataType.STRING)); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType(), "Reload never repairs the actual placed damage"); + assertEquals(beforeRolls, rolls.get()); + f.actor.nativePlayer.containerMenu = f.actor.menu; + var remaining = left.asQuantity(spent ? 2 : 3); + f.actor.inputs(withdrawReturned(f, remaining), withdrawReturned(f, remaining)); + assertEquals(possessions, nativePossessions(f), "Reopening moves returned inputs; it does not manufacture fresh stacks"); + assertRisk(f, spent ? 25 : 0); + var output = f.actor.output().clone(); + var expectedPossessions = new java.util.HashMap<>(possessions); + changePossessions(expectedPossessions, remaining.asQuantity(1), -2); + changePossessions(expectedPossessions, output, 1); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(output, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(remaining.asQuantity(remaining.getAmount() - 1), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(remaining.asQuantity(remaining.getAmount() - 1), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(expectedPossessions, nativePossessions(f), "Exactly two committed inputs become one exact merged output"); + assertEquals(beforeRolls + 1, rolls.get()); + var beforeClose = nativePossessions(f); + f.packets.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER); + f.actor.tick(); + assertEquals(beforeClose, nativePossessions(f)); + assertEquals(damaged, ItemStack.deserializeBytes(persistedItem(effects.block).serializeAsBytes())); + assertEquals("keep", reloadChunk(effects).get(foreign, PersistentDataType.STRING)); + assertEquals(beforeRolls + 1, rolls.get()); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + // This proves actual plugin callbacks + native chunk/ItemPDC codecs, NOT disk restart. + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void unloadedAcceptedNativeBreakCannotLendOldChargeToUntaggedFallingReplacementAfterFreshPlugin( + boolean lateLandingCancellation) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var falling = new FallingWorldPort(f, effects); + var reinforced = craft(f, markedAnvil(), 2); + place(f, effects, reinforced); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3); + f.actor.inputs(left, left.clone()); + assertRisk(f, 0); + int oldWire = f.actor.clientState(f.actor.menu); + var breaking = new BlockBreakEvent(effects.block, f.actor.player); + breaking.setDropItems(false); + f.actor.events.callEvent(breaking); + assertFalse(breaking.isCancelled()); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); // Accepted native break execution. + when(f.actor.world.isChunkLoaded(f.actor.position.getX() >> 4, f.actor.position.getZ() >> 4)).thenReturn(false); + when(effects.block.getChunk().isLoaded()).thenReturn(false); + f.actor.tick(); // Deferred cleanup has no loaded context: no invented successful retirement. + assertEquals(Material.AIR, effects.block.getType()); + var possessions = nativePossessions(f); + int nativeClicks = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertTrue(f.packets.received.getLast().isCancelled()); + assertEquals(nativeClicks, f.packets.clicks.size()); + assertEquals(possessions, nativePossessions(f)); + assertEquals(2, rolls.get()); + var previous = registeredStateListener(f); + f.actor.disableAndCancelTasks(); + assertEquals(possessions, nativePossessions(f)); + // A genuine untagged carrier supplied by native reload while the plugin is INACTIVE. + // Only entity registration/current position are ports; construction/PDC/removal run. + falling.restored = new FallingBlockEntity(f.actor.level, f.actor.position.getX() + .5, + f.actor.position.getY(), f.actor.position.getZ() + .5, Blocks.DAMAGED_ANVIL.defaultBlockState()); + falling.restored.valid = true; + falling.restored.getBukkitEntity().getPersistentDataContainer().set( + new NamespacedKey("acceptance", "foreign_carrier"), PersistentDataType.STRING, "keep"); + assertFalse(hasPluginData(falling.restored)); + when(f.actor.world.isChunkLoaded(f.actor.position.getX() >> 4, f.actor.position.getZ() >> 4)).thenReturn(true); + when(effects.block.getChunk().isLoaded()).thenReturn(true); + reenableWithNativeChunkCodec(f, effects, previous); + var cancelOnce = new java.util.concurrent.atomic.AtomicBoolean(lateLandingCancellation); + f.actor.events.registerEvent(EntityChangeBlockEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> { + var event = (EntityChangeBlockEvent) raw; + if (event.getTo() == Material.DAMAGED_ANVIL && cancelOnce.compareAndSet(true, false)) { + assertFalse(event.isCancelled()); + event.setCancelled(true); + } + }, f.actor.plugin, false); + if (lateLandingCancellation) { + assertFalse(falling.landOrdinaryReloaded(falling.restored)); + assertTrue(falling.changes.getLast().isCancelled()); + assertEquals(Material.AIR, effects.block.getType(), "Final native cancellation forbids any landing write"); + f.actor.tick(); + falling.restored = new FallingBlockEntity(f.actor.level, f.actor.position.getX() + .5, + f.actor.position.getY(), f.actor.position.getZ() + .5, Blocks.DAMAGED_ANVIL.defaultBlockState()); + falling.restored.valid = true; // A later DIFFERENT ordinary reload candidate, not destructive replay. + } + assertTrue(falling.landOrdinaryReloaded(falling.restored)); + assertFalse(falling.changes.getLast().isCancelled()); + assertTrue(falling.restored.isRemoved()); + f.actor.tick(); + f.actor.tick(); + assertEquals(Material.DAMAGED_ANVIL, effects.block.getType()); + assertFalse(hasPluginData(falling.restored)); + assertEquals("keep", reloadChunk(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + f.actor.nativePlayer.containerMenu = f.actor.menu; + f.actor.inputs(withdrawReturned(f, left), withdrawReturned(f, left)); + boolean offered = assertOrdinaryRiskOrWithheld(f, 20, + "An ordinary native fallen replacement must not inherit the unloaded broken owner's R=2"); + assertEquals(possessions, nativePossessions(f)); + nativeClicks = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP); + f.actor.tick(); + if (offered) { + assertTrue(f.packets.received.getLast().isCancelled(), "Old enhanced frame cannot take a fresh ordinary-risk result"); + assertEquals(nativeClicks, f.packets.clicks.size()); + } + assertTrue(f.actor.menu.getCarried().isEmpty(), "Withheld authority cannot turn an old packet into native output"); + assertEquals(possessions, nativePossessions(f)); + assertEquals(2, rolls.get()); + assertTrue(falling.spawned.isEmpty(), "The supplied reloaded entities are not newly spawned carriers"); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"throws", "ignored-write"}) + void rejectedNativeOwnedPdcSpendCannotRollDebitOrDeliverAndRestoredBackendSpendsPriorChargeOnlyOnce( + String failureMode) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + chunkData(effects).set(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING, "keep"); + var backend = strictNativeChunkSpy(effects); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(6, 3); + f.actor.inputs(left, left.clone()); + assertRisk(f, 0); + var before = backend.toTagCompound().copy(); + var possessions = nativePossessions(f); + var faults = new java.util.concurrent.atomic.AtomicInteger(); + var injected = new IllegalStateException("injected exact owned anvil spend install failure"); + // Inspected DirtyCraftPDC.set(NamespacedKey, PersistentDataType, V). + // Only this position's owned TAG_CONTAINER install fails; reads, nested/item + // codecs, adapter context, foreign keys and every other operation stay native. + doAnswer(inv -> { + faults.incrementAndGet(); + if (failureMode.equals("throws")) throw injected; + return null; // Readback refusal: accepted-looking install does not alter native storage. + }).when(backend).set(eq(ownedKey()), eq(PersistentDataType.TAG_CONTAINER), any(org.bukkit.persistence.PersistentDataContainer.class)); + int failedWire = f.actor.clientState(f.actor.menu); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + f.actor.tick(); + assertTrue(faults.get() > 0, "Actual spend reached the one injected native-key operation"); + assertEquals(2, rolls.get()); + assertEquals(possessions, nativePossessions(f)); + assertEquals(left, f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(left, f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertEquals(before, backend.toTagCompound()); + assertEquals(reinforced, persistedItem(effects.block), "A refused install did not silently claim charge was spent"); + assertTrue(effects.explosions.isEmpty()); + doCallRealMethod().when(backend).set(eq(ownedKey()), eq(PersistentDataType.TAG_CONTAINER), any(org.bukkit.persistence.PersistentDataContainer.class)); + f.actor.prepareAgain(); // Fresh displayed/admitted frame, never retry the uncertain attempt automatically. + assertRisk(f, 0); + int clicks = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, failedWire, 2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertTrue(f.packets.received.getLast().isCancelled()); + assertEquals(clicks, f.packets.clicks.size()); + assertEquals(possessions, nativePossessions(f)); + assertEquals(2, rolls.get()); + var output = f.actor.output().clone(); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(3, rolls.get()); + assertEquals(output, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + var expected = new java.util.HashMap<>(possessions); + changePossessions(expected, left.asQuantity(1), -2); + changePossessions(expected, output, 1); + assertEquals(expected, nativePossessions(f)); + assertEquals(markedAnvil(), persistedItem(effects.block)); + assertEquals("keep", reloadChunk(effects).get(new NamespacedKey("acceptance", "foreign_chunk"), PersistentDataType.STRING)); + // Preserve the real result by moving its cursor stack, not discarding it for a retry. + f.actor.realInventory.setItem(10, f.actor.menu.getCarried().copy()); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.prepareAgain(); + assertRisk(f, 25); + f.actor.tick(); + assertEquals(3, rolls.get(), "No reroll, replay or free repeated protection after successful recovery"); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"throws", "ignored-remove"}) + void acceptedOrdinaryPlayerReplacementWithOwnedPdcRetirementFailureCannotReloadOldCharge( + String failureMode) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + var foreign = new NamespacedKey("acceptance", "foreign_chunk"); + chunkData(effects).set(foreign, PersistentDataType.STRING, "keep"); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3); + f.actor.inputs(left, left.clone()); + assertRisk(f, 0); + int oldWire = f.actor.clientState(f.actor.menu); + var backend = strictNativeChunkSpy(effects); + var faults = new java.util.concurrent.atomic.AtomicInteger(); + var injected = new IllegalStateException("injected exact owned anvil replacement retirement failure"); + doAnswer(inv -> { + faults.incrementAndGet(); + if (failureMode.equals("throws")) throw injected; + return null; // Strict readback-refusal port: removal leaves the real old record intact. + }).when(backend).remove(eq(ownedKey())); + var replaced = CraftBlockStates.getBlockState(f.actor.world, f.actor.position, + ((CraftBlockData) effects.data.get()).getState(), null); + var ordinary = new ItemStack(Material.CHIPPED_ANVIL, 2); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(ordinary)); + effects.data.set(CraftBlockData.createData(Blocks.CHIPPED_ANVIL.defaultBlockState())); + var event = new BlockPlaceEvent(effects.block, replaced, effects.block, ordinary, + f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + dispatchQualifyingOnlyExactPdcFault(f, event, injected); + assertFalse(event.isCancelled()); + assertTrue(event.canBuild()); + f.actor.realInventory.getItem(0).shrink(1); // Native accepted PLAYER replacement pays one hand item. + assertEquals(ordinary.asQuantity(1), CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(0))); + assertTrue(faults.get() > 0); + var possessions = nativePossessions(f); + var previous = registeredStateListener(f); + // Before any queued replacement installation can hide a failed retirement: + // REAL disable cancels deferred work, then reload the actual native codec into + // a fresh owning listener. No event-bypassing silent API replacement is promised. + f.actor.disableAndCancelTasks(); + assertEquals(possessions, nativePossessions(f)); + reenableWithNativeChunkCodec(f, effects, previous); + assertEquals(Material.CHIPPED_ANVIL, effects.block.getType()); + assertEquals("keep", chunkData(effects).get(foreign, PersistentDataType.STRING)); + f.actor.nativePlayer.containerMenu = f.actor.menu; + f.actor.inputs(withdrawReturned(f, left), withdrawReturned(f, left)); + // Unknown ownership may withhold enhanced output entirely. If a usable preview + // is offered, it MUST have ordinary risk, never the retired player's old R=2. + boolean offered = assertOrdinaryRiskOrWithheld(f, 20, + "Failed owned-key removal must not authorize old protection after reload"); + int clicks = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP); + f.actor.tick(); + if (offered) { + assertTrue(f.packets.received.getLast().isCancelled(), "Old charged frame cannot authorize replacement's fresh result"); + assertEquals(clicks, f.packets.clicks.size()); + } + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertEquals(possessions, nativePossessions(f)); + assertEquals(2, rolls.get()); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"2,0", "3,100"}) + void registeredAllFiveAdminKeysAcknowledgeMonotonicEndpointsAndInvalidateActualCraftFrames(int width, int endpoint) + throws Exception { + for (int tier = 1; tier <= 5; tier++) { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + var nextRoll = new java.util.concurrent.atomic.AtomicInteger(99); + try (var f = new NativePowderSnowFixture(directory.resolve("tier-" + tier), width, + () -> { rolls.incrementAndGet(); return nextRoll.get(); })) { + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true); + var source = craft(f, markedAnvil(), tier - 1); // Genuine default-policy prior-tier payment. + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK, 2))); + f.actor.tick(); + int oldWire = f.actor.clientState(f.menu); + var grid = f.gridSnapshot(); + var configFile = f.actor.plugin.getDataFolder().toPath().resolve("config.yml"); + java.nio.file.Files.createDirectories(configFile.getParent()); + java.nio.file.Files.writeString(configFile, "unrelated: keep\n"); + for (int offset = 0; offset < 5; offset++) { + int level = endpoint == 0 ? offset + 1 : 5 - offset; + String key = reinforcementKey(level); + var sender = (level + width) % 2 == 0 ? f.actor.player : f.actor.console; + assertAdminRead(f.actor, sender, key, new int[] {20, 40, 60, 80, 99}[level - 1]); + f.actor.adminMessages.clear(); + f.actor.admin(sender, key, endpoint); + assertTrue(f.actor.adminMessages.isEmpty(), "No success before durable acknowledgment"); + f.actor.acknowledgeSave(); + assertEquals(List.of(key + " = " + endpoint + " (saved)"), f.actor.adminMessages); + for (org.bukkit.command.CommandSender reader : List.of(f.actor.player, f.actor.console)) { + assertAdminRead(f.actor, reader, key, endpoint); + var suggestions = f.actor.plugin.getCommand("inventoryhelperadmin").tabComplete(reader, + "inventoryhelperadmin", new String[] {"config", key, ""}); + var expectedSuggestions = new java.util.LinkedHashSet<>(List.of(Integer.toString(endpoint), + Integer.toString(new int[] {20, 40, 60, 80, 99}[level - 1]), "0", "100")); + assertEquals(List.copyOf(expectedSuggestions), suggestions, "Stable effective/default endpoint ordering"); + assertEquals(suggestions.size(), new java.util.LinkedHashSet<>(suggestions).size()); + } + } + assertEquals(tier - 1, rolls.get(), "Reads/completion/save/preview cannot roll"); + assertEquals(grid, f.gridSnapshot()); + String feedback = plain(f.actor.actionBars.getLast()); + assertTrue(feedback.contains("Failure " + endpoint + "%"), feedback); + assertTrue(feedback.contains(tier + "/5"), feedback); + int nativeEvents = f.packets.clicks.size(); + f.packets.click(f.menu.containerId, oldWire, 0, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertTrue(f.packets.received.getLast().isCancelled(), "Acknowledged policy withdraws the actual old craft frame"); + assertEquals(nativeEvents, f.packets.clicks.size()); + assertEquals(grid, f.gridSnapshot()); + assertTrue(f.menu.getCarried().isEmpty()); + assertEquals(tier - 1, rolls.get()); + nextRoll.set(endpoint == 0 ? 0 : 99); + f.packets.click(0, 0, ContainerInput.PICKUP); + assertFalse(f.packets.received.getLast().isCancelled(), "Fresh policy frame is usable"); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + f.actor.tick(); + assertEquals(tier, rolls.get()); + assertEquals(0, NativePowderSnowFixture.count(f.menu.craftSlots, Material.CHIPPED_ANVIL)); + assertEquals(1, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + if (endpoint == 100) assertTrue(f.menu.getCarried().isEmpty()); + else { + var result = CraftItemStack.asBukkitCopy(f.menu.getCarried()); + assertEquals(tier, result.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), PersistentDataType.INTEGER)); + assertEquals(source.getType(), result.getType()); + assertEquals(source.getItemMeta().displayName(), result.getItemMeta().displayName()); + assertEquals("keep", result.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("acceptance", "foreign"), PersistentDataType.STRING)); + } + var document = new org.yaml.snakeyaml.Yaml().load(java.nio.file.Files.readString( + f.actor.plugin.getDataFolder().toPath().resolve("config.yml"))); + var saved = assertInstanceOf(java.util.Map.class, document); + for (int level = 1; level <= 5; level++) assertEquals(endpoint, saved.get(reinforcementKey(level))); + assertEquals("keep", saved.get("unrelated"), "Actual route preserves unrelated durable settings"); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + } + + @Test + void registeredRouteRejectsBadValuesDeniedAccessAndFailedSaveWithoutPolicyOrPreviewMutation() throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var command = f.actor.plugin.getCommand("inventoryhelperadmin"); + String key = reinforcementKey(3); + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(markedAnvil())); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK))); + f.actor.tick(); + var grid = f.gridSnapshot(); + var output = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()); + int wire = f.actor.clientState(f.menu); + for (String value : List.of("not-a-number", "60.5", "-1", "101")) { + f.actor.adminMessages.clear(); + assertTrue(command.execute(f.actor.player, "inventoryhelperadmin", new String[] {"config", key, value})); + assertEquals(List.of("Value must be an integer from 0 through 100."), f.actor.adminMessages); + } + var initialFile = f.actor.plugin.getDataFolder().toPath().resolve("config.yml"); + java.nio.file.Files.createDirectories(initialFile.getParent()); + java.nio.file.Files.writeString(initialFile, "unrelated: keep\n"); + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.console, key, 81); // Valid integer, invalid complete nondecreasing table. + runConfigCallback(f.actor); + assertEquals(List.of("Configuration save failed; effective settings unchanged."), f.actor.adminMessages); + assertAdminRead(f.actor, f.actor.console, key, 60); + var file = f.actor.plugin.getDataFolder().toPath().resolve("config.yml"); + String original = java.nio.file.Files.readString(file); + java.nio.file.Files.delete(file); + java.nio.file.Files.createDirectory(file); // Real clone-local test target fault, no mocked save success. + try { + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.console, key, 65); + runConfigCallback(f.actor); + assertEquals(List.of("Configuration save failed; effective settings unchanged."), f.actor.adminMessages); + assertAdminRead(f.actor, f.actor.console, key, 60); + assertEquals(List.of("config"), command.tabComplete(f.actor.console, "inventoryhelperadmin", new String[] {})); + assertEquals(List.of("config"), command.tabComplete(f.actor.console, "inventoryhelperadmin", new String[] {"co"})); + var keys = command.tabComplete(f.actor.console, "inventoryhelperadmin", new String[] {"config", ""}); + assertEquals(8, keys.size()); + for (int level = 1; level <= 5; level++) assertTrue(keys.contains(reinforcementKey(level))); + assertEquals(List.of(key), command.tabComplete(f.actor.player, "inventoryhelperadmin", + new String[] {"config", "advanced-anvil-reinforcement-failure-3"})); + assertEquals(List.of("100"), command.tabComplete(f.actor.player, "inventoryhelperadmin", + new String[] {"config", key, "1"})); + for (String[] args : List.of(new String[] {"other", key}, new String[] {"config", "unknown", ""}, + new String[] {"config", key, "", "extra"})) + assertEquals(List.of(), command.tabComplete(f.actor.console, "inventoryhelperadmin", args)); + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(false); + f.actor.adminMessages.clear(); + command.execute(f.actor.player, "inventoryhelperadmin", new String[] {"config", key, "65"}); + assertFalse(f.actor.adminMessages.stream().anyMatch(message -> message.endsWith("(saved)"))); + assertEquals(List.of(), command.tabComplete(f.actor.player, "inventoryhelperadmin", new String[] {"config", ""})); + assertTrue(java.nio.file.Files.isDirectory(file), "Completion does not repair or read the inaccessible file target"); + assertTrue(f.actor.nextTick.isEmpty(), "Completion/denial schedules no persistence callbacks"); + } finally { + java.nio.file.Files.delete(file); + java.nio.file.Files.writeString(file, original); + } + assertEquals(grid, f.gridSnapshot()); + assertEquals(output, CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem())); + assertEquals(wire, f.actor.clientState(f.menu)); + assertEquals(0, rolls.get()); + assertAdminRead(f.actor, f.actor.console, key, 60); + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"permission", "offline", "plugin-lifetime"}) + void registeredDurableSaveCallbackNeedsCurrentPlayerAndPluginAuthority(String loss) throws Exception { + try (var f = new NativePowderSnowFixture(directory, 2)) { + String key = reinforcementKey(1); + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(markedAnvil())); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK))); + f.actor.tick(); + var output = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()); + int wire = f.actor.clientState(f.menu); + f.actor.adminMessages.clear(); + f.actor.admin(f.actor.player, key, 21); + Runnable saved = nextConfigCallback(f.actor); + assertTrue(java.nio.file.Files.readString(f.actor.plugin.getDataFolder().toPath().resolve("config.yml")) + .contains(key + ": 21"), "Actual durable save precedes the parked callback"); + switch (loss) { + case "permission" -> when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(false); + case "offline" -> when(f.actor.player.isOnline()).thenReturn(false); + case "plugin-lifetime" -> f.actor.disableAndCancelTasks(); + default -> throw new AssertionError(loss); + } + saved.run(); + assertFalse(f.actor.adminMessages.stream().anyMatch(message -> message.endsWith("(saved)"))); + if (!loss.equals("plugin-lifetime")) { + assertAdminRead(f.actor, f.actor.console, key, 20); + assertEquals(output, CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem())); + assertEquals(wire, f.actor.clientState(f.menu)); + when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true); + when(f.actor.player.isOnline()).thenReturn(true); + saved.run(); // Restoring permission/liveness cannot revive this already-consumed callback. + assertAdminRead(f.actor, f.actor.console, key, 20); + } else { + f.actor.plugin.setEnabled(true); + assertAdminRead(f.actor, f.actor.console, key, 21); // Startup loads real acknowledged disk history. + f.actor.adminMessages.clear(); + saved.run(); // Old settings owner must not publish into the new live owner. + assertTrue(f.actor.adminMessages.isEmpty()); + } + f.actor.assertNoEventErrors(); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"1,1,0", "3,2,20", "7,2,70", "8,2,95", "9,1,99"}) + void nativeAdvancedTakeShiftsActualExcessBeforeApplyingCurveAndCapWithoutChangingOutput(int excess, int tier, int percent) + throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), tier); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + openAnvil(f); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(4 + excess, 3); + var meta = left.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("Keep original weapon name")); + meta.lore(List.of(net.kyori.adventure.text.Component.text("Keep original weapon lore"))); + left.setItemMeta(meta); + var right = left.clone(); + f.actor.inputs(left, right); // Equal-level merge gives actual sharpness max(5)+E. + assertRisk(f, percent); + var expected = f.actor.output().clone(); + assertEquals(5 + excess, expected.getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertEquals(1, expected.getAmount()); + assertEquals(left.getItemMeta().displayName(), expected.getItemMeta().displayName()); + assertEquals(left.getItemMeta().lore(), expected.getItemMeta().lore()); + assertEquals(0, f.actor.menu.getBukkitView().getRepairCost()); + assertEquals(tier, rolls.get()); + f.packets.click(2, 0, ContainerInput.PICKUP); + f.actor.tick(); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(tier + 1, rolls.get()); + reloadChunk(effects); + assertEquals(markedAnvil(), persistedItem(effects.block), "ALL charge is spent, even when only one tier sufficed"); + f.actor.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.inputs(left, right); + assertEquals(expected, f.actor.output(), "Reinforcement changes risk, never the resulting enchantments or presentation"); + assertRisk(f, new int[] {0, 20, 25, 35, 50, 70, 95, 99, 99, 99}[excess]); + assertEquals(tier + 1, rolls.get()); + f.actor.assertNoEventErrors(); + } + } + + @Test + void chargedAnvilBookAndRefusalStayNativeAndAdvancedToNormalSwitchPaysOnlyNativeXp() throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + openAnvil(f); + var random = mock(net.minecraft.util.RandomSource.class); + when(random.nextFloat()).thenReturn(0.99F); + when(f.actor.nativePlayer.getRandom()).thenReturn(random); + doCallRealMethod().when(f.actor.nativePlayer).giveExperienceLevels(anyInt()); + f.actor.nativePlayer.experienceLevel = 100; + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(7), NativeAnvilCombiningRegressionTest.sword(7)); + assertRisk(f, 20); + int advancedWire = f.actor.clientState(f.actor.menu); + var book = new ItemStack(Material.ENCHANTED_BOOK); + var stored = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta(); + stored.addStoredEnchant(org.bukkit.enchantments.Enchantment.SHARPNESS, 7, true); + book.setItemMeta(stored); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(1), book); + assertFalse(f.actor.menu.bypassCost, "Book application stays native, never enhanced"); + assertEquals(5, f.actor.output().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + assertTrue(f.actor.menu.getCost() > 0); + f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(7), new ItemStack(Material.DIAMOND_AXE)); + assertNull(f.actor.output()); + assertFalse(f.actor.menu.bypassCost); + var expensive = NativeAnvilCombiningRegressionTest.sword(4); + var prior = (org.bukkit.inventory.meta.Repairable) expensive.getItemMeta(); + prior.setRepairCost(80); + expensive.setItemMeta(prior); + f.actor.inputs(expensive, NativeAnvilCombiningRegressionTest.sword(4)); + assertNull(f.actor.output()); + assertTrue(f.actor.menu.getCost() >= 40); + assertFalse(f.actor.menu.bypassCost); + var normal = NativeAnvilCombiningRegressionTest.sword(4); + f.actor.inputs(normal, normal); + var expected = f.actor.output().clone(); + int cost = f.actor.menu.getCost(); + assertTrue(cost > 0); + assertFalse(f.actor.menu.bypassCost); + assertEquals(5, expected.getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS)); + int before = f.packets.clicks.size(); + f.packets.click(f.actor.menu.containerId, advancedWire, 2, 0, ContainerInput.PICKUP); + assertTrue(f.packets.received.getLast().isCancelled()); + assertEquals(before, f.packets.clicks.size()); + assertEquals(100, f.actor.nativePlayer.experienceLevel); + f.packets.click(2, 0, ContainerInput.PICKUP); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(100 - cost, f.actor.nativePlayer.experienceLevel); + assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()); + assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()); + f.actor.tick(); + assertEquals(2, rolls.get(), "Only the two genuine prerequisite crafts roll"); + assertEquals(reinforced, persistedItem(effects.block), "Refused/book/ordinary paths preserve all protection"); + assertTrue(effects.explosions.isEmpty()); + f.actor.assertNoEventErrors(); + } + } + + @Test + void twoActualNativeUsersAdmitOneSharedChargedAnvilBeforePaymentOnlyFirstSpendsAndSecondFreshTakeWorks() + throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + var wears = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, 2, + () -> { rolls.incrementAndGet(); return 99; }, () -> { wears.incrementAndGet(); return 1.0; })) { + var effects = new NativeAnvilEffectsFixture(f.actor); + var reinforced = craft(f, markedAnvil(), 2); + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + place(f, effects, reinforced); + openAnvil(f); + var second = f.actor.secondActor(f.packets); + assertNotEquals(f.actor.player.getUniqueId(), second.player.getUniqueId()); + assertNotSame(f.actor.nativePlayer, second.nativePlayer); + assertNotSame(f.actor.realInventory, second.inventory); + assertNotSame(f.actor.connection, second.connection); + assertNotSame(f.actor.channel, second.channel); + assertNotEquals(f.actor.menu.containerId, second.menu.containerId); + assertSame(f.actor.level, second.nativePlayer.level()); + assertEquals(1, org.bukkit.event.HandlerList.getRegisteredListeners(f.actor.plugin).stream() + .map(org.bukkit.plugin.RegisteredListener::getListener) + .filter(AdvancedAnvilListener.class::isInstance).distinct().count(), "Exactly ONE live registered combining owner"); + assertEquals(f.actor.menu.getBukkitView().getTopInventory().getLocation(), + second.menu.getBukkitView().getTopInventory().getLocation()); + assertSame(effects.block.getChunk().getPersistentDataContainer(), + second.menu.getBukkitView().getTopInventory().getLocation().getBlock().getChunk().getPersistentDataContainer()); + var left = NativeAdvancedAnvilTransactionTest.stackedSword(7, 3); // Actual E=3, R=2 ->20%. + var right = left.clone(); + var foreign = new ItemStack(Material.EMERALD, 7); + f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(foreign)); + second.inventory.setItem(9, CraftItemStack.asNMSCopy(foreign)); + f.actor.inputs(left, right); + second.inputs(left, right); + f.actor.tick(); // Install BOTH native pipeline guards before capturing each real frame. + assertEquals(1, f.actor.channel.pipeline().toMap().values().stream().filter(NativeAnvilDispatch.class::isInstance).count()); + assertEquals(1, second.channel.pipeline().toMap().values().stream().filter(NativeAnvilDispatch.class::isInstance).count()); + assertRisk(f, 20); + assertTrue(plain(second.actionBars.getLast()).contains("Failure 20%")); + var expected = f.actor.output().clone(); + assertEquals(expected, second.menu.getBukkitView().getTopInventory().getItem(2)); + assertSame(f.actor.menu, f.actor.nativePlayer.containerMenu); + assertSame(second.menu, second.nativePlayer.containerMenu); + f.packets.click(2, 0, ContainerInput.PICKUP); + second.click(); // No scheduler pump/payment between these two independent users. + assertFalse(f.packets.received.getLast().isCancelled()); + assertFalse(second.received.getLast().isCancelled()); + assertFalse(f.packets.clicks.stream().filter(event -> event.getWhoClicked() == f.actor.player).toList().getLast().isCancelled()); + assertEquals(1, second.clicks.size()); + assertFalse(second.clicks.getLast().isCancelled()); + assertTrue(f.actor.menu.getCarried().isEmpty()); + assertTrue(second.menu.getCarried().isEmpty()); + assertEquals(left, f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(left, second.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(reinforced, persistedItem(effects.block)); + assertEquals(2, rolls.get()); + assertEquals(0, wears.get()); + f.actor.tick(); + f.actor.tick(); + assertEquals(expected, CraftItemStack.asBukkitCopy(f.actor.menu.getCarried())); + assertEquals(left.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right.asQuantity(2), f.actor.menu.getBukkitView().getTopInventory().getItem(1)); + assertTrue(second.menu.getCarried().isEmpty(), "Second admitted old-charge intent is stale, not rerolled"); + assertEquals(left, second.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right, second.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(3, rolls.get(), "Two genuine crafts + exactly one accepted combine"); + assertEquals(1, wears.get()); + assertEquals(markedAnvil(), persistedItem(effects.block)); + assertTrue(plain(second.actionBars.getLast()).contains("Failure 35%"), "Fresh second preview has ordinary E=3 risk"); + second.click(); + assertFalse(second.received.getLast().isCancelled()); + assertEquals(2, second.clicks.size()); + assertFalse(second.clicks.getLast().isCancelled()); + f.actor.tick(); + assertEquals(expected, CraftItemStack.asBukkitCopy(second.menu.getCarried())); + assertEquals(left.asQuantity(2), second.menu.getBukkitView().getTopInventory().getItem(0)); + assertEquals(right.asQuantity(2), second.menu.getBukkitView().getTopInventory().getItem(1)); + assertEquals(4, rolls.get()); + assertEquals(2, wears.get()); + assertEquals(foreign, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))); + assertEquals(foreign, CraftItemStack.asBukkitCopy(second.inventory.getItem(9))); + assertTrue(effects.explosions.isEmpty()); + assertNoMint(f); + f.actor.assertNoEventErrors(); + } + } + + private static String reinforcementKey(int level) { + return "advanced-anvil-reinforcement-failure-" + level + "-percent"; + } + + private static String plain(net.kyori.adventure.text.Component component) { + return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(component); + } + + private static void assertAdminRead(NativeAnvilFixture f, org.bukkit.command.CommandSender sender, String key, int expected) { + f.adminMessages.clear(); + assertTrue(f.plugin.getCommand("inventoryhelperadmin").execute(sender, "inventoryhelperadmin", new String[] {"config", key})); + assertEquals(List.of(key + " = " + expected), f.adminMessages); + } + + private static Runnable nextConfigCallback(NativeAnvilFixture f) throws InterruptedException { + Runnable callback = f.nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS); + assertNotNull(callback, "Real asynchronous durable save must schedule an acknowledgment"); + return callback; + } + + private static void runConfigCallback(NativeAnvilFixture f) throws InterruptedException { + nextConfigCallback(f).run(); + f.assertNoEventErrors(); + } + + private static boolean hasPluginData(FallingBlockEntity entity) { + return entity.getBukkitEntity().getPersistentDataContainer().getKeys().stream() + .anyMatch(key -> key.getNamespace().equals("spigotinventoryhelper")); + } + + private static NamespacedKey ownedKey(org.bukkit.block.Block block) { + return new NamespacedKey("spigotinventoryhelper", "anvil_" + (block.getX() & 15) + "_" + block.getY() + "_" + (block.getZ() & 15)); + } + + private static ItemStack persistedItem(org.bukkit.block.Block block) { + // Persistence audit accompanies real registered event/packet gameplay; never call + // ReinforcementState to supply the expected ownership or risk. + var record = block.getChunk().getPersistentDataContainer().get(ownedKey(block), PersistentDataType.TAG_CONTAINER); + assertNotNull(record, "Expected a durable physical owner at " + block.getLocation()); + var bytes = record.get(new NamespacedKey("spigotinventoryhelper", "item"), PersistentDataType.BYTE_ARRAY); + assertNotNull(bytes); + var item = ItemStack.deserializeBytes(bytes); + // Native damage is the live block's authority; the payload need not be rewritten on + // every wear change. Audit presentation without manufacturing reinforcement metadata. + item.setType(block.getType()); + return item; + } + + /** LOCAL strict two-position loaded-world/entity ports; no changes to shared fixtures. + * Departure, entity construction/Craft PDC, factory dispatch and native removal are real. + * Landing uses tick's inspected factory -> setBlock -> discard sequence, NOT tick(): the + * fixture has no movement/collision engine, portal runtime or chunkMap/tracking players. + * Only the current position/damage and accepted/failed world write are supplied here. + */ + private static final class FallingWorldPort { + final NativePowderSnowFixture f; + final NativeAnvilEffectsFixture effects; + final BlockPos source; + final org.bukkit.block.Block sourceBlock; + final AtomicReference sourceState = + new AtomicReference<>(Blocks.AIR.defaultBlockState()); + final List spawned = new ArrayList<>(); + final List spawnedDrops = new ArrayList<>(); + boolean nativeItemDropAllowed; + final List changes = new ArrayList<>(); + FallingBlockEntity restored; + boolean failLandingWrite; + + FallingWorldPort(NativePowderSnowFixture f, NativeAnvilEffectsFixture effects) throws Exception { + this.f = f; + this.effects = effects; + source = f.actor.position.above(); // Land one block lower at the ACTUAL native menu origin. + effects.data.set(CraftBlockData.createData(Blocks.AIR.defaultBlockState())); + when(f.actor.level.getMinecraftWorld()).thenReturn(f.actor.level); + when(f.actor.level.getCraftServer()).thenReturn((org.bukkit.craftbukkit.CraftServer) f.actor.server); + var ids = new java.util.concurrent.atomic.AtomicInteger(100); + when(f.actor.level.getNextEntityId()).thenAnswer(ignored -> ids.incrementAndGet()); + // Entity's inspected constructor reads these exact configuration fields. No deep + // stubs, new global configuration or permissive default native method execution. + NativeAnvilFixture.field(net.minecraft.world.level.Level.class, f.actor.level, "spigotConfig", + mock(org.spigotmc.SpigotWorldConfig.class)); + var config = mock(io.papermc.paper.configuration.WorldConfiguration.class); + config.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class); + config.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class); + config.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>(); + when(f.actor.level.paperConfig()).thenReturn(config); + when(f.actor.level.getBlockState(any(BlockPos.class))).thenAnswer(inv -> { + BlockPos pos = inv.getArgument(0); + if (source.equals(pos)) return sourceState.get(); + assertEquals(f.actor.position, pos, "Unexpected native block-read port"); + return ((CraftBlockData) effects.data.get()).getState(); + }); + sourceBlock = org.bukkit.craftbukkit.block.CraftBlock.at(f.actor.level, source); + when(f.actor.world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(inv -> + blockAt(new BlockPos(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)))); + when(f.actor.world.getBlockAt(any(org.bukkit.Location.class))).thenAnswer(inv -> { + org.bukkit.Location location = inv.getArgument(0); + assertSame(f.actor.world, location.getWorld()); + return blockAt(new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ())); + }); + when(f.actor.world.getChunkAt(any(org.bukkit.block.Block.class))).thenAnswer(inv -> { + org.bukkit.block.Block block = inv.getArgument(0); + assertSame(f.actor.world, block.getWorld()); + blockAt(new BlockPos(block.getX(), block.getY(), block.getZ())); + return effects.block.getChunk(); // The SAME loaded native PDC, also after reloadChunk. + }); + // doAnswer avoids executing an earlier strict ordinary-wear answer while replacing + // this world boundary for a later supported native falling replacement. + doAnswer(inv -> { + BlockPos pos = inv.getArgument(0); + net.minecraft.world.level.block.state.BlockState state = inv.getArgument(1); + assertEquals(3, inv.getArgument(2)); + if (source.equals(pos)) { + assertTrue(state.isAir(), "Only native source departure is supported"); + sourceState.set(state); + } else { + assertEquals(f.actor.position, pos, "Unexpected native block-write port"); + assertEquals(Blocks.DAMAGED_ANVIL.defaultBlockState(), state); + if (failLandingWrite) return false; + effects.data.set(CraftBlockData.createData(state)); + } + return true; + }).when(f.actor.level).setBlock(any(BlockPos.class), any(net.minecraft.world.level.block.state.BlockState.class), anyInt()); + when(f.actor.level.addFreshEntity(any(Entity.class))).thenAnswer(inv -> { + Entity candidate = inv.getArgument(0); + if (candidate instanceof net.minecraft.world.entity.item.ItemEntity item) { + assertTrue(nativeItemDropAllowed, "Older falling tests permit ONLY the actual native carrier"); + assertFalse(mockingDetails(item).isMock(), "Item construction must remain native"); + assertSame(f.actor.level, item.level()); + assertEquals(f.actor.position, item.blockPosition()); + assertEquals(1, spawned.size()); + assertTrue(spawned.getFirst().isRemoved(), "Inspected failed-place branch discards before spawning its drop"); + assertTrue(sourceState.get().isAir()); + assertTrue(effects.data.get().getMaterial().isAir()); + assertTrue(spawnedDrops.isEmpty(), "No second item may be spawned by callbacks"); + assertEquals(1, item.getItem().getCount()); + assertEquals(Material.DAMAGED_ANVIL, CraftItemStack.asBukkitCopy(item.getItem()).getType()); + spawnedDrops.add(item); + item.valid = true; // Qualified native world registration, not tracking/ticking. + return true; + } + var entity = assertInstanceOf(FallingBlockEntity.class, candidate); + assertFalse(mockingDetails(entity).isMock(), "Carrier construction must remain native"); + assertSame(f.actor.level, entity.level()); + assertEquals(source, entity.blockPosition()); + assertTrue(sourceState.get().isAir(), "Native source replacement precedes spawning"); + assertTrue(spawned.isEmpty(), "No extra carrier or item entity may be minted"); + spawned.add(entity); + // Accepted native world registration supplies Entity.valid; a constructed but + // unspawned cancellation candidate must remain invalid and receive no charge. + entity.valid = true; + return true; + }); + var lookup = mock(ca.spottedleaf.moonrise.patches.chunk_system.level.entity.EntityLookup.class); + // These native Level accessors are final: install the typed boundary through the real setter. + f.actor.level.moonrise$setEntityLookup(lookup); + assertSame(lookup, f.actor.level.moonrise$getEntityLookup()); + when(lookup.canRemoveEntity(any(Entity.class))).thenAnswer(inv -> { + Entity entity = inv.getArgument(0); + assertSame(f.actor.level, entity.level()); + assertTrue(spawned.contains(entity) || entity == restored, "Only our actual supplied carrier can be removed"); + return true; + }); + f.actor.events.registerEvent(EntityChangeBlockEvent.class, new Listener() {}, EventPriority.MONITOR, + (ignored, raw) -> changes.add((EntityChangeBlockEvent) raw), f.actor.plugin, false); + } + + void allowNativeItemDrop() throws Exception { + assertFalse(nativeItemDropAllowed); + nativeItemDropAllowed = true; + // Pinned ItemEntity.setItem reads these world-local fields. No global config, + // shared fixture or generic entity stub is changed; real Craft/NMS stacks run. + var config = f.actor.level.paperConfig(); + config.entities.spawning.altItemDespawnRate = mock( + io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.AltItemDespawnRate.class); + var purpur = mock(org.purpurmc.purpur.PurpurWorldConfig.class); + purpur.itemImmuneToCactus = List.of(); + purpur.itemImmuneToExplosion = List.of(); + purpur.itemImmuneToFire = List.of(); + purpur.itemImmuneToLightning = List.of(); + NativeAnvilFixture.field(net.minecraft.world.level.Level.class, f.actor.level, "purpurConfig", purpur); + } + + void assertNativeDropSpawns(int expectedDrops) { + assertTrue(nativeItemDropAllowed); + assertEquals(1, spawned.size()); + assertEquals(expectedDrops, spawnedDrops.size()); + assertTrue(f.packets.drops.isEmpty()); + assertTrue(mockingDetails(f.actor.world).getInvocations().stream().noneMatch(inv -> + inv.getMethod().getName().equals("dropItem") || inv.getMethod().getName().equals("dropItemNaturally"))); + long spawns = mockingDetails(f.actor.level).getInvocations().stream() + .filter(inv -> inv.getMethod().getName().startsWith("addFreshEntity")).count(); + assertEquals(1L + expectedDrops, spawns, "Only vanilla's one carrier and one accepted supplied drop, never an extra spawn"); + } + + private org.bukkit.block.Block blockAt(BlockPos pos) { + if (source.equals(pos)) return sourceBlock; + assertEquals(f.actor.position, pos, "Unexpected Bukkit position port"); + return effects.block; + } + + FallingBlockEntity depart() { + var entity = FallingBlockEntity.fall(f.actor.level, source, sourceState.get()); + assertEquals(1, changes.size()); + var event = changes.getFirst(); + assertSame(entity.getBukkitEntity(), event.getEntity()); + assertEquals(source, ((org.bukkit.craftbukkit.block.CraftBlock) event.getBlock()).getPosition()); + assertSame(f.actor.world, event.getBlock().getWorld()); + assertEquals(Material.AIR, event.getTo()); + f.actor.assertNoEventErrors(); + return entity; + } + + FallingBlockEntity reloadCarrier(FallingBlockEntity old) throws java.io.IOException { + assertTrue(hasPluginData(old), "A valid-looking persistent transport is required before testing landing"); + var data = old.getBukkitEntity().getPersistentDataContainer(); + data.set(new NamespacedKey("acceptance", "foreign_carrier"), PersistentDataType.STRING, "keep"); + byte[] saved = data.serializeToBytes(); + var before = data.toTagCompound().copy(); + // Actual Craft PDC binary reload into a DIFFERENT real entity/Craft wrapper. This + // is not full entity/chunk disk serialization or a claimed server restart. + old.remove(Entity.RemovalReason.UNLOADED_TO_CHUNK, org.bukkit.event.entity.EntityRemoveEvent.Cause.UNLOAD); + assertTrue(old.isRemoved(), "Reload must not leave a second live original carrier"); + restored = new FallingBlockEntity(f.actor.level, old.getX(), old.getY(), old.getZ(), old.blockState); + restored.setUUID(old.getUUID()); + restored.setStartPos(old.getStartPos()); + restored.valid = true; // Qualified native world-reload registration port. + restored.getBukkitEntity().getPersistentDataContainer().readFromBytes(saved, true); + assertEquals(before, restored.getBukkitEntity().getPersistentDataContainer().toTagCompound()); + assertEquals("keep", restored.getBukkitEntity().getPersistentDataContainer().get( + new NamespacedKey("acceptance", "foreign_carrier"), PersistentDataType.STRING)); + assertEquals(Material.CHIPPED_ANVIL, assertInstanceOf(org.bukkit.entity.FallingBlock.class, + restored.getBukkitEntity()).getMaterial()); + assertTrue(restored.isAlive()); + return restored; + } + + boolean land(FallingBlockEntity entity) { + assertTrue(entity.isAlive()); + assertSame(f.actor.level, entity.level()); + entity.setPos(f.actor.position.getX() + .5, f.actor.position.getY(), f.actor.position.getZ() + .5); + assertEquals(f.actor.position, entity.blockPosition()); + boolean accepted = org.bukkit.craftbukkit.event.CraftEventFactory.callEntityChangeBlockEvent( + entity, entity.blockPosition(), entity.blockState); + assertEquals(2, changes.size()); + var event = changes.getLast(); + assertSame(entity.getBukkitEntity(), event.getEntity()); + assertSame(f.actor.world, event.getBlock().getWorld()); + assertEquals(f.actor.position, ((org.bukkit.craftbukkit.block.CraftBlock) event.getBlock()).getPosition()); + assertEquals(Material.DAMAGED_ANVIL, event.getTo()); + if (!accepted) { + // Pinned tick's final cancellation branch discards without a block write/drop. + entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); + return false; + } + boolean written = f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3); + if (written) entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); + else assertFalse(entity.dropItem, "Only inspected failed-write/no-drop outcome is supplied"); + f.actor.assertNoEventErrors(); + return written; + } + + net.minecraft.world.entity.item.ItemEntity failedLandingThenNativeDrop(FallingBlockEntity entity) { + assertTrue(nativeItemDropAllowed); + assertTrue(failLandingWrite); + assertTrue(entity.isAlive()); + assertTrue(entity.dropItem, "This helper is ONLY the pinned failed-write/true-drop path"); + entity.setPos(f.actor.position.getX() + .5, f.actor.position.getY(), f.actor.position.getZ() + .5); + assertTrue(org.bukkit.craftbukkit.event.CraftEventFactory.callEntityChangeBlockEvent( + entity, entity.blockPosition(), entity.blockState)); + assertEquals(2, changes.size()); + var landing = changes.getLast(); + assertSame(entity.getBukkitEntity(), landing.getEntity()); + assertEquals(Material.DAMAGED_ANVIL, landing.getTo()); + assertEquals(f.actor.position, ((org.bukkit.craftbukkit.block.CraftBlock) landing.getBlock()).getPosition()); + var record = entity.getBukkitEntity().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "falling_reinforcement"), PersistentDataType.TAG_CONTAINER); + assertNotNull(record); + assertEquals((byte) 2, record.get(new NamespacedKey("spigotinventoryhelper", "transport_phase"), PersistentDataType.BYTE)); + assertFalse(f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3)); + assertEquals(Material.AIR, effects.block.getType()); + // Inspected FallingBlockEntity.tick branch, but NOT a claim to run physics/tick. + entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DROP); + assertTrue(entity.isRemoved()); + var supplied = entity.spawnAtLocation(f.actor.level, entity.blockState.getBlock()); + f.actor.assertNoEventErrors(); + return supplied; + } + + boolean landOrdinaryReloaded(FallingBlockEntity entity) { + assertSame(restored, entity, "Only the explicitly supplied native reload candidate is supported"); + assertTrue(entity.isAlive()); + assertTrue(entity.valid); + assertFalse(hasPluginData(entity)); + assertSame(f.actor.level, entity.level()); + assertEquals(f.actor.position, entity.blockPosition()); + assertEquals(Material.AIR, effects.block.getType()); + int before = changes.size(); + boolean accepted = org.bukkit.craftbukkit.event.CraftEventFactory.callEntityChangeBlockEvent( + entity, entity.blockPosition(), entity.blockState); + assertEquals(before + 1, changes.size()); + var event = changes.getLast(); + assertSame(entity.getBukkitEntity(), event.getEntity()); + assertSame(f.actor.world, event.getBlock().getWorld()); + assertEquals(f.actor.position, ((org.bukkit.craftbukkit.block.CraftBlock) event.getBlock()).getPosition()); + assertEquals(Material.DAMAGED_ANVIL, event.getTo()); + if (accepted) assertTrue(f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3)); + entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); + f.actor.assertNoEventErrors(); + return accepted; + } + + void assertOnlyNativeCarrierSpawn() { + assertEquals(1, spawned.size()); + assertTrue(f.packets.drops.isEmpty()); + assertTrue(mockingDetails(f.actor.world).getInvocations().stream().noneMatch(inv -> + inv.getMethod().getName().equals("dropItem") || inv.getMethod().getName().equals("dropItemNaturally"))); + long spawns = mockingDetails(f.actor.level).getInvocations().stream() + .filter(inv -> inv.getMethod().getName().startsWith("addFreshEntity")).count(); + assertEquals(1L, spawns, "Only pinned fall's one native carrier spawn is allowed"); + } + } + + private static NamespacedKey ownedKey() { + // Loaded-chunk persistence audit only; gameplay proof uses registered events/packets. + return new NamespacedKey("spigotinventoryhelper", "anvil_10_65_7"); + } + + private static CraftPersistentDataContainer chunkData(NativeAnvilEffectsFixture effects) { + return assertInstanceOf(CraftPersistentDataContainer.class, effects.block.getChunk().getPersistentDataContainer()); + } + + private static CraftPersistentDataContainer reloadChunk(NativeAnvilEffectsFixture effects) throws java.io.IOException { + // Actual native binary PDC and nested ItemStack codecs, not a mocked state helper or + // a claim that the fixture runs chunk disk I/O/restarts a real server. + byte[] saved = chunkData(effects).serializeToBytes(); + var loaded = new DirtyCraftPersistentDataContainer(new CraftPersistentDataTypeRegistry()); + loaded.readFromBytes(saved, true); + when(effects.block.getChunk().getPersistentDataContainer()).thenReturn(loaded); + return loaded; + } + + private static Listener registeredStateListener(NativePowderSnowFixture f) { + var owners = org.bukkit.event.HandlerList.getRegisteredListeners(f.actor.plugin).stream() + .map(org.bukkit.plugin.RegisteredListener::getListener) + .filter(listener -> listener instanceof ReinforcementState).distinct().toList(); + assertEquals(1, owners.size(), "Actual registered reinforcement state owner"); + return owners.getFirst(); + } + + private static void reenableWithNativeChunkCodec(NativePowderSnowFixture f, NativeAnvilEffectsFixture effects, + Listener previous) throws java.io.IOException { + assertFalse(f.actor.plugin.isEnabled()); + assertTrue(org.bukkit.event.HandlerList.getRegisteredListeners(f.actor.plugin).stream() + .noneMatch(registered -> registered.getListener() == previous)); + reloadChunk(effects); // Real binary chunk + nested item codec, no durable-world/disk restart claim. + f.actor.plugin.setEnabled(true); + assertNotSame(previous, registeredStateListener(f), "Actual re-enable creates a FRESH listener/state owner"); + f.actor.assertNoEventErrors(); + } + + private static DirtyCraftPersistentDataContainer strictNativeChunkSpy(NativeAnvilEffectsFixture effects) + throws java.io.IOException { + var real = new DirtyCraftPersistentDataContainer(new CraftPersistentDataTypeRegistry()); + real.readFromBytes(chunkData(effects).serializeToBytes(), true); + // Pinned DirtyCraftPDC is FINAL: explicit local INLINE spy, not a subclass or a + // permissive fake container. No shared mock-maker/fixture/configuration change. + var backend = mock(DirtyCraftPersistentDataContainer.class, withSettings() + .mockMaker(org.mockito.MockMakers.INLINE).spiedInstance(real).defaultAnswer(CALLS_REAL_METHODS)); + assertEquals(real.toTagCompound(), backend.toTagCompound()); + when(effects.block.getChunk().getPersistentDataContainer()).thenReturn(backend); + return backend; + } + + private static void dispatchQualifyingOnlyExactPdcFault(NativePowderSnowFixture f, BlockPlaceEvent event, + RuntimeException injected) { + // PaperEventManager logs listener failures through SERVER logger, not necessarily + // the plugin logger captured by the shared fixture. Observe just this dispatch. + var failures = new ArrayList(); + var logger = f.actor.server.getLogger(); + var handler = new java.util.logging.Handler() { + @Override public void publish(java.util.logging.LogRecord record) { + if (record.getThrown() != null) failures.add(record.getThrown()); + } + @Override public void flush() {} + @Override public void close() {} + }; + logger.addHandler(handler); + try { + try { f.actor.events.callEvent(event); } + catch (RuntimeException failure) { assertSame(injected, failure, "Only the exact injected backend exception is qualified"); } + } finally { + logger.removeHandler(handler); + } + for (var failure : failures) assertTrue(isExactInjectedPdcFault(failure, injected), + () -> "Unexpected native listener failure: " + failure); + f.actor.eventErrors.removeIf(failure -> isExactInjectedPdcFault(failure, injected)); + f.actor.assertNoEventErrors(); // No blanket error clearing or ignored native setup failures. + } + + private static boolean isExactInjectedPdcFault(Throwable failure, RuntimeException injected) { + if (failure == injected) return true; + // Only the inspected native event-executor wrapper, with the EXACT injected cause. + return failure instanceof org.bukkit.event.EventException && failure.getCause() == injected + && failure.getSuppressed().length == 0; + } + + private static java.util.Map nativePossessions(NativePowderSnowFixture f) { + var stacks = new ArrayList(f.actor.realInventory.getContents()); + stacks.addAll(f.menu.craftSlots.getItems()); + stacks.add(f.actor.menu.getSlot(0).getItem()); + stacks.add(f.actor.menu.getSlot(1).getItem()); + stacks.add(f.menu.getCarried()); + stacks.add(f.actor.menu.getCarried()); + var active = f.actor.nativePlayer.containerMenu; + if (active != f.menu && active != f.actor.menu) stacks.add(active.getCarried()); + stacks.addAll(f.packets.drops); + // Native preview result slots are not possessions. Each authoritative input/cursor + // is counted once, with exact metadata retained in normalized detached item keys. + java.util.Map counts = new java.util.HashMap<>(); + for (var stack : stacks) { + if (!stack.isEmpty()) changePossessions(counts, CraftItemStack.asBukkitCopy(stack), stack.getCount()); + } + return java.util.Map.copyOf(counts); + } + + private static void changePossessions(java.util.Map counts, ItemStack item, int delta) { + var key = item.asQuantity(1); + int total = counts.getOrDefault(key, 0) + delta; + assertTrue(total >= 0, "Exact conservation cannot subtract an absent possession"); + if (total == 0) counts.remove(key); + else counts.put(key, total); + } + + private static ItemStack withdrawReturned(NativePowderSnowFixture f, ItemStack expected) { + // Qualified native inventory -> menu movement, never conjure replacement inputs + // after removed() returned them. Preserve real stacks and metadata on both sides. + int remaining = expected.getAmount(); + for (var stack : f.actor.realInventory.getContents()) { + if (stack.isEmpty() || !CraftItemStack.asBukkitCopy(stack).isSimilar(expected)) continue; + int moved = Math.min(remaining, stack.getCount()); + stack.shrink(moved); + remaining -= moved; + if (remaining == 0) return expected.clone(); + } + fail("Native close did not return the expected input: " + expected); + return expected; // Unreachable; keeps the return type explicit without weakening fail(). + } + + private static ItemStack markedAnvil() { + var source = new ItemStack(Material.CHIPPED_ANVIL); + var meta = source.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("Foreign named anvil")); + meta.lore(List.of(net.kyori.adventure.text.Component.text("Foreign lore"))); + meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "foreign"), PersistentDataType.STRING, "keep"); + source.setItemMeta(meta); + return source; + } + + private static ItemStack craft(NativePowderSnowFixture f, ItemStack source, int tiers) { + for (int tier = 1; tier <= tiers; tier++) { + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(source)); + f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.NETHERITE_BLOCK))); + f.packets.click(0, 0, ContainerInput.PICKUP); + assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()); + f.actor.tick(); + source = ItemStack.deserializeBytes(CraftItemStack.asBukkitCopy(f.menu.getCarried()).serializeAsBytes()); + assertEquals(tier, source.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey("spigotinventoryhelper", "reinforcement"), PersistentDataType.INTEGER)); + assertEquals(0, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + assertEquals(0, NativePowderSnowFixture.count(f.menu.craftSlots, Material.CHIPPED_ANVIL)); + } + return source; + } + + private static void place(NativePowderSnowFixture f, NativeAnvilEffectsFixture effects, ItemStack item) { + place(f, effects.block, item, effects.data::set); + } + + private static void place(NativePowderSnowFixture f, org.bukkit.block.Block block, ItemStack item, + java.util.function.Consumer nativePlacement) { + assertEquals(Material.AIR, block.getType()); + var position = new BlockPos(block.getX(), block.getY(), block.getZ()); + var replaced = CraftBlockStates.getBlockState(f.actor.world, position, Blocks.AIR.defaultBlockState(), null); + // Native player/world hand execution is a qualified boundary: move the serialized + // source from the craft cursor into the real hand, then pay only after acceptance. + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + var held = ItemStack.deserializeBytes(item.serializeAsBytes()); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(held)); + nativePlacement.accept(CraftBlockData.fromString(item.getType().asBlockType(), null)); + var event = new BlockPlaceEvent(block, replaced, block, + held, f.actor.player, true, org.bukkit.inventory.EquipmentSlot.HAND); + f.actor.events.callEvent(event); + assertFalse(event.isCancelled()); + assertTrue(event.canBuild()); + f.actor.realInventory.getItem(0).shrink(1); + f.actor.tick(); + } + + private static void openAnvil(NativePowderSnowFixture f) { + f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView())); + f.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY); + f.actor.nativePlayer.containerMenu = f.actor.menu; + } + + private static void assertRisk(NativePowderSnowFixture f, int percent) { + assertRisk(f, percent, "Unexpected native effective risk"); + } + + private static void assertRisk(NativePowderSnowFixture f, int percent, String boundary) { + String risk = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() + .serialize(f.actor.actionBars.getLast()); + assertTrue(risk.contains("Failure " + percent + "%"), boundary + ": " + risk); + } + + private static boolean assertOrdinaryRiskOrWithheld(NativePowderSnowFixture f, int percent, String boundary) { + var output = f.actor.output(); + if (output == null || output.getType().isAir()) { + assertFalse(f.actor.menu.bypassCost, "Unknown ownership must withhold the enhanced/native XP bypass"); + return false; // Conservatively withheld authority is allowed; successful cleanup is not invented. + } + assertRisk(f, percent, boundary); + return true; + } + + private static org.bukkit.craftbukkit.entity.CraftItem dropPort(NativePowderSnowFixture f, ItemStack initial) { + // Only entity/world execution is doubled. CraftItem mirrors and mutates a REAL NMS + // ItemStack, so metadata and item serialization are never supplied by our state helper. + var stack = new AtomicReference<>(CraftItemStack.asNMSCopy(ItemStack.deserializeBytes(initial.serializeAsBytes()))); + var entity = mock(net.minecraft.world.entity.item.ItemEntity.class); + org.mockito.Mockito.doReturn(net.minecraft.world.entity.EntityTypes.ITEM).when(entity).getType(); + when(entity.level()).thenReturn(f.actor.level); + // CraftEntity.isDead delegates to native isAlive; this port represents a live supplied drop. + when(entity.isAlive()).thenReturn(true); + when(entity.getItem()).thenAnswer(ignored -> stack.get()); + doAnswer(inv -> { stack.set(inv.getArgument(0).copy()); return null; }) + .when(entity).setItem(any(net.minecraft.world.item.ItemStack.class)); + return new org.bukkit.craftbukkit.entity.CraftItem((org.bukkit.craftbukkit.CraftServer) f.actor.server, entity); + } + + private static void assertNoMint(NativePowderSnowFixture f) { + assertTrue(f.packets.drops.isEmpty()); + assertTrue(mockingDetails(f.actor.world).getInvocations().stream().noneMatch(inv -> + inv.getMethod().getName().equals("dropItem") || inv.getMethod().getName().equals("dropItemNaturally")), + "Lifecycle must not manufacture world drops outside the native supplied list"); + assertTrue(mockingDetails(f.actor.level).getInvocations().stream().noneMatch(inv -> + inv.getMethod().getName().startsWith("addFreshEntity")), "Lifecycle must not spawn a duplicate entity"); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {2, 3}) + void actualRegisteredMiscRecipeIsUniversallyDiscoveredAndAutofillsWithoutMintingProtection(int width) throws Exception { + var rolls = new java.util.concurrent.atomic.AtomicInteger(); + try (var f = new NativePowderSnowFixture(directory, width, () -> { rolls.incrementAndGet(); return 0; })) { + var recipe = assertInstanceOf(ShapelessRecipe.class, f.registered.get(KEY)); + assertEquals(CraftingBookCategory.MISC, recipe.getCategory()); + assertEquals(java.util.List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL), + assertInstanceOf(RecipeChoice.MaterialChoice.class, recipe.getChoiceList().getFirst()).getChoices()); + assertEquals(java.util.List.of(Material.NETHERITE_BLOCK), + assertInstanceOf(RecipeChoice.MaterialChoice.class, recipe.getChoiceList().getLast()).getChoices()); + assertTrue(f.book.contains(NativePowderSnowFixture.nativeKey(KEY))); + f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.ANVIL))); + f.actor.realInventory.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK))); + var holder = f.holders.get(KEY); + assertNotNull(holder); + f.menu.handlePlacement(false, false, holder, f.actor.level, f.actor.realInventory); + var output = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()); + assertEquals(Material.ANVIL, output.getType()); + assertFalse(output.getItemMeta().getPersistentDataContainer().has(new NamespacedKey("spigotinventoryhelper", "reinforcement")), + "Autofill/prepare must not create usable reinforcement authority"); + assertTrue(f.menu.getCarried().isEmpty()); + assertEquals(1, NativePowderSnowFixture.count(f.menu.craftSlots, Material.ANVIL)); + assertEquals(1, NativePowderSnowFixture.count(f.menu.craftSlots, Material.NETHERITE_BLOCK)); + assertEquals(0, rolls.get(), "Actual discovery/book/autofill/preview do not commit even a forced losing roll"); + f.actor.assertNoEventErrors(); + } + } +} diff --git a/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java index a7f830a..d3b2eeb 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/AdvancedAnvilSettingsTest.java @@ -116,13 +116,28 @@ final class AdvancedAnvilSettingsTest { assertEquals(List.of(BASE + " = 20"), fixture.messages); fixture.messages.clear(); callbacks.getLast().run(); + // A saved bundle is not publication authority for its neighboring keys. + AdvancedAnvilPolicy baseOnly = new AdvancedAnvilPolicy(42, 5, 99); + AdvancedAnvilPolicy withStep = new AdvancedAnvilPolicy(42, 7, 99); 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(baseOnly, settings.policy()); + assertEquals(List.of(baseOnly), changes); assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages); + callbacks.getFirst().run(); // Superseded BASE=31 cannot regress BASE=42. + assertEquals(baseOnly, settings.policy()); + assertEquals(List.of(baseOnly), changes); + assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages); + callbacks.get(1).run(); // STEP needs its own current acknowledgment. + assertEquals(withStep, settings.policy()); + assertEquals(List.of(baseOnly, withStep), changes); + assertEquals(List.of(BASE + " = 42 (saved)", STEP + " = 7 (saved)"), fixture.messages); + callbacks.get(2).run(); // CAP likewise publishes only its own key. + assertEquals(expected, settings.policy()); + callbacks.getLast().run(); // Repeated BASE=42 cannot duplicate publication. + assertEquals(expected, settings.policy()); + assertEquals(List.of(baseOnly, withStep, expected), changes); + assertEquals(List.of(BASE + " = 42 (saved)", STEP + " = 7 (saved)", + CAP + " = 100 (saved)"), fixture.messages); try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) { assertEquals(expected, restarted.policy()); } @@ -267,7 +282,12 @@ final class AdvancedAnvilSettingsTest { 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(BASE, STEP, CAP, + "advanced-anvil-reinforcement-failure-1-percent", + "advanced-anvil-reinforcement-failure-2-percent", + "advanced-anvil-reinforcement-failure-3-percent", + "advanced-anvil-reinforcement-failure-4-percent", + "advanced-anvil-reinforcement-failure-5-percent"), 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"; @@ -294,18 +314,92 @@ final class AdvancedAnvilSettingsTest { } } - @Test - void permissionRevokedBeforeAcknowledgmentDoesNotLeakSavedValue() throws Exception { + @ParameterizedTest + @ValueSource(strings = {BASE, STEP, CAP}) + void permissionRevokedBeforeExistingKeyAcknowledgmentRetainsPriorLivePolicy(String key) throws Exception { Fixture fixture = new Fixture(directory); List changes = new ArrayList<>(); try (AdvancedAnvilSettings settings = fixture.open(changes::add)) { - fixture.execute("config", BASE, "37"); + fixture.execute("config", key, "37"); Runnable acknowledgment = fixture.nextAcknowledgment(); + assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy()); + // Retain the real save proof: denial invalidates runtime authority, not disk history. + try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) { + assertEquals(key.equals(BASE) ? new AdvancedAnvilPolicy(37, 5, 99) + : key.equals(STEP) ? new AdvancedAnvilPolicy(20, 37, 99) + : new AdvancedAnvilPolicy(20, 5, 37), loaded.policy()); + } when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false); acknowledgment.run(); - assertEquals(37, settings.policy().basePercent()); - assertEquals(1, changes.size()); + acknowledgment.run(); + assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy(), + "RED: unauthorized acknowledgment must not publish even an existing combine key"); + assertTrue(changes.isEmpty()); + assertTrue(fixture.messages.isEmpty(), "No success feedback to revoked sender"); + } + } + + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + BASE + ",denied", BASE + ",disconnected", BASE + ",disabled", + STEP + ",denied", STEP + ",disconnected", STEP + ",disabled", + CAP + ",denied", CAP + ",disconnected", CAP + ",disabled"}) + void existingCombineKeyPlayerCallbacksRequireCurrentPermissionConnectionAndPluginLifetime( + String key, String lostAuthority) throws Exception { + Player player = mock(Player.class); + Fixture fixture = new Fixture(directory, player); + List changes = new ArrayList<>(); + try (AdvancedAnvilSettings settings = fixture.open(changes::add)) { + assertTrue(player.isOnline()); + fixture.execute("config", key, "37"); // Actual bound PluginCommand, not executor/policy injection. + Runnable acknowledgment = fixture.nextAcknowledgment(); + AdvancedAnvilPolicy saved = key.equals(BASE) ? new AdvancedAnvilPolicy(37, 5, 99) + : key.equals(STEP) ? new AdvancedAnvilPolicy(20, 37, 99) + : new AdvancedAnvilPolicy(20, 5, 37); + try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) { + assertEquals(saved, loaded.policy(), "Queued callback follows the real asynchronous durable save"); + } + assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy()); + assertTrue(changes.isEmpty()); assertTrue(fixture.messages.isEmpty()); + switch (lostAuthority) { + case "denied" -> when(player.hasPermission(PERMISSION)).thenReturn(false); + case "disconnected" -> when(player.isOnline()).thenReturn(false); + case "disabled" -> when(fixture.plugin.isEnabled()).thenReturn(false); + default -> fail("Unknown lifetime boundary " + lostAuthority); + } + acknowledgment.run(); + acknowledgment.run(); + assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy(), + "RED: stale existing-key callback retains prior live policy after " + lostAuthority); + assertTrue(changes.isEmpty(), "No gameplay publication after authority/lifetime loss"); + assertTrue(fixture.messages.isEmpty(), "No success feedback after authority/lifetime loss"); + } + } + + @Test + void registeredReinforcementPolicyAbaPublishesBothDurablyAcknowledgedChanges() throws Exception { + Fixture fixture = new Fixture(directory); + List changes = new ArrayList<>(); + String key = "advanced-anvil-reinforcement-failure-1-percent"; + AdvancedAnvilPolicy initial = AdvancedAnvilPolicy.defaults(); + try (AdvancedAnvilSettings settings = fixture.open(changes::add)) { + for (int percent : new int[] {21, 20}) { + fixture.messages.clear(); + fixture.execute("config", key, Integer.toString(percent)); + Runnable acknowledgment = fixture.nextAcknowledgment(); + assertEquals(changes.isEmpty() ? initial : changes.getLast(), settings.policy()); + assertTrue(fixture.messages.isEmpty(), "No publication feedback before durable acknowledgment"); + try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) { + assertEquals(percent, loaded.policy().reinforcementFailure(0)); + } + acknowledgment.run(); + assertEquals(percent, settings.policy().reinforcementFailure(0)); + assertEquals(List.of(key + " = " + percent + " (saved)"), fixture.messages); + } + assertEquals(List.of(new AdvancedAnvilPolicy(20, 5, 99, List.of(21, 40, 60, 80, 99)), initial), changes, + "A→B→A is two real authorized policy publications, not an equality-only no-op"); + assertEquals(initial, settings.policy()); } } @@ -335,6 +429,80 @@ final class AdvancedAnvilSettingsTest { } } + @Test + void reinforcementKeysReadDefaultTableAndPersistValidatedIndividualUpdates() throws Exception { + Fixture fixture = new Fixture(directory); + try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) { + assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy()); + int[] defaults = {20, 40, 60, 80, 99}; + for (int tier = 1; tier <= 5; tier++) { + String key = "advanced-anvil-reinforcement-failure-" + tier + "-percent"; + fixture.messages.clear(); + fixture.execute("config", key); + assertEquals(List.of(key + " = " + defaults[tier - 1]), fixture.messages); + assertTrue(fixture.complete("config", "").contains(key)); + assertEquals(List.of(Integer.toString(defaults[tier - 1]), "0", "100"), + fixture.complete("config", key, "")); + } + String key = "advanced-anvil-reinforcement-failure-3-percent"; + fixture.messages.clear(); + fixture.execute("config", key, "65"); + Runnable ack = fixture.nextAcknowledgment(); + fixture.execute("config", key); + assertEquals(List.of(key + " = 60"), fixture.messages); + fixture.messages.clear(); + ack.run(); + assertEquals(List.of(key + " = 65 (saved)"), fixture.messages); + fixture.messages.clear(); + fixture.execute("config", key, "81"); + fixture.nextAcknowledgment().run(); + fixture.execute("config", key); + assertTrue(fixture.messages.contains(key + " = 65")); + assertFalse(fixture.messages.stream().anyMatch(m -> m.contains("81 (saved)"))); + assertTrue(Files.readString(directory.resolve("config.yml")).contains(key + ": 65")); + } + Fixture restart = new Fixture(directory); + try (AdvancedAnvilSettings loaded = restart.open(policy -> {})) { + assertNotNull(loaded.policy()); + restart.execute("config", "advanced-anvil-reinforcement-failure-3-percent"); + assertEquals(List.of("advanced-anvil-reinforcement-failure-3-percent = 65"), restart.messages); + } + } + + @Test + void reinforcementAcknowledgmentsCannotPublishDeniedNeighborOrChangeWithoutCurrentPermission() throws Exception { + Fixture fixture = new Fixture(directory); + List changes = new ArrayList<>(); + String first = "advanced-anvil-reinforcement-failure-1-percent"; + String second = "advanced-anvil-reinforcement-failure-2-percent"; + try (AdvancedAnvilSettings settings = fixture.open(changes::add)) { + fixture.execute("config", first, "30"); + Runnable denied = fixture.nextAcknowledgment(); + when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false); + denied.run(); + assertEquals(20, settings.policy().reinforcementFailure(0)); + assertTrue(changes.isEmpty()); + when(fixture.sender.hasPermission(PERMISSION)).thenReturn(true); + fixture.execute("config", second, "50"); + fixture.nextAcknowledgment().run(); + assertEquals(20, settings.policy().reinforcementFailure(0), "Only the acknowledged key may publish"); + assertEquals(50, settings.policy().reinforcementFailure(1)); + assertEquals(1, changes.size()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "101", "20.0", "'20'", "true", "null"}) + void malformedReinforcementConfigurationFailsClosedForEveryTier(String raw) throws Exception { + for (int tier = 1; tier <= 5; tier++) { + String key = "advanced-anvil-reinforcement-failure-" + tier + "-percent"; + Files.writeString(directory.resolve("config.yml"), key + ": " + raw + "\n"); + assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> fail("Partial installation"))); + } + Files.writeString(directory.resolve("config.yml"), "advanced-anvil-reinforcement-failure-3-percent: 81\n"); + assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> {})); + } + private static final class Fixture { private final Plugin plugin = mock(Plugin.class); private final BukkitScheduler scheduler = mock(BukkitScheduler.class); @@ -357,6 +525,7 @@ final class AdvancedAnvilSettingsTest { when(plugin.getServer()).thenReturn(server); when(server.getScheduler()).thenReturn(scheduler); when(sender.hasPermission(PERMISSION)).thenReturn(true); + if (sender instanceof Player player) when(player.isOnline()).thenReturn(true); doAnswer(call -> { assertSame(mainThread, Thread.currentThread(), "Feedback must stay on main thread"); messages.add(call.getArgument(0, String.class)); diff --git a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java index e3027a6..908539b 100644 --- a/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotinventoryhelper/PurpurDistributionTest.java @@ -30,7 +30,12 @@ class PurpurDistributionTest { 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); + "advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99, + "advanced-anvil-reinforcement-failure-1-percent", 20, + "advanced-anvil-reinforcement-failure-2-percent", 40, + "advanced-anvil-reinforcement-failure-3-percent", 60, + "advanced-anvil-reinforcement-failure-4-percent", 80, + "advanced-anvil-reinforcement-failure-5-percent", 99), config); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.class")); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilListener.class")); assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class"));