Compare commits

..
12 Commits
Author SHA1 Message Date
dmg ed02f46272 docs: point story tracking to Kaneo
CI / build (push) Successful in 1m51s
Release / release (push) Successful in 1m59s
2026-10-28 20:36:17 -04:00
dmg e4b2e004cd feat(platform): support Purpur 26.3
Release / release (push) Successful in 2m6s
CI / build (push) Successful in 58s
2026-10-17 04:03:03 +00:00
dmg 95a7ac41e1 feat(platform): support Purpur 26.3
CI / build (push) Successful in 54s
CI / build (pull_request) Successful in 1m43s
2026-10-16 23:46:50 -04:00
dmg 8d21617553 feat(anvil): reinforce anvils for safer advanced combining
Release / release (push) Successful in 2m1s
CI / build (push) Successful in 1m26s
2026-10-13 16:47:58 +00:00
dmg 856c707637 feat(anvil): reinforce anvils for safer advanced combining
CI / build (push) Successful in 1m35s
CI / build (pull_request) Successful in 1m38s
2026-10-13 12:45:41 -04:00
dmg f5298543d0 fix(inventory-helper): restore native and advanced anvil combining
Release / release (push) Successful in 1m49s
CI / build (push) Successful in 1m22s
US-010: 275 automated cases pass; exact candidate tree d0497330ba. Final security/release/live gates separate.
2026-10-13 01:16:27 +00:00
dmg 990028c728 fix(anvil): finalize queued dispatch ownership safely
CI / build (push) Successful in 1m26s
CI / build (pull_request) Successful in 1m28s
2026-10-12 21:11:48 -04:00
dmg 09bc15e979 fix(inventory-helper): restore native and advanced anvil combining
CI / build (push) Successful in 1m23s
CI / build (pull_request) Successful in 1m23s
2026-10-12 20:03:25 -04:00
dmg a08754a3aa feat(crafting): craft powder snow from snow and a bucket (#3)
Release / release (push) Successful in 1m44s
CI / build (push) Successful in 1m21s
2026-10-12 13:22:36 +00:00
dmg b4613d2abf feat(crafting): craft powder snow from snow and a bucket
CI / build (push) Successful in 1m4s
CI / build (pull_request) Successful in 2m3s
2026-10-12 09:18:37 -04:00
dmg 1cd894f980 feat(anvil): combine items beyond enchantment limits (#2)
Release / release (push) Successful in 3m18s
CI / build (push) Successful in 50s
2026-10-12 03:59:48 +00:00
dmg de045fb458 feat(anvil): combine items beyond enchantment limits
CI / build (pull_request) Successful in 2m27s
CI / build (push) Successful in 1m59s
2026-10-11 23:51:17 -04:00
47 changed files with 11941 additions and 38 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
# purpur-inventory-helper agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-inventory-helper/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-inventory-helper/` stories. Also follow the parent workspace `AGENTS.md` when present.
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-inventory-helper] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-inventory-helper/index.md`, `engineering.md` in that project section, and relevant Kaneo tickets with subject prefix `[purpur-inventory-helper]`. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+93 -6
View File
@@ -1,6 +1,6 @@
# Purpur Inventory Helper
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
A Purpur 26.3 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
@@ -8,8 +8,9 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
## Requirements
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- Java 25 or newer
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
## Build
@@ -19,14 +20,15 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
The plugin JAR is written to `build/libs/`.
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.2 build 2618 launcher,
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.3 build 2651 launcher,
verifies SHA-256
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in
`0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and runs Paperclip in
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
The narrowly scoped anvil-generation adapter also uses this checksum-pinned native runtime as a
compile-only dependency. Server/API and ProtocolLib classes are not bundled.
CraftServer, CraftPlayer, and client boundaries are doubled; byte I/O is a controlled port. Anvil tests execute actual ProtocolLib forwarding, one registered local Netty pipeline/event loop, the declared library scheduler with a queued main-task port, and the real native packet queue, including final cancellation/replacement, skipped handling, owner disable and late-forward retirement. This is not evidence of live client rendering, search interaction, socket/installed-stack packet transport, or a full server lifecycle. Those remain
non-blocking live checks.
## Sorting Stick recipe
@@ -49,6 +51,22 @@ existing manual crafting and inventory behavior remain available.
One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration.
## Powder Snow recipe
One ordinary Snow Block + empty Bucket produces one ordinary Powder Snow Bucket through a
shapeless recipe in both the personal 2×2 and crafting-table 3×3 grids. All players discover it
in the recipe book's `MISC` category after successful registration. Native autofill and ordinary
or shift/batch crafting consume one Snow Block and one Bucket per output, with **no additional
empty Bucket remainder**. The filled Bucket is unstackable; native output-space and cancellation
rules remain authoritative. Placement, recovery and Powder Snow hazards are unchanged.
The recipe owns only `spigotinventoryhelper:snow_powder_snow` and its discovery listener.
Conflicts/failures withhold its discovery without disabling other features; discovery can retry
on later joins and successful re-enable. Disable removes owned registration/listeners, not native
recipe-book knowledge or unrelated recipes. Native automated tests cover both grids, actual
registered events/packet completion, metadata/quantity conservation and failure boundaries;
real-client visuals and live placement/recovery remain unperformed non-blocking follow-ups.
## Sorting inventories
Hold an authentic Sorting Stick and right-click:
@@ -63,6 +81,75 @@ 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.
Within-limit combinations (for example Sharpness I + I → II or IV + IV → V) stay entirely vanilla, including normal XP/prior-work costs, naming, repair and refusals. Removing advanced inputs and inserting normal items switches back immediately; **no closing/reopening or confirmation is required**. Old advanced clicks cannot take a newly prepared normal result.
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
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**:
| Excess | Failure chance |
| --- | --- |
| 0 | Native combining; no enhanced roll |
| 1 | 20% |
| 2 | 25% |
| 3 | 35% |
| 4 | 50% |
| 5 | 70% |
| 6 | 95% |
| 7 or more | 99% |
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.3 build 2651 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
Administrators with `spigotinventoryhelper.admin` (default OP) and authorized console can read/set durable balance values:
```text
/inventoryhelperadmin config <key>
/inventoryhelperadmin config <key> <value>
```
| Key in `plugins/SpigotInventoryHelper/config.yml` | Default |
| --- | --- |
| `advanced-anvil-base-failure-percent` | 20 |
| `advanced-anvil-risk-step-percent` | 5 |
| `advanced-anvil-max-failure-percent` | 99 |
| `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 |
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.
## Releases
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
+12 -7
View File
@@ -26,9 +26,11 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -47,20 +49,20 @@ tasks.test {
// Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved.
val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime")
val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
val launcher = inventoryRuntime.map { it.file("purpur-26.2-2618.jar") }
val launcher = inventoryRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download")
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download")
.toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
@@ -68,7 +70,7 @@ val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") {
dependsOn(downloadInventoryRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(inventoryRuntime.map { it.file("purpur-26.2-2618.jar") })
classpath = files(inventoryRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(inventoryRuntime)
@@ -77,6 +79,7 @@ val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime"
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
@@ -87,6 +90,8 @@ dependencies {
val nativeRuntimeJars = files(fileTree(inventoryRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareInventoryRuntime)
// US-009's narrowly pinned generation adapter is compile-only native linkage, never bundled.
sourceSets.main.get().compileClasspath += nativeRuntimeJars
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeInventoryTest = tasks.register<Test>("nativeInventoryTest") {
@@ -0,0 +1,125 @@
package games.dmg.spigotinventoryhelper;
import java.util.Arrays;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.ItemStack;
/** Main-thread destination reservation: no overflow drops, no overwrite of unrelated items. */
final class AdvancedAnvilDelivery {
private final Player player;
private final ItemStack cursorBefore;
private final ItemStack cursorAfter;
private final ItemStack[] storageBefore;
private final ItemStack[] storageAfter;
private final ItemStack offhandBefore;
private final ItemStack offhandAfter;
private final boolean cursorDelivery;
private final boolean offhandDelivery;
private AdvancedAnvilDelivery(Player player, ItemStack cursorBefore, ItemStack cursorAfter,
ItemStack[] storageBefore, ItemStack[] storageAfter, ItemStack offhandBefore,
ItemStack offhandAfter, boolean cursorDelivery, boolean offhandDelivery) {
this.player = player;
this.cursorBefore = cursorBefore;
this.cursorAfter = cursorAfter;
this.storageBefore = storageBefore;
this.storageAfter = storageAfter;
this.offhandBefore = offhandBefore;
this.offhandAfter = offhandAfter;
this.cursorDelivery = cursorDelivery;
this.offhandDelivery = offhandDelivery;
}
static AdvancedAnvilDelivery reserve(InventoryClickEvent event, ItemStack output) {
if (!(event.getWhoClicked() instanceof Player player) || event.getRawSlot() != 2) return null;
ItemStack cursor = copy(player.getItemOnCursor());
switch (event.getAction()) {
case PICKUP_ALL, PICKUP_HALF, PICKUP_ONE, PICKUP_SOME -> {
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) return null;
ItemStack next = copy(output);
if (cursor != null) {
if (!cursor.isSimilar(output) || cursor.getAmount() >= cursor.getMaxStackSize()) return null;
next = cursor.clone();
next.setAmount(next.getAmount() + 1);
}
return new AdvancedAnvilDelivery(player, cursor, next, null, null, null, null, true, false);
}
case MOVE_TO_OTHER_INVENTORY -> {
ItemStack[] before = copies(player.getInventory().getStorageContents());
ItemStack[] after = copies(before);
if (!allocate(after, output)) return null;
return new AdvancedAnvilDelivery(player, cursor, null, before, after, null, null, false, false);
}
case HOTBAR_SWAP, HOTBAR_MOVE_AND_READD -> {
int target = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
if ((target < 0 || target > 8) && target != 40) return null;
ItemStack[] before = copies(player.getInventory().getStorageContents());
ItemStack[] after = copies(before);
boolean offhand = target == 40;
ItemStack old = offhand ? copy(player.getInventory().getItemInOffHand()) : copy(before[target]);
if (!offhand) after[target] = output.clone();
if (old != null && !allocate(after, old)) return null;
return new AdvancedAnvilDelivery(player, cursor, null, before, after,
offhand ? old : null, offhand ? output.clone() : null, false, offhand);
}
default -> { return null; }
}
}
boolean stillValid() {
return Objects.equals(cursorBefore, copy(player.getItemOnCursor()))
&& (storageBefore == null || Arrays.equals(storageBefore,
copies(player.getInventory().getStorageContents())))
&& (!offhandDelivery || Objects.equals(offhandBefore,
copy(player.getInventory().getItemInOffHand())));
}
void deliver() {
if (cursorDelivery) player.setItemOnCursor(copy(cursorAfter));
if (storageAfter != null) {
for (int slot = 0; slot < storageAfter.length; slot++) {
if (!Objects.equals(storageBefore[slot], storageAfter[slot])) {
player.getInventory().setItem(slot, copy(storageAfter[slot]));
}
}
}
if (offhandDelivery) player.getInventory().setItemInOffHand(copy(offhandAfter));
}
/** Native anvil quick-move searches hotbar8..0 then storage35..9, merge pass before empty pass. */
private static boolean allocate(ItemStack[] storage, ItemStack source) {
ItemStack remaining = source.clone();
int[] order = new int[36];
for (int i = 0; i < 9; i++) order[i] = 8 - i;
for (int i = 9; i < 36; i++) order[i] = 44 - i;
for (int pass = 0; pass < 2; pass++) {
for (int slot : order) {
ItemStack present = storage[slot];
if (pass == 0 && present != null && present.isSimilar(remaining)) {
int moved = Math.min(remaining.getAmount(), present.getMaxStackSize() - present.getAmount());
if (moved > 0) {
present.setAmount(present.getAmount() + moved);
remaining.setAmount(remaining.getAmount() - moved);
}
} else if (pass == 1 && present == null) {
int moved = Math.min(remaining.getAmount(), remaining.getMaxStackSize());
storage[slot] = remaining.asQuantity(moved);
remaining.setAmount(remaining.getAmount() - moved);
}
if (remaining.getAmount() == 0) return true;
}
}
return false;
}
private static ItemStack copy(ItemStack item) {
return item == null || item.isEmpty() ? null : item.clone();
}
private static ItemStack[] copies(ItemStack[] items) {
return Arrays.stream(items).map(AdvancedAnvilDelivery::copy).toArray(ItemStack[]::new);
}
}
@@ -0,0 +1,87 @@
package games.dmg.spigotinventoryhelper;
import com.destroystokyo.paper.event.block.AnvilDamagedEvent;
import java.util.function.DoubleSupplier;
import org.bukkit.Effect;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.entity.Player;
import org.bukkit.inventory.view.AnvilView;
/** Native protection-aware side effects, invoked only after a committed inventory transaction. */
final class AdvancedAnvilEffects {
private AdvancedAnvilEffects() {}
static void explode(Player player, Location origin) {
// Purpur's final flag is essential: the combining player must remain damage-eligible.
origin.getWorld().createExplosion(player, origin.clone().add(.5, .5, .5),
6F, false, true, false);
}
static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear) {
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) {
Material next = switch (type) {
case ANVIL -> Material.CHIPPED_ANVIL;
case CHIPPED_ANVIL -> Material.DAMAGED_ANVIL;
default -> null;
};
BlockData proposal = next == null ? null : next.createBlockData();
var event = new AnvilDamagedEvent(view, proposal);
if (!event.callEvent() || !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);
return;
}
BlockData chosen = event.getDamageState().getMaterial().createBlockData();
if (chosen instanceof Directional facing && block.getBlockData() instanceof Directional old) {
facing.setFacing(old.getFacing());
}
block.setBlockData(chosen, false);
}
origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0);
}
/** 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;
}
}
@@ -0,0 +1,428 @@
package games.dmg.spigotinventoryhelper;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.PrepareAnvilEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.view.AnvilView;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Owns native preview and deferred, final-cancellation-aware one-item transactions. */
final class AdvancedAnvilListener implements Listener, AutoCloseable {
private static final Component WARNING = Component.text(
"Taking this result commits the attempt 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;
private final java.util.function.DoubleSupplier wear;
private final Map<AnvilView, Preview> previews = new IdentityHashMap<>();
private final Map<AnvilView, Attempt> pending = new IdentityHashMap<>();
private final Map<org.bukkit.event.inventory.InventoryClickEvent, Boolean> seen = new java.util.WeakHashMap<>();
private static final class Attempt {
final Preview preview;
final org.bukkit.event.inventory.InventoryClickEvent event;
final AdvancedAnvilDelivery delivery;
final AdvancedAnvilProtocolGuard.Admission admission;
boolean invalid;
Attempt(Preview preview, org.bukkit.event.inventory.InventoryClickEvent event,
AdvancedAnvilDelivery delivery, AdvancedAnvilProtocolGuard.Admission admission, boolean invalid) {
this.preview = preview;
this.event = event;
this.delivery = delivery;
this.admission = admission;
this.invalid = invalid;
}
}
private final AdvancedAnvilProtocolGuard protocol;
private final 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,
java.util.function.IntSupplier risk, java.util.function.DoubleSupplier wear,
com.comphenix.protocol.ProtocolManager manager, String version) {
this.plugin = Objects.requireNonNull(plugin);
this.policy = Objects.requireNonNull(policy);
this.risk = Objects.requireNonNull(risk);
this.wear = Objects.requireNonNull(wear);
protocol = new AdvancedAnvilProtocolGuard(plugin, manager, version, this);
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;
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void prepare(PrepareAnvilEvent event) {
if (closed) {
return;
}
AnvilView view = event.getView();
Attempt attempt = pending.get(view);
if (attempt != null) {
// Any intervening preparation invalidates the captured generation, even an ABA edit.
attempt.invalid = true;
protocol.invalidate(view);
event.setResult(null);
return;
}
Preview previous = previews.get(view);
ItemStack left = view.getTopInventory().getItem(0);
ItemStack right = view.getTopInventory().getItem(1);
AdvancedAnvilPolicy effective = policy.get();
AdvancedAnvilMerge.Proposal proposal = AdvancedAnvilMerge.prepare(left, right, effective);
if (proposal == null) {
if (previous != null) {
release(view);
// Prior bypass can alter vanilla createResult's Too Expensive decision before this
// event runs. Recompute once with restored flags instead of leaking an exemption.
recompute(view);
event.setResult(view.getTopInventory().getItem(2));
}
return;
}
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.
return;
}
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, ownedAnvil, proposal,
originalBypass, message) : previous);
if (view.getPlayer() instanceof Player player) {
player.sendActionBar(message);
if (changed(previous, proposal)) {
player.sendMessage(proposal.output() == null ? message : WARNING);
}
}
// Exact private risk is published before CraftEventFactory installs/broadcasts the output.
event.setResult(proposal.output());
if (proposal.output() != null) {
view.setRepairCost(0);
view.setBypassCost(true);
} else view.setBypassCost(originalBypass);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void intercept(org.bukkit.event.inventory.InventoryClickEvent event) {
if (closed || !(event.getView() instanceof AnvilView view)) return;
Preview preview = previews.get(view);
boolean collection = event.getAction() == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR;
if (event.getRawSlot() != 2 && !collection) return;
if (pending.containsKey(view)) {
if (pending.get(view).event != event) event.setCancelled(true);
view.getTopInventory().setItem(2, null);
return;
}
if (preview == null) return; // Normal output/XP/cancellation stay native, including after enhanced use.
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
var admission = protocol.consume(view, preview, event);
if (duplicate || admission == null) {
event.setCancelled(true);
if (preview != null) rerender(view);
return;
}
boolean stale = !matches(view, preview)
|| !Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2));
AdvancedAnvilDelivery delivery = preview.proposal().output() == null ? null
: AdvancedAnvilDelivery.reserve(event, preview.proposal().output());
Attempt attempt = new Attempt(preview, event, delivery, admission,
stale || event.isCancelled() || delivery == null);
pending.put(view, attempt);
// Do not self-cancel accepted intents: continuation can observe the final native denial.
// Empty output prevents native onTake, bulk loops and collection from producing/payment.
view.getTopInventory().setItem(2, null);
try {
plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt));
} catch (RuntimeException unavailable) {
pending.remove(view);
event.setCancelled(true);
rerender(view);
}
}
private void finish(AnvilView view, Attempt attempt) {
if (closed || pending.get(view) != attempt) return;
pending.remove(view);
if (attempt.invalid || attempt.event.isCancelled() || !attempt.admission.valid()
|| previews.get(view) != attempt.preview
|| !current(view) || !matches(view, attempt.preview)
|| !attempt.delivery.stillValid()) {
rerender(view);
return;
}
org.bukkit.Location origin = view.getTopInventory().getLocation();
if (origin == null || !isAnvil(origin.getBlock().getType())) {
rerender(view);
return;
}
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();
ItemStack left = view.getTopInventory().getItem(0);
ItemStack right = view.getTopInventory().getItem(1);
// Craft anvil getItem returns live native mirrors. Change both counts without invoking
// slotsChanged between debits, so close/death/prepare callbacks cannot return a paid item.
left.setAmount(left.getAmount() - 1);
right.setAmount(right.getAmount() - 1);
view.setBypassCost(attempt.preview.originalBypass());
try {
if (!failed) attempt.delivery.deliver();
try {
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);
}
}
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())
&& reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement());
}
private void rerender(AnvilView view) {
org.bukkit.Location origin = view.getTopInventory().getLocation();
if (current(view) && origin != null && isAnvil(origin.getBlock().getType())) {
recompute(view);
} else release(view);
}
private static boolean isAnvil(org.bukkit.Material material) {
return AdvancedAnvilEffects.isAnvil(material);
}
/** Durable settings acknowledgment invalidates snapshots through real input preparation. */
void policyChanged() {
if (closed) {
return;
}
if (crafting != null) crafting.policyChanged();
for (AnvilView view : List.copyOf(previews.keySet())) {
if (current(view)) {
recompute(view);
} else {
release(view);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void closeView(InventoryCloseEvent event) {
if (event.getView() instanceof AnvilView view) {
pending.remove(view);
release(view);
protocol.forget(view);
}
}
private void refreshActionBars() {
if (closed) {
return;
}
protocol.prune();
for (AnvilView view : List.copyOf(previews.keySet())) {
if (!current(view)) {
release(view);
continue;
}
Preview preview = previews.get(view);
// A changed input/policy cannot keep displaying a stale risk between preparations.
if (!Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|| !Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|| !preview.policy().equals(policy.get())
|| !reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement())) {
recompute(view);
} else if (view.getPlayer() instanceof Player player) {
player.sendActionBar(preview.message());
}
}
}
void protocolHookReady(AnvilView view) {
if (!closed && current(view) && !pending.containsKey(view)) recompute(view);
}
void protocolRejected(AnvilView view) {
// Exact native dispatch ended without a Bukkit admission (expiry/refusal/revocation).
// Correct client prediction only for this still-current window, never a reopened one.
if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory();
}
Object protocolPreview(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) {
Preview preview = previews.get(view);
return !closed && current(view) && !pending.containsKey(view)
&& (preview == null || (matches(view, preview)
&& Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2))));
}
private static boolean current(AnvilView view) {
return view.getPlayer().getOpenInventory() == view
&& (!(view.getPlayer() instanceof Player player) || (player.isOnline() && !player.isDead()));
}
private static void recompute(AnvilView view) {
ItemStack left = view.getTopInventory().getItem(0);
view.getTopInventory().setItem(0, left == null ? null : left.clone());
// Preparation has now returned through CraftEventFactory's final result installation.
// Identical-output policy changes still need a real header/frame, not merely a counter.
NativeAnvilGeneration.publish(view);
}
private void release(AnvilView view) {
protocol.invalidate(view);
Preview preview = previews.remove(view);
if (preview != null) {
view.setBypassCost(preview.originalBypass());
if (view.getPlayer() instanceof Player player) {
player.sendActionBar(Component.empty());
}
}
}
private static boolean changed(Preview previous, AdvancedAnvilMerge.Proposal next) {
return previous == null || !Objects.equals(previous.proposal().output(), next.output())
|| previous.proposal().failurePercent() != next.failurePercent()
|| !Objects.equals(previous.proposal().refusal(), next.refusal());
}
private static Component message(AdvancedAnvilMerge.Proposal proposal) {
if (proposal.output() == null) {
return Component.text("Advanced Anvil: " + proposal.refusal());
}
ItemStack output = proposal.output();
Component result = Component.text(title(output.getType().name()));
Component customName = output.getItemMeta().customName();
if (customName != null) {
result = result.append(Component.text(" (" )).append(customName).append(Component.text(")"));
}
String enchantments = output.getEnchantments().entrySet().stream()
.sorted(Map.Entry.comparingByKey(java.util.Comparator.comparing(
enchantment -> enchantment.getKey().toString())))
.map(entry -> title(entry.getKey().getKey().getKey()) + " " + roman(entry.getValue()))
.collect(Collectors.joining(" · "));
return result.append(Component.text(" · " + enchantments + " · Success "
+ (100 - proposal.failurePercent()) + "% · Failure " + proposal.failurePercent()
+ "% — explosion"));
}
private static String title(String identifier) {
return java.util.Arrays.stream(identifier.toLowerCase(Locale.ROOT).split("_"))
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
.collect(Collectors.joining(" "));
}
private static String roman(int level) {
int[] values = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
String[] digits = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};
StringBuilder result = new StringBuilder();
for (int index = 0; index < values.length; index++) {
while (level >= values[index]) {
result.append(digits[index]);
level -= values[index];
}
}
return result.toString();
}
@Override public void close() {
if (closed) {
return;
}
closed = true;
if (crafting != null) crafting.close();
reinforcement.close();
pending.clear();
seen.clear();
refresh.cancel();
HandlerList.unregisterAll(this);
for (AnvilView view : List.copyOf(previews.keySet())) {
release(view);
if (current(view)) {
recompute(view);
}
}
protocol.close();
}
}
@@ -0,0 +1,117 @@
package games.dmg.spigotinventoryhelper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable;
/** Native item proposal, independent of payment and risk rolling. Never mutates either input. */
final class AdvancedAnvilMerge {
private AdvancedAnvilMerge() {}
/** Null prepare = unrelated vanilla operation; null output = explained enhanced refusal. */
record Proposal(ItemStack output, int failurePercent, String refusal) {}
static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy) {
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;
}
Map<Enchantment, Integer> levels = new LinkedHashMap<>(left.getEnchantments());
Map<Enchantment, Integer> rightLevels = right.getEnchantments();
if (levels.isEmpty() && rightLevels.isEmpty()) {
return null; // Repair/rename-only belongs exclusively to native handling.
}
try {
for (var entry : rightLevels.entrySet()) {
levels.merge(entry.getKey(), entry.getValue(), (first, second) ->
first.equals(second) ? Math.addExact(first, 1) : Math.max(first, second));
}
} catch (ArithmeticException overflow) {
return refused("Enchantment level cannot be represented by this server.");
}
int excess = levels.entrySet().stream()
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
.max().orElse(0);
if (excess == 0) return null; // Native XP, rename, repair and conflict decisions stay native.
ItemStack output = left.clone();
output.setAmount(1);
var enchantments = new ArrayList<>(levels.keySet());
for (int index = 0; index < enchantments.size(); index++) {
Enchantment enchantment = enchantments.get(index);
if (levels.get(enchantment) <= 0 || !enchantment.canEnchantItem(output)) {
return refused("An enchantment is not applicable to this item.");
}
for (int other = index + 1; other < enchantments.size(); other++) {
Enchantment candidate = enchantments.get(other);
if (enchantment.conflictsWith(candidate) || candidate.conflictsWith(enchantment)) {
return refused("The resulting enchantments conflict.");
}
}
}
try {
output.addUnsafeEnchantments(levels);
repair(output, right);
// Unsafe API setters may clamp silently; persistence can independently reject a result.
// Inspect the actual native representation instead of imposing a lower gameplay cap.
if (!output.getEnchantments().equals(levels)) {
return refused("Enchantment level cannot be represented by this server.");
}
ItemStack restored = ItemStack.deserializeBytes(output.serializeAsBytes());
if (!restored.equals(output) || !restored.getEnchantments().equals(levels)) {
return refused("Result cannot be represented exactly by this server.");
}
} catch (RuntimeException unsupported) {
return refused("Result cannot be represented exactly by this server.");
}
return new Proposal(output, policy.failurePercent(Math.max(0, excess - reinforcement)), null);
}
private static Proposal refused(String reason) {
return new Proposal(null, 0, reason);
}
private static boolean book(Material material) {
return switch (material) {
case BOOK, ENCHANTED_BOOK, WRITABLE_BOOK, WRITTEN_BOOK, KNOWLEDGE_BOOK -> true;
default -> false;
};
}
private static void repair(ItemStack output, ItemStack right) {
if (!(output.getItemMeta() instanceof Damageable first) || first.isUnbreakable()) {
return;
}
int maximum = maxDamage(output, first);
if (maximum <= 0 || !first.hasDamageValue()) {
return;
}
if (!(right.getItemMeta() instanceof Damageable second)) {
return;
}
// Native same-item formula uses each input's effective MAX_DAMAGE component, not material
// durability for both. Use long intermediates so custom component values cannot overflow.
long remaining = (long) maximum - first.getDamage()
+ maxDamage(right, second) - second.getDamage() + (long) maximum * 12 / 100;
long damage = Math.max(0, maximum - remaining);
if (damage < first.getDamage()) {
first.setDamage((int) damage);
output.setItemMeta(first);
}
}
private static int maxDamage(ItemStack item, Damageable meta) {
Integer maximum = item.getData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
return maximum == null ? 0 : maximum;
}
}
@@ -0,0 +1,60 @@
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,
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() {
return new AdvancedAnvilPolicy(20, 5, 99);
}
int failurePercent(int greatestExcess) {
if (greatestExcess < 0) {
throw new IllegalArgumentException("Excess must be nonnegative");
}
if (greatestExcess == 0) {
return 0;
}
if (basePercent >= maxFailurePercent) {
return maxFailurePercent;
}
if (stepPercent == 0) {
return basePercent;
}
// int-sized excess has a representable long triangular number. Compare before
// multiplying by step: that multiplication could overflow even a long.
long triangular = (long) greatestExcess * (greatestExcess - 1) / 2;
if (triangular > (maxFailurePercent - basePercent) / stepPercent) {
return maxFailurePercent;
}
return basePercent + (int) triangular * stepPercent;
}
private static void validate(int percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("Percent must be an integer from 0 through 100");
}
}
}
@@ -0,0 +1,330 @@
package games.dmg.spigotinventoryhelper;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolManager;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.events.PacketContainer;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.InventoryView;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Main-thread, one-use protocol-to-Bukkit admission for exact watched native anvil views. */
final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// SYNC invokes adapters on main, not native handling: ProtocolLib forwards via Netty and
// Purpur queues back to main. Bound lost intents without racing the next scheduler tick.
static final long ADMISSION_TICKS = 40L;
static final class Admission {
final PacketEvent event;
final ServerboundContainerClickPacket packet;
final Object preview;
final java.util.function.BooleanSupplier available;
BukkitTask expiry;
volatile boolean sealed;
volatile boolean revoked;
// Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement).
boolean wrapped;
boolean consumed;
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, InventoryView view) {
this.view = view;
this.nativeListener = NativeAnvilGeneration.player(view).connection;
this.event = event;
this.packet = packet;
this.preview = preview;
this.available = available;
}
boolean valid() {
// Consumption removes the map ticket, but bridge loss must still revoke deferred payment.
return !revoked && available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
}
}
private final Plugin plugin;
private final ProtocolManager manager;
private final AdvancedAnvilListener owner;
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<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<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;
private final PacketAdapter gate;
private final PacketAdapter seal;
private volatile boolean closed;
AdvancedAnvilProtocolGuard(Plugin plugin, ProtocolManager manager, String version, AdvancedAnvilListener owner) {
this.plugin = Objects.requireNonNull(plugin);
this.manager = Objects.requireNonNull(manager);
this.owner = Objects.requireNonNull(owner);
if (version == null || !version.contains("Purpur") || !version.contains("26.3.build.2651-stable")
|| manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge");
gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
.listenerPriority(ListenerPriority.LOWEST).optionSync()) {
@Override public void onPacketReceiving(PacketEvent event) { receive(event); }
};
seal = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
.listenerPriority(ListenerPriority.MONITOR).optionSync()) {
@Override public void onPacketReceiving(PacketEvent event) { seal(event); }
};
outbound = new PacketAdapter(PacketAdapter.params(plugin,
PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT)
.listenerPriority(ListenerPriority.HIGHEST).optionSync()) {
@Override public void onPacketSending(PacketEvent event) { send(event); }
};
try {
manager.addPacketListener(outbound);
manager.addPacketListener(gate);
manager.addPacketListener(seal);
} catch (RuntimeException | LinkageError failure) {
manager.removePacketListener(outbound);
manager.removePacketListener(gate);
manager.removePacketListener(seal);
throw failure;
}
}
boolean watch(InventoryView view) {
if (closed || manager.isClosed() || versions.exhausted() || !plugin.getServer().isPrimaryThread()) return false;
try {
NativeAnvilGeneration.menu(view);
if (!(view.getPlayer() instanceof Player player)) return false;
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null && !dispatch.attached()) {
dispatches.remove(player);
dispatch = null;
}
if (dispatch == null) {
var nativePlayer = NativeAnvilGeneration.player(view);
dispatch = new NativeAnvilDispatch(nativePlayer.connection.connection,
nativePlayer.level().getServer().packetProcessor(), () -> {
if (!closed && !plugin.getServer().isPrimaryThread()) plugin.getServer().getScheduler().runTask(plugin,
() -> { if (!closed && !watched.contains(view)) owner.protocolHookReady(view); });
}, () -> plugin.getServer().isPrimaryThread());
dispatches.put(player, dispatch);
}
if (!dispatch.ready()) return false;
watched.add(view);
actors.add(player);
invalidate(view);
return true;
} catch (RuntimeException | LinkageError unavailable) { return false; }
}
boolean watched(InventoryView view) { return watched.contains(view); }
void invalidate(InventoryView view) {
Admission admission = admissions.remove(view);
if (admission != null) {
admission.revoked = true;
// Retain expiry for a cancelled/non-forwarded record too; cancelling it here
// would leak transport ownership when a newer intent replaces this admission.
}
}
private void send(PacketEvent event) {
if (closed) return;
if (!plugin.getServer().isPrimaryThread()) {
if (actors.contains(event.getPlayer())) event.setCancelled(true);
return;
}
InventoryView view = event.getPlayer().getOpenInventory();
if (!watched.contains(view)) return;
try {
Object raw = event.getPacket().getHandle();
int containerId;
int nativeState;
if (raw instanceof ClientboundContainerSetContentPacket content) {
containerId = content.containerId();
nativeState = content.stateId();
} else if (raw instanceof ClientboundContainerSetSlotPacket slot) {
containerId = slot.getContainerId();
nativeState = slot.getStateId();
} else throw new IllegalStateException("Unsupported native anvil frame");
if (containerId != NativeAnvilGeneration.menu(view).containerId) return;
frames.remove(view);
if (event.isCancelled()) return;
int version = versions.next();
if (version < 0 || manager.isClosed()) {
event.setCancelled(true);
invalidate(view);
return;
}
Object translated = raw instanceof ClientboundContainerSetContentPacket content
? new ClientboundContainerSetContentPacket(containerId, version, content.items(), content.carriedItem())
: new ClientboundContainerSetSlotPacket(containerId, version,
((ClientboundContainerSetSlotPacket) raw).getSlot(),
((ClientboundContainerSetSlotPacket) raw).getItem());
event.setPacket(new PacketContainer(event.getPacketType(), translated));
frames.put(view, new Frame(event, translated, version, nativeState, owner.protocolPreview(view)));
} catch (RuntimeException | LinkageError unavailable) {
frames.remove(view);
invalidate(view);
event.setCancelled(true);
}
}
private void receive(PacketEvent event) {
if (closed) return;
if (!plugin.getServer().isPrimaryThread()) {
if (actors.contains(event.getPlayer())) event.setCancelled(true);
return; // Never inspect or mutate a Bukkit view off the tick thread.
}
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;
try {
Object raw = event.getPacket().getHandle();
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|| !(raw instanceof ServerboundContainerClickPacket packet)
|| packet.containerInput() == null || !currentFrame(view, packet)
|| (NativeAnvilGeneration.take(view, packet) && !NativeAnvilGeneration.supportedTake(packet))
|| !owner.protocolReady(view)
|| (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) {
refuse(event, view);
return;
}
// Typed reconstruction changes ONLY stateId. Native classification and predictions
// receive every original action/input/hash field, with the menu's untouched state.
var translated = new ServerboundContainerClickPacket(packet.containerId(), frames.get(view).nativeState(),
packet.slotNum(), packet.buttonNum(), packet.containerInput(), packet.changedSlots(), packet.carriedItem());
event.setPacket(new PacketContainer(event.getPacketType(), translated));
// Formerly enhanced windows retain only frame translation/refusal for normal
// operations. No normal take depends on enhanced identity-budget/queue ownership.
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);
admission.dispatchable = () -> admissions.get(view) == admission && watched.contains(view)
&& NativeAnvilGeneration.current(view, admission.packet)
&& owner.protocolPreview(view) == admission.preview && owner.protocolReady(view);
admission.completed = () -> {
if (admissions.get(view) == admission) admissions.remove(view);
if (admission.expiry != null) admission.expiry.cancel();
if (!admission.consumed && !closed && !admissions.containsKey(view)) owner.protocolRejected(view);
};
if (!dispatch.admit(admission)) { refuse(event, view); return; }
acquiredDispatch = dispatch;
acquired = admission;
admissions.put(view, admission);
// No owned post listener/marker: foreign markers remain entirely untouched.
admission.expiry = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
dispatch.revoke(admission);
if (admissions.get(view) == admission) admissions.remove(view);
// Unresolved raw ownership waits for the two-queue fence or exact raw arrival.
// Already-wrapped ownership is safe to release even if native skips handle.
}, ADMISSION_TICKS));
} catch (RuntimeException | LinkageError unavailable) {
if (acquired != null) {
acquiredDispatch.revoke(acquired);
if (admissions.get(view) == acquired) admissions.remove(view);
}
refuse(event, view);
} finally {
// Also fences a partially acquired, cancelled record after timer setup throws.
// Cancellation inside a callback is never upfront proof of no raw forward.
if (acquired != null && !acquiredDispatch.requestFence()) {
invalidate(view);
refuse(event, view);
}
}
}
private boolean currentFrame(InventoryView view, ServerboundContainerClickPacket packet) {
Frame frame = frames.get(view);
var menu = NativeAnvilGeneration.menu(view);
return !manager.isClosed() && !versions.exhausted() && frame != null && frame.valid()
&& packet.containerId() == menu.containerId && packet.stateId() == frame.version()
&& frame.nativeState() == menu.getStateId() && frame.preview() == owner.protocolPreview(view);
}
private void seal(PacketEvent event) {
if (closed || !plugin.getServer().isPrimaryThread()) return;
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
// at Bukkit dispatch and deferred commit, including later MONITOR listeners.
admission.sealed = !event.isCancelled() && event.getPacket().getHandle() == admission.packet;
if (!admission.sealed) invalidate(view);
}
Admission consume(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
|| !NativeAnvilGeneration.current(view, admission.packet)
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
admissions.remove(view);
admission.consumed = true;
if (admission.expiry != null) admission.expiry.cancel();
return admission;
}
private static void refuse(PacketEvent event, InventoryView view) {
event.setCancelled(true);
if (view.getPlayer() instanceof Player player) player.updateInventory();
}
void forget(InventoryView view) {
invalidate(view);
watched.remove(view);
frames.remove(view);
if (view.getPlayer() instanceof Player player
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) {
actors.remove(player);
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null) dispatch.retire();
// Reuse a draining hook across window reuse, never install a second boundary.
if (dispatch != null && !dispatch.ready()) dispatches.remove(player);
}
}
void prune() {
for (InventoryView view : List.copyOf(watched)) {
if (view.getPlayer().getOpenInventory() != view) forget(view);
}
}
@Override public void close() {
if (closed) return;
closed = true;
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.
if (view.getPlayer().getOpenInventory() == view) view.getPlayer().closeInventory();
}
dispatches.values().forEach(NativeAnvilDispatch::retire);
dispatches.clear();
actors.clear();
seen.clear();
manager.removePacketListener(outbound);
manager.removePacketListener(gate);
manager.removePacketListener(seal);
}
}
@@ -0,0 +1,343 @@
package games.dmg.spigotinventoryhelper;
import java.io.IOException;
import java.io.Reader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.Plugin;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.error.YAMLException;
/** Owns startup loading and durable admin updates, not plugin registration or gameplay. */
final class AdvancedAnvilSettings implements AutoCloseable {
private static final String PERMISSION = "spigotinventoryhelper.admin";
private static final List<String> KEYS = List.of(
"advanced-anvil-base-failure-percent",
"advanced-anvil-risk-step-percent",
"advanced-anvil-max-failure-percent",
"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;
private final ThreadPoolExecutor writer;
private final Object lifecycle = new Object();
private volatile AdvancedAnvilPolicy effective;
private volatile boolean closed;
// Writer-only state, independent of delayed scheduler acknowledgments.
private AdvancedAnvilPolicy persisted;
private long savedRevision;
// Accessed only under lifecycle, including main-thread acknowledgments.
private final long[] publishedRevisions = new long[KEYS.size()];
static AdvancedAnvilSettings open(
Plugin plugin, Consumer<AdvancedAnvilPolicy> onAcknowledgedChange) throws IOException {
Objects.requireNonNull(plugin);
Objects.requireNonNull(onAcknowledgedChange);
Path file = plugin.getDataFolder().toPath().resolve("config.yml");
AdvancedAnvilPolicy initial = parsePolicy(read(file));
return new AdvancedAnvilSettings(plugin, file, onAcknowledgedChange, initial);
}
private AdvancedAnvilSettings(Plugin plugin, Path file,
Consumer<AdvancedAnvilPolicy> onChange, AdvancedAnvilPolicy initial) {
this.plugin = plugin;
this.file = file;
this.onChange = onChange;
effective = initial;
persisted = initial;
writer = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(128), runnable -> {
Thread thread = new Thread(runnable, "inventory-helper-config");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
}
AdvancedAnvilPolicy policy() {
return effective;
}
/** Call on enable with this plugin's actual inventoryhelperadmin command. */
void bind(PluginCommand command) {
Objects.requireNonNull(command);
if (command.getPlugin() != plugin || !command.getName().equals("inventoryhelperadmin")) {
throw new IllegalArgumentException("Expected this plugin's inventoryhelperadmin command");
}
synchronized (lifecycle) {
if (closed) {
throw new IllegalStateException("Settings closed");
}
command.setPermission(PERMISSION);
command.permissionMessage(Component.text("Permission denied."));
command.setExecutor((sender, ignored, label, args) -> execute(sender, args));
command.setTabCompleter((sender, ignored, label, args) -> complete(sender, args));
}
}
private boolean execute(CommandSender sender, String[] args) {
// Revalidate even if the caller bypasses Command.testPermission.
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("Permission denied.");
return true;
}
if (closed) {
sender.sendMessage("Configuration unavailable.");
return true;
}
if (args.length < 2 || args.length > 3 || !args[0].equals("config")
|| !KEYS.contains(args[1])) {
sender.sendMessage("Usage: /inventoryhelperadmin config <key> [value]");
return true;
}
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + " = " + value(effective, key));
return true;
}
int percent;
try {
// Parse integers rather than restricting users to suggested spellings.
// No trimming or floating-point/YAML coercion; range validation remains mandatory.
percent = Integer.parseInt(args[2]);
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException();
}
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Value must be an integer from 0 through 100.");
return true;
}
try {
writer.execute(() -> save(sender, key, percent));
} catch (RejectedExecutionException unavailable) {
sender.sendMessage("Configuration unavailable; no update accepted.");
}
return true;
}
private List<String> complete(CommandSender sender, String[] args) {
if (closed || !sender.hasPermission(PERMISSION) || args.length > 3) {
return List.of();
}
if (args.length <= 1) {
return prefix(List.of("config"), args.length == 0 ? "" : args[0]);
}
if (!args[0].equals("config")) {
return List.of();
}
if (args.length == 2) {
return prefix(KEYS, args[1]);
}
if (!KEYS.contains(args[1])) {
return List.of();
}
LinkedHashSet<String> candidates = new LinkedHashSet<>();
candidates.add(Integer.toString(value(effective, args[1])));
candidates.add(Integer.toString(value(AdvancedAnvilPolicy.defaults(), args[1])));
candidates.add("0");
candidates.add("100");
return prefix(List.copyOf(candidates), args[2]);
}
private static List<String> prefix(List<String> candidates, String prefix) {
String lower = prefix.toLowerCase(Locale.ROOT);
return candidates.stream().filter(value -> value.startsWith(lower)).toList();
}
private void save(CommandSender sender, String key, int percent) {
if (closed) {
return;
}
try {
Map<Object, Object> document = read(file);
parsePolicy(document); // Refuse a malformed intervening file, without clobbering it.
AdvancedAnvilPolicy next = replace(persisted, key, percent);
for (String setting : KEYS) {
document.put(setting, value(next, setting));
}
writeAtomically(document);
persisted = next;
long revision = ++savedRevision;
schedule(() -> acknowledge(sender, key, next, revision));
} catch (IOException | YAMLException | IllegalArgumentException failure) {
schedule(() -> {
synchronized (lifecycle) {
if (!closed && sender.hasPermission(PERMISSION)) {
sender.sendMessage("Configuration save failed; effective settings unchanged.");
}
}
});
}
}
private void schedule(Runnable acknowledgment) {
if (closed) {
return;
}
try {
plugin.getServer().getScheduler().runTask(plugin, acknowledgment);
} catch (RuntimeException unavailable) {
// A disabled/rejected scheduler cannot safely publish gameplay state off-thread.
plugin.getLogger().warning("Configuration acknowledgment unavailable; restart reloads disk settings.");
}
}
private void acknowledge(CommandSender sender, String key, AdvancedAnvilPolicy next, long revision) {
synchronized (lifecycle) {
if (closed) 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;
}
effective = next;
try {
onChange.accept(next);
} catch (RuntimeException failure) {
plugin.getLogger().warning("Configuration saved but policy-change callback failed.");
}
if (!closed && sender.hasPermission(PERMISSION)) {
sender.sendMessage(key + " = " + value(next, key) + " (saved)");
}
}
}
private void writeAtomically(Map<Object, Object> document) throws IOException {
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), ".config-", ".tmp");
try {
DumperOptions options = new DumperOptions();
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
byte[] bytes = new Yaml(options).dump(document).getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
if (closed) {
throw new IOException("Settings closed");
}
// No non-atomic fallback: an unsupported atomic replacement is a failed save.
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
// Publication itself must be durable, not just the temporary file's contents.
try (FileChannel directory = FileChannel.open(file.getParent(), StandardOpenOption.READ)) {
directory.force(true);
}
} finally {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupFailure) {
plugin.getLogger().warning("Could not remove configuration temporary file.");
}
}
}
private static Map<Object, Object> read(Path file) throws IOException {
if (Files.notExists(file)) {
return new LinkedHashMap<>();
}
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
Object loaded = new Yaml(new SafeConstructor(options)).load(reader);
if (loaded == null) {
return new LinkedHashMap<>();
}
if (!(loaded instanceof Map<?, ?> values)) {
throw new IOException("Configuration must be a YAML mapping");
}
return new LinkedHashMap<>(values);
} catch (YAMLException invalid) {
throw new IOException("Malformed configuration", invalid);
}
}
private static AdvancedAnvilPolicy parsePolicy(Map<Object, Object> document) throws IOException {
AdvancedAnvilPolicy defaults = AdvancedAnvilPolicy.defaults();
int[] values = new int[KEYS.size()];
for (int index = 0; index < KEYS.size(); index++) {
String key = KEYS.get(index);
Object raw = document.get(key);
if (!document.containsKey(key)) {
values[index] = value(defaults, key);
} else if ((raw instanceof Integer || raw instanceof Long)
&& ((Number) raw).longValue() >= 0 && ((Number) raw).longValue() <= 100) {
values[index] = ((Number) raw).intValue();
} else {
throw new IOException("Invalid integer percent for " + key);
}
}
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) {
return switch (KEYS.indexOf(key)) {
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(), failures);
}
/** Nonblocking close: drop queued work, interrupt the daemon writer, suppress every late ack. */
@Override
public void close() {
synchronized (lifecycle) {
closed = true;
writer.shutdownNow();
}
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotinventoryhelper;
/** Positive VarInt frame IDs outside the native 15-bit range; never wrap or recycle. */
final class AnvilFrameVersions {
private long next = 32768;
private boolean exhausted;
int next() {
if (next > Integer.MAX_VALUE) {
exhausted = true;
return -1;
}
return (int) next++;
}
boolean exhausted() { return exhausted; }
}
@@ -0,0 +1,241 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelPipeline;
import java.util.IdentityHashMap;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.PacketType;
import net.minecraft.network.protocol.PacketUtils;
import net.minecraft.network.protocol.game.ServerGamePacketListener;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Exact record ownership behind ProtocolLib, retained through the native thread hop.
* The synchronized ledger is transport-only; all Bukkit/policy validation runs on native main.
*/
final class NativeAnvilDispatch extends ChannelInboundHandlerAdapter {
static final int MAX_OUTSTANDING = 32;
private static final ThreadLocal<AdvancedAnvilProtocolGuard.Admission> CURRENT = new ThreadLocal<>();
private final Connection connection;
private final PacketProcessor processor;
private final Runnable readyCallback;
private final Map<ServerboundContainerClickPacket, AdvancedAnvilProtocolGuard.Admission> records =
new IdentityHashMap<>();
private final com.comphenix.protocol.scheduler.ProtocolScheduler scheduler;
private final java.util.function.BooleanSupplier mainThread;
private boolean mainFencePending;
private volatile boolean unavailable;
private volatile boolean ready;
private volatile boolean removing;
private boolean retiring;
NativeAnvilDispatch(Connection connection, PacketProcessor processor, Runnable readyCallback,
java.util.function.BooleanSupplier mainThread) {
// Link the public, library-owned main queue BEFORE enhanced output/hook installation.
// DefaultScheduler.runTask runs after the executing library filters/posts return;
// this is an ordering fence, not a promise to wait a strict server tick.
scheduler = java.util.Objects.requireNonNull(com.comphenix.protocol.ProtocolLibrary.getScheduler(),
"Missing ProtocolLib main scheduler");
this.mainThread = java.util.Objects.requireNonNull(mainThread);
NativeAnvilPacketLimits.link();
this.connection = java.util.Objects.requireNonNull(connection);
this.processor = java.util.Objects.requireNonNull(processor);
this.readyCallback = readyCallback;
ChannelPipeline pipeline = java.util.Objects.requireNonNull(connection.channel).pipeline();
ChannelHandlerContext nativeContext = pipeline.context(connection);
if (nativeContext == null) throw new IllegalStateException("Missing native connection boundary");
// The pinned Connection handler must be last; ProtocolLib resumes fireChannelRead ahead
// of it. Do not replace, reorder or remove any foreign handler.
if (pipeline.last() != connection) throw new IllegalStateException("Unsupported connection pipeline");
pipeline.addBefore(nativeContext.name(), null, this);
}
boolean attached() { return !removing && connection.channel.pipeline().context(this) != null; }
boolean ready() {
var pipeline = connection.channel.pipeline();
var context = pipeline.context(this);
return ready && !unavailable && !removing && connection.channel.isActive() && context != null
&& pipeline.context(connection) != null && pipeline.last() == connection
&& pipeline.names().indexOf(context.name()) + 1
== pipeline.names().indexOf(pipeline.context(connection).name());
}
@Override public void handlerAdded(ChannelHandlerContext context) {
ready = true;
readyCallback.run(); // Schedules owner recomputation; never accesses a view here.
}
@Override public void handlerRemoved(ChannelHandlerContext context) {
ready = false;
synchronized (this) { records.values().forEach(admission -> admission.revoked = true); }
}
synchronized boolean admit(AdvancedAnvilProtocolGuard.Admission admission) {
if (!ready() || records.size() >= MAX_OUTSTANDING || records.containsKey(admission.packet)) return false;
retiring = false;
records.put(admission.packet, admission);
return true;
}
/** One pending library-main callback per dispatch, never one job per admission. */
synchronized boolean requestFence() {
if (unavailable) return false;
if (mainFencePending) return true;
mainFencePending = true;
try {
if (scheduler.runTask(this::mainFence) == null) {
failBridge();
return false;
}
return true;
} catch (RuntimeException | LinkageError failure) {
failBridge();
return false;
}
}
private void mainFence() {
try {
if (!mainThread.getAsBoolean()) {
failBridge();
return;
}
final java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot;
synchronized (this) {
mainFencePending = false;
if (unavailable) return;
// Snapshot HERE, not on Netty: later unfinished receives cannot be declared
// NO_FORWARD by a barrier queued before their actual library raw forwards.
snapshot = java.util.List.copyOf(records.values()); // bounded by MAX_OUTSTANDING
}
connection.channel.eventLoop().execute(() -> terminalFence(snapshot));
} catch (RuntimeException | LinkageError failure) {
// Rejected submission is not evidence of NO_FORWARD.
failBridge();
}
}
private synchronized void terminalFence(java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot) {
for (var admission : snapshot) {
if (records.get(admission.packet) != admission) continue;
if (!admission.wrapped) {
// All actual raw forwards captured before mainFence are ahead of us on
// this same event loop. Never infer acknowledgement from mutable events/posts.
admission.revoked = true;
records.remove(admission.packet);
} else if (admission.revoked) records.remove(admission.packet);
}
// Transport-only completion: no Bukkit/map/policy callback on the socket loop.
removeIfDrained();
}
private synchronized void failBridge() {
unavailable = true;
mainFencePending = false;
revokeRecords(); // Keep unresolved exact-raw tombstones; no false cleanup claim.
removeIfDrained();
}
static AdvancedAnvilProtocolGuard.Admission current() { return CURRENT.get(); }
@Override public void channelRead(ChannelHandlerContext context, Object message) {
AdvancedAnvilProtocolGuard.Admission admission;
synchronized (this) {
admission = records.get(message);
if (admission != null) {
if (admission.wrapped) return; // Duplicate exact raw while still owned.
admission.wrapped = true; // Sticky, even after ledger retirement.
if (admission.revoked) complete(admission);
}
}
if (admission == null) {
context.fireChannelRead(message);
} else {
// Even a revoked owned record must traverse native connection admission/limits.
// Its exact wrapper denies gameplay on main; do not hide accepted traffic from
// aggregate or record-class native rate counters by dropping it ahead of Connection.
context.fireChannelRead(new BoundPacket(admission));
}
}
synchronized void complete(AdvancedAnvilProtocolGuard.Admission admission) {
if (records.get(admission.packet) == admission) records.remove(admission.packet);
removeIfDrained();
}
synchronized void revoke(AdvancedAnvilProtocolGuard.Admission admission) {
admission.revoked = true;
// The wrapper retains irreversible denial even if native never calls its handle.
if (admission.wrapped) complete(admission);
}
private void revokeRecords() {
records.values().removeIf(admission -> {
admission.revoked = true;
return admission.wrapped;
});
}
synchronized void retire() {
retiring = true;
revokeRecords();
removeIfDrained();
}
private void removeIfDrained() {
if (retiring && records.isEmpty()) {
var pipeline = connection.channel.pipeline();
if (!removing && pipeline.context(this) != null) {
removing = true; // Do not reactivate a hook whose event-loop removal is queued.
pipeline.remove(this);
}
}
}
@Override public void channelInactive(ChannelHandlerContext context) {
synchronized (this) {
records.values().forEach(admission -> admission.revoked = true);
records.clear();
retiring = true;
removeIfDrained();
}
context.fireChannelInactive();
}
final class BoundPacket implements Packet<ServerGamePacketListener> {
private final AdvancedAnvilProtocolGuard.Admission admission;
private boolean nativeLimitChecked;
BoundPacket(AdvancedAnvilProtocolGuard.Admission admission) { this.admission = admission; }
@Override public PacketType<? extends Packet<ServerGamePacketListener>> type() { return admission.packet.type(); }
@Override public void handle(ServerGamePacketListener listener) {
if (!nativeLimitChecked) {
nativeLimitChecked = true;
if (NativeAnvilPacketLimits.refuse(connection, admission.packet)) {
admission.revoked = true;
complete(admission);
// This refusal can occur on Netty: leave the main-thread view ledger to
// its bounded expiry/new intent, never invoke its completion callback here.
return;
}
}
// Queue THIS wrapper, not the original record, so identity survives native PacketProcessor.
PacketUtils.ensureRunningOnSameThread(this, listener, processor);
var previous = CURRENT.get();
try {
if (ready() && admission.valid() && admission.dispatchable.getAsBoolean()
&& listener == admission.nativeListener) {
CURRENT.set(admission);
admission.packet.handle(listener);
}
} finally {
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
complete(admission); // Includes native silent refusal before any Bukkit event.
admission.completed.run();
}
}
}
}
@@ -0,0 +1,87 @@
package games.dmg.spigotinventoryhelper;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.world.inventory.AnvilMenu;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.craftbukkit.inventory.view.CraftAnvilView;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.view.AnvilView;
/** Compile-only linkage to the checksum-pinned Purpur 26.3 build 2651 menu/packet API. */
final class NativeAnvilGeneration {
private NativeAnvilGeneration() {}
static AnvilMenu menu(AnvilView view) {
if (!(view instanceof CraftAnvilView craft)) throw new IllegalStateException("Unsupported native anvil view");
if (!(craft.getHandle() instanceof AnvilMenu menu)) throw new IllegalStateException("Unsupported native anvil menu");
return menu;
}
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");
}
return player.getHandle();
}
/** Ensure CraftEventFactory's post-event broadcast emits the final installed result.
* Quantity/risk-only changes may otherwise be skipped by native result diffing.
* Invalidate only the owned result's remote prediction, never an actual item or input.
*/
static void requireResultFrame(AnvilView view, boolean emptyOutput) {
var menu = menu(view);
menu.setRemoteSlot(2, emptyOutput ? menu.getSlot(0).getItem().copy()
: net.minecraft.world.item.ItemStack.EMPTY);
}
/** Publish after native preparation has installed the final result, even when diffing skips it. */
static void publish(AnvilView view) {
menu(view).sendAllDataToRemote();
}
static boolean current(org.bukkit.inventory.InventoryView view, ServerboundContainerClickPacket packet) {
var menu = menu(view);
return packet.containerId() == menu.containerId && packet.stateId() == menu.getStateId();
}
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) {
return click(packet) != ClickType.UNKNOWN;
}
private static ClickType click(ServerboundContainerClickPacket packet) {
int button = packet.buttonNum();
return switch (packet.containerInput()) {
case PICKUP -> button == 0 ? ClickType.LEFT : button == 1 ? ClickType.RIGHT : ClickType.UNKNOWN;
case QUICK_MOVE -> button == 0 ? ClickType.SHIFT_LEFT : button == 1 ? ClickType.SHIFT_RIGHT : ClickType.UNKNOWN;
case SWAP -> button == 40 ? ClickType.SWAP_OFFHAND
: button >= 0 && button <= 8 ? ClickType.NUMBER_KEY : ClickType.UNKNOWN;
case CLONE -> button == 2 ? ClickType.MIDDLE : ClickType.UNKNOWN;
case THROW -> button == 0 ? ClickType.DROP : button == 1 ? ClickType.CONTROL_DROP : ClickType.UNKNOWN;
case PICKUP_ALL -> button == 0 ? ClickType.DOUBLE_CLICK : ClickType.UNKNOWN;
case QUICK_CRAFT -> ClickType.UNKNOWN;
};
}
static boolean matches(ServerboundContainerClickPacket packet, InventoryClickEvent event) {
if (packet.slotNum() != event.getRawSlot()) return false;
ClickType click = click(packet);
return click != ClickType.UNKNOWN && event.getClick() == click
&& (click != ClickType.NUMBER_KEY || event.getHotbarButton() == packet.buttonNum());
}
}
@@ -0,0 +1,87 @@
package games.dmg.spigotinventoryhelper;
import io.papermc.paper.configuration.GlobalConfiguration;
import io.papermc.paper.util.IntervalledCounter;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Pinned Connection class-specific admission for a record carried by a dispatch wrapper.
* Connection still performs its aggregate limit first. Use its SAME lock/counters/config, once
* before the wrapper's native thread hop, not an independent plugin rate policy or counter.
*/
final class NativeAnvilPacketLimits {
private static final Field LOCK = field("PACKET_LIMIT_LOCK", Object.class);
private static final Field ALL = field("allPacketCounts", IntervalledCounter.class);
private static final Field SPECIFIC = field("packetSpecificLimits", Map.class);
private static final Field RECEIVED = field("receivedPackets", int.class);
private static final MethodHandle PUT;
private static final MethodHandle KICK;
static {
try {
PUT = MethodHandles.lookup().findVirtual(Map.class, "put",
MethodType.methodType(Object.class, Object.class, Object.class));
var method = Connection.class.getDeclaredMethod("killForPacketSpam");
method.setAccessible(true);
KICK = MethodHandles.lookup().unreflect(method);
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
private NativeAnvilPacketLimits() {}
static void link() { /* Initialize/verify pinned linkage before any enhanced output. */ }
static boolean refuse(Connection connection, ServerboundContainerClickPacket record) {
try {
var configuration = GlobalConfiguration.get().packetLimiter;
if (configuration == null) throw new IllegalStateException("Missing native packet limiter");
// Match native fast-path: superclass overrides are inspected only if an aggregate
// counter exists or the exact incoming record class has a configured entry.
if (ALL.get(connection) == null && !configuration.overrides.containsKey(record.getClass())) return false;
Object lock = LOCK.get(connection);
if (lock == null || !(SPECIFIC.get(connection) instanceof Map<?, ?> counters)) {
throw new IllegalStateException("Unsupported native packet counters");
}
long now = System.nanoTime();
synchronized (lock) {
for (Class<?> type = record.getClass(); type != Object.class; type = type.getSuperclass()) {
var limit = configuration.overrides.get(type);
if (limit == null || !limit.isEnabled()) continue;
Object stored = counters.get(type);
IntervalledCounter counter;
if (stored == null) {
counter = new IntervalledCounter((long) (limit.interval() * 1.0E9));
PUT.invoke(counters, type, counter);
} else if (stored instanceof IntervalledCounter existing) counter = existing;
else throw new IllegalStateException("Unsupported native packet counter type");
counter.updateAndAdd(1L, now);
if (counter.getRate() < limit.maxPacketRate()) continue;
if (limit.action() == GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK) {
KICK.invoke(connection); // Native disconnect/cancellation path, not a custom kick.
} else if (limit.action() != GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP) {
continue;
}
// Native class refusal returns before receivedPackets++. The native outer
// Connection will increment after this callback, so preserve that accounting.
RECEIVED.setInt(connection, RECEIVED.getInt(connection) - 1);
return true;
}
}
return false;
} catch (Throwable failure) {
if (failure instanceof Error error) throw error;
throw new IllegalStateException("Unavailable native anvil packet-limit bridge", failure);
}
}
private static Field field(String name, Class<?> type) {
try {
var field = Connection.class.getDeclaredField(name);
if (field.getType() != type) throw new IllegalStateException("Unsupported native field " + name);
field.setAccessible(true);
return field;
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
}
@@ -0,0 +1,72 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
/** Independently owns the fixed Snow Block and empty Bucket conversion. */
public final class PowderSnowRecipeRegistrar implements RecipeRegistrar {
private final NamespacedKey recipeKey;
private Server registeredServer;
private boolean owned;
public PowderSnowRecipeRegistrar(NamespacedKey recipeKey) {
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
}
@Override
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
if (owned) {
return true;
}
registeredServer = server;
if (server.getRecipe(recipeKey) != null) {
return false;
}
ShapelessRecipe recipe = new ShapelessRecipe(recipeKey, new ItemStack(Material.POWDER_SNOW_BUCKET));
recipe.addIngredient(Material.SNOW_BLOCK);
recipe.addIngredient(Material.BUCKET);
recipe.setCategory(CraftingBookCategory.MISC);
try {
owned = server.addRecipe(recipe);
return owned;
} catch (RuntimeException exception) {
try {
owned = server.getRecipe(recipeKey) != null;
unregister();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
throw exception;
}
}
@Override
public List<NamespacedKey> recipeKeys() {
return List.of(recipeKey);
}
@Override
public void unregister() {
if (!owned || registeredServer == null) {
return;
}
// A prior removal may have succeeded before its port threw. Retain ownership on
// failure, but let a later cleanup confirm absence instead of stranding the lifecycle.
if (!registeredServer.removeRecipe(recipeKey) && registeredServer.getRecipe(recipeKey) != null) {
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
}
owned = false;
}
@Override
public String description() {
return "Powder Snow crafting recipe";
}
}
@@ -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);
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotinventoryhelper;
import java.io.File;
import java.io.IOException;
import java.util.List;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.PluginDescriptionFile;
@@ -10,6 +11,16 @@ import org.bukkit.plugin.java.JavaPluginLoader;
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
private SortingStickItemFactory sortingSticks;
private RecipeLifecycleGroup recipeLifecycles;
private AdvancedAnvilSettings advancedAnvilSettings;
private AdvancedAnvilListener advancedAnvil;
private 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();
private java.util.function.Supplier<com.comphenix.protocol.ProtocolManager> anvilProtocol =
com.comphenix.protocol.ProtocolLibrary::getProtocolManager;
private java.util.function.Supplier<String> anvilVersion =
() -> getServer().getName() + " " + getServer().getBukkitVersion();
public SpigotInventoryHelperPlugin() {}
@@ -20,6 +31,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
super(loader, description, dataFolder, file);
}
SpigotInventoryHelperPlugin(JavaPluginLoader loader, PluginDescriptionFile description,
File dataFolder, File file, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear) {
this(loader, description, dataFolder, file);
anvilRisk = java.util.Objects.requireNonNull(risk);
anvilWear = java.util.Objects.requireNonNull(wear);
}
SpigotInventoryHelperPlugin(JavaPluginLoader loader, PluginDescriptionFile description,
File dataFolder, File file, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, com.comphenix.protocol.ProtocolManager manager,
java.util.function.Supplier<String> version) {
this(loader, description, dataFolder, file, risk, wear);
anvilProtocol = () -> java.util.Objects.requireNonNull(manager);
anvilVersion = java.util.Objects.requireNonNull(version);
}
@Override
public void onEnable() {
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
@@ -42,8 +70,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
getLogger()
);
RecipeLifecycle powderSnowRecipe = new RecipeLifecycle(
getServer(),
this,
new PowderSnowRecipeRegistrar(new NamespacedKey(this, "snow_powder_snow")),
getLogger()
);
recipeLifecycles = new RecipeLifecycleGroup(
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe, powderSnowRecipe));
recipeLifecycles.enableAll();
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
getServer().getPluginManager().registerEvents(
@@ -54,15 +88,72 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
new BrokenToolListener(this, new ToolReplacementService()),
this
);
try {
advancedAnvilSettings = AdvancedAnvilSettings.open(this, ignored -> {
if (advancedAnvil != null) {
advancedAnvil.policyChanged();
}
});
advancedAnvilSettings.bind(java.util.Objects.requireNonNull(
getCommand("inventoryhelperadmin"), "Missing inventoryhelperadmin command"));
advancedAnvil = new AdvancedAnvilListener(this, advancedAnvilSettings::policy, anvilRisk, anvilWear,
anvilProtocol.get(), anvilVersion.get());
getServer().getPluginManager().registerEvents(advancedAnvil, this);
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,
"Advanced Anvil unavailable; configuration or initialization failed.", failure);
}
getLogger().info("Spigot Inventory Helper enabled.");
}
@Override
public void onDisable() {
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) {
try {
advancedAnvil.close();
advancedAnvil = null;
} catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); }
}
if (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() {
+12
View File
@@ -0,0 +1,12 @@
# Advanced Anvil: integer percentages, inclusive 0–100.
# E is the greatest resulting enchantment excess above its native maximum.
# E=0: no failure; otherwise min(cap, base + step * E * (E - 1) / 2).
advanced-anvil-base-failure-percent: 20
advanced-anvil-risk-step-percent: 5
advanced-anvil-max-failure-percent: 99
# 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
+10
View File
@@ -2,5 +2,15 @@ name: SpigotInventoryHelper
version: ${version}
main: games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin
api-version: "1.20"
depend: [ProtocolLib]
description: Inventory sorting and automatic tool replacement utilities.
author: dmg.games
commands:
inventoryhelperadmin:
description: Read and durably update Inventory Helper balance settings.
usage: /inventoryhelperadmin config <key> [value]
permission: spigotinventoryhelper.admin
permissions:
spigotinventoryhelper.admin:
description: Administer Inventory Helper balance settings.
default: op
@@ -0,0 +1,423 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.destroystokyo.paper.event.block.AnvilDamagedEvent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicInteger;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Effect;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Directional;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
/** Final safety boundaries through registered commands, native packets and native menu removal. */
class NativeAdvancedAnvilBoundaryTest {
@TempDir Path directory;
private static final String BASE = "advanced-anvil-base-failure-percent";
private static final String STEP = "advanced-anvil-risk-step-percent";
private static final String CAP = "advanced-anvil-max-failure-percent";
@Test void identicalInputRemovalAndReinsertionInvalidatesPendingGeneration() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack oldResult = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
f.menu.getSlot(0).set(net.minecraft.world.item.ItemStack.EMPTY);
f.menu.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(5, 3)));
f.tick();
unpaid(f, rolls);
assertEquals(oldResult, f.output());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {BASE, STEP, CAP})
void acknowledgedCommandDuringPendingInvalidatesCapturedTake(String key) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
f.inputs(sword(6, 3), sword(6, 3)); // E2: base, step and ceiling all affect risk.
assertRisk(f, 25);
packet.click(2, 0, ContainerInput.PICKUP);
Runnable capturedTake = f.nextTick.remove();
when(f.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
f.admin(f.player, key, 0);
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(3, f.menu.getSlot(0).getItem().getCount());
f.acknowledgeSave();
capturedTake.run();
assertEquals(sword(6, 3), input(f, 0));
assertEquals(sword(6, 3), input(f, 1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertRisk(f, key.equals(BASE) ? 5 : key.equals(STEP) ? 20 : 0);
}
}
@ParameterizedTest @CsvSource({BASE + ",30,35", STEP + ",10,30", CAP + ",7,7"})
void allKeysConnectRegisteredDurableCommandToPreviewAttemptAndRestart(String key, int value, int failure)
throws Exception {
AtomicInteger rolls = new AtomicInteger();
AtomicInteger selectedRoll = new AtomicInteger(failure);
for (boolean restart : new boolean[] {false, true}) {
selectedRoll.set(failure);
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
f.inputs(sword(6, 3), sword(6, 3));
if (!restart) {
assertRisk(f, 25);
f.admin(f.console, key, value);
assertRisk(f, 25); // Writer completion alone cannot publish policy off-thread.
f.acknowledgeSave();
String disk = Files.readString(directory.resolve("data/config.yml"));
assertTrue(disk.contains(key + ": " + value), disk);
}
assertRisk(f, failure);
var command = f.plugin.getCommand("inventoryhelperadmin");
assertNotNull(command);
assertTrue(command.execute(f.console, "inventoryhelperadmin", new String[] {"config", key}));
assertTrue(f.adminMessages.contains(key + " = " + value));
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(expected, f.player.getItemOnCursor(), "Roll at the effective cutoff succeeds");
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertTrue(effects.explosions.isEmpty());
// The other side of THIS effective cutoff distinguishes runtime application
// from merely updating the preview while still rolling against old defaults.
f.player.setItemOnCursor(null);
selectedRoll.set(failure - 1);
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, input(f, 0).getAmount());
assertEquals(1, input(f, 1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, effects.explosions.size(), "Roll immediately below effective cutoff fails");
assertEquals(0, count(f, expected));
}
}
assertEquals(4, rolls.get());
}
@ParameterizedTest @ValueSource(strings = {"cursor-full", "storage-full", "storage-changed", "offhand-changed"})
void capacityAndDestinationChangesRefuseWithoutPaymentOrRoll(String scenario) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack result = f.output().clone();
if (scenario.equals("cursor-full")) f.player.setItemOnCursor(result.asQuantity(3));
if (scenario.equals("storage-full")) {
for (int slot = 0; slot < 36; slot++) f.player.getInventory().setItem(slot, new ItemStack(Material.EMERALD, 64));
}
if (scenario.equals("offhand-changed")) packet.click(2, 40, ContainerInput.SWAP);
else packet.click(2, 0, scenario.equals("cursor-full") ? ContainerInput.PICKUP : ContainerInput.QUICK_MOVE);
if (scenario.equals("storage-changed")) f.player.getInventory().setItem(8, new ItemStack(Material.EMERALD, 17));
if (scenario.equals("offhand-changed")) f.player.getInventory().setItemInOffHand(new ItemStack(Material.EMERALD, 17));
ItemStack[] storage = storage(f);
ItemStack cursor = f.player.getItemOnCursor().clone();
ItemStack offhand = f.player.getInventory().getItemInOffHand().clone();
f.tick();
unpaid(f, rolls);
assertArrayEquals(storage, storage(f));
assertEquals(cursor, f.player.getItemOnCursor());
assertEquals(offhand, f.player.getInventory().getItemInOffHand());
assertEquals(result, f.output());
}
}
@ParameterizedTest @ValueSource(strings = {"right", "offhand"})
void nativeEligibleRightClickAndEmptyOffhandDeliverOnePaidOutput(String destination) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(5, marker);
packet.click(2, destination.equals("right") ? 1 : 40,
destination.equals("right") ? ContainerInput.PICKUP : ContainerInput.SWAP);
assertEquals(destination.equals("right") ? ClickType.RIGHT : ClickType.SWAP_OFFHAND,
packet.clicks.getFirst().getClick());
assertEquals(destination.equals("right") ? InventoryAction.PICKUP_HALF : InventoryAction.HOTBAR_SWAP,
packet.clicks.getFirst().getAction());
f.tick();
paid(f, rolls);
assertEquals(expected, destination.equals("right") ? f.player.getItemOnCursor()
: f.player.getInventory().getItemInOffHand());
assertEquals(marker, f.player.getInventory().getItem(5));
assertEquals(1, count(f, expected));
}
}
@Test void exactSameEventReplayAndPendingPacketRepeatCannotSpendTwice() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
var sameEvent = packet.clicks.getFirst();
f.events.callEvent(sameEvent);
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, f.nextTick.size());
f.tick();
paid(f, rolls);
assertEquals(1, count(f, expected));
f.events.callEvent(sameEvent); // SAME object after the first attempt, not a lookalike.
f.tick();
paid(f, rolls);
assertEquals(1, count(f, expected));
assertEquals(expected, f.output());
}
}
@Test void prepareReenteredDuringPacketDispatchInvalidatesWithoutFreeOutputOrDoubleSpend() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
packet.listen(EventPriority.MONITOR, ignored -> { f.prepareAgain(); f.prepareAgain(); });
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
unpaid(f, rolls);
assertTrue(f.player.getItemOnCursor().isEmpty());
assertNotNull(f.output());
f.tick();
unpaid(f, rolls);
}
}
@Test void actualCloseAndRemovedReturnUnspentInputsAndInvalidatePendingTake() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = successFixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack result = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
packet.closeMenu(InventoryCloseEvent.Reason.PLAYER);
f.tick();
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertEquals(6, count(f, sword(5, 1)), "Native removed/placeItemBackInInventory returns BOTH stacks");
assertEquals(0, count(f, result));
assertEquals(0, rolls.get());
assertTrue(packet.drops.isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"close", "death", "destroy"})
void synchronousFailureLifecycleRetainsExactlyOneDebitNoOutputAndNoRetry(String lifecycle) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
seed(f);
ItemStack result = f.output().clone();
effects.duringExplosion = () -> {
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
if (lifecycle.equals("destroy")) effects.block.setType(Material.AIR, false);
if (lifecycle.equals("death")) packet.dieAndClose();
else packet.closeMenu(lifecycle.equals("destroy") ? InventoryCloseEvent.Reason.CANT_USE
: InventoryCloseEvent.Reason.PLAYER);
};
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(0, count(f, result));
assertEquals(4, count(f, sword(5, 1)) + packet.drops.stream()
.filter(item -> org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(item).isSimilar(sword(5, 1)))
.mapToInt(net.minecraft.world.item.ItemStack::getCount).sum());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertNull(f.output());
packet.click(f.menu.containerId, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(0, count(f, result));
}
}
@ParameterizedTest @ValueSource(strings = {"cancel", "changed", "below", "at", "above", "creative"})
void nativeWearProtectionStateFacingAndTwelvePercentBoundary(String scenario) throws Exception {
AtomicInteger wearRolls = new AtomicInteger();
double wear = scenario.equals("at") ? .12 : scenario.equals("above") ? .1200001 : Math.nextDown(.12);
try (var f = new NativeAnvilFixture(directory, () -> 99, () -> { wearRolls.incrementAndGet(); return wear; });
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
if (scenario.equals("creative")) packet.creative();
var facing = (Directional) effects.data.get();
facing.setFacing(BlockFace.WEST);
effects.data.set(facing);
AtomicInteger damageEvents = new AtomicInteger();
f.events.registerEvent(AnvilDamagedEvent.class, new Listener() {}, EventPriority.HIGHEST,
(ignored, event) -> {
var damage = (AnvilDamagedEvent) event;
damageEvents.incrementAndGet();
if (scenario.equals("cancel")) damage.setCancelled(true);
if (scenario.equals("changed")) damage.setDamageState(AnvilDamagedEvent.DamageState.DAMAGED);
}, f.plugin, false);
seed(f);
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
boolean damages = !scenario.equals("at") && !scenario.equals("above") && !scenario.equals("creative");
assertEquals(damages ? 1 : 0, damageEvents.get());
assertEquals(scenario.equals("creative") ? 0 : 1, wearRolls.get());
Material expected = scenario.equals("changed") ? Material.DAMAGED_ANVIL
: damages && !scenario.equals("cancel") ? Material.CHIPPED_ANVIL : Material.ANVIL;
assertEquals(expected, effects.block.getType());
assertEquals(BlockFace.WEST, ((Directional) effects.data.get()).getFacing());
verify(f.world, times(scenario.equals("cancel") ? 0 : 1))
.playEffect(any(org.bukkit.Location.class), eq(Effect.ANVIL_USE), eq(0));
assertEquals(1, f.player.getItemOnCursor().getAmount());
assertEquals(2, input(f, 0).getAmount());
}
}
@ParameterizedTest @ValueSource(strings = {"refused", "uncertain-throw"})
void nativeExplosionRequestsCorrectFlagsAndNeverRefundsOrRetries(String behavior) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
effects.cancelExplosion = behavior.equals("refused");
var uncertain = new IllegalStateException("World may already have applied damage");
if (behavior.equals("uncertain-throw")) effects.duringExplosion = () -> { throw uncertain; };
seed(f);
ItemStack result = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
if (behavior.equals("uncertain-throw")) assertSame(uncertain, assertThrows(IllegalStateException.class, f::tick));
else f.tick();
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertEquals(1, rolls.get());
assertEquals(java.util.List.of(false), effects.requestedFire);
var explosion = effects.explosions.getFirst();
assertFalse(explosion.excludeSourceFromDamage);
assertSame(f.nativePlayer, explosion.getDirectSourceEntity());
assertSame(f.nativePlayer, explosion.getIndirectSourceEntity());
assertEquals(6F, explosion.radius());
assertEquals(behavior.equals("refused"), explosion.wasCanceled);
assertEquals(0, count(f, result));
f.tick();
assertEquals(1, rolls.get());
assertEquals(1, effects.explosions.size());
assertEquals(2, input(f, 0).getAmount());
assertEquals(2, input(f, 1).getAmount());
assertEquals(0, count(f, result));
// Last so incorrect interaction does not mask no-refund/no-retry/attribution proof.
assertEquals(java.util.List.of(net.minecraft.world.level.Level.ExplosionInteraction.MOB), effects.requestedInteractions);
}
}
@ParameterizedTest @ValueSource(strings = {"inputs", "acknowledged-policy", "acknowledged-policy-resync"})
void oldClientStateAfterNewServerPreviewMustRefuseWithoutPaymentOrRiskRoll(String change) throws Exception {
AtomicInteger rolls = new AtomicInteger();
// Roll50 succeeds at the displayed OLD 20% risk, but fails at the unseen 80/95% risk.
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
seed(f);
f.menu.sendAllDataToRemote();
int oldNativeState = f.menu.getStateId();
int oldState = f.clientState(f.menu);
String oldRisk = latestRisk(f);
assertRisk(f, 20);
if (change.equals("inputs")) f.inputs(sword(10, 3), sword(10, 3)); // new Sharpness XI: 95% failure
else {
f.admin(f.console, BASE, 80);
f.acknowledgeSave();
}
int reprepareState = f.menu.getStateId();
// Approved A requires a new native epoch even for identical-output risk changes.
assertNotEquals(oldNativeState, reprepareState, "Every changed risk must version the native menu");
// Also retain the full-resync variant sent but not yet observed by the client.
if (change.equals("acknowledged-policy-resync")) f.player.updateInventory();
int currentState = f.clientState(f.menu);
assertNotEquals(oldState, currentState, "A real newer server menu generation must exist");
assertRisk(f, change.equals("inputs") ? 95 : 80);
ItemStack left = input(f, 0).clone();
ItemStack right = input(f, 1).clone();
String currentRisk = latestRisk(f);
// Outbound packets/action bars are captured, NOT delivered to a client. This packet
// was created from the older rendered preview, not from the latest server preview.
packet.packets.clear();
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
assertEquals(0, packet.clicks.size(), "Protocol rejection must precede Bukkit dispatch");
assertTrue(packet.received.getLast().isCancelled());
if (oldState != currentState) {
assertTrue(packet.packets.stream().anyMatch(
net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
}
f.tick();
String facts = "change=" + change + ", oldState=" + oldState + ", stateAfterReprepare=" + reprepareState
+ ", currentStateBeforePacket=" + currentState
+ ", oldRisk=" + oldRisk + ", currentRisk=" + currentRisk + ", selectedRoll=50, rolls=" + rolls.get()
+ ", explosions=" + effects.explosions.size() + ", leftAfter=" + input(f, 0).getAmount();
System.out.println("STALE_CLIENT_FACTS " + facts);
assertAll(facts,
() -> assertEquals(left, input(f, 0), "Old client take must not debit the NEW preview"),
() -> assertEquals(right, input(f, 1)),
() -> assertEquals(0, rolls.get(), "Resync is not refusal: outdated displayed risk must not commit"),
() -> assertTrue(effects.explosions.isEmpty()),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()));
}
}
private NativeAnvilFixture successFixture(AtomicInteger rolls) throws Exception {
return new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
}
private static ItemStack sword(int level, int count) {
return NativeAdvancedAnvilTransactionTest.stackedSword(level, count);
}
private static void seed(NativeAnvilFixture f) { f.inputs(sword(5, 3), sword(5, 3)); }
private static ItemStack input(NativeAnvilFixture f, int slot) { return f.menu.getBukkitView().getTopInventory().getItem(slot); }
private static void unpaid(NativeAnvilFixture f, AtomicInteger rolls) {
assertEquals(sword(5, 3), input(f, 0));
assertEquals(sword(5, 3), input(f, 1));
assertEquals(0, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static void paid(NativeAnvilFixture f, AtomicInteger rolls) {
assertEquals(sword(5, 2), input(f, 0));
assertEquals(sword(5, 2), input(f, 1));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static ItemStack[] storage(NativeAnvilFixture f) {
return Arrays.stream(f.player.getInventory().getStorageContents())
.map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
}
static int count(NativeAnvilFixture f, ItemStack expected) {
return Arrays.stream(f.player.getInventory().getContents()).filter(item -> item != null && item.isSimilar(expected))
.mapToInt(ItemStack::getAmount).sum() + (f.player.getItemOnCursor().isSimilar(expected)
? f.player.getItemOnCursor().getAmount() : 0);
}
static String latestRisk(NativeAnvilFixture f) {
return PlainTextComponentSerializer.plainText().serialize(f.actionBars.getLast());
}
static void assertRisk(NativeAnvilFixture f, int failure) {
String preview = latestRisk(f);
assertTrue(preview.contains("Success " + (100 - failure) + "% · Failure " + failure + "%"), preview);
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.Effect;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAdvancedAnvilEffectsTest {
@TempDir Path directory;
@Test void failureConsumesOneOfEachBeforeRealProtectedSourceDamagingExplosionAndNeverRetries() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 0; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
effects.cancelExplosion = true;
f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(5, 3));
effects.duringExplosion = () -> {
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertNull(f.output());
};
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(1, effects.explosions.size(), "A committed failed attempt causes one native explosion");
var explosion = effects.explosions.getFirst();
assertEquals(6F, explosion.radius());
assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center());
assertSame(f.nativePlayer, explosion.getDirectSourceEntity());
assertSame(f.nativePlayer, explosion.getIndirectSourceEntity());
assertFalse(explosion.excludeSourceFromDamage);
assertTrue(explosion.wasCanceled);
assertEquals(1, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
f.tick();
assertEquals(1, effects.explosions.size());
verify(f.level).explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
eq(6F), eq(false), any(), any(), any(), any(), any(), any());
}
}
@Test void successfulSurvivalUsePreservesNativeWearFacingAndUseEffect() throws Exception {
try (var f = new NativeAnvilFixture(directory, () -> 99, () -> 0);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
var facing = ((org.bukkit.block.data.Directional) effects.data.get()).getFacing();
f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(5, 3));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertEquals(Material.CHIPPED_ANVIL, effects.block.getType());
assertEquals(facing, ((org.bukkit.block.data.Directional) effects.data.get()).getFacing());
assertEquals(1, f.player.getItemOnCursor().getAmount());
verify(f.world).playEffect(any(org.bukkit.Location.class), eq(Effect.ANVIL_USE), eq(0));
assertTrue(effects.explosions.isEmpty());
}
}
}
@@ -0,0 +1,339 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.EventPriority;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Packet -> native classification -> registered plugin -> untouched native phase -> deferred commit. */
class NativeAdvancedAnvilPacketTest {
@TempDir Path directory;
@Test void zeroXpSurvivalPickupReachesNativeEventAndPaysExactlyOnceOnTick() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(left, left.clone());
assertEquals(0, f.menu.getCost());
assertTrue(f.menu.getSlot(2).mayPickup(f.nativePlayer), "Real zero-XP survival result must be takeable");
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size(), "Handler must dispatch a registered Bukkit event");
assertEquals(InventoryAction.PICKUP_ALL, packet.clicks.getFirst().getAction());
assertEquals(ClickType.LEFT, packet.clicks.getFirst().getClick());
assertFalse(packet.clicks.getFirst().isCancelled(), "Gate empties output, not fabricated cancellation");
assertTrue(f.menu.getSlot(2).getItem().isEmpty());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get(), "No native-phase roll/payment");
assertEquals(1, f.nextTick.size());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
assertEquals(6, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void shiftQuickMoveAndRepeatBeforeTickCommitOnlyOneOutput() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(4, marker);
ItemStack[] before = storage(f);
packet.click(2, 0, ContainerInput.QUICK_MOVE);
packet.click(2, 0, ContainerInput.QUICK_MOVE);
assertEquals(1, packet.clicks.size(), "Pending repeat is refused before native Bukkit dispatch");
assertTrue(packet.received.getLast().isCancelled());
assertEquals(InventoryAction.MOVE_TO_OTHER_INVENTORY, packet.clicks.getFirst().getAction());
assertEquals(ClickType.SHIFT_LEFT, packet.clicks.getFirst().getClick());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size(), "Repeat packet cannot reserve a second payment");
f.tick();
assertPaid(f, rolls);
assertEquals(expected, f.player.getInventory().getItem(8), "Native reverse hotbar destination");
assertEquals(marker, f.player.getInventory().getItem(4));
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, countOutput(f, expected));
f.tick();
assertEquals(1, countOutput(f, expected));
assertEquals(1, rolls.get());
}
}
@Test void numberKeyDeliversOnceToEmptyHotbarSlotWithoutChangingUnrelatedItem() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(5, marker);
ItemStack[] before = storage(f);
packet.click(2, 4, ContainerInput.SWAP);
assertEquals(1, packet.clicks.size());
assertEquals(InventoryAction.HOTBAR_SWAP, packet.clicks.getFirst().getAction());
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
assertEquals(4, packet.clicks.getFirst().getHotbarButton());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
f.tick();
assertPaid(f, rolls);
assertEquals(expected, f.player.getInventory().getItem(4));
assertEquals(marker, f.player.getInventory().getItem(5));
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(1, countOutput(f, expected));
f.tick();
assertEquals(1, rolls.get());
}
}
@Test void occupiedHotbarNumberKeyRetainsNativeNothingClassificationAndConsumesNothing() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.getInventory().setItem(4, marker);
ItemStack[] before = storage(f);
packet.click(2, 4, ContainerInput.SWAP);
assertEquals(1, packet.clicks.size());
assertEquals(InventoryAction.NOTHING, packet.clicks.getFirst().getAction(),
"Native result slot may not accept the existing hotbar item");
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
f.tick();
assertUnpaid(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
}
}
@Test void finalHighestNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
finalCancellation(EventPriority.HIGHEST);
}
@Test void finalMonitorNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
finalCancellation(EventPriority.MONITOR);
}
private void finalCancellation(EventPriority priority) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.listen(priority, event -> {
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Cancellation runs after plugin's gate");
assertEquals(InventoryAction.PICKUP_ALL, event.getAction());
event.setCancelled(true);
});
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertTrue(packet.clicks.getFirst().isCancelled(), "Final native event, not substituted event");
assertGated(f, rolls, null);
assertEquals(1, f.nextTick.size());
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(expected, f.output(), "Denied native intent restores preview without payment");
assertEquals(0, countOutput(f, expected));
}
}
@Test void creativeCloneCannotCopyUnpaidEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.CLONE, 2, ClickType.MIDDLE, InventoryAction.CLONE_STACK, true);
}
@Test void dropOneCannotEmitOrPayForEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.THROW, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT, false);
}
@Test void dropStackCannotEmitOrPayForEnhancedResult() throws Exception {
unsupportedResult(ContainerInput.THROW, 1, ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT, false);
}
private void unsupportedResult(ContainerInput input, int button, ClickType click,
InventoryAction action, boolean creative) throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
if (creative) packet.creative();
seed(f);
ItemStack expected = f.output().clone();
ItemStack[] before = storage(f);
packet.click(2, button, input);
assertEquals(1, packet.clicks.size());
assertEquals(click, packet.clicks.getFirst().getClick());
assertEquals(action, packet.clicks.getFirst().getAction());
assertGated(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size());
f.tick();
assertUnpaid(f, rolls, null);
assertArrayEquals(before, storage(f));
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never())
.drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class),
org.mockito.ArgumentMatchers.anyBoolean(),
org.mockito.ArgumentMatchers.any(net.minecraft.util.Prediction.class),
org.mockito.ArgumentMatchers.anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
}
}
@Test void doubleClickOutsideResultCannotCollectUnpaidPreviewIntoMatchingCursor() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack carried = f.output().clone();
f.player.setItemOnCursor(carried.clone());
ItemStack[] before = storage(f);
packet.click(3, 0, ContainerInput.PICKUP_ALL); // empty native inventory slot, not result
assertEquals(1, packet.clicks.size());
assertEquals(3, packet.clicks.getFirst().getRawSlot());
assertEquals(InventoryAction.COLLECT_TO_CURSOR, packet.clicks.getFirst().getAction());
assertEquals(ClickType.DOUBLE_CLICK, packet.clicks.getFirst().getClick());
assertGated(f, rolls, carried);
assertArrayEquals(before, storage(f));
assertEquals(1, f.nextTick.size(), "Outside-result collection still passes through enhanced gate");
f.tick();
assertUnpaid(f, rolls, carried);
assertArrayEquals(before, storage(f));
assertEquals(carried, f.output());
assertEquals(1, countOutput(f, carried), "Only the pre-existing cursor item remains");
}
}
@Test void mismatchedContainerIdIsRefusedByNativeHandlerBeforeEventAndGate() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack preview = f.output().clone();
packet.click(f.menu.containerId + 1, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
assertTrue(packet.clicks.isEmpty());
assertTrue(f.nextTick.isEmpty());
assertEquals(preview, f.output(), "Native container-ID guard precedes the registered gate");
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(preview, f.output());
}
}
@Test void stateIdMismatchRefusesBeforeDispatchOrPayment() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.packets.clear();
packet.click(f.menu.containerId, -1, 2, 0, ContainerInput.PICKUP);
assertEquals(0, packet.clicks.size(), "Approved A refuses the old native resync-but-accept path");
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.packets.stream().anyMatch(
net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance),
"Real native synchronizer emits a full resync through the connection sink");
assertEquals(expected, f.output());
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(expected, f.output());
}
}
@Test void inactiveViewBeforeDeferredCommitCannotPayOldMenu() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertGated(f, rolls, null);
packet.inactiveView();
assertSame(packet.inventoryMenu.getBukkitView(), f.player.getOpenInventory());
packet.click(f.menu.containerId, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size(), "Old container packet cannot reach anvil after native menu replacement");
f.tick();
assertUnpaid(f, rolls, null);
assertEquals(0, countOutput(f, expected));
assertFalse(f.menu.bypassCost, "Inactive view releases owned exemption");
}
}
@Test void destinationChangeAfterNativeGateInvalidatesPaymentWithoutOverwritingCursor() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
ItemStack expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertGated(f, rolls, null);
ItemStack marker = new ItemStack(Material.EMERALD, 17);
f.player.setItemOnCursor(marker);
f.tick();
assertUnpaid(f, rolls, marker);
assertEquals(expected, f.output());
assertEquals(0, countOutput(f, expected));
}
}
private NativeAnvilFixture fixture(AtomicInteger rolls) throws Exception {
return new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
}
private static void seed(NativeAnvilFixture f) {
ItemStack input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(input, input.clone());
}
private static void assertGated(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Real native result is gated");
assertUnpaid(f, rolls, cursor);
}
private static void assertUnpaid(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
if (cursor == null) assertTrue(f.player.getItemOnCursor().isEmpty());
else assertEquals(cursor, f.player.getItemOnCursor());
assertEquals(0, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static void assertPaid(NativeAnvilFixture f, AtomicInteger rolls) {
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 2);
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
private static ItemStack[] storage(NativeAnvilFixture f) {
return java.util.Arrays.stream(f.player.getInventory().getStorageContents())
.map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
}
private static int countOutput(NativeAnvilFixture f, ItemStack output) {
int count = java.util.Arrays.stream(f.player.getInventory().getContents())
.filter(item -> item != null && item.isSimilar(output)).mapToInt(ItemStack::getAmount).sum();
ItemStack cursor = f.player.getItemOnCursor();
return count + (cursor.isSimilar(output) ? cursor.getAmount() : 0);
}
}
@@ -0,0 +1,347 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.ListenerOptions;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAdvancedAnvilProtocolTest {
@TempDir Path directory;
@Test void enableRegistersSynchronousDeclaredLibraryListenerAndRemovesOnlyOwnedListeners() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
assertFalse(f.protocolListeners.isEmpty(), "Real enable must register ProtocolLib adapter");
for (var listener : f.protocolListeners) {
assertSame(f.plugin, listener.getPlugin());
if (!listener.getReceivingWhitelist().getTypes().isEmpty()) {
assertEquals(java.util.Set.of(PacketType.Play.Client.WINDOW_CLICK), listener.getReceivingWhitelist().getTypes());
assertTrue(listener.getReceivingWhitelist().getOptions().contains(ListenerOptions.SYNC));
} else {
assertEquals(java.util.Set.of(PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT),
listener.getSendingWhitelist().getTypes());
assertTrue(listener.getSendingWhitelist().getOptions().contains(ListenerOptions.SYNC));
}
}
var foreign = org.mockito.Mockito.mock(com.comphenix.protocol.events.PacketListener.class);
f.protocolListeners.add(foreign);
f.plugin.setEnabled(false);
assertEquals(java.util.List.of(foreign), f.protocolListeners);
org.mockito.Mockito.verify(f.protocol, org.mockito.Mockito.never()).removePacketListeners(org.mockito.ArgumentMatchers.any());
f.assertNoEventErrors();
}
}
@Test void unsupportedBukkitTakeWithoutRawAdmissionRefusesAndConserves() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
seed(f);
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var event = new InventoryClickEvent(f.menu.getBukkitView(), InventoryType.SlotType.RESULT,
2, ClickType.LEFT, InventoryAction.PICKUP_ALL);
f.events.callEvent(event);
assertTrue(event.isCancelled(), "Missing client generation must fail closed");
f.tick();
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@Test void unknownPayloadCancelsAndNativeResynchronizesBeforeAnyBukkitClick() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
packet.packets.clear();
var event = f.receive(new Object());
assertTrue(event.isCancelled());
assertTrue(packet.clicks.isEmpty());
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
}
}
@Test void policyAndInputsChangedBetweenLibraryAdmissionAndNativeBukkitDispatchCannotRepurposeTicket() throws Exception {
for (boolean policyOnly : new boolean[] {true, false}) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(Boolean.toString(policyOnly)),
() -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
var admitted = f.receive(raw);
assertFalse(admitted.isCancelled());
if (policyOnly) {
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
// Wait for ACTUAL durable publication with the bounded admission expiry
// still pending; policy invalidation, not elapsed time, must refuse the take.
f.acknowledgeSave();
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var right = f.menu.getBukkitView().getTopInventory().getItem(1).clone();
packet.handler.handleContainerClick((net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
admitted.getPacket().getHandle());
assertEquals(1, packet.clicks.size());
assertTrue(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(right, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
}
@Test void ticketCannotAuthorizeDifferentSlotButtonOrInputAndExpiresIfNativeDispatchIsLost() throws Exception {
for (String mode : java.util.List.of("slot", "button", "input", "lost")) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var input = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
assertFalse(f.receive(raw(f, mode.equals("slot") ? 3 : 2, 0,
mode.equals("slot") ? ContainerInput.PICKUP_ALL : ContainerInput.PICKUP)).isCancelled());
if (mode.equals("lost")) {
for (long tick = 0; tick < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; tick++) f.tick();
}
packet.handler.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) (mode.equals("button") ? 1 : 0),
mode.equals("input") ? ContainerInput.QUICK_MOVE : ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
assertTrue(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
}
}
}
@Test void earlierAndFinalProtocolCancellationNeverAuthorizesPaymentEvenIfLaterListenerUncancels() throws Exception {
for (String mode : java.util.List.of("earlier", "late", "uncancel", "after-bukkit")) {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
var event = f.clientEvent(raw);
if (mode.equals("earlier") || mode.equals("uncancel")) event.setCancelled(true);
if (mode.equals("late") || mode.equals("uncancel")) f.protocolListeners.add(
new com.comphenix.protocol.events.PacketAdapter(f.plugin,
com.comphenix.protocol.events.ListenerPriority.HIGHEST, PacketType.Play.Client.WINDOW_CLICK) {
@Override public void onPacketReceiving(com.comphenix.protocol.events.PacketEvent callback) {
callback.setCancelled(!mode.equals("uncancel"));
}
});
f.dispatchProtocol(event);
if (!event.isCancelled()) packet.handler.handleContainerClick(
(net.minecraft.network.protocol.game.ServerboundContainerClickPacket) event.getPacket().getHandle());
if (mode.equals("after-bukkit")) event.setCancelled(true);
f.tick();
assertEquals(0, rolls.get());
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
}
@Test void offThreadCallbackCancelsOnlyWatchedActorWithoutMutatingBukkit() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var event = f.clientEvent(raw(f, 2, 0, ContainerInput.PICKUP));
org.mockito.Mockito.clearInvocations(f.player);
org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(false);
try {
var thread = new Thread(() -> f.dispatchProtocol(event));
thread.start();
thread.join();
assertTrue(event.isCancelled());
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).getOpenInventory();
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).updateInventory();
assertTrue(f.nextTick.isEmpty());
assertTrue(packet.clicks.isEmpty());
} finally { org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(true); }
}
}
@Test void unavailableManagerWithholdsNewEnhancedOutputAndRestoresOwnedBypass() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
org.mockito.Mockito.when(f.protocol.isClosed()).thenReturn(true);
f.prepareAgain();
assertNull(f.output());
assertFalse(f.menu.bypassCost);
assertTrue(packet.clicks.isEmpty());
f.assertNoEventErrors();
}
}
@Test void watchedEnhancedToVanillaRejectsOldTakeButFreshNormalBookOperationStaysNative() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
int oldState = f.clientState(f.menu);
var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 1);
var book = new org.bukkit.inventory.ItemStack(org.bukkit.Material.ENCHANTED_BOOK);
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
meta.addStoredEnchant(org.bukkit.enchantments.Enchantment.UNBREAKING, 3, false);
book.setItemMeta(meta);
f.inputs(left, book);
var expected = f.output().clone();
int cost = f.menu.getCost();
assertTrue(cost > 0);
assertFalse(f.menu.bypassCost);
assertNotEquals(oldState, f.clientState(f.menu));
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(book, f.menu.getBukkitView().getTopInventory().getItem(1));
f.nativePlayer.experienceLevel = 100;
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(1F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
assertFalse(packet.clicks.getFirst().isCancelled());
assertEquals(expected, f.player.getItemOnCursor());
org.mockito.Mockito.verify(f.nativePlayer).giveExperienceLevels(-cost);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
f.tick();
assertEquals(0, rolls.get());
}
}
@Test void disableRetiresOwnedNativeWindowReturnsInputsAndOldPacketCannotBecomeVanillaTake() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
assertFalse(f.receive(raw).isCancelled()); // A held, admitted raw packet has not reached native.
f.plugin.setEnabled(false);
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
assertTrue(f.protocolListeners.isEmpty());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(f.menu.bypassCost);
packet.handler.handleContainerClick(raw);
f.tick();
assertTrue(packet.clicks.isEmpty());
assertEquals(0, rolls.get());
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f,
NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1,
"Purpur 26.3.build.2652-stable")) {
assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure");
assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst());
assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage());
f.eventErrors.clear(); // The exact expected enable refusal is asserted above, never suppressed.
// The quantity fixture removes MAX_DAMAGE to make custom swords stackable; vanilla
// same-material combining requires damageable items. Use genuinely ordinary swords.
var input = new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND_SWORD);
input.addUnsafeEnchantment(org.bukkit.enchantments.Enchantment.SHARPNESS, 5);
f.inputs(input, input.clone());
assertTrue(f.protocolListeners.isEmpty());
assertFalse(f.menu.bypassCost);
assertEquals(5, f.output().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
assertTrue(f.menu.getCost() > 0);
assertTrue(f.actionBars.isEmpty());
assertEquals(0, rolls.get());
f.assertNoEventErrors();
}
}
@Test void initialAndIdenticalOutputRiskOnlyFramesPublishExactPrivateRiskBeforeDelivery() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
int initial = f.clientState(f.menu);
packet.packets.clear();
seed(f);
assertNotEquals(initial, f.clientState(f.menu));
assertRiskFrames(packet, f.output(), initial, 20);
int oldState = f.clientState(f.menu);
var oldOutput = f.output().clone();
packet.packets.clear();
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
f.acknowledgeSave();
assertEquals(oldOutput, f.output());
assertNotEquals(oldState, f.clientState(f.menu));
assertRiskFrames(packet, oldOutput, oldState, 80);
}
}
@Test void malformedTakingHeadersCancelAndResyncBeforeNativeEventsAndOtherExactViewsStayUnwatched() throws Exception {
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
for (ContainerInput input : java.util.List.of(ContainerInput.PICKUP, ContainerInput.SWAP, ContainerInput.QUICK_CRAFT)) {
packet.packets.clear();
packet.click(2, -1, input);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
}
var unrelated = new net.minecraft.world.inventory.AnvilMenu(19, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
unrelated.setTitle(net.minecraft.network.chat.Component.literal("Other ordinary anvil"));
f.nativePlayer.containerMenu = unrelated;
assertFalse(f.receive(new Object()).isCancelled(), "Same actor/menu kind is not exact owned-view scope");
assertTrue(f.nextTick.isEmpty());
}
}
private static void assertRiskFrames(NativeAnvilPacketFixture packet, org.bukkit.inventory.ItemStack expected,
int oldState, int failure) {
int frames = 0;
for (var frame : packet.packets) {
net.minecraft.world.item.ItemStack output = null;
int state = -1;
if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId) {
output = content.items().get(2);
state = content.stateId();
} else if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId && slot.getSlot() == 2) {
output = slot.getItem();
state = slot.getStateId();
}
if (output != null && org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(output).equals(expected)) {
frames++;
assertNotEquals(oldState, state);
assertTrue(packet.privateRiskAtSend.get(frame).contains("Failure " + failure + "%"));
}
}
assertTrue(frames > 0, "Must prove actual native output-frame delivery, not only server-side state");
}
private static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(
NativeAnvilFixture f, int slot, int button, ContainerInput input) {
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
f.clientState(f.menu), (short) slot, (byte) button, input,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
}
static void seed(NativeAnvilFixture f) {
var input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
f.inputs(input, input.clone());
}
}
@@ -0,0 +1,109 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/** Native displayed risk and committed outcomes for every controlled roll, not domain-only math. */
class NativeAdvancedAnvilRiskTest {
@TempDir Path directory;
@ParameterizedTest @CsvSource({"0,0", "1,20", "2,25", "3,35", "4,50", "5,70", "6,95", "7,99", "20,99"})
void largestResultExcessDefaultTableControlsEveryRollFromZeroThroughNinetyNine(int excess, int failure)
throws Exception {
AtomicInteger selectedRoll = new AtomicInteger();
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
if (excess == 0) {
// US-010 supersedes enhanced E0 routing: keep the 100 controls, but native
// payment/output and absence of ANY enhanced roll/preview are authoritative.
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.player.setItemOnCursor(null);
var ordinary = NativeAnvilCombiningRegressionTest.sword(4);
ordinary.addUnsafeEnchantment(Enchantment.UNBREAKING, 2);
f.inputs(ordinary, ordinary.clone());
assertTrue(f.actionBars.isEmpty());
assertFalse(f.menu.bypassCost);
int cost = f.menu.getCost();
assertTrue(cost > 0);
var expected = f.output().clone();
assertEquals(5, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, expected.getEnchantmentLevel(Enchantment.UNBREAKING));
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(effects.explosions.isEmpty());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
return;
}
for (int roll = 0; roll < 100; roll++) {
// 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);
// A second compatible enchantment has a smaller excess: SUM would give a
// different risk at E>=2. Native maxima differ (Sharpness5, Unbreaking3).
left.addUnsafeEnchantment(Enchantment.UNBREAKING, 2 + Math.max(0, excess - 1));
f.inputs(left, left.clone());
NativeAdvancedAnvilBoundaryTest.assertRisk(f, failure);
ItemStack expected = f.output().clone();
assertEquals(5 + excess, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3 + Math.max(0, excess - 1), expected.getEnchantmentLevel(Enchantment.UNBREAKING));
assertEquals(roll, rolls.get(), "Preparation never rolls");
int explosionsBefore = effects.explosions.size();
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
String context = "E=" + excess + ", cutoff=" + failure + ", roll=" + roll;
assertEquals(roll + 1, rolls.get(), context);
assertEquals(2, f.menu.getSlot(0).getItem().getCount(), context);
assertEquals(2, f.menu.getSlot(1).getItem().getCount(), context);
assertEquals(0, f.nativePlayer.experienceLevel, context);
if (roll < failure) {
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);
}
}
assertEquals(failure, effects.explosions.size(), "100 rolls: precisely the displayed failure count");
assertEquals(100, rolls.get());
assertEquals(1, f.player.getItemOnCursor().getAmount(), "Roll99 succeeds even at the 99% ceiling");
}
}
}
@@ -0,0 +1,295 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.List;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.EventPriority;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.PrepareAnvilEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable;
import org.bukkit.inventory.meta.Repairable;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered plugin -> native slotsChanged/result event -> Craft result and private preview. */
class NativeAdvancedAnvilTest {
@TempDir Path directory;
NativeAnvilFixture fixture;
@BeforeEach void enable() throws Exception { fixture = new NativeAnvilFixture(directory); }
@AfterEach void disable() { if (fixture != null) fixture.close(); }
@Test void registeredHighestListenerMergesEqualAboveNormalMaximumAtZeroXpAndTooExpensive() {
assertTrue(java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin
&& listener.getPriority() == EventPriority.HIGHEST));
var left = sword(5);
var meta = left.getItemMeta();
((Repairable) meta).setRepairCost(80);
left.setItemMeta(meta);
fixture.nativePlayer.experienceLevel = 0;
fixture.inputs(left, sword(5));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(0, fixture.menu.getCost());
assertTrue(fixture.menu.getBukkitView().canBypassCost());
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
assertFalse(fixture.menu.canDoUnsafeEnchants);
assertFalse(fixture.menu.bypassEnchantmentLevelRestriction);
assertEquals(80, ((Repairable) fixture.output().getItemMeta()).getRepairCost());
assertInputs(left, sword(5));
}
@Test void equalUnequalAndSingleSourceUnionUseEntireEnchantments() {
fixture.inputs(sword(6), sword(5));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
fixture.inputs(sword(5), sword(7));
assertEquals(7, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.UNBREAKING, 3);
fixture.inputs(sword(6), right);
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), sword(6));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(0, fixture.menu.getCost());
fixture.inputs(sword(6), new ItemStack(Material.DIAMOND_SWORD));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
}
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
var left = sword(5);
var meta = (Damageable) left.getItemMeta();
meta.setDamage(1200);
left.setItemMeta(meta);
var right = sword(5);
right.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
fixture.inputs(left, right);
assertEquals(1013, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
fixture.inputs(sword(6), right);
assertNull(fixture.output());
assertTrue(lastAction().contains("conflict"));
assertInputs(sword(6), right);
var alreadyConflicting = sword(6);
alreadyConflicting.addUnsafeEnchantment(Enchantment.SMITE, 4);
fixture.inputs(alreadyConflicting, new ItemStack(Material.DIAMOND_SWORD));
assertNull(fixture.output(), "Conflicts already present on left cannot be silently retained");
}
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
fixture.inputs(sword(6), invalid);
assertNull(fixture.output());
assertTrue(lastAction().contains("applicable"));
fixture.inputs(invalid, sword(6));
assertNull(fixture.output());
assertInputs(invalid, sword(6));
}
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
fixture.inputs(sword(254), sword(254));
assertNotNull(fixture.output());
assertEquals(255, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(fixture.output(), ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
fixture.inputs(sword(255), sword(255));
assertNull(fixture.output());
assertTrue(lastAction().contains("represent"));
assertInputs(sword(255), sword(255));
assertNotNull(AdvancedAnvilMerge.prepare(sword(255), sword(255), AdvancedAnvilPolicy.defaults()).refusal());
}
@Test void exactStyledUnicodeNameLorePdcAndLeftOnlyMetadataSurviveCraftRoundTrip() {
var left = sword(5);
left.setAmount(3);
var meta = left.getItemMeta();
meta.customName(Component.text("Épée 雪 🗡", NamedTextColor.AQUA).decorate(TextDecoration.BOLD));
meta.lore(List.of(Component.text("Lore λ", NamedTextColor.GOLD), Component.empty()));
meta.itemName(Component.text("Separate native item-name"));
meta.setUnbreakable(true);
meta.setMaxStackSize(3);
meta.getPersistentDataContainer().set(key("left"), PersistentDataType.STRING, "雪");
left.setItemMeta(meta);
// Native slots clamp ordinary swords to one; a real stackable sword must also remove
// MAX_DAMAGE, since the native codec rejects damageable + stackable components.
left.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
assertEquals(left, ItemStack.deserializeBytes(left.serializeAsBytes()));
var right = sword(5);
var rightMeta = right.getItemMeta();
rightMeta.customName(Component.text("DO NOT IMPORT"));
rightMeta.lore(List.of(Component.text("foreign")));
rightMeta.getPersistentDataContainer().set(key("right"), PersistentDataType.STRING, "foreign");
right.setItemMeta(rightMeta);
fixture.menu.itemName = "rename field ignored";
fixture.inputs(left, right);
var expected = left.clone();
expected.setAmount(1);
expected.addUnsafeEnchantment(Enchantment.SHARPNESS, 6);
assertEquals(expected, fixture.output());
assertEquals(expected, ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
assertFalse(fixture.output().getItemMeta().getPersistentDataContainer().has(key("right")));
assertEquals(0, fixture.menu.getBukkitView().getRepairCost());
assertTrue(fixture.menu.getBukkitView().canBypassCost());
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
assertInputs(left, right);
}
@Test void absentNameAndLoreStayAbsentAndEnhancedRenameDoesNotChangeThem() {
fixture.menu.itemName = "Injected rename";
fixture.inputs(sword(5), sword(5));
assertFalse(fixture.output().getItemMeta().hasCustomName());
assertFalse(fixture.output().getItemMeta().hasLore());
fixture.menu.setItemName("second rename");
fixture.prepareAgain();
assertFalse(fixture.output().getItemMeta().hasCustomName());
assertFalse(fixture.output().getItemMeta().hasLore());
}
@Test void nativeNormalDurabilityRepairUsesTwelvePercentAndOnlyImprovesDamage() {
var left = damaged(sword(5), 1500);
var right = damaged(sword(5), 1500);
fixture.inputs(left, right);
int maximum = left.getType().getMaxDurability();
int expected = Math.max(0, maximum - ((maximum - 1500) * 2 + maximum * 12 / 100));
assertEquals(expected, ((Damageable) fixture.output().getItemMeta()).getDamage());
assertInputs(left, right);
fixture.inputs(damaged(sword(6), 2), damaged(sword(4), maximum - 1));
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@Test void customNativeMaximumDurabilityAndNonDamageableMaterialsRemainRepresentable() {
var left = sword(5);
var meta = (Damageable) left.getItemMeta();
meta.setMaxDamage(1000);
meta.setDamage(900);
left.setItemMeta(meta);
var right = damaged(sword(5), 1500);
fixture.inputs(left, right);
assertEquals(719, ((Damageable) fixture.output().getItemMeta()).getDamage());
assertEquals(1000, ((Damageable) fixture.output().getItemMeta()).getMaxDamage());
var helmet = enchanted(Material.DIAMOND_HELMET, Enchantment.PROTECTION, 4);
fixture.inputs(helmet, helmet);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.PROTECTION));
}
@Test void booksAndMismatchedMaterialsRemainNativeAndDoNotSendEnhancedWarnings() {
var book = new ItemStack(Material.ENCHANTED_BOOK);
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
meta.addStoredEnchant(Enchantment.SHARPNESS, 5, true);
book.setItemMeta(meta);
fixture.inputs(sword(5), book);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
assertTrue(fixture.chats.isEmpty());
assertTrue(fixture.actionBars.isEmpty());
fixture.inputs(book, book);
assertNotNull(fixture.output());
assertEquals(5, ((org.bukkit.inventory.meta.EnchantmentStorageMeta) fixture.output().getItemMeta())
.getStoredEnchantLevel(Enchantment.SHARPNESS));
fixture.inputs(sword(5), enchanted(Material.NETHERITE_SWORD, Enchantment.SHARPNESS, 5));
assertNull(fixture.output());
assertTrue(fixture.chats.isEmpty());
assertNull(AdvancedAnvilMerge.prepare(book, book, AdvancedAnvilPolicy.defaults()));
}
@Test void zeroEnchantmentRepairAndRenameRemainNativeAfterEnhancedPreview() {
fixture.inputs(sword(5), sword(5));
int notices = fixture.chats.size();
// Remove the enchanted right input before replacing the left, as a player can do.
// Left-first replacement creates a genuine intermediate enhanced merge with the old right.
fixture.inputs(sword(5), null);
fixture.inputs(damaged(new ItemStack(Material.DIAMOND_SWORD), 1200),
damaged(new ItemStack(Material.DIAMOND_SWORD), 1200));
assertNotNull(fixture.output());
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
assertEquals(notices, fixture.chats.size());
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), null);
fixture.menu.setItemName("native rename");
fixture.prepareAgain();
assertEquals(Component.text("native rename"), fixture.output().getItemMeta().customName());
assertTrue(fixture.menu.getCost() > 0);
assertFalse(fixture.menu.bypassCost);
}
@Test void exactPrivateRomanPreviewAndChangedOnlyWarningDoNotAlterItemsOrSpam() {
fixture.inputs(sword(5), sword(5));
assertEquals("Diamond Sword · Sharpness VI · Success 80% · Failure 20% — explosion", lastAction());
assertEquals(1, fixture.chats.size());
assertTrue(plain(fixture.chats.getFirst()).contains("Failure destroys both items and causes an explosion"));
for (int i = 0; i < 5; i++) { fixture.prepareAgain(); fixture.tick(); }
assertEquals(1, fixture.chats.size());
fixture.inputs(sword(6), sword(6));
assertEquals(2, fixture.chats.size());
assertTrue(lastAction().contains("Sharpness VII · Success 75% · Failure 25%"));
fixture.inputs(sword(10), sword(10));
assertTrue(lastAction().contains("Sharpness XI · Success 5% · Failure 95%"));
verify(fixture.server, never()).broadcast(any(Component.class));
}
@Test void policyChangeRerendersCurrentViewAndCloseDisableReleasePreviewLifecycle() throws Exception {
fixture.inputs(sword(5), sword(5));
var admin = mock(org.bukkit.command.CommandSender.class);
when(admin.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
assertTrue(fixture.commands.dispatch(admin,
"inventoryhelperadmin config advanced-anvil-base-failure-percent 45"));
var acknowledgment = fixture.nextTick.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(acknowledgment, "Real durable save must schedule acknowledgment");
assertTrue(lastAction().contains("Success 80% · Failure 20%"), "No off-thread publication");
acknowledgment.run();
assertTrue(lastAction().contains("Success 55% · Failure 45%"));
var preview = java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
.map(org.bukkit.plugin.RegisteredListener::getListener)
.filter(AdvancedAnvilListener.class::isInstance).map(AdvancedAnvilListener.class::cast)
.findFirst().orElseThrow();
int chats = fixture.chats.size();
preview.policyChanged();
assertEquals(chats, fixture.chats.size());
fixture.events.callEvent(new InventoryCloseEvent(fixture.menu.getBukkitView()));
int bars = fixture.actionBars.size();
fixture.tick();
assertEquals(bars, fixture.actionBars.size());
fixture.plugin.setEnabled(false);
assertTrue(fixture.repeating.isEmpty());
assertFalse(fixture.menu.bypassCost);
}
private void assertInputs(ItemStack left, ItemStack right) {
assertEquals(left, fixture.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(right, fixture.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, fixture.nativePlayer.experienceLevel);
fixture.assertNoEventErrors();
}
private String lastAction() { assertFalse(fixture.actionBars.isEmpty()); return plain(fixture.actionBars.getLast()); }
private static String plain(Component message) { return PlainTextComponentSerializer.plainText().serialize(message); }
private static NamespacedKey key(String key) { return new NamespacedKey("test", key); }
private static ItemStack sword(int level) { return enchanted(Material.DIAMOND_SWORD, Enchantment.SHARPNESS, level); }
private static ItemStack enchanted(Material material, Enchantment enchantment, int level) {
var item = new ItemStack(material);
item.addUnsafeEnchantment(enchantment, level);
return item;
}
private static ItemStack damaged(ItemStack item, int damage) {
var meta = (Damageable) item.getItemMeta();
meta.setDamage(damage);
item.setItemMeta(meta);
return item;
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered click gate plus native clicked/slots/cursor and deterministic next-tick commit. */
class NativeAdvancedAnvilTransactionTest {
@TempDir Path directory;
@Test void nativeTakeConsumesOneOfEachStackAndRollsOnceWithoutXpOrFreeRepeat() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
f.inputs(stackedSword(5, 3), stackedSword(5, 3));
f.click(2, ClickType.LEFT, InventoryAction.PICKUP_ALL, 0, ContainerInput.PICKUP);
f.tick();
assertNotNull(f.menu.getBukkitView().getTopInventory().getItem(0), "Native payment must leave the two unpaid items");
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
assertEquals(1, f.player.getItemOnCursor().getAmount());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(1, rolls.get());
assertEquals(0, f.nativePlayer.experienceLevel);
}
}
static ItemStack stackedSword(int level, int count) {
var item = new ItemStack(Material.DIAMOND_SWORD);
item.addUnsafeEnchantment(Enchantment.SHARPNESS, level);
var meta = item.getItemMeta();
meta.setMaxStackSize(3);
item.setItemMeta(meta);
item.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
item.setAmount(count);
return item;
}
}
@@ -0,0 +1,468 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Ordinary and over-limit combining through registered native result-taking paths. */
class NativeAnvilCombiningRegressionTest {
@TempDir Path directory;
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 4})
void withinLimitCombiningRetainsNativeExperienceAndResultTaking(int level) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = 20;
var left = sword(level); var right = sword(level);
f.inputs(left, right);
int cost = f.menu.getCost();
assertTrue(cost > 0, "Within-limit merging must retain its actual native XP cost");
assertFalse(f.menu.getBukkitView().canBypassCost(), "No advanced cost bypass for an ordinary merge");
assertTrue(f.actionBars.isEmpty(), "An ordinary merge must not acquire an advanced risk preview");
assertEquals(level + 1, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(20 - cost, f.nativePlayer.experienceLevel);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
f.tick();
assertEquals(0, rolls.get(), "Native merging performs no enhanced roll or deferred payment");
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void queuedDeclaredLibraryTakeSurvivesSchedulerBeforeNativeDispatch() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
assertEquals(6, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
assertEquals(1, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertTrue(packet.clicks.isEmpty(), "ProtocolLib queues forwarding rather than native inline dispatch");
f.tick(); // Scheduler can run before the Netty forward/native-main handoff.
assertEquals(0, rolls.get());
transport.forwardToNativeQueue();
assertTrue(packet.clicks.isEmpty(), "Native PacketUtils must queue off-thread dispatch");
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled(), "An unchanged admitted generation remains takeable across real queues");
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void earlierForwardedCancellationCannotBorrowALaterIdenticalAdmission() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
transport.forwardToNativeQueue(); // First exact wrapper is already in native PacketProcessor.
transport.receive(raw);
transport.forwardToNativeQueue();
assertEquals(2, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertFalse(transport.received.getLast().isCancelled());
// ProtocolLib has already scheduled both forwards. The first event is cancelled
// before its native event; the later admission must not authorize the first event.
transport.received.getFirst().setCancelled(true);
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertTrue(packet.clicks.isEmpty(), "Each native take must use its OWN final protocol cancellation: revoked wrapper is dropped before Bukkit");
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(1));
assertTrue(transport.processor.executeSinglePacket(), "Second exact wrapper still owns its admission");
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void realConnectionClickSpecificNativeRefusalMustStillApplyToOwnedDispatch() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previousLimiter = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP));
config.packetLimiter = limiter;
// Actual pinned Connection, not the ordinary fixture's controlled handler port.
var nativeConnection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
nativeConnection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, nativeConnection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", nativeConnection);
f.channel.pipeline().replace(f.connection, "packet_handler", nativeConnection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
var nativeRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(nativeRaw);
assertEquals(0, handled.get(), "Positive control: real native click-specific limiter refuses the original record");
var wireRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
var admitted = f.receive(wireRaw);
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(0, handled.get(), "Owned identity wrapper must not bypass native click-specific refusal");
} finally { config.packetLimiter = previousLimiter; }
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"shared-drop", "class-kick"})
void ownedDispatchUsesSharedNativeClassCountersAndNativeViolationAction(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previous = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
var action = mode.equals("class-kick")
? io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK
: io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP;
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, mode.equals("class-kick") ? 0.1 : 2.0, action));
config.packetLimiter = limiter;
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
connection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", connection);
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
if (mode.equals("shared-drop")) {
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(original);
assertEquals(1, handled.get(), "Native first class-rate count permits the original record");
}
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(mode.equals("shared-drop") ? 1 : 0, handled.get());
var received = net.minecraft.network.Connection.class.getDeclaredField("receivedPackets");
received.setAccessible(true);
assertEquals(mode.equals("shared-drop") ? 1 : 0, received.getInt(connection),
"Native class-refused packets must not increment successful receive accounting");
if (mode.equals("class-kick")) {
var stopped = net.minecraft.network.Connection.class.getDeclaredField("stopReadingPackets");
stopped.setAccessible(true);
assertTrue(stopped.getBoolean(connection), "Invoke the native kick/read-stop action");
}
} finally { config.packetLimiter = previous; }
}
}
@Test void removingAdvancedInputsAndPlacingNormalItemsViaNativePacketsImmediatelyRestoresVanilla() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
int oldFrame = f.clientState(f.menu);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(3, 0, ContainerInput.PICKUP); // Return left to native player slot9.
packet.click(1, 0, ContainerInput.PICKUP);
packet.click(4, 0, ContainerInput.PICKUP);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(f.menu.bypassCost);
f.realInventory.setItem(11, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.realInventory.setItem(12, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.menu.broadcastChanges();
packet.click(5, 0, ContainerInput.PICKUP);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(6, 0, ContainerInput.PICKUP);
packet.click(1, 0, ContainerInput.PICKUP);
assertSame(f.menu, f.nativePlayer.containerMenu, "No closing/reopening or advanced mode");
assertFalse(f.menu.bypassCost);
assertEquals(5, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(net.kyori.adventure.text.Component.empty(), f.actionBars.getLast());
int cost = f.menu.getCost();
assertTrue(cost > 0);
int eventsBefore = packet.clicks.size();
packet.click(f.menu.containerId, oldFrame, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertEquals(eventsBefore, packet.clicks.size());
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(5, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(sword(5), f.player.getInventory().getItem(9));
assertEquals(sword(5), f.player.getInventory().getItem(10));
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalRenameRepairPriorWorkAndRefusalRemainNative(boolean previouslyAdvanced) throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
packet.survival();
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
var left = sword(1);
var meta = (org.bukkit.inventory.meta.Damageable) left.getItemMeta();
meta.setDamage(1500);
((org.bukkit.inventory.meta.Repairable) meta).setRepairCost(8);
left.setItemMeta(meta);
var right = left.clone();
f.inputs(left, right);
f.menu.setItemName("ordinary rename");
f.prepareAgain();
assertFalse(f.menu.bypassCost);
assertTrue(f.menu.getCost() > 16, "Prior work plus native merge/repair/naming XP retained");
assertEquals(net.kyori.adventure.text.Component.text("ordinary rename"), f.output().getItemMeta().customName());
assertTrue(((org.bukkit.inventory.meta.Damageable) f.output().getItemMeta()).getDamage() < 1500);
assertEquals(17, ((org.bukkit.inventory.meta.Repairable) f.output().getItemMeta()).getRepairCost());
var expensive = sword(4);
var prior = (org.bukkit.inventory.meta.Repairable) expensive.getItemMeta();
prior.setRepairCost(80); expensive.setItemMeta(prior);
f.inputs(expensive, sword(4));
assertFalse(f.menu.bypassCost);
assertNull(f.output(), "Native Too Expensive refusal must not inherit enhanced bypass");
assertTrue(f.menu.getCost() >= 40);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalInsufficientExperienceAndFinalCancellationSpendNothing(boolean previouslyAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 0);
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
f.inputs(sword(1), sword(1));
assertTrue(f.menu.getCost() > 0);
assertFalse(f.menu.getSlot(2).mayPickup(f.nativePlayer));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
nativeExperienceAndWear(f, 100);
packet.listen(org.bukkit.event.EventPriority.MONITOR, event -> event.setCancelled(true));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"expiry", "inputs", "policy", "close", "shutdown", "native-refusal"})
void queuedRetiredOrRefusedIntentsNeverDebitAndCannotAuthorizeLaterTakes(String mode) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
transport.receive(raw(f));
transport.forwardToNativeQueue();
switch (mode) {
case "expiry" -> { for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick(); }
case "inputs" -> f.inputs(sword(6), sword(6));
case "policy" -> { f.admin(f.console, "advanced-anvil-base-failure-percent", 80); f.acknowledgeSave(); }
case "close" -> packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
case "shutdown" -> f.plugin.setEnabled(false);
case "native-refusal" -> org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(true);
default -> throw new AssertionError(mode);
}
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
if (mode.equals("close") || mode.equals("shutdown")) {
assertEquals(2, NativeAdvancedAnvilBoundaryTest.count(f, sword(5)));
assertEquals(1, f.channel.pipeline().names().stream().filter(n -> n.equals("packet_handler")).count());
} else {
org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(false);
var expected = f.output().clone();
transport.receive(raw(f)); transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(1, rolls.get(), "A refused old dispatch must not strand or lend a ticket to the fresh take");
assertEquals(expected, f.player.getItemOnCursor());
assertFalse(transport.processor.executeSinglePacket());
}
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void alreadyQueuedOldIdentityCannotTakeFromAReusedNativeWindow(boolean newAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
transport.receive(raw(f)); transport.forwardToNativeQueue();
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
var input = sword(newAdvanced ? 5 : 4);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
int cost = reopened.getCost();
assertEquals(newAdvanced, reopened.bypassCost);
transport.receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
reopened.containerId, f.clientState(reopened), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket()); // Retired exact old-window wrapper.
transport.assertNoErrors();
assertTrue(packet.clicks.isEmpty());
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(input, reopened.getBukkitView().getTopInventory().getItem(0));
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(newAdvanced ? 1 : 0, rolls.get());
assertEquals(newAdvanced ? 100 : 100 - cost, f.nativePlayer.experienceLevel);
assertTrue(reopened.getSlot(0).getItem().isEmpty());
assertTrue(reopened.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void outstandingIdentityBudgetRefusesOverflowAndRecoversOnlyAfterExactRetirement() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
f.inputs(sword(5), sword(5));
var held = new java.util.ArrayList<Object>();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
held.add(admitted.getPacket().getHandle());
}
assertTrue(f.receive(raw(f)).isCancelled(), "Bound outstanding exact records instead of replacing their identities");
for (var record : held) f.dispatchInbound(record);
f.tick();
assertTrue(packet.clicks.isEmpty());
assertEquals(0, rolls.get());
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void hookPassesUnrelatedTrafficAndRemovesOnlyItselfAfterShutdownDrain() throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
var forwarded = new java.util.ArrayList<Object>();
var probe = new io.netty.channel.ChannelInboundHandlerAdapter() {
@Override public void channelRead(io.netty.channel.ChannelHandlerContext context, Object message) {
forwarded.add(message); context.fireChannelRead(message);
}
};
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
var baseline = f.channel.pipeline().names();
var message = new Object();
// Replace only the fixture's controlled transport port behavior for non-packets.
org.mockito.Mockito.doAnswer(inv -> null).when(f.connection).channelRead(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.same(message));
f.channel.pipeline().fireChannelRead(message);
assertEquals(java.util.List.of(message), forwarded);
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.plugin.setEnabled(false);
f.dispatchInbound(admitted.getPacket().getHandle());
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(baseline.size() - 1, f.channel.pipeline().names().size(), "Remove only drained owned hook");
assertTrue(packet.clicks.isEmpty());
}
}
static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(NativeAnvilFixture f) {
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
}
static void nativeExperienceAndWear(NativeAnvilFixture f, int xp) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = xp;
}
static ItemStack sword(int sharpness) {
var item = new ItemStack(Material.DIAMOND_SWORD);
item.addUnsafeEnchantment(Enchantment.SHARPNESS, sharpness);
return item;
}
}
@@ -0,0 +1,77 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.world.level.ServerExplosion;
import net.minecraft.world.phys.Vec3;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.craftbukkit.block.data.CraftBlockData;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.ExplosionResult;
/** Real Craft explosion overload and ServerExplosion; world/protection execution is the external port. */
final class NativeAnvilEffectsFixture {
final NativeAnvilFixture anvil;
final Block block;
final AtomicReference<BlockData> data = new AtomicReference<>(
CraftBlockData.createData(net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState()));
final List<ServerExplosion> explosions = new ArrayList<>();
final List<EntityExplodeEvent> protectionEvents = new ArrayList<>();
final List<net.minecraft.world.level.Level.ExplosionInteraction> requestedInteractions = new ArrayList<>();
final List<Boolean> requestedFire = new ArrayList<>();
Runnable duringExplosion = () -> {};
boolean cancelExplosion;
NativeAnvilEffectsFixture(NativeAnvilFixture anvil) throws Exception {
this.anvil = anvil;
block = anvil.world.getBlockAt(10, 65, -9);
when(block.getType()).thenAnswer(ignored -> data.get().getMaterial());
when(block.getBlockData()).thenAnswer(ignored -> data.get().clone());
when(block.getLocation()).thenAnswer(ignored -> anvil.menu.getBukkitView().getTopInventory().getLocation());
when(anvil.server.createBlockData(any(Material.class)))
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null));
when(anvil.server.createBlockData(any(Material.class), nullable(String.class)))
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), inv.getArgument(1)));
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class));
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class), anyBoolean());
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
.when(block).setType(any(Material.class));
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
.when(block).setType(any(Material.class), anyBoolean());
when(anvil.level.getBlockState(anvil.position)).thenAnswer(ignored -> ((CraftBlockData) data.get()).getState());
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
worldField.setAccessible(true);
worldField.set(anvil.world, anvil.level);
var sources = mock(net.minecraft.world.damagesource.DamageSources.class);
var damage = mock(net.minecraft.world.damagesource.DamageSource.class);
when(anvil.level.damageSources()).thenReturn(sources);
when(sources.explosion(anvil.nativePlayer, anvil.nativePlayer)).thenReturn(damage);
when(anvil.level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
// Capture requested native flags before the external world adapter maps block behavior.
requestedInteractions.add(inv.getArgument(8));
requestedFire.add(inv.getArgument(7));
var explosion = new ServerExplosion(anvil.level, inv.getArgument(0), inv.getArgument(1), null,
new Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
java.util.function.Consumer<ServerExplosion> configure = inv.getArgument(13);
if (configure != null) configure.accept(explosion);
var event = new EntityExplodeEvent(anvil.player, anvil.menu.getBukkitView().getTopInventory()
.getLocation().clone().add(.5, .5, .5), new ArrayList<>(), 1F, ExplosionResult.DESTROY);
event.setCancelled(cancelExplosion);
anvil.events.callEvent(event);
explosion.wasCanceled = event.isCancelled();
protectionEvents.add(event);
explosions.add(explosion);
duringExplosion.run();
return explosion;
});
doCallRealMethod().when(anvil.world).createExplosion(any(org.bukkit.entity.Entity.class),
any(org.bukkit.Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
}
}
@@ -0,0 +1,671 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityEquipment;
import net.minecraft.world.inventory.AnvilMenu;
import net.minecraft.world.inventory.ContainerLevelAccess;
import net.minecraft.world.level.block.Blocks;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.PluginCommandYamlParser;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.event.HandlerList;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.java.JavaPluginLoader;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.purpurmc.purpur.PurpurWorldConfig;
/** Real menu/inventory/events/items; only server, world, network and scheduler boundaries are ports. */
final class NativeAnvilFixture implements AutoCloseable {
static {
try {
NativeRuntime.bootstrap();
} catch (Exception failure) {
throw new ExceptionInInitializerError(failure);
}
}
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
final net.minecraft.server.MinecraftServer nativeServer = mock(net.minecraft.server.MinecraftServer.class);
final net.minecraft.network.PacketProcessor processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
final net.minecraft.network.Connection connection;
final net.minecraft.server.network.ServerGamePacketListenerImpl transport =
mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
io.netty.channel.Channel channel;
private java.util.function.Consumer<net.minecraft.network.protocol.game.ServerboundContainerClickPacket> legacyClick;
final List<Object> unrelatedInbound = new ArrayList<>();
private final io.papermc.paper.configuration.GlobalConfiguration previousConfiguration;
final CraftPlayer player = mock(CraftPlayer.class);
final ServerLevel level = mock(ServerLevel.class);
final CraftWorld world = mock(CraftWorld.class);
final BlockPos position = new BlockPos(10, 65, -9);
final net.minecraft.world.entity.player.Inventory realInventory =
new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment());
final SimpleCommandMap commands = new SimpleCommandMap(server, new HashMap<>());
final PaperPluginManagerImpl events = new PaperPluginManagerImpl(server, commands, null);
final List<Component> actionBars = new ArrayList<>();
final List<Component> chats = new ArrayList<>();
final List<String> adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>();
final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class);
final java.util.concurrent.BlockingQueue<Runnable> nextTick = new java.util.concurrent.LinkedBlockingQueue<>();
private long tickCount;
RuntimeException admissionSchedulingFailure;
RuntimeException librarySchedulingFailure;
final java.util.Queue<Runnable> libraryTasks = new java.util.concurrent.ConcurrentLinkedQueue<>();
final org.bukkit.plugin.Plugin libraryPlugin = mock(org.bukkit.plugin.Plugin.class);
private final Object previousLibraryScheduler;
private final Object previousLibraryPlugin;
private int taskId;
private final Map<Long, java.util.Collection<Runnable>> delayed = new java.util.TreeMap<>();
final List<Runnable> repeating = new ArrayList<>();
final List<BukkitTask> tasks = new ArrayList<>();
final List<Throwable> eventErrors = new ArrayList<>();
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;
NativeAnvilFixture(Path directory) throws Exception {
this(directory, () -> 99, () -> 1.0);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear) throws Exception {
this(directory, risk, wear, "Purpur 26.3.build.2651-stable");
}
NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception {
this(directory, () -> 99, () -> 1.0, "Purpur 26.3.build.2651-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
this(directory, risk, wear, "Purpur 26.3.build.2651-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version) throws Exception {
this(directory, risk, wear, version, false);
}
private NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version, boolean queuedTransport) throws Exception {
NativeRuntime.bootstrap();
previousConfiguration = io.papermc.paper.configuration.GlobalConfiguration.get();
if (previousConfiguration == null) {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.misc = config.new Misc();
config.packetLimiter = config.new PacketLimiter();
config.unsupportedSettings = config.new UnsupportedSettings();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", config);
}
connection = mock(net.minecraft.network.Connection.class);
when(level.getServer()).thenReturn(nativeServer);
when(nativeServer.packetProcessor()).thenReturn(processor);
when(transport.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; })
.when(transport).onPacketError(any(), any());
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "connection", connection);
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "server", nativeServer);
doAnswer(inv -> {
Object message = inv.getArgument(1);
try {
if (message instanceof net.minecraft.network.protocol.Packet<?> raw) dispatchPacket(raw);
else unrelatedInbound.add(message);
} catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
// Actual PacketUtils enqueued the wrapper or unrelated native packet.
}
return null;
}).when(connection).channelRead(any(), any());
channel = queuedTransport ? new NativeAnvilTestChannel() : new io.netty.channel.embedded.EmbeddedChannel();
channel.pipeline().addLast("packet_handler", connection);
connection.channel = channel;
nativePlayer.connection = transport;
doAnswer(inv -> {
if (legacyClick == null) throw new AssertionError("Unconfigured native click port");
legacyClick.accept(inv.getArgument(0));
return null;
}).when(transport).handleContainerClick(any());
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; })
.when(protocol).addPacketListener(any());
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; })
.when(protocol).removePacketListener(any());
previous = Bukkit.getServer();
setServer(server);
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
var mainThread = Thread.currentThread();
when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread);
when(server.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(server.getMinecraftVersion()).thenReturn("26.3");
when(server.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
when(server.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(server.getPluginManager()).thenReturn(events);
when(server.getPluginCommand(any(String.class))).thenAnswer(invocation -> {
var command = commands.getCommand(invocation.getArgument(0));
return command instanceof PluginCommand owned ? owned : null;
});
Map<NamespacedKey, Recipe> recipes = new HashMap<>();
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.get(inv.getArgument(0)));
when(server.addRecipe(any(Recipe.class))).thenAnswer(inv -> {
Recipe recipe = inv.getArgument(0);
return recipes.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.remove(inv.getArgument(0)) != null);
doReturn(List.of()).when(server).getOnlinePlayers();
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> {
if (inv.getArgument(0) == libraryPlugin) {
if (!libraryPlugin.isEnabled()) throw new IllegalStateException("Required library disabled");
if (librarySchedulingFailure != null) throw librarySchedulingFailure;
return schedule(inv.getArgument(1), libraryTasks);
}
return schedule(inv.getArgument(1), nextTick);
});
when(libraryPlugin.getServer()).thenReturn(server);
when(libraryPlugin.isEnabled()).thenReturn(true);
var schedulerField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("scheduler");
schedulerField.setAccessible(true);
previousLibraryScheduler = schedulerField.get(null);
var pluginField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("plugin");
pluginField.setAccessible(true);
previousLibraryPlugin = pluginField.get(null);
schedulerField.set(null, new com.comphenix.protocol.scheduler.DefaultScheduler(libraryPlugin));
pluginField.set(null, libraryPlugin);
when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(inv -> {
if (admissionSchedulingFailure != null) throw admissionSchedulingFailure;
long delay = inv.getArgument(2);
if (delay < 1) throw new AssertionError("Unexpected non-positive delayed task");
return schedule(inv.getArgument(1), delayed.computeIfAbsent(tickCount + delay, ignored -> new ArrayList<>()));
});
doAnswer(ignored -> { List.copyOf(tasks).forEach(BukkitTask::cancel); return null; })
.when(scheduler).cancelTasks(any(org.bukkit.plugin.Plugin.class));
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
when(player.getHandle()).thenReturn(nativePlayer);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn("AnvilPlayer");
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
when(nativePlayer.hasInfiniteMaterials()).thenReturn(true);
doCallRealMethod().when(player).getItemOnCursor();
doCallRealMethod().when(player).setItemOnCursor(any());
when(player.getInventory()).thenReturn(new CraftInventoryPlayer(realInventory));
when(player.hasPermission(any(String.class))).thenReturn(false);
when(console.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
.when(console).sendMessage(any(String.class));
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
.when(player).sendMessage(any(String.class));
doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; }).when(player).sendActionBar(any(Component.class));
doAnswer(inv -> { chats.add(inv.getArgument(0)); return null; }).when(player).sendMessage(any(Component.class));
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(nativePlayer.getInventory()).thenReturn(realInventory);
when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
when(nativePlayer.level()).thenReturn(level);
when(level.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
when(level.getWorld()).thenReturn(world);
when(level.getBlockState(position)).thenReturn(Blocks.ANVIL.defaultBlockState());
var block = mock(org.bukkit.block.Block.class);
when(block.getType()).thenReturn(org.bukkit.Material.ANVIL);
when(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);
when(world.getUID()).thenReturn(UUID.randomUUID());
var configField = net.minecraft.world.level.Level.class.getDeclaredField("purpurConfig");
configField.setAccessible(true);
configField.set(level, mock(PurpurWorldConfig.class));
menu = new AnvilMenu(7, realInventory, ContainerLevelAccess.create(level, position));
menu.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
nativePlayer.containerMenu = menu;
when(player.getOpenInventory()).thenAnswer(ignored -> nativePlayer.containerMenu.getBukkitView());
plugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(server),
description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), risk, wear, protocol,
() -> version);
commands.registerAll(plugin.getName(), PluginCommandYamlParser.parse(plugin));
plugin.getLogger().addHandler(new java.util.logging.Handler() {
@Override public void publish(java.util.logging.LogRecord record) {
if (record.getThrown() != null) eventErrors.add(record.getThrown());
}
@Override public void flush() {}
@Override public void close() {}
});
if (plugin.getServer() != server || plugin.getCommand("inventoryhelperadmin") == null) {
throw new AssertionError("Explicit native command wiring failed: server=" + (plugin.getServer() == server)
+ ", command=" + commands.getCommand("inventoryhelperadmin")
+ ", owner=" + (commands.getCommand("inventoryhelperadmin") instanceof PluginCommand command
&& command.getPlugin() == plugin));
}
plugin.setEnabled(true);
// Even event-level fixtures must echo a genuinely published client frame, never the
// server counter. Reuse the pinned native synchronizer with only its transport mocked.
doAnswer(inv -> { send(inv.getArgument(0)); return null; })
.when(transport).send(any(net.minecraft.network.protocol.Packet.class));
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
.getDeclaredConstructor(ServerPlayer.class);
constructor.setAccessible(true);
Object created = constructor.newInstance(nativePlayer);
if (!(created instanceof net.minecraft.world.inventory.ContainerSynchronizer synchronizer)) {
throw new IllegalStateException("Unexpected native synchronizer");
}
var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(realInventory, true, nativePlayer);
var inventoryField = net.minecraft.world.entity.player.Player.class.getDeclaredField("inventoryMenu");
if (inventoryField.getType() != net.minecraft.world.inventory.InventoryMenu.class) {
throw new IllegalStateException("Unexpected inventory-menu port");
}
inventoryField.setAccessible(true);
inventoryField.set(nativePlayer, inventoryMenu);
inventoryMenu.setSynchronizer(synchronizer);
menu.setSynchronizer(synchronizer);
}
/** 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(),
any(net.minecraft.util.Prediction.class), anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
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));
menu.getSlot(1).set(CraftItemStack.asNMSCopy(right));
assertNoEventErrors();
}
void admin(org.bukkit.command.CommandSender sender, String key, int value) {
var command = plugin.getCommand("inventoryhelperadmin");
if (command == null || command != commands.getCommand("inventoryhelperadmin")) {
throw new AssertionError("Missing actual registered administrator command");
}
if (!command.execute(sender, "inventoryhelperadmin", new String[] {"config", key, Integer.toString(value)})) {
throw new AssertionError("Registered administrator command refused execution");
}
}
void acknowledgeSave() throws InterruptedException {
Runnable acknowledgment = nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (acknowledgment == null) throw new AssertionError("Durable save did not schedule acknowledgment");
acknowledgment.run();
assertNoEventErrors();
if (adminMessages.stream().noneMatch(message -> message.endsWith("(saved)"))) {
throw new AssertionError("No durable success acknowledgment: " + adminMessages);
}
}
void prepareAgain() {
menu.slotsChanged(menu.getSlot(0).container);
assertNoEventErrors();
}
org.bukkit.event.inventory.InventoryClickEvent click(int rawSlot,
org.bukkit.event.inventory.ClickType click, org.bukkit.event.inventory.InventoryAction action,
int button, net.minecraft.world.inventory.ContainerInput input) {
var event = new org.bukkit.event.inventory.InventoryClickEvent(menu.getBukkitView(),
rawSlot == 2 ? org.bukkit.event.inventory.InventoryType.SlotType.RESULT
: org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
rawSlot, click, action, button);
var packet = receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
menu.containerId, clientState(menu), (short) rawSlot, (byte) button, input,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
event.setCancelled(true); // A dropped/revoked transport record never reaches Bukkit.
if (!packet.isCancelled()) {
legacyClick = translated -> {
event.setCancelled(false);
events.callEvent(event);
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
};
try { dispatchInbound(packet.getPacket().getHandle()); }
finally { legacyClick = null; }
}
assertNoEventErrors();
return event;
}
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
var event = clientEvent(payload);
dispatchProtocol(event);
if (!event.isCancelled()) {
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event));
}
return event;
}
void dispatchInbound(Object record) {
channel.pipeline().fireChannelRead(record);
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.checkException();
}
private void dispatchPacket(net.minecraft.network.protocol.Packet<?> raw) {
if (raw instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(transport);
else if (raw instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(transport);
else throw new AssertionError("Unsupported native game packet port: " + raw.getClass());
}
static void field(Class<?> owner, Object target, String name, Object value) throws ReflectiveOperationException {
var field = owner.getDeclaredField(name);
if (value != null && !field.getType().isInstance(value)) throw new IllegalStateException("Unexpected native field: " + field);
field.setAccessible(true);
field.set(target, value);
}
com.comphenix.protocol.events.PacketEvent clientEvent(Object payload) {
var container = new com.comphenix.protocol.events.PacketContainer(
com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, payload);
return com.comphenix.protocol.events.PacketEvent.fromClient(protocol, container, player);
}
void dispatchProtocol(com.comphenix.protocol.events.PacketEvent event) {
List.copyOf(protocolListeners).stream()
.filter(listener -> listener.getReceivingWhitelist().getTypes().contains(event.getPacketType()))
.sorted(java.util.Comparator.comparingInt(listener -> listener.getReceivingWhitelist().getPriority().getSlot()))
.forEach(listener -> listener.onPacketReceiving(event));
}
int clientState(net.minecraft.world.inventory.AbstractContainerMenu active) {
Integer state = displayedStates.get(active.containerId);
if (state == null) throw new AssertionError("Menu has no displayed frame: " + active.containerId);
return state;
}
com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw) {
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;
} else if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket) {
type = com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
} else return null; // Non-state packets are outside this fixture's client header port.
var event = com.comphenix.protocol.events.PacketEvent.fromServer(protocol,
new com.comphenix.protocol.events.PacketContainer(type, raw), recipient);
dispatchSending(event);
if (!event.isCancelled()) {
Object wire = event.getPacket().getHandle();
if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content) {
states.put(content.containerId(), content.stateId());
} else if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot) {
states.put(slot.getContainerId(), slot.getStateId());
} else throw new AssertionError("Unsupported wire frame");
}
return event;
}
void dispatchSending(com.comphenix.protocol.events.PacketEvent event) {
List.copyOf(protocolListeners).stream()
.filter(listener -> listener.getSendingWhitelist().getTypes().contains(event.getPacketType()))
.sorted(java.util.Comparator.comparingInt(listener -> listener.getSendingWhitelist().getPriority().getSlot()))
.forEach(listener -> listener.onPacketSending(event));
}
ItemStack output() {
return menu.getBukkitView().getTopInventory().getItem(2);
}
void pumpLibraryTasks() {
if (!server.isPrimaryThread()) throw new AssertionError("Library callbacks must run on main");
Runnable task;
while ((task = libraryTasks.poll()) != null) task.run();
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks();
additionalActors.forEach(actor -> actor.channel.runPendingTasks());
}
void tick() {
pumpLibraryTasks();
var due = delayed.remove(++tickCount);
if (due != null) List.copyOf(due).forEach(Runnable::run);
var ready = List.copyOf(nextTick);
nextTick.clear();
ready.forEach(Runnable::run);
List.copyOf(repeating).forEach(Runnable::run);
assertNoEventErrors();
}
// Paper disables the plugin before cancelling its scheduler tasks. Do not let a fixture
// silently supply the expiry callbacks that real disable removes.
void disableAndCancelTasks() {
plugin.setEnabled(false);
server.getScheduler().cancelTasks(plugin);
}
void assertNoEventErrors() {
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
}
private BukkitTask schedule(Runnable runnable, java.util.Collection<Runnable> queue) {
queue.add(runnable);
BukkitTask task = mock(BukkitTask.class);
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
when(task.getTaskId()).thenReturn(++taskId);
// Only owning-plugin tasks belong to realistic owner-disable cancellation.
if (queue != libraryTasks) tasks.add(task);
return task;
}
@Override public void close() {
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);
field(com.comphenix.protocol.ProtocolLibrary.class, null, "plugin", previousLibraryPlugin);
processor.close();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", previousConfiguration);
setServer(previous);
} catch (Exception failure) {
throw new AssertionError("Could not restore server", failure);
}
}
static PluginDescriptionFile description() throws java.io.IOException, org.bukkit.plugin.InvalidDescriptionException {
// Declared ProtocolLib also ships plugin.yml. Select the actual generated owning metadata,
// not whichever dependency happens to be first on the native-test classpath.
PluginDescriptionFile selected = null;
var resources = SpigotInventoryHelperPlugin.class.getClassLoader().getResources("plugin.yml");
while (resources.hasMoreElements()) {
try (var stream = resources.nextElement().openStream()) {
var description = new PluginDescriptionFile(stream);
if (description.getMain().equals(SpigotInventoryHelperPlugin.class.getName())) {
if (selected != null) throw new AssertionError("Duplicate owning plugin metadata");
selected = description;
}
}
}
if (selected == null) throw new AssertionError("Missing generated owning plugin metadata");
return selected;
}
static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,329 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
import net.minecraft.world.inventory.ContainerInput;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NativeAnvilFrameGenerationTest {
@TempDir Path directory;
@Test void oldTwentyPercentFrameCannotPayNewNinetyFivePercentPreviewAfterNativeCounterReuse() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 50; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
NativeAdvancedAnvilProtocolTest.seed(f);
f.player.updateInventory();
int oldWire = lastFrame(packet);
int oldNative = f.menu.getStateId();
assertTrue(risk(f).contains("Failure 20%"));
var newInput = NativeAdvancedAnvilTransactionTest.stackedSword(10, 3);
f.inputs(newInput, newInput.clone());
assertTrue(risk(f).contains("Failure 95%"));
int synchronizations = 0;
// Ordinary full native syncs only: no synthetic field writes or increment shortcut.
do {
f.player.updateInventory();
synchronizations++;
} while (f.menu.getStateId() != oldNative && synchronizations < 32768);
assertEquals(oldNative, f.menu.getStateId());
assertTrue(synchronizations > 32000 && synchronizations <= 32768);
int newWire = lastFrame(packet);
packet.click(f.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
f.tick();
System.out.println("WRAP_FACTS oldWire=" + oldWire + ", newWire=" + newWire
+ ", reusedNative=" + oldNative + ", fullSyncs=" + synchronizations
+ ", rolls=" + rolls.get() + ", explosions=" + effects.explosions.size());
assertAll("Old displayed 20% risk must not commit the new 95% attempt",
() -> assertTrue(packet.received.getLast().isCancelled()),
() -> assertTrue(packet.clicks.isEmpty()),
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(0)),
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(1)),
() -> assertEquals(0, rolls.get()),
() -> assertTrue(effects.explosions.isEmpty()),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()));
assertNotEquals(oldWire, newWire);
}
}
@Test void supportedNativeCodecsRoundTripFullPositiveIntegerVersionsAndEveryField() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var item = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
changes.put(0, hash);
changes.put(2, net.minecraft.network.HashedStack.EMPTY);
for (int version : new int[] {32767, 32768, 65536, Integer.MAX_VALUE}) {
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, version, java.util.List.of(item), item);
var decodedContent = roundTrip(ClientboundContainerSetContentPacket.STREAM_CODEC, content);
assertEquals(content.containerId(), decodedContent.containerId());
assertEquals(version, decodedContent.stateId());
assertEquals(content.items().size(), decodedContent.items().size());
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.items().getFirst()));
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.carriedItem()));
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, version, 2, item);
var decodedSlot = roundTrip(ClientboundContainerSetSlotPacket.STREAM_CODEC, slot);
assertEquals(slot.getContainerId(), decodedSlot.getContainerId());
assertEquals(version, decodedSlot.getStateId());
assertEquals(slot.getSlot(), decodedSlot.getSlot());
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedSlot.getItem()));
for (ContainerInput input : ContainerInput.values()) {
var click = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
version, (short) -999, (byte) 40, input, changes, hash);
assertEquals(click, roundTrip(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.STREAM_CODEC, click));
}
}
}
}
@Test void slotAndContentFramesOnlyReplaceHeaderAndCurrentEchoReachesNativeHandler() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
int nativeState = f.menu.getStateId();
var unrelated = new ClientboundContainerSetContentPacket(19, nativeState, f.menu.getItems(), f.menu.getCarried());
var unrelatedEvent = f.send(unrelated);
assertFalse(unrelatedEvent.isCancelled());
assertSame(unrelated, unrelatedEvent.getPacket().getHandle(), "Never rewrite another window's frame");
var carried = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.EMERALD, 17);
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, nativeState, f.menu.getItems(), carried);
var contentEvent = f.send(content);
var translatedContent = (ClientboundContainerSetContentPacket) contentEvent.getPacket().getHandle();
assertNotEquals(nativeState, translatedContent.stateId());
assertEquals(content.containerId(), translatedContent.containerId());
assertSame(content.items(), translatedContent.items());
assertSame(carried, translatedContent.carriedItem());
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, nativeState, 2, f.menu.getSlot(2).getItem());
var slotEvent = f.send(slot);
var translatedSlot = (ClientboundContainerSetSlotPacket) slotEvent.getPacket().getHandle();
assertTrue(translatedSlot.getStateId() > translatedContent.stateId());
assertEquals(slot.getContainerId(), translatedSlot.getContainerId());
assertEquals(slot.getSlot(), translatedSlot.getSlot());
assertTrue(net.minecraft.world.item.ItemStack.matches(slot.getItem(), translatedSlot.getItem()));
assertEquals(nativeState, f.menu.getStateId(), "Translation must not touch native state");
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
changes.put(3, hash);
for (ContainerInput input : ContainerInput.values()) {
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
translatedSlot.getStateId(), (short) -999,
(byte) (input == ContainerInput.PICKUP_ALL ? 0 : 40), input, changes, hash);
var received = f.receive(original);
assertFalse(received.isCancelled()); // Non-take actions must also be translated.
var translated = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket) received.getPacket().getHandle();
assertEquals(nativeState, translated.stateId());
assertEquals(original.containerId(), translated.containerId());
assertEquals(original.slotNum(), translated.slotNum());
assertEquals(original.buttonNum(), translated.buttonNum());
assertSame(original.containerInput(), translated.containerInput());
assertEquals(original.changedSlots(), translated.changedSlots());
assertSame(original.carriedItem(), translated.carriedItem());
assertEquals(nativeState, f.menu.getStateId());
}
var expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
var admitted = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
packet.received.getLast().getPacket().getHandle();
assertEquals(nativeState, admitted.stateId());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
}
}
@Test void exhaustionSuppressesFramesAndRevokesAlreadyQueuedPaymentWithoutClosingView() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled());
nearExhaustion(f);
f.player.updateInventory();
assertEquals(Integer.MAX_VALUE, lastFrame(packet));
long count = ownedFrameCount(packet);
f.player.updateInventory();
assertEquals(count, ownedFrameCount(packet), "No repeated/wrapped owned frame may escape");
packet.click(f.menu.containerId, Integer.MAX_VALUE, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
f.tick();
assertAll("Exhaustion must also revoke an already consumed admission",
() -> assertEquals(0, rolls.get()),
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0)),
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1)),
() -> assertTrue(f.player.getItemOnCursor().isEmpty()),
() -> assertNull(f.output()),
() -> assertSame(f.menu.getBukkitView(), f.player.getOpenInventory()));
f.prepareAgain();
assertNull(f.output());
assertFalse(f.menu.bypassCost);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"content,cancel", "content,replace", "slot,cancel", "slot,replace"})
void finallyCancelledOrReplacedOutboundFrameCannotAuthorizePaymentAndFreshFrameRecovers(String kind, String mode)
throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
var tamperedVersion = new AtomicInteger();
var tamperOnce = new java.util.concurrent.atomic.AtomicBoolean(true);
var type = kind.equals("content") ? com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS
: com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
var foreign = new com.comphenix.protocol.events.PacketAdapter(f.plugin,
com.comphenix.protocol.events.ListenerPriority.MONITOR, type) {
@Override public void onPacketSending(com.comphenix.protocol.events.PacketEvent event) {
if (!tamperOnce.getAndSet(false)) return;
Object raw = event.getPacket().getHandle();
tamperedVersion.set(raw instanceof ClientboundContainerSetContentPacket content
? content.stateId() : ((ClientboundContainerSetSlotPacket) raw).getStateId());
if (mode.equals("cancel")) event.setCancelled(true);
else event.setPacket(new com.comphenix.protocol.events.PacketContainer(type,
new ClientboundContainerSetContentPacket(f.menu.containerId, tamperedVersion.get(),
f.menu.getItems(), f.menu.getCarried())));
}
};
f.protocol.addPacketListener(foreign);
try {
var outgoing = f.send(kind.equals("content")
? new ClientboundContainerSetContentPacket(f.menu.containerId, f.menu.getStateId(), f.menu.getItems(), f.menu.getCarried())
: new ClientboundContainerSetSlotPacket(f.menu.containerId, f.menu.getStateId(), 2, f.menu.getSlot(2).getItem()));
assertEquals(mode.equals("cancel"), outgoing.isCancelled());
assertTrue(tamperedVersion.get() >= 32768);
packet.click(f.menu.containerId, tamperedVersion.get(), 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertTrue(packet.clicks.isEmpty());
f.tick();
assertEquals(0, rolls.get());
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
assertTrue(f.player.getItemOnCursor().isEmpty());
var expected = f.output().clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
} finally { f.protocol.removePacketListener(foreign); }
}
}
@Test void nativeCloseRevokesQueuedAdmissionAndReusedWindowKeepsVersionsUnique() throws Exception {
AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
NativeAdvancedAnvilProtocolTest.seed(f);
int oldWire = f.clientState(f.menu);
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.clicks.getLast().isCancelled());
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
f.tick();
assertEquals(0, rolls.get());
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f, NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
assertFalse(f.menu.bypassCost);
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(before));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(before));
int newWire = f.clientState(reopened);
assertTrue(newWire > oldWire);
packet.click(reopened.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertEquals(1, packet.clicks.size(), "Reopened stale intent never reaches Bukkit");
f.tick();
assertEquals(0, rolls.get());
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(0));
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(1));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
packet.click(2, 0, ContainerInput.PICKUP);
assertFalse(packet.received.getLast().isCancelled());
f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(1, rolls.get());
}
}
// Test-only boundary arrangement avoids billions of packets; production has no reset hook.
private static void nearExhaustion(NativeAnvilFixture f) throws ReflectiveOperationException {
var listenerField = SpigotInventoryHelperPlugin.class.getDeclaredField("advancedAnvil");
listenerField.setAccessible(true);
var listener = listenerField.get(f.plugin);
var protocolField = AdvancedAnvilListener.class.getDeclaredField("protocol");
protocolField.setAccessible(true);
var guard = protocolField.get(listener);
var versionsField = AdvancedAnvilProtocolGuard.class.getDeclaredField("versions");
versionsField.setAccessible(true);
var nextField = AnvilFrameVersions.class.getDeclaredField("next");
nextField.setAccessible(true);
nextField.setLong(versionsField.get(guard), Integer.MAX_VALUE);
}
private static long ownedFrameCount(NativeAnvilPacketFixture packet) {
return packet.packets.stream().filter(raw ->
raw instanceof ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId
|| raw instanceof ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId).count();
}
static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
T packet) throws ReflectiveOperationException {
// Paper's real item sanitizer consults the server registry during class initialization.
// Supply only that external registry port, and restore the singleton even on failure.
var serverField = net.minecraft.server.MinecraftServer.class.getDeclaredField("SERVER");
assertEquals(net.minecraft.server.MinecraftServer.class, serverField.getType());
serverField.setAccessible(true);
Object previous = serverField.get(null);
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
org.mockito.Mockito.when(server.registryAccess()).thenReturn(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry().freeze());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
serverField.set(null, server);
codec.encode(buffer, packet);
T decoded = codec.decode(buffer);
assertEquals(0, buffer.readableBytes());
return decoded;
} finally {
buffer.release();
serverField.set(null, previous);
}
}
static int lastFrame(NativeAnvilPacketFixture packet) {
for (int index = packet.packets.size() - 1; index >= 0; index--) {
var raw = packet.packets.get(index);
if (raw instanceof ClientboundContainerSetContentPacket content
&& content.containerId() == packet.anvil.menu.containerId) return content.stateId();
if (raw instanceof ClientboundContainerSetSlotPacket slot
&& slot.getContainerId() == packet.anvil.menu.containerId) return slot.getStateId();
}
throw new AssertionError("No real displayed menu frame");
}
private static String risk(NativeAnvilFixture f) {
return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(f.actionBars.getLast());
}
}
@@ -0,0 +1,342 @@
package games.dmg.spigotinventoryhelper;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.raw;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.sword;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import net.minecraft.network.HashedStack;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.world.inventory.ContainerInput;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Terminal ownership through actual local ProtocolLib forwarding/pipeline/native queue.
* No sockets, live server, or arbitrary gameplay-plugin compatibility claim.
*/
class NativeAnvilLifecycleRegressionTest {
@TempDir Path directory;
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void finalFilterNoForwardOfAdmittedHandleRestoresCapacityAtExpiry(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var foreign = filter(f, event -> {
if (mode.equals("cancel")) event.setCancelled(true);
else event.setPacket(new PacketContainer(event.getPacketType(), inert(f)));
});
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) transport.receive(raw(f));
for (var event : transport.received) assertEquals(mode.equals("cancel"), event.isCancelled());
expire(f);
transport.forwardToNativeQueue(); // Resolve queued fence before asserting capacity.
f.protocol.removePacketListener(foreign);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Final filter never forwarded admitted A: expiry must restore its capacity");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void cancelledPacketDisableDoesNotRelyOnPluginExpiryAndPreservesForeignHandler() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var probe = new io.netty.channel.ChannelInboundHandlerAdapter();
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> event.setCancelled(true));
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Cancelled means no native forward");
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Resolved no-forward shutdown removes only owned hook without a plugin timer");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void admissionTimerAcquisitionFailureRollsBackCapacity() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
f.admissionSchedulingFailure = new IllegalStateException("Controlled timer acquisition failure");
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertTrue(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
f.admissionSchedulingFailure = null;
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Failed acquisition must not fill the exact-identity budget");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void nativeQueueSkipWithoutWrapperHandleRestoresCapacityAtExpiry() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
assertTrue(transport.processor.executeSinglePacket());
}
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty(), "Native queue really skipped wrapper.handle");
expire(f);
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Revoked already-wrapped ownership can retire without handle/finally");
}
}
@Test void aggregateNativeRefusalWithoutWrapperHandleRetiresHookAtExpiryAndClose() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previous = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK);
limiter.overrides = new java.util.HashMap<>();
config.packetLimiter = limiter;
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
connection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", connection);
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
// Keep the inspected frame-synchronizer port: changing the listener's
// connection must not bypass the fixture's actual ProtocolLib frame publication.
doAnswer(inv -> { f.connection.send(inv.getArgument(0), inv.getArgument(1), true); return null; })
.when(f.transport).send(any(net.minecraft.network.protocol.Packet.class), any());
var disconnects = new java.util.concurrent.CopyOnWriteArrayList<Object>();
// Controlled outbound port: hold completion of the real native disconnect
// write so the channel stays active; do not silently let disconnect clear ownership.
var foreign = new io.netty.channel.ChannelOutboundHandlerAdapter() {
@Override public void write(io.netty.channel.ChannelHandlerContext context, Object message,
io.netty.channel.ChannelPromise promise) { disconnects.add(message); }
};
f.channel.pipeline().addFirst("held-disconnect-write", foreign);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertFalse(transport.received.getFirst().isCancelled());
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Aggregate refusal happens before wrapper.handle");
assertTrue(disconnects.stream().anyMatch(message -> message instanceof
net.minecraft.network.protocol.common.ClientboundDisconnectPacket), "Native kick path actually executed");
assertTrue(f.channel.isActive(), "Retirement proof must not rely on channelInactive");
expire(f);
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
assertSame(foreign, f.channel.pipeline().get("held-disconnect-write"));
assertSame(connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Already-wrapped aggregate refusal must not strand retired owned hook");
assertTrue(packet.clicks.isEmpty());
} finally { config.packetLimiter = previous; }
}
}
@ParameterizedTest @ValueSource(strings = {"filter", "post"})
void reentrantDisableKeepsUnresolvedOrScheduledExactRawSafeBeyondExpiryAndWindowReuse(String phase)
throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> {
if (phase.equals("filter")) {
// Cancellation is NOT terminal inside a filter: the same callback can
// still restore the captured exact raw before ProtocolLib schedules it.
event.setCancelled(true);
f.disableAndCancelTasks();
event.setCancelled(false);
} else {
post(f, event, forwarded -> {
// ProtocolLib has already captured/scheduled the exact raw. A
// post cancellation is not proof that this raw will never arrive.
forwarded.setReadOnly(false);
forwarded.setCancelled(true);
f.disableAndCancelTasks();
});
}
});
transport.receive(raw(f));
assertEquals(phase.equals("post"), transport.received.getFirst().isCancelled());
expire(f); f.tick(); // Raw exact forward is still held in the real library event loop.
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.tick();
assertTrue(packet.clicks.isEmpty(), "Late revoked exact raw must not fall through to vanilla");
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(sword(4), reopened.getBukkitView().getTopInventory().getItem(0));
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void postHandleMutationCannotEraseCapturedForwardAndRetiredWrapperDenial(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
filter(f, event -> post(f, event, forwarded -> {
forwarded.setReadOnly(false);
if (mode.equals("cancel")) forwarded.setCancelled(true);
else forwarded.setPacket(new PacketContainer(forwarded.getPacketType(), inert(f)));
f.disableAndCancelTasks();
}));
transport.receive(raw(f));
assertEquals(1, NetworkMarker.getNetworkMarker(transport.received.getFirst()).getPostListeners().size(),
"Only the foreign marker listener exists; ownership never adds an acknowledgement");
expire(f);
assertEquals(before, f.channel.pipeline().names().size(), "Captured exact raw is still unresolved");
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
transport.forwardToNativeQueue();
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Late raw is wrapped revoked and releases the retired hook without wrapper.handle");
assertTrue(transport.processor.executeSinglePacket());
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty());
assertTrue(f.player.getItemOnCursor().isEmpty(), "Skipped revoked wrapper delivers nothing");
}
}
@Test void mainFenceSnapshotExcludesSubsequentReceiveWhoseRawIsBehindBarrier() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var cancellation = filter(f, event -> event.setCancelled(true));
transport.receive(raw(f));
transport.pumpLibraryTasks(); // First snapshot/barrier precedes the next raw forward.
f.protocol.removePacketListener(cancellation);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
assertEquals(1, packet.clicks.size(), "Later admitted raw must still reach the exact native wrapper");
f.tick();
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
}
}
@Test void unavailablePublicLibrarySchedulerWithholdsEnhancedOutputBeforeHookInstallation() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var scheduler = com.comphenix.protocol.ProtocolLibrary.getScheduler();
int before = f.channel.pipeline().names().size();
try {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", null);
f.inputs(sword(5), sword(5));
transport.acknowledgeLoop();
assertNull(f.output(), "No library-main fence port means no enhanced bypass/output");
assertEquals(before, f.channel.pipeline().names().size());
} finally {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", scheduler);
}
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
assertNotNull(f.output(), "A failed constructor did not leave an owned boundary behind");
}
}
@Test void rejectedLibraryFenceKeepsUnresolvedTombstoneRatherThanClaimingNoForward() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
f.librarySchedulingFailure = new IllegalStateException("Controlled library fence rejection");
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket());
assertEquals(before, f.channel.pipeline().names().size(),
"Failed fence cannot prove NO_FORWARD; unresolved ownership stays until raw/disconnect");
assertTrue(packet.clicks.isEmpty());
}
}
private static void post(NativeAnvilFixture f, PacketEvent event, Consumer<PacketEvent> callback) {
// A foreign post listener owns its own marker; production no longer creates one.
var marker = NetworkMarker.getNetworkMarker(event);
if (marker == null) {
marker = new NetworkMarker(ConnectionSide.CLIENT_SIDE, event.getPacketType());
event.setNetworkMarker(marker);
}
marker.addPostListener(new PacketPostListener() {
@Override public org.bukkit.plugin.Plugin getPlugin() { return f.plugin; }
@Override public void onPostEvent(PacketEvent forwarded) { callback.accept(forwarded); }
});
}
private static PacketAdapter filter(NativeAnvilFixture f, Consumer<PacketEvent> callback) {
var listener = new PacketAdapter(f.plugin, ListenerPriority.MONITOR, PacketType.Play.Client.WINDOW_CLICK) {
@Override public void onPacketReceiving(PacketEvent event) { callback.accept(event); }
};
f.protocol.addPacketListener(listener);
return listener;
}
private static ServerboundContainerClickPacket inert(NativeAnvilFixture f) {
return new ServerboundContainerClickPacket(f.menu.containerId, f.menu.getStateId(), (short) -999,
(byte) 0, ContainerInput.PICKUP, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
}
private static void expire(NativeAnvilFixture f) {
for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick();
}
}
@@ -0,0 +1,201 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import io.papermc.paper.configuration.GlobalConfiguration;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import net.minecraft.network.Connection;
import net.minecraft.network.HashedStack;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerCommonPacketListenerImpl;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.entity.player.Abilities;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.ContainerSynchronizer;
import net.minecraft.world.inventory.InventoryMenu;
import net.minecraft.world.level.GameType;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
/** Real packet classification/event dispatch/native clicked; explicit world/thread/network ports only. */
final class NativeAnvilPacketFixture implements AutoCloseable {
final NativeAnvilFixture anvil;
final MinecraftServer nativeServer;
final PacketProcessor processor;
// Final external CraftServer port only; retain the suite's subclass maker everywhere else.
final CraftServer craftServer = mock(CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final Connection connection;
final ServerGamePacketListenerImpl handler;
private final GlobalConfiguration previousConfiguration;
final Abilities abilities = new Abilities();
final InventoryMenu inventoryMenu;
final ContainerSynchronizer synchronizer;
final List<Packet<?>> packets = new ArrayList<>();
final java.util.Map<Packet<?>, String> privateRiskAtSend = new java.util.IdentityHashMap<>();
final List<InventoryClickEvent> clicks = new ArrayList<>();
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
final List<net.minecraft.world.item.ItemStack> drops = new ArrayList<>();
NativeAnvilPacketFixture(NativeAnvilFixture anvil) throws Exception {
this.anvil = anvil;
previousConfiguration = GlobalConfiguration.get();
if (previousConfiguration == null) {
// Connection static initialization and the packet equipment guard need default config.
// No file/configuration I/O; restore the singleton at this fixture's boundary.
var config = new GlobalConfiguration();
config.misc = config.new Misc();
config.unsupportedSettings = config.new UnsupportedSettings();
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
}
connection = anvil.connection;
handler = anvil.transport;
nativeServer = anvil.nativeServer;
processor = anvil.processor;
when(anvil.level.getServer()).thenReturn(nativeServer);
when(anvil.level.getCraftServer()).thenReturn(craftServer);
when(anvil.nativePlayer.isAlive()).thenReturn(true);
doCallRealMethod().when(anvil.nativePlayer).doCloseContainer();
doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean(),
any(net.minecraft.util.Prediction.class), anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
when(nativeServer.packetProcessor()).thenReturn(processor);
when(craftServer.getPluginManager()).thenReturn(anvil.events);
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
when(anvil.nativePlayer.getAbilities()).thenReturn(abilities);
when(anvil.nativePlayer.registryAccess()).thenReturn(
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
survival();
inventoryMenu = new InventoryMenu(anvil.realInventory, true, anvil.nativePlayer);
field(Player.class, anvil.nativePlayer, "inventoryMenu", InventoryMenu.class, inventoryMenu);
field(ServerCommonPacketListenerImpl.class, handler, "server", MinecraftServer.class, nativeServer);
field(ServerCommonPacketListenerImpl.class, handler, "cserver", CraftServer.class, craftServer);
field(ServerCommonPacketListenerImpl.class, handler, "connection", Connection.class, connection);
field(ServerGamePacketListenerImpl.class, handler, "player", net.minecraft.server.level.ServerPlayer.class,
anvil.nativePlayer);
anvil.nativePlayer.connection = handler;
doAnswer(inv -> {
Packet<?> sent = inv.getArgument(0);
var outgoing = anvil.send(sent);
if (outgoing != null) {
if (outgoing.isCancelled()) return null;
Object wire = outgoing.getPacket().getHandle();
if (!(wire instanceof Packet<?> translated)) throw new AssertionError("Unsupported wire payload");
sent = translated;
}
packets.add(sent);
privateRiskAtSend.put(sent, anvil.actionBars.isEmpty() ? ""
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(anvil.actionBars.getLast()));
return null;
}).when(connection).send(any(Packet.class), any(), anyBoolean());
doCallRealMethod().when(handler).send(any(Packet.class));
doCallRealMethod().when(handler).send(any(Packet.class), any());
doCallRealMethod().when(handler).handleContainerClick(any(ServerboundContainerClickPacket.class));
// Reuse the runtime's cheap native synchronizer, including actual hashed remote slots and packets.
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
.getDeclaredConstructor(net.minecraft.server.level.ServerPlayer.class);
constructor.setAccessible(true);
Object created = constructor.newInstance(anvil.nativePlayer);
if (!(created instanceof ContainerSynchronizer nativeSynchronizer)) {
throw new IllegalStateException("Unexpected native player synchronizer: " + created.getClass());
}
synchronizer = nativeSynchronizer;
field(net.minecraft.server.level.ServerPlayer.class, anvil.nativePlayer, "containerSynchronizer",
ContainerSynchronizer.class, synchronizer);
inventoryMenu.setSynchronizer(synchronizer);
anvil.menu.setSynchronizer(synchronizer);
doCallRealMethod().when(anvil.player).updateInventory();
doCallRealMethod().when(anvil.player).closeInventory();
doCallRealMethod().when(anvil.player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
// An observer of the registered event, never a replacement event supplied to production.
listen(EventPriority.MONITOR, clicks::add);
}
void survival() {
abilities.instabuild = false;
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(false);
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.SURVIVAL);
}
void creative() {
abilities.instabuild = true;
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(true);
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.CREATIVE);
}
void listen(EventPriority priority, Consumer<InventoryClickEvent> observer) {
anvil.events.registerEvent(InventoryClickEvent.class, new Listener() {}, priority,
(ignored, event) -> {
if (!(event instanceof InventoryClickEvent click)) {
throw new IllegalStateException("Unexpected native click event: " + event.getClass());
}
observer.accept(click);
}, anvil.plugin, false);
}
void click(int slot, int button, ContainerInput input) {
click(anvil.nativePlayer.containerMenu.containerId, anvil.clientState(anvil.nativePlayer.containerMenu),
slot, button, input);
}
void click(int containerId, int stateId, int slot, int button, ContainerInput input) {
var raw = new ServerboundContainerClickPacket(containerId, stateId,
(short) slot, (byte) button, input, new Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
var event = anvil.receive(raw);
received.add(event);
if (!event.isCancelled()) {
Object admitted = event.getPacket().getHandle();
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
anvil.dispatchInbound(translated);
}
anvil.assertNoEventErrors();
}
void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason reason) {
anvil.nativePlayer.closeContainer(reason);
anvil.assertNoEventErrors();
}
void dieAndClose() {
// The world/damage boundary reports death; native menu removal owns remainder drops.
when(anvil.nativePlayer.isAlive()).thenReturn(false);
when(anvil.nativePlayer.isDeadOrDying()).thenReturn(true);
when(anvil.player.isDead()).thenReturn(true);
closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DEATH);
}
void inactiveView() {
anvil.nativePlayer.containerMenu = inventoryMenu;
}
@Override public void close() {
try {
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, previousConfiguration);
} catch (ReflectiveOperationException failure) {
throw new AssertionError("Could not restore native config port", failure);
}
}
private static <T> void field(Class<?> owner, Object target, String name, Class<T> type, T value)
throws ReflectiveOperationException {
var field = owner.getDeclaredField(name);
if (field.getType() != type) throw new IllegalStateException("Unexpected native field type: " + field);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** Real registered local pipeline/event loop, with no byte transport or sockets. */
final class NativeAnvilTestChannel extends AbstractChannel {
final DefaultEventLoop loop = new DefaultEventLoop();
private final ChannelConfig config = new DefaultChannelConfig(this);
final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private volatile boolean open = true;
NativeAnvilTestChannel() throws InterruptedException {
super(null);
loop.register(this).sync();
}
void acknowledge() throws Exception { loop.submit(() -> {}).get(10, TimeUnit.SECONDS); }
void finish() throws InterruptedException {
close().sync();
loop.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync();
}
@Override public ChannelConfig config() { return config; }
@Override public ChannelMetadata metadata() { return new ChannelMetadata(false); }
@Override public boolean isOpen() { return open; }
@Override public boolean isActive() { return open && isRegistered(); }
@Override protected boolean isCompatible(EventLoop candidate) { return candidate == loop; }
@Override protected java.net.SocketAddress localAddress0() { return null; }
@Override protected java.net.SocketAddress remoteAddress0() { return null; }
@Override protected void doBind(java.net.SocketAddress address) { throw new UnsupportedOperationException("No sockets"); }
@Override protected void doDisconnect() { doClose(); }
@Override protected void doClose() { open = false; }
@Override protected void doBeginRead() { }
@Override protected void doWrite(ChannelOutboundBuffer buffer) { while (buffer.current() != null) buffer.remove(); }
@Override protected AbstractUnsafe newUnsafe() {
return new AbstractUnsafe() {
@Override public void connect(java.net.SocketAddress remote, java.net.SocketAddress local,
ChannelPromise promise) { promise.setFailure(new UnsupportedOperationException("No sockets")); }
};
}
@Override protected DefaultChannelPipeline newChannelPipeline() {
return new DefaultChannelPipeline(this) {
@Override protected void onUnhandledInboundException(Throwable failure) {
if (!(failure instanceof net.minecraft.server.RunningOnDifferentThreadException)) failures.add(failure);
}
};
}
}
@@ -0,0 +1,115 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.injector.ListenerManager;
import com.comphenix.protocol.injector.netty.channel.NettyChannelInjector;
import io.netty.channel.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Actual library forwarding, one registered real event loop/pipeline, and real native queue.
* Only byte I/O, packet lookup/filter invocation and external player/world ports are controlled.
* No sockets/server startup. Handler readiness and library-main callbacks require explicit pumping.
*/
final class NativeAnvilTransportFixture implements AutoCloseable {
final NativeAnvilPacketFixture packet;
final PacketProcessor processor;
private final NativeAnvilFixture f;
private final DefaultEventLoop loop;
private final CountDownLatch forward = new CountDownLatch(1);
private boolean held;
private boolean receiving;
private final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private final NettyChannelInjector injector;
private final ChannelHandlerContext context;
final List<PacketEvent> received = new ArrayList<>();
NativeAnvilTransportFixture(NativeAnvilFixture f, NativeAnvilPacketFixture packet) throws Exception {
this.f = f;
this.packet = packet;
this.processor = f.processor;
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { failures.add(inv.getArgument(1)); return null; }).when(packet.handler).onPacketError(any(), any());
if (!(f.channel instanceof NativeAnvilTestChannel channel)) {
throw new AssertionError("Transport must start with its real channel; native handlers are not sharable");
}
loop = channel.loop;
var listeners = mock(ListenerManager.class);
when(listeners.hasMainThreadListener(PacketType.Play.Client.WINDOW_CLICK)).thenReturn(true);
doAnswer(inv -> {
PacketEvent event = inv.getArgument(0);
received.add(event);
f.dispatchProtocol(event);
return null;
}).when(listeners).invokeInboundPacketListeners(any());
injector = new NettyChannelInjector(f.player, packet.connection, channel, listeners,
mock(com.comphenix.protocol.injector.netty.channel.InjectionFactory.class),
mock(com.comphenix.protocol.error.ErrorReporter.class));
channel.pipeline().addFirst("protocollib-boundary", new ChannelInboundHandlerAdapter());
acknowledgeLoop();
context = channel.pipeline().context("protocollib-boundary");
if (context.channel() != packet.connection.channel || context.executor() != channel.eventLoop()
|| channel.eventLoop() != loop) throw new AssertionError("Library and native loop must be identical");
}
void acknowledgeHook() throws Exception {
acknowledgeLoop(); // handlerAdded on the actual event loop, not an inline readiness mock
f.tick(); // queued owner readiness/recompute callback on main
acknowledgeLoop();
if (f.output() == null) throw new AssertionError("Readiness callback did not publish enhanced output");
}
void receive(ServerboundContainerClickPacket raw) throws Exception {
if (!held) {
held = true;
loop.execute(() -> {
try { if (!forward.await(10, TimeUnit.SECONDS)) failures.add(new AssertionError("Forward gate timed out")); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); failures.add(failure); }
});
}
var method = NettyChannelInjector.class.getDeclaredMethod("processInboundInternal",
ChannelHandlerContext.class, PacketContainer.class);
method.setAccessible(true);
receiving = true;
try { method.invoke(injector, context, new PacketContainer(PacketType.Play.Client.WINDOW_CLICK, raw)); }
finally { receiving = false; }
}
void pumpLibraryTasks() {
if (receiving) throw new AssertionError("Fence main callback must run after library callback returns");
f.pumpLibraryTasks();
}
void forwardToNativeQueue() throws Exception {
pumpLibraryTasks(); // queues barriers behind captured raw tasks, never inline with filters/posts
forward.countDown();
acknowledgeLoop();
}
void acknowledgeLoop() throws Exception {
loop.submit(() -> {}).get(10, TimeUnit.SECONDS);
failures.addAll(((NativeAnvilTestChannel) f.channel).failures);
assertNoErrors();
}
void assertNoErrors() {
if (!failures.isEmpty()) throw new AssertionError("Transport/native dispatch failed", failures.getFirst());
}
@Override public void close() {
forward.countDown();
try { ((NativeAnvilTestChannel) f.channel).acknowledge(); }
catch (Exception failure) { throw new AssertionError("Transport shutdown acknowledgment failed", failure); }
assertNoErrors();
}
}
@@ -52,7 +52,7 @@ import org.junit.jupiter.api.io.TempDir;
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
class NativeCobwebPluginWiringTest {
private static final NamespacedKey COBWEB = key("string_cobweb");
private static final Set<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
private static final Set<NamespacedKey> EXISTING = Set.of(key("snow_powder_snow"), key("dropper_dispenser"),
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
private static Server previousServer;
@@ -60,25 +60,38 @@ class NativeCobwebPluginWiringTest {
@TempDir Path directory;
private Map<NamespacedKey, Recipe> registry;
private PaperPluginManagerImpl events;
private SimpleCommandMap commands;
private Logger logger;
private List<LogRecord> logs;
private Handler logHandler;
private SpigotInventoryHelperPlugin createdPlugin;
private com.comphenix.protocol.ProtocolManager protocol;
private List<com.comphenix.protocol.events.PacketListener> protocolListeners;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
serverUnderTest = mock(Server.class);
// ProtocolLib derives CraftBukkit's package from this external server's concrete class.
serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class,
org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE));
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
protocol = mock(com.comphenix.protocol.ProtocolManager.class);
protocolListeners = new ArrayList<>();
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
when(serverUnderTest.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
logger = Logger.getAnonymousLogger();
logs = new ArrayList<>();
logHandler = new Handler() {
@@ -98,8 +111,15 @@ class NativeCobwebPluginWiringTest {
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
events = new PaperPluginManagerImpl(serverUnderTest,
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
commands = new SimpleCommandMap(serverUnderTest, new HashMap<>());
when(serverUnderTest.getPluginCommand(any(String.class))).thenAnswer(invocation ->
commands.getCommand(invocation.getArgument(0)));
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
when(serverUnderTest.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(any(), any(Runnable.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()))
.thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class));
events = new PaperPluginManagerImpl(serverUnderTest, commands, null);
when(serverUnderTest.getPluginManager()).thenReturn(events);
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
}
@@ -254,10 +274,13 @@ class NativeCobwebPluginWiringTest {
}
private SpigotInventoryHelperPlugin plugin() {
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
try {
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile());
NativeAnvilFixture.description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol,
() -> "Purpur 26.3.build.2651-stable");
commands.registerAll(createdPlugin.getName(),
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
createdPlugin.getLogger().addHandler(logHandler);
return createdPlugin;
} catch (java.io.IOException | InvalidDescriptionException exception) {
@@ -293,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;
}
@@ -0,0 +1,382 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket;
import net.minecraft.stats.ServerRecipeBook;
import net.minecraft.world.item.crafting.RecipeBookCategories;
import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay;
import net.minecraft.world.item.crafting.display.SlotDisplay;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerRecipeDiscoverEvent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real native book/display codecs and actual registered join handlers, not a set-based book fake. */
class NativePowderSnowBookTest {
@TempDir Path directory;
private static final NamespacedKey KEY = NativePowderSnowRecipeTest.KEY;
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
private static long addedEntries(List<net.minecraft.network.protocol.Packet<?>> packets) {
return packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
.map(ClientboundRecipeBookAddPacket.class::cast).mapToLong(packet -> packet.entries().size()).sum();
}
private static boolean powderEntry(ClientboundRecipeBookAddPacket.Entry entry) {
return entry.contents().display().result() instanceof SlotDisplay.ItemStackSlotDisplay result
&& result.stack().create().is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET);
}
@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 = net.minecraft.util.context.ContextMap.builder()
.set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
.buildAndValidate(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());
}
@Test
void onlineDiscoverySendsOrdinarySearchableMiscDisplayWithOneNotificationAndHighlight() throws Exception {
try (var f = new NativePowderSnowFixture(directory, 2)) {
var recipe = f.powder().value();
assertEquals(RecipeBookCategories.CRAFTING_MISC, recipe.recipeBookCategory());
var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, recipe.display().getFirst());
var context = net.minecraft.util.context.ContextMap.builder()
.set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
.buildAndValidate(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList());
assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream()
.map(items -> CraftItemStack.asBukkitCopy(items.getFirst()).getType()).toList());
var result = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, display.result()).stack().create();
assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(result).getType());
assertFalse(CraftItemStack.asBukkitCopy(result).hasItemMeta());
assertEquals(1, result.getCount());
// Search presentation is supplied by the ordinary native translated output, not a custom name.
assertEquals("item.minecraft.powder_snow_bucket", assertInstanceOf(
net.minecraft.network.chat.contents.TranslatableContents.class, result.getHoverName().getContents()).getKey());
var displays = new java.util.ArrayList<net.minecraft.world.item.crafting.display.RecipeDisplayEntry>();
f.manager.listDisplaysForRecipe(f.powder().id(), displays::add);
var id = displays.getFirst().id();
var powderPackets = f.packets.packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
.map(ClientboundRecipeBookAddPacket.class::cast)
.filter(packet -> packet.entries().stream().anyMatch(NativePowderSnowBookTest::powderEntry)).toList();
assertEquals(1, powderPackets.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.highlight());
assertTrue(powderPackets.getFirst().entries().stream().filter(NativePowderSnowBookTest::powderEntry)
.findFirst().orElseThrow().notification());
assertEquals(2, entry.contents().craftingRequirements().orElseThrow().size());
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());
f.actor.events.callEvent(new PlayerJoinEvent(f.actor.player, "repeat"));
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).count());
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
assertTrue(f.menu.craftSlots.isEmpty());
}
}
@Test
void nativeKnownHighlightCodecJoinReconnectDisableAndActualReenableRetainUnrelatedState() throws Exception {
try (var f = new NativePowderSnowFixture(directory, 3)) {
var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA);
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);
joined.book().add(vanillaKey);
f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "join"));
assertTrue(joined.book().contains(NativePowderSnowFixture.nativeKey(KEY)));
assertTrue(joined.book().contains(vanillaKey));
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(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);
assertPackedEquals(packed, reconnected.book().pack());
assertTrue(reconnected.book().isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
assertTrue(reconnected.book().isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
f.actor.events.callEvent(new PlayerJoinEvent(reconnected.player(), "reconnected"));
assertTrue(reconnected.sent().isEmpty(), "No second discovery/notification for known recipe");
assertTrue(reconnected.book().contains(reinforcedKey));
assertTrue(reconnected.book().contains(vanillaKey));
f.actor.plugin.setEnabled(false);
assertTrue(f.registered.isEmpty());
assertPackedEquals(packed, f.book.pack());
assertPackedEquals(packed, reconnected.book().pack());
var disabledJoiner = f.joiningPlayer(null);
f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "disabled"));
assertTrue(disabledJoiner.book().pack().known().isEmpty());
doReturn(List.of(f.actor.player, reconnected.player())).when(f.actor.server).getOnlinePlayers();
f.actor.plugin.setEnabled(true);
assertEquals(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);
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType());
f.actor.assertNoEventErrors();
}
}
@Test
void nativeDiscoveryCancellationAndPerPlayerPortFailureAreIsolatedAndRetryOnRealJoin() throws Exception {
try (var f = new NativePowderSnowFixture(directory, 2)) {
var cancelled = f.joiningPlayer(null);
var failed = f.joiningPlayer(null);
var healthy = f.joiningPlayer(null);
var cancel = new AtomicBoolean(true);
f.actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.HIGHEST,
(ignored, raw) -> {
var event = (PlayerRecipeDiscoverEvent) raw;
if (event.getPlayer() == cancelled.player() && event.getRecipe().equals(KEY)) event.setCancelled(cancel.get());
}, f.actor.plugin, false);
var injected = new IllegalStateException("injected player transport failure");
doThrow(injected).when(failed.player()).discoverRecipes(List.of(KEY));
for (var player : List.of(cancelled, failed, healthy)) f.actor.events.callEvent(new PlayerJoinEvent(player.player(), "join"));
var key = NativePowderSnowFixture.nativeKey(KEY);
assertFalse(cancelled.book().contains(key));
assertFalse(failed.book().contains(key));
assertTrue(healthy.book().contains(key));
assertEquals(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(6, addedEntries(cancelled.sent()));
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "known"));
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");
}
}
}
@@ -0,0 +1,214 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.stats.ServerRecipeBook;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.inventory.AbstractCraftingMenu;
import net.minecraft.world.inventory.ContainerLevelAccess;
import net.minecraft.world.inventory.CraftingMenu;
import net.minecraft.world.item.crafting.CraftingRecipe;
import net.minecraft.world.item.crafting.RecipeHolder;
import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraft.world.item.crafting.RecipeMap;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerRecipeDiscoverEvent;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
/** Reuses strict actor/packet ports. Native recipe manager/book/menu/result/event code stays real.
* The Server.addRecipe port converts the exact recipe submitted by actual onEnable, following
* pinned CraftShaped/ShapelessRecipe conversion (including MaterialChoice's native matching).
* No worlds or clients run; packets are captured by the existing native synchronizer port.
*/
final class NativePowderSnowFixture implements AutoCloseable {
final NativeAnvilFixture actor;
final NativeAnvilPacketFixture packets;
final RecipeManager manager;
final ServerRecipeBook book;
final Map<NamespacedKey, Recipe> registered = new HashMap<>();
final Map<NamespacedKey, RecipeHolder<CraftingRecipe>> holders = new HashMap<>();
final List<PlayerRecipeDiscoverEvent> discoveries = new ArrayList<>();
final AbstractCraftingMenu menu;
NamespacedKey removalFailureKey;
RuntimeException removalFailure;
NativePowderSnowFixture(Path directory, int width) throws Exception {
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);
actor.plugin.setEnabled(false);
manager = NativeRuntime.recipeManager();
when(packets.nativeServer.getRecipeManager()).thenReturn(manager);
when(actor.level.recipeAccess()).thenReturn(manager);
when(actor.level.getGameRules()).thenReturn(
new net.minecraft.world.level.gamerules.GameRules(FeatureFlags.VANILLA_SET));
book = new ServerRecipeBook(manager::listDisplaysForRecipe);
var bookField = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook");
assertEquals(ServerRecipeBook.class, bookField.getType());
bookField.setAccessible(true);
bookField.set(actor.nativePlayer, book);
when(actor.nativePlayer.getRecipeBook()).thenReturn(book);
when(actor.nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
doCallRealMethod().when(actor.nativePlayer).awardRecipes(any());
doCallRealMethod().when(actor.player).hasDiscoveredRecipe(any());
doCallRealMethod().when(actor.player).discoverRecipes(any());
when(actor.server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> registered.get(inv.getArgument(0)));
when(actor.server.addRecipe(any(Recipe.class))).thenAnswer(inv -> {
Recipe bukkit = inv.getArgument(0);
NamespacedKey key = ((Keyed) bukkit).getKey();
if (registered.putIfAbsent(key, bukkit) != null) return false;
holders.put(key, new RecipeHolder<>(nativeKey(key), convert(bukkit)));
refreshManager();
return true;
});
when(actor.server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> {
NamespacedKey key = inv.getArgument(0);
if (key.equals(removalFailureKey) && removalFailure != null) throw removalFailure;
holders.remove(key);
refreshManager();
return registered.remove(key) != null;
});
doReturn(List.of(actor.player)).when(actor.server).getOnlinePlayers();
actor.plugin.setEnabled(true);
assertTrue(book.contains(nativeKey(NativePowderSnowRecipeTest.KEY)));
assertEquals(6, registered.size());
assertEquals(6, discoveries.size());
assertEquals(0, count(actor.realInventory, org.bukkit.Material.POWDER_SNOW_BUCKET));
if (width == 2) {
menu = packets.inventoryMenu;
} else if (width == 3) {
when(actor.level.getBlockState(actor.position)).thenReturn(
net.minecraft.world.level.block.Blocks.CRAFTING_TABLE.defaultBlockState());
menu = new CraftingMenu(8, actor.realInventory, ContainerLevelAccess.create(actor.level, actor.position));
menu.setTitle(net.minecraft.network.chat.Component.literal("Crafting"));
} else {
throw new IllegalArgumentException("Unsupported grid width " + width);
}
actor.nativePlayer.containerMenu = menu;
menu.setSynchronizer(packets.synchronizer);
actor.assertNoEventErrors();
}
/** A later connection has a real native book, with only player/world/network ports doubled. */
BookPlayer joiningPlayer(ServerRecipeBook.Packed saved) throws Exception {
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var recipeBook = new ServerRecipeBook(manager::listDisplaysForRecipe);
if (saved != null) recipeBook.loadUntrusted(saved, key -> true);
var field = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook");
assertEquals(ServerRecipeBook.class, field.getType());
field.setAccessible(true);
field.set(nativePlayer, recipeBook);
when(nativePlayer.getRecipeBook()).thenReturn(recipeBook);
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(nativePlayer.level()).thenReturn(actor.level);
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
when(player.getHandle()).thenReturn(nativePlayer);
when(player.getName()).thenReturn("JoiningPowderPlayer");
doCallRealMethod().when(nativePlayer).awardRecipes(any());
doCallRealMethod().when(player).hasDiscoveredRecipe(any());
doCallRealMethod().when(player).discoverRecipes(any());
nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
var sent = new ArrayList<net.minecraft.network.protocol.Packet<?>>();
doAnswer(inv -> { sent.add(inv.getArgument(0)); return null; })
.when(nativePlayer.connection).send(any(net.minecraft.network.protocol.Packet.class));
return new BookPlayer(player, nativePlayer, recipeBook, sent);
}
record BookPlayer(org.bukkit.craftbukkit.entity.CraftPlayer player,
net.minecraft.server.level.ServerPlayer nativePlayer, ServerRecipeBook book,
List<net.minecraft.network.protocol.Packet<?>> sent) {}
RecipeHolder<CraftingRecipe> powder() { return holders.get(NativePowderSnowRecipeTest.KEY); }
net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(boolean batch) {
var action = menu.handlePlacement(batch, false, powder(), actor.level, actor.realInventory);
actor.assertNoEventErrors();
return action;
}
List<org.bukkit.inventory.ItemStack> gridSnapshot() {
return menu.craftSlots.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
}
static long count(net.minecraft.world.Container container, org.bukkit.Material material) {
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
.mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum();
}
static ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(NamespacedKey key) {
return CraftNamespacedKey.toResourceKey(Registries.RECIPE, key);
}
private void refreshManager() {
manager.recipes = RecipeMap.createEmpty();
holders.values().forEach(manager.recipes::addRecipe);
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) {
if (recipe instanceof ShapelessRecipe shapeless) {
return new net.minecraft.world.item.crafting.ShapelessRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shapeless.getCategory()), shapeless.getGroup()),
CraftItemStack.asTemplate(shapeless.getResult()), shapeless.getChoiceList().stream()
.map(choice -> CraftRecipe.toIngredient(choice, true)).toList());
}
if (recipe instanceof ShapedRecipe shaped) {
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
shaped.getChoiceMap().forEach((symbol, choice) -> {
if (choice != null) ingredients.put(symbol, CraftRecipe.toIngredient(choice, true));
});
return new net.minecraft.world.item.crafting.ShapedRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shaped.getCategory()), shaped.getGroup()),
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, shaped.getShape()),
CraftItemStack.asTemplate(shaped.getResult()));
}
throw new AssertionError("Unexpected registered recipe " + recipe.getClass());
}
@Override public void close() {
packets.close();
actor.close();
}
}
@@ -0,0 +1,698 @@
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;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.InvalidDescriptionException;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPluginLoader;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
class NativePowderSnowPluginWiringTest {
private static final NamespacedKey POWDER = key("snow_powder_snow");
private static final Set<NamespacedKey> EXISTING = Set.of(key("string_cobweb"), key("dropper_dispenser"),
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
private static Server previousServer;
private static Server serverUnderTest;
@TempDir Path directory;
private Map<NamespacedKey, Recipe> registry;
private PaperPluginManagerImpl events;
private SimpleCommandMap commands;
private Logger logger;
private List<LogRecord> logs;
private Handler logHandler;
private SpigotInventoryHelperPlugin createdPlugin;
private com.comphenix.protocol.ProtocolManager protocol;
private List<com.comphenix.protocol.events.PacketListener> protocolListeners;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
// ProtocolLib derives CraftBukkit's package from this external server's concrete class.
serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class,
org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE));
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
protocol = mock(com.comphenix.protocol.ProtocolManager.class);
protocolListeners = new ArrayList<>();
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
when(serverUnderTest.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
logger = Logger.getAnonymousLogger();
logs = new ArrayList<>();
logHandler = new Handler() {
@Override public void publish(LogRecord record) { logs.add(record); }
@Override public void flush() {}
@Override public void close() {}
};
logger.addHandler(logHandler);
when(serverUnderTest.getLogger()).thenReturn(logger);
registry = new HashMap<>();
registry.put(VANILLA, mock(Recipe.class));
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.get(invocation.getArgument(0)));
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
commands = new SimpleCommandMap(serverUnderTest, new HashMap<>());
when(serverUnderTest.getPluginCommand(any(String.class))).thenAnswer(invocation ->
commands.getCommand(invocation.getArgument(0)));
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
when(serverUnderTest.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(any(), any(Runnable.class),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()))
.thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class));
events = new PaperPluginManagerImpl(serverUnderTest, commands, null);
when(serverUnderTest.getPluginManager()).thenReturn(events);
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
}
@AfterEach
void removeLogHandler() {
logger.removeHandler(logHandler);
if (createdPlugin != null) {
createdPlugin.getLogger().removeHandler(logHandler);
}
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
@Test
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
AtomicInteger onlineGrants = new AtomicInteger();
AtomicInteger joiningGrants = new AtomicInteger();
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
Recipe vanilla = registry.get(VANILLA);
var plugin = plugin();
try {
plugin.setEnabled(true);
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(registry.containsKey(POWDER));
assertEquals(expectedKnown(), onlineKnown);
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());
assertSame(vanilla, registry.get(VANILLA));
assertEquals(expectedKnown(), onlineKnown);
assertEquals(expectedKnown(), joiningKnown);
Set<NamespacedKey> afterDisable = new LinkedHashSet<>();
events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable,
new AtomicInteger(), new AtomicBoolean()), "joined"));
assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes");
plugin.setEnabled(true);
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(registry.containsKey(POWDER));
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
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);
HandlerList.unregisterAll(plugin);
}
}
@Test
void powderSnowKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
Recipe conflict = mock(Recipe.class);
registry.put(POWDER, conflict);
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
AtomicInteger grants = new AtomicInteger();
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
.when(serverUnderTest).getOnlinePlayers();
var plugin = plugin();
try {
plugin.setEnabled(true);
assertSame(conflict, registry.get(POWDER));
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(known.containsAll(EXISTING));
assertFalse(known.contains(POWDER));
assertEquals(0, grants.get());
plugin.setEnabled(false);
assertEquals(Set.of(VANILLA, POWDER), registry.keySet());
assertSame(conflict, registry.get(POWDER));
registry.remove(POWDER);
plugin.setEnabled(true);
assertTrue(registry.containsKey(POWDER));
assertTrue(known.contains(POWDER));
assertEquals(1, grants.get());
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
"Only the deliberately injected key conflict is severe");
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
AtomicBoolean fail = new AtomicBoolean(true);
AtomicInteger failedGrants = new AtomicInteger();
Player failed = player("Failed", failedKnown, failedGrants, fail);
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
var plugin = plugin();
try {
plugin.setEnabled(true);
assertFalse(failedKnown.contains(POWDER));
assertEquals(expectedKnown(), healthyKnown);
assertTrue(failedKnown.containsAll(EXISTING));
fail.set(false);
events.callEvent(new PlayerJoinEvent(failed, "retry"));
assertEquals(expectedKnown(), failedKnown);
assertEquals(1, failedGrants.get());
assertNoSevereLogs();
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void partialDiscoveryListenerFailureRollsBackOnlyPowderSnowAndWithholdsJoinDiscovery() {
PluginManager throwing = mock(PluginManager.class);
AtomicInteger discoveries = new AtomicInteger();
doAnswer(invocation -> {
Listener listener = invocation.getArgument(0);
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
events.registerEvents(listener, owner);
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 4) {
throw new IllegalStateException("simulated post-listener-registration failure");
}
return null;
}).when(throwing).registerEvents(any(), any());
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
var plugin = plugin();
try {
plugin.setEnabled(true);
assertFalse(registry.containsKey(POWDER));
assertTrue(registry.keySet().containsAll(EXISTING));
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
new AtomicBoolean()), "joined"));
assertTrue(known.containsAll(EXISTING));
assertFalse(known.contains(POWDER));
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
"Only the deliberately injected listener failure is severe");
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"refused", "before-insert", "after-insert", "lookup"})
void registrationPortFailuresPreserveOtherFeaturesAndRetryThroughActualEnable(String failure) {
var fail = new AtomicBoolean(true);
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
NamespacedKey requested = invocation.getArgument(0);
if (requested.equals(POWDER) && failure.equals("lookup") && fail.get())
throw new IllegalStateException("injected lookup failure");
return registry.get(requested);
});
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
var key = ((Keyed) recipe).getKey();
if (key.equals(POWDER) && fail.get()) {
if (failure.equals("refused")) return false;
if (failure.equals("before-insert")) throw new IllegalStateException("injected pre-insert failure");
if (failure.equals("after-insert")) {
registry.put(key, recipe);
throw new IllegalStateException("injected post-insert failure");
}
}
return registry.putIfAbsent(key, recipe) == null;
});
var known = new LinkedHashSet<>(Set.of(VANILLA));
Player online = player("Online", known, new AtomicInteger(), new AtomicBoolean());
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
Recipe vanilla = registry.get(VANILLA);
var plugin = plugin();
try {
plugin.setEnabled(true);
assertFalse(registry.containsKey(POWDER));
assertFalse(known.contains(POWDER));
assertTrue(registry.keySet().containsAll(EXISTING));
assertTrue(known.containsAll(EXISTING));
assertSame(vanilla, registry.get(VANILLA));
// Sorting/admin/native-anvil registration is not withheld by a failed independent recipe.
assertNotNull(plugin.sortingSticks());
assertFalse(protocolListeners.isEmpty());
plugin.setEnabled(false);
assertEquals(Set.of(VANILLA), registry.keySet());
assertSame(vanilla, registry.get(VANILLA));
fail.set(false);
plugin.setEnabled(true);
assertTrue(registry.containsKey(POWDER));
assertTrue(known.contains(POWDER));
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
} finally {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"false", "throw"})
void realLifecycleRemovalFailureRetainsOwnershipForRetryWithoutListenersOrOtherRecipeLoss(String failure) {
var plugin = plugin();
plugin.setEnabled(true);
var separateKey = key("powder_removal_probe");
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
new PowderSnowRecipeRegistrar(separateKey), plugin.getLogger());
var fail = new AtomicBoolean(true);
try {
assertTrue(lifecycle.enable());
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
NamespacedKey requested = invocation.getArgument(0);
if (requested.equals(separateKey) && fail.get()) {
if (failure.equals("false")) return false;
throw new IllegalStateException("injected removal failure");
}
return registry.remove(requested) != null;
});
assertThrows(IllegalStateException.class, lifecycle::disable);
assertEquals(before, registry.keySet());
var known = new LinkedHashSet<NamespacedKey>();
events.callEvent(new PlayerJoinEvent(player("After failed disable", known,
new AtomicInteger(), new AtomicBoolean()), "joined"));
assertFalse(known.contains(separateKey), "Unregistered discovery must not grant the stranded recipe");
assertTrue(known.contains(POWDER), "Other independently active listeners remain usable");
fail.set(false);
lifecycle.disable();
before.remove(separateKey);
assertEquals(before, registry.keySet());
assertTrue(lifecycle.enable());
assertTrue(registry.containsKey(separateKey));
lifecycle.disable();
assertEquals(before, registry.keySet());
} finally {
fail.set(false);
lifecycle.disable();
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void exceptionAfterSuccessfulRemovalCanRetryCleanupAndRegisterAgain() {
var plugin = plugin();
plugin.setEnabled(true);
var key = key("powder_post_removal_probe");
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
new PowderSnowRecipeRegistrar(key), plugin.getLogger());
try {
assertTrue(lifecycle.enable());
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
NamespacedKey requested = invocation.getArgument(0);
boolean removed = registry.remove(requested) != null;
if (requested.equals(key) && removed)
throw new IllegalStateException("injected exception after successful removal");
return removed;
});
assertThrows(IllegalStateException.class, lifecycle::disable);
before.remove(key);
assertEquals(before, registry.keySet());
assertDoesNotThrow(lifecycle::disable, "Absent owned registration must permit cleanup retry");
assertTrue(lifecycle.enable());
assertTrue(registry.containsKey(key));
} finally {
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> registry.remove(invocation.getArgument(0)) != null);
// A deliberately stranded absent registration must not mask the RED assertion in cleanup.
if (registry.containsKey(key)) lifecycle.disable();
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@Test
void postInsertionAndRollbackRemovalFailureKeepRealLifecycleRetryable() {
var plugin = plugin();
plugin.setEnabled(true);
var key = key("powder_insertion_probe");
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
new PowderSnowRecipeRegistrar(key), plugin.getLogger());
var failRemoval = new AtomicBoolean(true);
try {
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
var requested = ((Keyed) recipe).getKey();
registry.put(requested, recipe);
if (requested.equals(key)) throw new IllegalStateException("injected post-insert failure");
return true;
});
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
NamespacedKey requested = invocation.getArgument(0);
if (requested.equals(key) && failRemoval.get())
throw new IllegalStateException("injected rollback removal failure");
return registry.remove(requested) != null;
});
assertFalse(lifecycle.enable());
assertTrue(registry.containsKey(key), "Failed cleanup honestly retains ownership");
assertTrue(registry.keySet().containsAll(before));
var known = new LinkedHashSet<NamespacedKey>();
events.callEvent(new PlayerJoinEvent(player("During failure", known,
new AtomicInteger(), new AtomicBoolean()), "joined"));
assertFalse(known.contains(key));
failRemoval.set(false);
lifecycle.disable();
assertEquals(before, registry.keySet());
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
});
assertTrue(lifecycle.enable());
events.callEvent(new PlayerJoinEvent(player("Retry", known,
new AtomicInteger(), new AtomicBoolean()), "joined"));
assertTrue(known.contains(key));
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
} finally {
failRemoval.set(false);
lifecycle.disable();
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
}
}
@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),
NativeAnvilFixture.description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol,
() -> "Purpur 26.3.build.2651-stable");
commands.registerAll(createdPlugin.getName(),
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
createdPlugin.getLogger().addHandler(logHandler);
return createdPlugin;
} catch (java.io.IOException | InvalidDescriptionException exception) {
throw new AssertionError("Could not read generated plugin metadata", exception);
}
}
private static Player player(String name, Set<NamespacedKey> known,
AtomicInteger powderSnowGrants, AtomicBoolean failPowderSnow) {
Player player = mock(Player.class);
when(player.getName()).thenReturn(name);
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
invocation -> known.contains(invocation.getArgument(0)));
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
if (requested.contains(POWDER)) {
if (failPowderSnow.get()) {
throw new IllegalStateException("simulated player discovery failure");
}
powderSnowGrants.incrementAndGet();
}
known.addAll(requested);
return requested.size();
});
return player;
}
private void assertNoSevereLogs() {
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
}
private static Set<NamespacedKey> expectedKnown() {
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
expected.add(POWDER);
expected.add(NativeReinforcementCraftTest.KEY); // Approved additional independently discoverable recipe.
expected.add(VANILLA);
return expected;
}
private static NamespacedKey key(String name) {
return new NamespacedKey("spigotinventoryhelper", name);
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,312 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapelessRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual plugin registration, pinned native crafting engine; no replacement plugin logic. */
class NativePowderSnowRecipeTest {
static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "snow_powder_snow");
@TempDir Path directory;
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void discoveredNativeAutofillAndActualPacketTakePayOnceInBothMenus(int width) throws Exception {
try (var fixture = new NativePowderSnowFixture(directory, width)) {
var inventory = fixture.actor.realInventory;
inventory.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK, 2)));
inventory.setItem(1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
new org.bukkit.inventory.ItemStack(Material.BUCKET, 2)));
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, fixture.autofill(false));
assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.SNOW_BLOCK));
assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.BUCKET));
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
assertEquals(Material.POWDER_SNOW_BUCKET,
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getResultSlot().getItem()).getType());
fixture.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, fixture.packets.clicks.getLast());
assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey());
assertFalse(event.isCancelled());
assertEquals(Material.POWDER_SNOW_BUCKET,
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getCarried()).getType());
assertTrue(fixture.menu.craftSlots.isEmpty());
assertEquals(1, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK));
assertEquals(1, NativePowderSnowFixture.count(inventory, Material.BUCKET));
fixture.actor.assertNoEventErrors();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void shapelessNativeMatchingRequiresExactlyBothMaterialsInEveryPairOfPositions(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
var recipe = f.powder().value();
for (int snow = 0; snow < width * width; snow++) {
for (int bucket = 0; bucket < width * width; bucket++) {
if (snow == bucket) continue;
var items = new java.util.ArrayList<>(java.util.Collections.nCopies(width * width,
net.minecraft.world.item.ItemStack.EMPTY));
items.set(snow, nms(Material.SNOW_BLOCK, 2));
items.set(bucket, nms(Material.BUCKET, 3));
assertTrue(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
items.set(snow, net.minecraft.world.item.ItemStack.EMPTY);
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.DIRT)) {
items.set(snow, nms(wrong, 1));
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
}
items.set(snow, nms(Material.SNOW_BLOCK, 2));
items.set(bucket, net.minecraft.world.item.ItemStack.EMPTY);
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.WATER_BUCKET, Material.POWDER_SNOW_BUCKET)) {
items.set(bucket, nms(wrong, 1));
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
}
items.set(bucket, nms(Material.BUCKET, 1));
int extra = java.util.stream.IntStream.range(0, items.size())
.filter(i -> items.get(i).isEmpty()).findFirst().orElseThrow();
for (Material material : List.of(Material.BUCKET, Material.SNOW_BLOCK, Material.DIRT)) {
items.set(extra, nms(material, 1));
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
}
}
}
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"2,1", "2,3", "2,16", "3,1", "3,3", "3,16"})
void nativeBatchPacketConservesEachBucketAndSnowWithUnstackableOutput(int width, int crafts) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
var inventory = f.actor.realInventory;
var keep = marked(Material.DIAMOND, 7);
inventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep));
inventory.setItem(0, nms(Material.SNOW_BLOCK, crafts));
inventory.setItem(1, nms(Material.BUCKET, crafts));
assertEquals(16, nms(Material.BUCKET, 1).getMaxStackSize());
assertEquals(1, nms(Material.POWDER_SNOW_BUCKET, 1).getMaxStackSize());
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(true));
assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
assertFalse(event.isCancelled());
assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey());
assertEquals(crafts, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.BUCKET));
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK));
assertTrue(f.menu.craftSlots.isEmpty());
assertTrue(f.menu.getCarried().isEmpty());
assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(inventory.getItem(20)));
for (var item : inventory.getNonEquipmentItems()) {
if (item.is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET)) ordinaryOutput(item);
}
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
"Native empty-stack drop calls award nothing");
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void ordinaryPacketPreservesRemainingIngredientMetadataAndAwardsNoExtraBucket(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
var snow = marked(Material.SNOW_BLOCK, 3);
var buckets = marked(Material.BUCKET, 3);
var keep = marked(Material.DIAMOND, 7);
f.actor.realInventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep));
f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(snow));
f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(buckets));
var before = f.gridSnapshot();
ordinaryOutput(f.menu.getResultSlot().getItem());
assertEquals(before, f.gridSnapshot(), "Preview is not payment");
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
ordinaryOutput(f.menu.getCarried());
assertEquals(marked(Material.SNOW_BLOCK, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0)));
assertEquals(marked(Material.BUCKET, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(width * width - 1)));
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET));
assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(20)));
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
"Native empty-stack drop calls award nothing");
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"2,PICKUP", "2,QUICK_MOVE", "3,PICKUP", "3,QUICK_MOVE"})
void actualNativeCraftItemCancellationSpendsNothingAndLaterRetrySucceeds(int width, net.minecraft.world.inventory.ContainerInput input) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
f.autofill(true);
var grid = f.gridSnapshot();
var inventory = snapshot(f.actor.realInventory);
var cancel = new java.util.concurrent.atomic.AtomicBoolean(true);
f.packets.listen(org.bukkit.event.EventPriority.HIGHEST, event -> {
if (event instanceof org.bukkit.event.inventory.CraftItemEvent) event.setCancelled(cancel.get());
});
f.packets.click(0, 0, input);
assertTrue(assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()).isCancelled());
assertEquals(grid, f.gridSnapshot());
assertEquals(inventory, snapshot(f.actor.realInventory));
assertTrue(f.menu.getCarried().isEmpty());
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
"Native empty-stack drop calls award nothing");
cancel.set(false);
f.packets.click(0, 0, input);
assertFalse(f.packets.clicks.getLast().isCancelled());
long outputs = NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)
+ (f.menu.getCarried().isEmpty() ? 0 : f.menu.getCarried().getCount());
assertEquals(input == net.minecraft.world.inventory.ContainerInput.PICKUP ? 1 : 3, outputs);
assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void fullInventoryIncompatibleCursorAndWrongContainerRefuseNativeCompletion(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3));
f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3));
var grid = f.gridSnapshot();
var inventory = snapshot(f.actor.realInventory);
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
assertEquals(grid, f.gridSnapshot());
assertEquals(inventory, snapshot(f.actor.realInventory));
assertTrue(f.menu.getCarried().isEmpty());
f.menu.setCarried(nms(Material.DIAMOND, 1));
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
assertEquals(grid, f.gridSnapshot());
assertEquals(inventory, snapshot(f.actor.realInventory));
assertEquals(Material.DIAMOND, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType());
int events = f.packets.clicks.size();
f.packets.click(f.menu.containerId + 99, 0, 0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
assertEquals(events, f.packets.clicks.size());
assertEquals(grid, f.gridSnapshot());
assertEquals(inventory, snapshot(f.actor.realInventory));
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
"Native empty-stack drop calls award nothing");
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void ghostAndAutofillRelocationNeverAwardOrLoseItems(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, f.autofill(false));
assertEquals(3, NativePowderSnowFixture.count(f.actor.realInventory, Material.SNOW_BLOCK));
assertTrue(f.menu.craftSlots.isEmpty());
assertTrue(f.menu.resultSlots.isEmpty());
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
assertTrue(f.menu.getCarried().isEmpty());
assertFalse(f.packets.clicks.getLast() instanceof org.bukkit.event.inventory.CraftItemEvent);
var displaced = marked(Material.DIRT, 2);
f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(displaced));
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false));
assertEquals(2, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT));
assertTrue(snapshot(f.actor.realInventory).contains(displaced));
ordinaryOutput(f.menu.getResultSlot().getItem());
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
assertTrue(f.menu.getCarried().isEmpty());
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
"Native empty-stack drop calls award nothing");
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void batchWithOnlyOneOutputSlotPaysOnceAndRetainsAllUncraftedInputs(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
for (int slot = 0; slot < 35; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3));
f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3));
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
assertEquals(1, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
assertEquals(35L * 64, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT));
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET));
var remaining = f.gridSnapshot();
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
assertEquals(remaining, f.gridSnapshot(), "Now-full output space cannot spend again");
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
void autofillCannotDiscardMetadataWhenExistingGridHasNoRelocationSpace(int width) throws Exception {
try (var f = new NativePowderSnowFixture(directory, width)) {
for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(marked(Material.DIRT, 2)));
var before = f.gridSnapshot();
var inventory = snapshot(f.actor.realInventory);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false));
assertEquals(before, f.gridSnapshot());
assertEquals(inventory, snapshot(f.actor.realInventory));
assertTrue(f.menu.resultSlots.isEmpty());
assertTrue(f.menu.getCarried().isEmpty());
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty));
}
}
private static List<org.bukkit.inventory.ItemStack> snapshot(net.minecraft.world.Container container) {
return java.util.stream.IntStream.range(0, container.getContainerSize())
.mapToObj(i -> org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(container.getItem(i))).toList();
}
private static net.minecraft.world.item.ItemStack nms(Material material, int count) {
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(material, count));
}
private static org.bukkit.inventory.ItemStack marked(Material material, int count) {
var item = new org.bukkit.inventory.ItemStack(material, count);
var meta = item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Keep metadata"));
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
org.bukkit.persistence.PersistentDataType.STRING, "unchanged");
item.setItemMeta(meta);
return item;
}
private static void ordinaryOutput(net.minecraft.world.item.ItemStack item) {
var bukkit = org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(item);
assertEquals(Material.POWDER_SNOW_BUCKET, bukkit.getType());
assertEquals(1, bukkit.getAmount());
assertFalse(bukkit.hasItemMeta());
}
@Test
void actualEnableRegistersExactlySnowBlockAndEmptyBucketForOneOrdinaryMiscOutput() throws Exception {
try (var actor = new NativeAnvilFixture(directory)) {
var recipe = assertInstanceOf(ShapelessRecipe.class, actor.server.getRecipe(KEY));
assertEquals(KEY, recipe.getKey());
assertEquals(List.of(List.of(Material.SNOW_BLOCK), List.of(Material.BUCKET)),
recipe.getChoiceList().stream().map(choice ->
assertInstanceOf(RecipeChoice.MaterialChoice.class, choice).getChoices()).toList());
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(Material.POWDER_SNOW_BUCKET, recipe.getResult().getType());
assertEquals(1, recipe.getResult().getAmount());
assertFalse(recipe.getResult().hasItemMeta());
actor.assertNoEventErrors();
}
}
}
@@ -28,6 +28,7 @@ import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries, tags, and components using the server test bootstrap path. */
final class NativeRuntime {
private static boolean bootstrapped;
private static HolderLookup.Provider reloadableRegistries;
private NativeRuntime() {}
@@ -47,12 +48,12 @@ final class NativeRuntime {
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(
resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(
layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
@@ -61,7 +62,15 @@ final class NativeRuntime {
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
reloadableRegistries = datapack.fullRegistries().lookup();
bootstrapped = true;
}
}
static net.minecraft.world.item.crafting.RecipeManager recipeManager() {
if (!bootstrapped) {
throw new IllegalStateException("Native runtime has not been bootstrapped");
}
return new net.minecraft.world.item.crafting.RecipeManager(reloadableRegistries);
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigInteger;
import org.junit.jupiter.api.Test;
final class AdvancedAnvilPolicyTest {
@Test
void defaultCurveMatchesApprovedTableAndHigherLevels() {
AdvancedAnvilPolicy policy = AdvancedAnvilPolicy.defaults();
assertEquals(new AdvancedAnvilPolicy(20, 5, 99), policy);
int[] expected = {0, 20, 25, 35, 50, 70, 95, 99, 99, 99};
for (int excess = 0; excess < expected.length; excess++) {
assertEquals(expected[excess], policy.failurePercent(excess), "E=" + excess);
}
}
@Test
void settingsIndependentlyControlBaseStepAndCapIncludingEndpoints() {
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 100).failurePercent(0));
assertEquals(0, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(1));
assertEquals(5, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(2));
assertEquals(20, new AdvancedAnvilPolicy(20, 0, 99).failurePercent(Integer.MAX_VALUE));
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 0).failurePercent(Integer.MAX_VALUE));
assertEquals(100, new AdvancedAnvilPolicy(20, 5, 100).failurePercent(7));
assertEquals(12, new AdvancedAnvilPolicy(20, 5, 12).failurePercent(1));
assertEquals(99, AdvancedAnvilPolicy.defaults().failurePercent(Integer.MAX_VALUE));
}
@Test
void triangularGrowthMatchesExactArithmeticWithoutOverflowOrArbitraryCap() {
int[] excesses = {0, 1, 2, 3, 6, 7, 10, 100, 65536, 1000000, Integer.MAX_VALUE};
for (int base : new int[] {0, 1, 20, 100}) {
for (int step : new int[] {0, 1, 5, 100}) {
for (int cap : new int[] {0, 1, 99, 100}) {
AdvancedAnvilPolicy policy = new AdvancedAnvilPolicy(base, step, cap);
for (int excess : excesses) {
BigInteger e = BigInteger.valueOf(excess);
int expected = excess == 0 ? 0 : e.multiply(e.subtract(BigInteger.ONE))
.divide(BigInteger.TWO).multiply(BigInteger.valueOf(step))
.add(BigInteger.valueOf(base)).min(BigInteger.valueOf(cap)).intValueExact();
assertEquals(expected, policy.failurePercent(excess), policy + " E=" + excess);
}
}
}
}
}
@Test
void rejectsInvalidPolicyFieldsAndNegativeExcess() {
for (int invalid : new int[] {-1, 101, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(invalid, 5, 99));
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, invalid, 99));
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, 5, invalid));
}
assertThrows(IllegalArgumentException.class, () -> AdvancedAnvilPolicy.defaults().failurePercent(-1));
}
}
@@ -0,0 +1,565 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Server;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
final class AdvancedAnvilSettingsTest {
private static final String BASE = "advanced-anvil-base-failure-percent";
private static final String STEP = "advanced-anvil-risk-step-percent";
private static final String CAP = "advanced-anvil-max-failure-percent";
private static final String PERMISSION = "spigotinventoryhelper.admin";
@TempDir Path directory;
@Test
void boundConsoleCommandPublishesOnlyAfterDurableMainThreadAcknowledgmentAndRestarts()
throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE);
assertEquals(List.of(BASE + " = 20"), fixture.messages);
assertFalse(Files.exists(directory.resolve("config.yml")));
fixture.messages.clear();
fixture.execute("config", BASE, "37");
Runnable acknowledgment = fixture.nextAcknowledgment();
assertEquals(20, settings.policy().basePercent());
assertTrue(changes.isEmpty());
assertTrue(fixture.messages.isEmpty(), "No success before acknowledgment");
Fixture restart = new Fixture(directory);
try (AdvancedAnvilSettings loaded = restart.open(ignored -> {})) {
assertEquals(37, loaded.policy().basePercent(), "Acknowledgment follows durable save");
}
acknowledgment.run();
assertEquals(37, settings.policy().basePercent());
assertEquals(List.of(settings.policy()), changes);
assertEquals(List.of(BASE + " = 37 (saved)"), fixture.messages);
}
}
@Test
void absentAndPartialConfigurationUseDefaultsWithoutStartupWrites() throws Exception {
Fixture fixture = new Fixture(directory.resolve("absent"));
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Startup callback"))) {
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("absent")));
}
Files.writeString(directory.resolve("config.yml"), STEP + ": 0\nunrelated: keep\n");
try (AdvancedAnvilSettings settings = new Fixture(directory).open(ignored -> {})) {
assertEquals(new AdvancedAnvilPolicy(20, 0, 99), settings.policy());
}
assertEquals(STEP + ": 0\nunrelated: keep\n", Files.readString(directory.resolve("config.yml")));
}
@ParameterizedTest
@ValueSource(strings = {"-1", "101", "20.0", "'20'", "true", "null", "999999999999999999999999"})
void presentInvalidSettingsFailClosedForEveryKey(String invalid) throws Exception {
for (String key : List.of(BASE, STEP, CAP)) {
Files.writeString(directory.resolve("config.yml"), key + ": " + invalid + "\n");
Fixture fixture = new Fixture(directory);
assertThrows(IOException.class, () -> fixture.open(ignored -> fail("Partial installation")));
assertNull(fixture.command.getTabCompleter());
assertTrue(fixture.acknowledgments.isEmpty());
assertEquals(key + ": " + invalid + "\n", Files.readString(directory.resolve("config.yml")));
}
}
@ParameterizedTest
@ValueSource(strings = {"[broken", "- list", "!!java/object:java.lang.Runtime {}",
"advanced-anvil-base-failure-percent: 20\nadvanced-anvil-base-failure-percent: 30\n"})
void malformedYamlAndDuplicateSettingsFailBeforeOpening(String document) throws Exception {
Files.writeString(directory.resolve("config.yml"), document);
assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> {}));
}
@Test
void serializedQueuedWritesComposeBeforeAnyAcknowledgmentAndPreserveUnknownKeys() throws Exception {
Files.writeString(directory.resolve("config.yml"), "unrelated:\n list: [one, two]\nother: true\n");
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE, "31");
fixture.execute("config", STEP, "7");
fixture.execute("config", CAP, "100");
fixture.execute("config", BASE, "42");
List<Runnable> callbacks = new ArrayList<>();
for (int count = 0; count < 4; count++) {
callbacks.add(fixture.nextAcknowledgment());
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
fixture.execute("config", BASE);
assertEquals(List.of(BASE + " = 20"), fixture.messages);
fixture.messages.clear();
callbacks.getLast().run();
// 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(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());
}
Object document = new org.yaml.snakeyaml.Yaml().load(Files.readString(directory.resolve("config.yml")));
assertInstanceOf(java.util.Map.class, document);
java.util.Map<?, ?> values = (java.util.Map<?, ?>) document;
assertEquals(java.util.Map.of("list", List.of("one", "two")), values.get("unrelated"));
assertEquals(true, values.get("other"));
try (var files = Files.list(directory)) {
assertEquals(List.of("config.yml"), files.map(path -> path.getFileName().toString()).toList());
}
}
}
@Test
void failedSaveRetainsEffectivePolicyAndDoesNotPoisonSubsequentQueuedUpdates() throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
fixture.execute("config", BASE, "31");
fixture.nextAcknowledgment().run();
Path file = directory.resolve("config.yml");
String prior = Files.readString(file);
Files.delete(file);
Files.createDirectory(file); // Real unwritable target, not a mocked save failure.
fixture.messages.clear();
fixture.execute("config", STEP, "17");
fixture.nextAcknowledgment().run();
assertEquals(new AdvancedAnvilPolicy(31, 5, 99), settings.policy());
assertEquals(1, changes.size());
assertEquals(List.of("Configuration save failed; effective settings unchanged."), fixture.messages);
Files.delete(file);
Files.writeString(file, prior);
fixture.messages.clear();
fixture.execute("config", CAP, "0");
fixture.nextAcknowledgment().run();
assertEquals(new AdvancedAnvilPolicy(31, 5, 0), settings.policy());
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
assertEquals(settings.policy(), restarted.policy());
}
}
}
@ParameterizedTest
@ValueSource(strings = {"-1", "101", "2147483648", "1.5", "true", "", " 20", "20 ", "garbage"})
void invalidCommandValuesNeverWriteOrPublish(String invalid) throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Invalid update"))) {
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, invalid);
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertEquals(3, fixture.messages.size());
assertTrue(fixture.messages.stream().allMatch(message -> message.contains("0 through 100")));
}
}
@ParameterizedTest
@ValueSource(strings = {"37", "+37", "00037", "00000000000000000037", "-0"})
void validManuallyEnteredIntegersAreNotLimitedToCompletionSuggestions(String raw) throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
fixture.execute("config", BASE, raw);
assertTrue(fixture.messages.isEmpty(), "Valid integer should be accepted, not rejected");
fixture.nextAcknowledgment().run();
assertEquals(Integer.parseInt(raw), settings.policy().basePercent());
}
}
@Test
void unsupportedRoutesAndExtraArgumentsAreRejectedWithoutMutation() throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Unsupported update"))) {
for (String[] args : List.of(new String[0], new String[] {"config"},
new String[] {"other", BASE, "10"}, new String[] {"config", "unknown", "10"},
new String[] {"config", BASE, "10", "extra"})) {
fixture.execute(args);
if (args.length >= 2) {
assertEquals(List.of(), fixture.complete(args));
}
}
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.stream().allMatch(message -> message.startsWith("Usage:")));
}
}
@Test
void authorizationDeniesReadsWritesCompletionAndExecutorBypassForPlayersAndConsole() throws Exception {
for (CommandSender sender : List.of(mock(ConsoleCommandSender.class), mock(Player.class))) {
Fixture fixture = new Fixture(directory, sender);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Denied update"))) {
when(sender.hasPermission(PERMISSION)).thenReturn(false);
fixture.execute("config", BASE);
fixture.execute("config", BASE, "10");
assertTrue(fixture.command.getExecutor().onCommand(sender, fixture.command,
"inventoryhelperadmin", new String[] {"config", BASE, "10"}));
assertEquals(List.of(), fixture.complete());
assertEquals(List.of(), fixture.complete("config", BASE, ""));
assertEquals(List.of("Permission denied."), fixture.messages);
verify(sender, times(2)).sendMessage(Component.text("Permission denied."));
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
}
}
}
@Test
void authorizedPlayerCanSetEndpointsAndReadAllThreeEffectiveSettings() throws Exception {
Fixture fixture = new Fixture(directory, mock(Player.class));
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, "100");
fixture.nextAcknowledgment().run();
fixture.messages.clear();
fixture.execute("config", key);
assertEquals(List.of(key + " = 100"), fixture.messages);
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
assertEquals(List.of("100", defaultValue, "0"), fixture.complete("config", key, ""));
}
assertEquals(new AdvancedAnvilPolicy(100, 100, 100), settings.policy());
for (String key : List.of(BASE, STEP, CAP)) {
fixture.execute("config", key, "0");
fixture.nextAcknowledgment().run();
}
assertEquals(new AdvancedAnvilPolicy(0, 0, 0), settings.policy());
assertEquals(List.of("0", "20", "100"), fixture.complete("config", BASE, ""));
}
}
@Test
void boundCompletionIsStablePrefixFilteredDeduplicatedAndSideEffectFree() throws Exception {
Fixture fixture = new Fixture(directory);
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
assertNotNull(fixture.command.getExecutor());
assertNotNull(fixture.command.getTabCompleter());
assertEquals(List.of("config"), fixture.complete());
assertEquals(List.of("config"), fixture.complete(""));
assertEquals(List.of("config"), fixture.complete("con"));
assertEquals(List.of(), fixture.complete("bad"));
assertEquals(List.of(BASE, STEP, CAP,
"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";
assertEquals(List.of(defaultValue, "0", "100"), fixture.complete("config", key, ""));
assertEquals(List.of("100"), fixture.complete("config", key, "1"));
assertEquals(List.of(), fixture.complete("config", key, "garbage"));
}
assertEquals(List.of(), fixture.complete("other", BASE, ""));
assertEquals(List.of(), fixture.complete("config", "unknown", ""));
assertEquals(List.of(), fixture.complete("config", BASE, "", "extra"));
assertFalse(Files.exists(directory.resolve("config.yml")));
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.isEmpty());
fixture.execute("config", BASE, "37");
fixture.nextAcknowledgment().run();
Files.delete(directory.resolve("config.yml"));
Files.createDirectory(directory.resolve("config.yml")); // Completion must never read disk.
fixture.messages.clear();
assertEquals(List.of("37", "20", "0", "100"), fixture.complete("config", BASE, ""));
assertEquals(List.of("37"), fixture.complete("config", BASE, "3"));
assertEquals(37, settings.policy().basePercent());
assertTrue(fixture.acknowledgments.isEmpty());
assertTrue(fixture.messages.isEmpty());
}
}
@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", 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();
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());
}
}
@Test
void closeIsNonblockingIdempotentAndSuppressesLateCallbacksAndCompletion() throws Exception {
Fixture fixture = new Fixture(directory);
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
AdvancedAnvilSettings settings = fixture.open(changes::add);
try {
fixture.execute("config", BASE, "37");
Runnable acknowledgment = fixture.nextAcknowledgment();
assertTimeout(java.time.Duration.ofMillis(250), settings::close);
settings.close();
acknowledgment.run();
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
assertTrue(changes.isEmpty());
assertTrue(fixture.messages.isEmpty());
assertEquals(List.of(), fixture.complete());
fixture.execute("config", BASE, "50");
assertEquals(List.of("Configuration unavailable."), fixture.messages);
assertThrows(IllegalStateException.class, () -> settings.bind(fixture.command));
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
assertEquals(37, restarted.policy().basePercent());
}
} finally {
settings.close();
}
}
@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);
private final CommandSender sender;
private final BlockingQueue<Runnable> acknowledgments = new LinkedBlockingQueue<>();
private final List<String> messages = new ArrayList<>();
private final PluginCommand command;
private final Thread mainThread = Thread.currentThread();
Fixture(Path dataFolder) throws Exception {
this(dataFolder, mock(ConsoleCommandSender.class));
}
Fixture(Path dataFolder, CommandSender sender) throws Exception {
this.sender = sender;
Server server = mock(Server.class);
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(sender.hasPermission(PERMISSION)).thenReturn(true);
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));
return null;
}).when(sender).sendMessage(anyString());
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
assertNotSame(mainThread, Thread.currentThread(), "Persistence must be asynchronous");
acknowledgments.add(call.getArgument(1, Runnable.class));
return mock(BukkitTask.class);
});
Constructor<PluginCommand> constructor =
PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
constructor.setAccessible(true);
command = constructor.newInstance("inventoryhelperadmin", plugin);
}
AdvancedAnvilSettings open(Consumer<AdvancedAnvilPolicy> changes) throws Exception {
AdvancedAnvilSettings settings = AdvancedAnvilSettings.open(plugin, changes);
settings.bind(command);
return settings;
}
void execute(String... args) {
assertTrue(command.execute(sender, "inventoryhelperadmin", args));
}
List<String> complete(String... args) {
return command.tabComplete(sender, "inventoryhelperadmin", args);
}
Runnable nextAcknowledgment() throws InterruptedException {
Runnable acknowledgment = acknowledgments.poll(5, TimeUnit.SECONDS);
assertNotNull(acknowledgment, "Expected scheduled durable acknowledgment");
return acknowledgment;
}
}
}
@@ -20,9 +20,28 @@ class PurpurDistributionTest {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("1.20", metadata.get("api-version"));
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
assertFalse(metadata.containsKey("permissions"));
assertFalse(metadata.containsKey("commands"));
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
assertEquals(java.util.Set.of("spigotinventoryhelper.admin"), permissions.keySet());
assertEquals("op", ((Map<?, ?>) permissions.get("spigotinventoryhelper.admin")).get("default"));
assertEquals(java.util.Set.of("inventoryhelperadmin"), commands.keySet());
assertEquals("spigotinventoryhelper.admin", ((Map<?, ?>) commands.get("inventoryhelperadmin")).get("permission"));
Map<?, ?> config = new Yaml().load(jar.getInputStream(jar.getJarEntry("config.yml")));
assertEquals(Map.of("advanced-anvil-base-failure-percent", 20,
"advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99,
"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"));
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/NativeAnvilGeneration.class"));
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.class"));
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
assertNotNull(jar.getJarEntry(
@@ -34,7 +53,8 @@ class PurpurDistributionTest {
assertNotNull(jar.getJarEntry(
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/comphenix/") || entry.getName().endsWith("Test.class")
|| entry.getName().contains("NativeRuntime")));
}
}
@@ -49,7 +69,9 @@ class PurpurDistributionTest {
assertTrue(readme.contains("Dropper + Tripwire Hook"));
assertTrue(readme.contains("Redstone category"));
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
assertTrue(readme.contains("Purpur 26.3 build 2651"));
assertTrue(readme.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(readme.contains("26.2") || readme.contains("2618"));
}
@Test
@@ -64,13 +86,20 @@ class PurpurDistributionTest {
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
assertFalse(build.contains("26.2") || build.contains("2618"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
assertTrue(build.contains("nativeInventoryTest"));
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
String guard = Files.readString(Path.of("src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java"));
assertTrue(guard.contains("26.3.build.2651-stable"));
assertFalse(guard.contains("26.2") || guard.contains("2618"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));