feat(anvil): reinforce anvils for safer advanced combining
Release / release (push) Successful in 2m1s
CI / build (push) Successful in 1m26s

This commit was merged in pull request #5.
This commit is contained in:
dmg committed 2026-10-13 16:47:58 +00:00
commit 8d21617553
24 files changed
+4584 -91

No files matched your search

+27 -2
View File
@@ -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.
@@ -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<org.bukkit.event.block.BlockBreakEvent> 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;
@@ -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<AdvancedAnvilPolicy> 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<AdvancedAnvilPolicy> 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();
@@ -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) {
@@ -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<Integer> 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() {
@@ -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<AnvilView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
private final Set<InventoryView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
// Only this immutable actor membership is read on an unexpectedly off-thread library callback.
private final Set<Player> actors = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final Map<AnvilView, Admission> admissions = new IdentityHashMap<>();
private final Map<InventoryView, Admission> admissions = new IdentityHashMap<>();
private final Map<Player, NativeAnvilDispatch> dispatches = new IdentityHashMap<>();
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
}
private final Map<AnvilView, Frame> frames = new IdentityHashMap<>();
private final Map<InventoryView, Frame> frames = new IdentityHashMap<>();
// Guard-wide, not reset on forget: even a reused window ID cannot reuse a displayed version.
private final AnvilFrameVersions versions = new AnvilFrameVersions();
private final PacketAdapter outbound;
@@ -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.
@@ -36,7 +36,12 @@ final class AdvancedAnvilSettings implements AutoCloseable {
private static final List<String> 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<AdvancedAnvilPolicy> 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<AdvancedAnvilPolicy> 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<Object, Object> 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. */
@@ -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) {
@@ -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<AdvancedAnvilPolicy> policy;
private final IntSupplier risk;
private final ReinforcementRecipeRegistrar recipe;
private final AdvancedAnvilProtocolGuard protocol;
private final ReinforcementItems items;
private final Map<InventoryView, Preview> previews = new IdentityHashMap<>();
private final Map<InventoryView, Attempt> pending = new IdentityHashMap<>();
private final Map<InventoryClickEvent, Boolean> 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<AdvancedAnvilPolicy> 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);
}
}
}
@@ -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<Component>() : 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<Component>() : 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<Component> 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"); }
}
@@ -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<NamespacedKey> 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;
}
}
@@ -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<UUID, Object> landings = new HashMap<>();
private final Map<UUID, org.bukkit.event.entity.EntityDropItemEvent> dropping = new HashMap<>();
private final Map<Position, Object> transitions = new HashMap<>();
private final Map<Position, Breakage> 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<Drop> 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<Block> candidates, java.util.function.BooleanSupplier accepted, List<Block> 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);
}
}
@@ -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() {
+6
View File
@@ -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
@@ -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);
@@ -93,6 +93,7 @@ final class NativeAnvilFixture implements AutoCloseable {
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
final List<com.comphenix.protocol.events.PacketListener> protocolListeners = new ArrayList<>();
private final Map<Integer, Integer> displayedStates = new HashMap<>();
private final List<SharedAnvilActor> 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<Component> actionBars = new ArrayList<>();
final List<org.bukkit.event.inventory.InventoryClickEvent> clicks = new ArrayList<>();
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
private final Map<Integer, Integer> 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<Integer, Integer> 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);
@@ -287,7 +287,7 @@ class NativeAnvilFrameGenerationTest {
&& slot.getContainerId() == packet.anvil.menu.containerId).count();
}
private static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
T packet) throws ReflectiveOperationException {
// Paper's real item sanitizer consults the server registry during class initialization.
// Supply only that external registry port, and restore the singleton even on failure.
@@ -316,6 +316,7 @@ class NativeCobwebPluginWiringTest {
private static Set<NamespacedKey> expectedKnown() {
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
expected.add(COBWEB);
expected.add(NativeReinforcementCraftTest.KEY);
expected.add(VANILLA);
return expected;
}
@@ -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<net.minecraft.world.item.crafting.display.RecipeDisplayEntry>();
f.manager.listDisplaysForRecipe(holder.id(), displays::add);
assertEquals(1, displays.size());
var target = displays.getFirst();
var entries = new java.util.ArrayList<ClientboundRecipeBookAddPacket.Entry>();
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<String>();
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<String> 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");
}
@@ -47,9 +47,20 @@ final class NativePowderSnowFixture implements AutoCloseable {
final Map<NamespacedKey, RecipeHolder<CraftingRecipe>> holders = new HashMap<>();
final List<PlayerRecipeDiscoverEvent> 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<RecipeHolder<?>>(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) {
@@ -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<org.bukkit.inventory.ItemStack, Integer> shutdownPossessions(NativePowderSnowFixture f) {
var stacks = new ArrayList<net.minecraft.world.item.ItemStack>();
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<org.bukkit.inventory.ItemStack, Integer> 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<NamespacedKey> expectedKnown() {
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
expected.add(POWDER);
expected.add(NativeReinforcementCraftTest.KEY); // Approved additional independently discoverable recipe.
expected.add(VANILLA);
return expected;
}
@@ -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<AdvancedAnvilPolicy> 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<AdvancedAnvilPolicy> 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<AdvancedAnvilPolicy> 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<AdvancedAnvilPolicy> 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));
@@ -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"));